Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cea1167be | ||
|
|
0c04f7516b |
No files matched your search
@@ -1126,6 +1126,10 @@ VOID CScreenSpyDlg::OnReceiveComplete()
|
|||||||
case TOKEN_CLIPBOARD_TEXT: {
|
case TOKEN_CLIPBOARD_TEXT: {
|
||||||
Buffer str = m_ContextObject->InDeCompressedBuffer.GetMyBuffer(1);
|
Buffer str = m_ContextObject->InDeCompressedBuffer.GetMyBuffer(1);
|
||||||
UpdateServerClipboard(str.c_str(), str.length());
|
UpdateServerClipboard(str.c_str(), str.length());
|
||||||
|
// 反向回传:远程剪贴板文本(UTF-8)→ Web 客户端
|
||||||
|
if (m_bIsWebSession && WebService().IsRunning()) {
|
||||||
|
WebService().NotifyClipboardText(m_ClientID, std::string(str.c_str(), str.length()));
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case TOKEN_BITMAPINFO: {
|
case TOKEN_BITMAPINFO: {
|
||||||
|
|||||||
@@ -393,6 +393,10 @@ void CWebService::ServerThread(int port) {
|
|||||||
HandleMouse(ws_ptr, msg);
|
HandleMouse(ws_ptr, msg);
|
||||||
} else if (cmd == "key") {
|
} else if (cmd == "key") {
|
||||||
HandleKey(ws_ptr, msg);
|
HandleKey(ws_ptr, msg);
|
||||||
|
} else if (cmd == "clipboard_set") {
|
||||||
|
HandleClipboardSet(ws_ptr, msg);
|
||||||
|
} else if (cmd == "clipboard_get") {
|
||||||
|
HandleClipboardGet(ws_ptr, token);
|
||||||
} else if (cmd == "rdp_reset") {
|
} else if (cmd == "rdp_reset") {
|
||||||
HandleRdpReset(ws_ptr, token);
|
HandleRdpReset(ws_ptr, token);
|
||||||
} else if (cmd == "audio_toggle") {
|
} else if (cmd == "audio_toggle") {
|
||||||
@@ -929,6 +933,93 @@ void CWebService::HandleKey(void* ws_ptr, const std::string& msg) {
|
|||||||
ctx->Send2Client(szData, length);
|
ctx->Send2Client(szData, length);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void CWebService::HandleClipboardSet(void* ws_ptr, const std::string& msg) {
|
||||||
|
// Parse JSON
|
||||||
|
Json::Value root;
|
||||||
|
Json::Reader reader;
|
||||||
|
if (!reader.parse(msg, root)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string token = root.get("token", "").asString();
|
||||||
|
std::string text = root.get("text", "").asString();
|
||||||
|
|
||||||
|
if (text.empty()) return;
|
||||||
|
|
||||||
|
// Validate token
|
||||||
|
std::string username, role;
|
||||||
|
if (!ValidateToken(token, username, role)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get device being watched
|
||||||
|
uint64_t device_id = 0;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(m_ClientsMutex);
|
||||||
|
auto it = m_Clients.find(ws_ptr);
|
||||||
|
if (it != m_Clients.end()) {
|
||||||
|
device_id = it->second.watch_device_id;
|
||||||
|
it->second.last_activity = (uint64_t)time(nullptr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (device_id == 0) return;
|
||||||
|
|
||||||
|
// Get screen context
|
||||||
|
CONTEXT_OBJECT* ctx = GetScreenContext(device_id);
|
||||||
|
if (!ctx) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// UTF-8 → UTF-16 → GBK(936)。客户端 UpdateClientClipboard 以 CF_TEXT(ANSI/GBK) 落剪贴板,
|
||||||
|
// 故显式用 936 而非 CP_ACP,避免服务端 locale 非中文时出错。
|
||||||
|
int wlen = MultiByteToWideChar(CP_UTF8, 0, text.c_str(), (int)text.size(), nullptr, 0);
|
||||||
|
if (wlen <= 0) return;
|
||||||
|
std::wstring wide(wlen, L'\0');
|
||||||
|
MultiByteToWideChar(CP_UTF8, 0, text.c_str(), (int)text.size(), &wide[0], wlen);
|
||||||
|
|
||||||
|
int glen = WideCharToMultiByte(936, 0, wide.c_str(), wlen, nullptr, 0, nullptr, nullptr);
|
||||||
|
if (glen <= 0) return;
|
||||||
|
|
||||||
|
// 命令字节 + GBK 文本
|
||||||
|
std::vector<BYTE> szData(1 + glen);
|
||||||
|
szData[0] = COMMAND_SCREEN_SET_CLIPBOARD;
|
||||||
|
WideCharToMultiByte(936, 0, wide.c_str(), wlen, (char*)szData.data() + 1, glen, nullptr, nullptr);
|
||||||
|
|
||||||
|
ctx->Send2Client(szData.data(), (ULONG)szData.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
void CWebService::HandleClipboardGet(void* ws_ptr, const std::string& token) {
|
||||||
|
// Validate token
|
||||||
|
std::string username, role;
|
||||||
|
if (!ValidateToken(token, username, role)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get device being watched
|
||||||
|
uint64_t device_id = 0;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(m_ClientsMutex);
|
||||||
|
auto it = m_Clients.find(ws_ptr);
|
||||||
|
if (it != m_Clients.end()) {
|
||||||
|
device_id = it->second.watch_device_id;
|
||||||
|
it->second.last_activity = (uint64_t)time(nullptr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (device_id == 0) return;
|
||||||
|
|
||||||
|
// Get screen context
|
||||||
|
CONTEXT_OBJECT* ctx = GetScreenContext(device_id);
|
||||||
|
if (!ctx) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单字节命令,客户端以 SendClientClipboard → TOKEN_CLIPBOARD_TEXT 回包
|
||||||
|
BYTE bToken = COMMAND_SCREEN_GET_CLIPBOARD;
|
||||||
|
ctx->Send2Client(&bToken, sizeof(BYTE));
|
||||||
|
}
|
||||||
|
|
||||||
void CWebService::HandleRdpReset(void* ws_ptr, const std::string& token) {
|
void CWebService::HandleRdpReset(void* ws_ptr, const std::string& token) {
|
||||||
std::string username, role;
|
std::string username, role;
|
||||||
if (!ValidateToken(token, username, role)) {
|
if (!ValidateToken(token, username, role)) {
|
||||||
@@ -1711,6 +1802,26 @@ void CWebService::BroadcastCursor(uint64_t device_id, uint8_t cursor_index) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void CWebService::NotifyClipboardText(uint64_t device_id, const std::string& text) {
|
||||||
|
if (m_bStopping) return;
|
||||||
|
|
||||||
|
Json::Value res;
|
||||||
|
res["cmd"] = "clipboard_text";
|
||||||
|
res["id"] = device_id;
|
||||||
|
res["text"] = text;
|
||||||
|
|
||||||
|
Json::StreamWriterBuilder builder;
|
||||||
|
builder["indentation"] = "";
|
||||||
|
std::string json = Json::writeString(builder, res);
|
||||||
|
|
||||||
|
std::lock_guard<std::mutex> lock(m_ClientsMutex);
|
||||||
|
for (auto& [ws_ptr, client] : m_Clients) {
|
||||||
|
if (client.watch_device_id == device_id) {
|
||||||
|
SendText(ws_ptr, json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
bool CWebService::StartRemoteDesktop(uint64_t device_id) {
|
bool CWebService::StartRemoteDesktop(uint64_t device_id) {
|
||||||
if (!m_pParentDlg) return false;
|
if (!m_pParentDlg) return false;
|
||||||
|
|
||||||
|
|||||||
@@ -160,6 +160,9 @@ public:
|
|||||||
// Cursor change notification (called from ScreenSpyDlg)
|
// Cursor change notification (called from ScreenSpyDlg)
|
||||||
void BroadcastCursor(uint64_t device_id, uint8_t cursor_index);
|
void BroadcastCursor(uint64_t device_id, uint8_t cursor_index);
|
||||||
|
|
||||||
|
// 远程剪贴板文本回传通知(called from ScreenSpyDlg,payload 为 UTF-8)
|
||||||
|
void NotifyClipboardText(uint64_t device_id, const std::string& text);
|
||||||
|
|
||||||
// Get count of web clients watching a device
|
// Get count of web clients watching a device
|
||||||
int GetWebClientCount(uint64_t device_id);
|
int GetWebClientCount(uint64_t device_id);
|
||||||
|
|
||||||
@@ -187,6 +190,8 @@ private:
|
|||||||
void HandlePing(void* ws_ptr, const std::string& token);
|
void HandlePing(void* ws_ptr, const std::string& token);
|
||||||
void HandleMouse(void* ws_ptr, const std::string& msg);
|
void HandleMouse(void* ws_ptr, const std::string& msg);
|
||||||
void HandleKey(void* ws_ptr, const std::string& msg);
|
void HandleKey(void* ws_ptr, const std::string& msg);
|
||||||
|
void HandleClipboardSet(void* ws_ptr, const std::string& msg);
|
||||||
|
void HandleClipboardGet(void* ws_ptr, const std::string& token);
|
||||||
void HandleRdpReset(void* ws_ptr, const std::string& token);
|
void HandleRdpReset(void* ws_ptr, const std::string& token);
|
||||||
void HandleAudioToggle(void* ws_ptr, const std::string& token);
|
void HandleAudioToggle(void* ws_ptr, const std::string& token);
|
||||||
|
|
||||||
|
|||||||
@@ -135,6 +135,8 @@ func (h *MyHandler) OnReceive(ctx *connection.Context, data []byte) {
|
|||||||
// arrives shortly after via TOKEN_NEXTSCREEN.
|
// arrives shortly after via TOKEN_NEXTSCREEN.
|
||||||
case protocol.TokenNextScreen:
|
case protocol.TokenNextScreen:
|
||||||
h.handleScreenFrame(ctx, data, false)
|
h.handleScreenFrame(ctx, data, false)
|
||||||
|
case protocol.TokenClipboardText:
|
||||||
|
h.handleClipboardText(ctx, data)
|
||||||
case protocol.TokenKeyframe:
|
case protocol.TokenKeyframe:
|
||||||
// Sent by the client only when frameID % m_GOP == 0; the client's
|
// Sent by the client only when frameID % m_GOP == 0; the client's
|
||||||
// DEFAULT_GOP is 0x7FFFFFFF (effectively infinite), so this token
|
// DEFAULT_GOP is 0x7FFFFFFF (effectively infinite), so this token
|
||||||
@@ -427,6 +429,18 @@ func (h *MyHandler) handleScreenFrame(ctx *connection.Context, data []byte, alwa
|
|||||||
h.hub.PublishScreenFrame(deviceID, packet, isKey)
|
h.hub.PublishScreenFrame(deviceID, packet, isKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleClipboardText handles TOKEN_CLIPBOARD_TEXT (118): the device replies to
|
||||||
|
// a COMMAND_SCREEN_GET_CLIPBOARD request with its clipboard text as
|
||||||
|
// [118][UTF-8 text][NUL]. data[0] is the token; the payload is data[1:].
|
||||||
|
func (h *MyHandler) handleClipboardText(ctx *connection.Context, data []byte) {
|
||||||
|
deviceID := h.hub.ScreenDeviceID(ctx)
|
||||||
|
if deviceID == "" {
|
||||||
|
return // not a bound screen sub-conn — drop
|
||||||
|
}
|
||||||
|
text := strings.TrimRight(string(data[1:]), "\x00")
|
||||||
|
h.hub.PublishClipboardText(deviceID, text)
|
||||||
|
}
|
||||||
|
|
||||||
// handleLogin handles client login (TOKEN_LOGIN = 102)
|
// handleLogin handles client login (TOKEN_LOGIN = 102)
|
||||||
func (h *MyHandler) handleLogin(ctx *connection.Context, data []byte) {
|
func (h *MyHandler) handleLogin(ctx *connection.Context, data []byte) {
|
||||||
info, err := protocol.ParseLoginInfo(data)
|
info, err := protocol.ParseLoginInfo(data)
|
||||||
|
|||||||
@@ -187,6 +187,9 @@ type EventHandler interface {
|
|||||||
// Duplicates (same index as the previous frame) are filtered out by the
|
// Duplicates (same index as the previous frame) are filtered out by the
|
||||||
// hub before reaching subscribers.
|
// hub before reaching subscribers.
|
||||||
OnCursorChange(deviceID string, index byte)
|
OnCursorChange(deviceID string, index byte)
|
||||||
|
// OnClipboardText delivers the device's clipboard text (already UTF-8),
|
||||||
|
// captured in response to a browser "clipboard_get" request.
|
||||||
|
OnClipboardText(deviceID string, text string)
|
||||||
// OnTerminalReady fires once the device's shell sub-conn is bound and
|
// OnTerminalReady fires once the device's shell sub-conn is bound and
|
||||||
// the server has sent COMMAND_NEXT to start its output read loop.
|
// the server has sent COMMAND_NEXT to start its output read loop.
|
||||||
// isPTY=true means PTY mode (Linux/macOS or ConPTY); false means the
|
// isPTY=true means PTY mode (Linux/macOS or ConPTY); false means the
|
||||||
@@ -606,6 +609,14 @@ func (h *Hub) PublishScreenFrame(deviceID string, packet []byte, isKeyframe bool
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PublishClipboardText relays a device's clipboard text (UTF-8) to all
|
||||||
|
// subscribers, e.g. in response to a browser "clipboard_get" request.
|
||||||
|
func (h *Hub) PublishClipboardText(deviceID string, text string) {
|
||||||
|
for _, s := range h.snapshotSubscribers() {
|
||||||
|
s.OnClipboardText(deviceID, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateLive applies a heartbeat-derived RTT and active-window title to the
|
// UpdateLive applies a heartbeat-derived RTT and active-window title to the
|
||||||
// device's live fields, then notifies subscribers. No-op if the device is
|
// device's live fields, then notifies subscribers. No-op if the device is
|
||||||
// not registered (e.g. heartbeat arriving for a connection that never sent
|
// not registered (e.g. heartbeat arriving for a connection that never sent
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ func (c *captureHandler) OnResolutionChange(_ string, _, _ int) {}
|
|||||||
|
|
||||||
func (c *captureHandler) OnCursorChange(_ string, _ byte) {}
|
func (c *captureHandler) OnCursorChange(_ string, _ byte) {}
|
||||||
|
|
||||||
|
func (c *captureHandler) OnClipboardText(_ string, _ string) {}
|
||||||
|
|
||||||
func (c *captureHandler) OnTerminalReady(_ string, _ bool) {}
|
func (c *captureHandler) OnTerminalReady(_ string, _ bool) {}
|
||||||
|
|
||||||
func (c *captureHandler) OnTerminalData(_ string, _ []byte) {}
|
func (c *captureHandler) OnTerminalData(_ string, _ []byte) {}
|
||||||
|
|||||||
@@ -106,6 +106,8 @@ const (
|
|||||||
CommandActived byte = 0 // COMMAND_ACTIVED
|
CommandActived byte = 0 // COMMAND_ACTIVED
|
||||||
CommandScreenSpy byte = 16 // COMMAND_SCREEN_SPY - start screen capture
|
CommandScreenSpy byte = 16 // COMMAND_SCREEN_SPY - start screen capture
|
||||||
CommandScreenControl byte = 20 // COMMAND_SCREEN_CONTROL - mouse/keyboard input (MSG64 batches)
|
CommandScreenControl byte = 20 // COMMAND_SCREEN_CONTROL - mouse/keyboard input (MSG64 batches)
|
||||||
|
CommandScreenGetClipboard byte = 24 // COMMAND_SCREEN_GET_CLIPBOARD - request remote clipboard (single byte)
|
||||||
|
CommandScreenSetClipboard byte = 25 // COMMAND_SCREEN_SET_CLIPBOARD - set remote clipboard [25][GBK text]
|
||||||
CommandNext byte = 30 // COMMAND_NEXT - "control-side dialog is open, you may stream"
|
CommandNext byte = 30 // COMMAND_NEXT - "control-side dialog is open, you may stream"
|
||||||
CommandShell byte = 40 // COMMAND_SHELL - ask device to open a shell sub-connection
|
CommandShell byte = 40 // COMMAND_SHELL - ask device to open a shell sub-connection
|
||||||
CommandTerminalRsize byte = 81 // CMD_TERMINAL_RESIZE - [cmd:1][cols:2 LE][rows:2 LE]
|
CommandTerminalRsize byte = 81 // CMD_TERMINAL_RESIZE - [cmd:1][cols:2 LE][rows:2 LE]
|
||||||
@@ -120,6 +122,7 @@ const (
|
|||||||
TokenBitmapInfo byte = 115 // TOKEN_BITMAPINFO - screen sub-connection header
|
TokenBitmapInfo byte = 115 // TOKEN_BITMAPINFO - screen sub-connection header
|
||||||
TokenFirstScreen byte = 116 // TOKEN_FIRSTSCREEN - raw BGRA baseline frame (NOT H264)
|
TokenFirstScreen byte = 116 // TOKEN_FIRSTSCREEN - raw BGRA baseline frame (NOT H264)
|
||||||
TokenNextScreen byte = 117 // TOKEN_NEXTSCREEN - non-keyframe H264 (P-frame)
|
TokenNextScreen byte = 117 // TOKEN_NEXTSCREEN - non-keyframe H264 (P-frame)
|
||||||
|
TokenClipboardText byte = 118 // TOKEN_CLIPBOARD_TEXT - remote clipboard text [118][UTF-8][NUL]
|
||||||
TokenShellStart byte = 128 // TOKEN_SHELL_START - legacy cmd-pipe shell sub-conn open
|
TokenShellStart byte = 128 // TOKEN_SHELL_START - legacy cmd-pipe shell sub-conn open
|
||||||
TokenKeyframe byte = 134 // TOKEN_KEYFRAME - H264 IDR (sent on GOP boundary)
|
TokenKeyframe byte = 134 // TOKEN_KEYFRAME - H264 IDR (sent on GOP boundary)
|
||||||
TokenTerminalStart byte = 232 // TOKEN_TERMINAL_START - modern PTY shell sub-conn open
|
TokenTerminalStart byte = 232 // TOKEN_TERMINAL_START - modern PTY shell sub-conn open
|
||||||
|
|||||||
@@ -300,6 +300,23 @@ func (h *wsHub) OnResolutionChange(deviceID string, width, height int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OnClipboardText relays a device's clipboard text back to every browser
|
||||||
|
// watching it (in response to a "clipboard_get" request).
|
||||||
|
func (h *wsHub) OnClipboardText(deviceID string, text string) {
|
||||||
|
msg := mustJSON(map[string]any{
|
||||||
|
"cmd": "clipboard_text",
|
||||||
|
"id": deviceID,
|
||||||
|
"text": text,
|
||||||
|
})
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
for c := range h.clients {
|
||||||
|
if c.watching == deviceID && c.token != "" {
|
||||||
|
c.queue(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// OnScreenFrame ships a screen packet to every browser currently watching
|
// OnScreenFrame ships a screen packet to every browser currently watching
|
||||||
// this device. We hold the read lock for the whole iteration, but each
|
// this device. We hold the read lock for the whole iteration, but each
|
||||||
// queueBinary is non-blocking (drops on backpressure) so a slow viewer
|
// queueBinary is non-blocking (drops on backpressure) so a slow viewer
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
|
||||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/protocol"
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/protocol"
|
||||||
"github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth"
|
"github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth"
|
||||||
|
"golang.org/x/text/encoding/simplifiedchinese"
|
||||||
)
|
)
|
||||||
|
|
||||||
// rotateChallenge replaces the client's nonce with a fresh one and pushes a
|
// rotateChallenge replaces the client's nonce with a fresh one and pushes a
|
||||||
@@ -64,6 +65,10 @@ func (h *wsHub) dispatch(c *wsClient, cmd string, raw []byte) {
|
|||||||
h.handleMouse(c, raw)
|
h.handleMouse(c, raw)
|
||||||
case "key":
|
case "key":
|
||||||
h.handleKey(c, raw)
|
h.handleKey(c, raw)
|
||||||
|
case "clipboard_set":
|
||||||
|
h.handleClipboardSet(c, raw)
|
||||||
|
case "clipboard_get":
|
||||||
|
h.handleClipboardGet(c, raw)
|
||||||
case "term_open":
|
case "term_open":
|
||||||
h.handleTermOpen(c, raw)
|
h.handleTermOpen(c, raw)
|
||||||
case "term_input":
|
case "term_input":
|
||||||
@@ -559,6 +564,59 @@ func (h *wsHub) handleKey(c *wsClient, raw []byte) {
|
|||||||
_ = h.devices.SendToScreen(deviceID, pkt)
|
_ = h.devices.SendToScreen(deviceID, pkt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleClipboardSet relays the browser's local clipboard text into the remote
|
||||||
|
// device's clipboard via COMMAND_SCREEN_SET_CLIPBOARD (25). The client's
|
||||||
|
// UpdateClientClipboard stores the payload as CF_TEXT (ANSI/GBK), so we encode
|
||||||
|
// the UTF-8 input to GBK here — matching the C++ WebService (explicit code
|
||||||
|
// page 936) and the existing MCP remote_clipboard convention. Non-GBK glyphs
|
||||||
|
// (emoji, some accented Latin) collapse to '?' — an accepted limitation.
|
||||||
|
func (h *wsHub) handleClipboardSet(c *wsClient, raw []byte) {
|
||||||
|
if !h.requireAuth(c, raw, "clipboard_set_result") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &in); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if in.Text == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deviceID := c.watching
|
||||||
|
if deviceID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gbk, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte(in.Text))
|
||||||
|
if err != nil {
|
||||||
|
h.log.Warn("clipboard_set: GBK encode failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload := append([]byte{protocol.CommandScreenSetClipboard}, gbk...)
|
||||||
|
if err := h.devices.SendToScreen(deviceID, payload); err != nil {
|
||||||
|
h.log.Warn("clipboard_set: device=%s: %v", deviceID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.log.Info("clipboard_set sent: device=%s len=%d", deviceID, len(gbk))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleClipboardGet asks the remote device for its clipboard text via
|
||||||
|
// COMMAND_SCREEN_GET_CLIPBOARD (24, a single byte). The device replies with
|
||||||
|
// TOKEN_CLIPBOARD_TEXT, which the hub relays back to the browser.
|
||||||
|
func (h *wsHub) handleClipboardGet(c *wsClient, raw []byte) {
|
||||||
|
if !h.requireAuth(c, raw, "clipboard_get_result") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deviceID := c.watching
|
||||||
|
if deviceID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.devices.SendToScreen(deviceID, []byte{protocol.CommandScreenGetClipboard}); err != nil {
|
||||||
|
h.log.Warn("clipboard_get: device=%s: %v", deviceID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// handleCreateUser provisions a new web account. Admin-only. Mirrors the
|
// handleCreateUser provisions a new web account. Admin-only. Mirrors the
|
||||||
// C++ CWebService::HandleCreateUser semantics (WebService.cpp:1009): the
|
// C++ CWebService::HandleCreateUser semantics (WebService.cpp:1009): the
|
||||||
// password is hashed with a fresh salt, the user table is persisted, and
|
// password is hashed with a fresh salt, the user table is persisted, and
|
||||||
|
|||||||
+295
-8
@@ -2,7 +2,7 @@
|
|||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content">
|
||||||
<title>__BRAND_NAME__</title>
|
<title>__BRAND_NAME__</title>
|
||||||
<!-- PWA / iOS Standalone App Support -->
|
<!-- PWA / iOS Standalone App Support -->
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
@@ -1078,6 +1078,56 @@
|
|||||||
}
|
}
|
||||||
.input-shortcuts .shortcut-btn:hover { background: rgba(128,128,128,0.5); }
|
.input-shortcuts .shortcut-btn:hover { background: rgba(128,128,128,0.5); }
|
||||||
.input-shortcuts .shortcut-btn:active { transform: scale(0.95); background: rgba(128,128,128,0.6); }
|
.input-shortcuts .shortcut-btn:active { transform: scale(0.95); background: rgba(128,128,128,0.6); }
|
||||||
|
.input-shortcuts .shortcut-btn.active {
|
||||||
|
background: rgba(52,199,89,0.85);
|
||||||
|
border-color: rgba(52,199,89,0.9);
|
||||||
|
}
|
||||||
|
/* "I" 手机输入法组合框(微信聊天风格):固定在屏幕底部,键盘弹出时随视觉视口上浮 */
|
||||||
|
.ime-bar {
|
||||||
|
display: none;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: rgba(24,24,28,0.97);
|
||||||
|
border-top: 1px solid rgba(128,128,128,0.4);
|
||||||
|
flex-shrink: 0;
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
.ime-bar.visible { display: flex; }
|
||||||
|
.ime-bar textarea {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 40px;
|
||||||
|
max-height: 80px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid rgba(128,128,128,0.5);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
color: #111;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 20px;
|
||||||
|
outline: none;
|
||||||
|
resize: none;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.ime-bar .ime-send,
|
||||||
|
.ime-bar .ime-collapse {
|
||||||
|
flex: none;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.ime-bar .ime-send { background: rgba(52,199,89,0.9); }
|
||||||
|
.ime-bar .ime-collapse { background: rgba(128,128,128,0.5); width: 40px; padding: 0; }
|
||||||
|
|
||||||
/* Android virtual navigation bar - overlays bottom of canvas when controlling Android */
|
/* Android virtual navigation bar - overlays bottom of canvas when controlling Android */
|
||||||
.android-navbar {
|
.android-navbar {
|
||||||
@@ -1264,11 +1314,11 @@
|
|||||||
<button class="shortcut-btn" data-key="50" tabindex="-1">2</button>
|
<button class="shortcut-btn" data-key="50" tabindex="-1">2</button>
|
||||||
<button class="shortcut-btn" data-key="51" tabindex="-1">3</button>
|
<button class="shortcut-btn" data-key="51" tabindex="-1">3</button>
|
||||||
<button class="shortcut-btn" data-key="52" tabindex="-1">4</button>
|
<button class="shortcut-btn" data-key="52" tabindex="-1">4</button>
|
||||||
<button class="shortcut-btn" data-key="53" tabindex="-1">5</button>
|
|
||||||
<button class="shortcut-btn" data-key="190" tabindex="-1">.</button>
|
<button class="shortcut-btn" data-key="190" tabindex="-1">.</button>
|
||||||
<button class="shortcut-btn" data-key="188" tabindex="-1">,</button>
|
<button class="shortcut-btn" data-key="188" tabindex="-1">,</button>
|
||||||
<button class="shortcut-btn" data-key="191" data-shift="1" tabindex="-1">?</button>
|
<button class="shortcut-btn" data-key="191" data-shift="1" tabindex="-1">?</button>
|
||||||
<button class="shortcut-btn" data-key="32" data-ctrl="1" tabindex="-1" title="Ctrl+Space">中</button>
|
<button class="shortcut-btn" data-key="32" data-ctrl="1" tabindex="-1" title="Ctrl+Space">中</button>
|
||||||
|
<button class="shortcut-btn" id="btn-ime" tabindex="-1" title="Phone IME input">I</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="touch-indicator" id="touch-indicator"></div>
|
<div class="touch-indicator" id="touch-indicator"></div>
|
||||||
@@ -1283,6 +1333,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="zoom-indicator" id="zoom-indicator">100%</div>
|
<div class="zoom-indicator" id="zoom-indicator">100%</div>
|
||||||
<input type="text" id="mobile-keyboard" style="position:fixed;left:-9999px;opacity:0;" autocomplete="off" autocorrect="off" autocapitalize="off">
|
<input type="text" id="mobile-keyboard" style="position:fixed;left:-9999px;opacity:0;" autocomplete="off" autocorrect="off" autocapitalize="off">
|
||||||
|
<div class="ime-bar" id="ime-bar">
|
||||||
|
<button type="button" class="ime-collapse" id="ime-collapse" title="Collapse" tabindex="-1" onmousedown="event.preventDefault()">▼</button>
|
||||||
|
<textarea id="ime-input" rows="1" enterkeyhint="send" autocomplete="off" autocorrect="off" autocapitalize="off" placeholder="Type to send"></textarea>
|
||||||
|
<button type="button" class="ime-send" id="ime-send" tabindex="-1" onmousedown="event.preventDefault()">Send</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Terminal Page (xterm.js) -->
|
<!-- Terminal Page (xterm.js) -->
|
||||||
@@ -1365,6 +1420,8 @@
|
|||||||
<script>
|
<script>
|
||||||
let ws = null, token = null, decoder = null, devices = [], currentDevice = null;
|
let ws = null, token = null, decoder = null, devices = [], currentDevice = null;
|
||||||
let frameCount = 0, lastFrameTime = 0, fps = 0, pingInterval = null;
|
let frameCount = 0, lastFrameTime = 0, fps = 0, pingInterval = null;
|
||||||
|
let skipNextCtrlVKeyup = false; // 剪贴板桥:抑制 pasteToRemote 合成的 Ctrl+V 物理 keyup
|
||||||
|
let imeMode = false; // "I" 手机输入法组合框是否展开(隐藏时不丢内容)
|
||||||
// FPS 计数:原始风格——decoder.onOutput 里 ++,每经过 1 秒采样一次。
|
// FPS 计数:原始风格——decoder.onOutput 里 ++,每经过 1 秒采样一次。
|
||||||
// 简单直接,与本次会话改动前一致。
|
// 简单直接,与本次会话改动前一致。
|
||||||
// 网络流量统计:handleBinaryFrame 累加,每 1 秒钟 renderStats 读出
|
// 网络流量统计:handleBinaryFrame 累加,每 1 秒钟 renderStats 读出
|
||||||
@@ -1863,6 +1920,12 @@
|
|||||||
applyRemoteCursor(msg.index);
|
applyRemoteCursor(msg.index);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case 'clipboard_text':
|
||||||
|
// 远程剪贴板文本回传:写回浏览器(即本机)剪贴板
|
||||||
|
if (msg.text) {
|
||||||
|
try { navigator.clipboard.writeText(msg.text); } catch (e) {}
|
||||||
|
}
|
||||||
|
break;
|
||||||
case 'device_offline':
|
case 'device_offline':
|
||||||
// Only handle if this is the device we're currently viewing
|
// Only handle if this is the device we're currently viewing
|
||||||
if (!token) break;
|
if (!token) break;
|
||||||
@@ -3221,7 +3284,7 @@
|
|||||||
document.getElementById('screen-page').classList.remove('android-portrait');
|
document.getElementById('screen-page').classList.remove('android-portrait');
|
||||||
quickControls.classList.add('visible');
|
quickControls.classList.add('visible');
|
||||||
// Input shortcuts only visible when keyboard is open
|
// Input shortcuts only visible when keyboard is open
|
||||||
const keyboardOpen = document.activeElement === mobileKeyboard;
|
const keyboardOpen = document.activeElement === mobileKeyboard || imeMode;
|
||||||
inputShortcuts.classList.toggle('visible', keyboardOpen);
|
inputShortcuts.classList.toggle('visible', keyboardOpen);
|
||||||
toolbarToggle.classList.add('ui-hidden');
|
toolbarToggle.classList.add('ui-hidden');
|
||||||
floatingToolbar.classList.remove('visible');
|
floatingToolbar.classList.remove('visible');
|
||||||
@@ -3243,9 +3306,9 @@
|
|||||||
const shortcuts = uiElements.inputShortcuts;
|
const shortcuts = uiElements.inputShortcuts;
|
||||||
if (!canvas || canvas.offsetHeight === 0) return;
|
if (!canvas || canvas.offsetHeight === 0) return;
|
||||||
|
|
||||||
// Position shortcuts 8px below canvas
|
// Position shortcuts 2px below canvas
|
||||||
const canvasBottom = canvas.offsetTop + canvas.offsetHeight;
|
const canvasBottom = canvas.offsetTop + canvas.offsetHeight;
|
||||||
shortcuts.style.top = (canvasBottom + 8) + 'px';
|
shortcuts.style.top = (canvasBottom + 2) + 'px';
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendRdpReset() {
|
function sendRdpReset() {
|
||||||
@@ -3714,6 +3777,141 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 本地剪贴板 → 远程:读浏览器剪贴板,先 clipboard_set 到远程,再合成一次完整 Ctrl+V。
|
||||||
|
// 合成按键放在 readText 的 then 里,保证 clipboard_set 先于 Ctrl+V 到达(同一条 WS 有序)。
|
||||||
|
function pasteToRemote() {
|
||||||
|
const press = () => { sendKey(86, true, false); sendKey(86, false, false); };
|
||||||
|
navigator.clipboard.readText().then(function(text) {
|
||||||
|
if (text && ws && ws.readyState === WebSocket.OPEN && token) {
|
||||||
|
ws.send(JSON.stringify({ cmd: 'clipboard_set', token, text }));
|
||||||
|
}
|
||||||
|
press();
|
||||||
|
}).catch(function() { press(); }); // 读剪贴板被拒/为空:仍执行一次普通远程粘贴
|
||||||
|
}
|
||||||
|
|
||||||
|
// 远程剪贴板 → 本地:等远程完成拷贝(启发式延时)后请求远程剪贴板,回包写回浏览器剪贴板。
|
||||||
|
// 不保证 100% 命中,失败用户二次 Ctrl+C 即可。
|
||||||
|
function scheduleCopyFromRemote() {
|
||||||
|
setTimeout(function() {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN && token) {
|
||||||
|
ws.send(JSON.stringify({ cmd: 'clipboard_get', token }));
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 直接注入单个按键到远程(不经过 sendKey 的 controlEnabled 门槛)。
|
||||||
|
// "I" 组合框和 1/2/3…/中 同属输入辅助栏,不应要求先开鼠标控制。
|
||||||
|
function sendKeyRaw(keyCode, isDown) {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN && token) {
|
||||||
|
ws.send(JSON.stringify({ cmd: 'key', token, keyCode, down: isDown, alt: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "I" 手机输入法组合框:一次性把文本经剪贴板桥发到远程光标处。
|
||||||
|
// 与 pasteToRemote 同款:先 clipboard_set(服务端转 GBK),再合成完整 Ctrl+V。
|
||||||
|
function sendTextToRemote(text) {
|
||||||
|
if (!text) return;
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN && token) {
|
||||||
|
ws.send(JSON.stringify({ cmd: 'clipboard_set', token, text }));
|
||||||
|
}
|
||||||
|
sendKeyRaw(17, true); // Ctrl down
|
||||||
|
sendKeyRaw(86, true); // V down
|
||||||
|
sendKeyRaw(86, false); // V up
|
||||||
|
sendKeyRaw(17, false); // Ctrl up
|
||||||
|
}
|
||||||
|
|
||||||
|
// 空输入时向远程发送 Backspace(删除远程窗口内容)
|
||||||
|
function imeBackspace() {
|
||||||
|
sendKeyRaw(8, true); // VK_BACK
|
||||||
|
sendKeyRaw(8, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 输入框随内容自动长高:文字换行时像微信一样扩展为多行(超过 max-height 后滚动)
|
||||||
|
function autoGrowImeInput() {
|
||||||
|
const el = document.getElementById('ime-input');
|
||||||
|
if (!el) return;
|
||||||
|
el.style.height = 'auto';
|
||||||
|
// box-sizing:border-box 下 scrollHeight 不含上下 border(各 1px),补回 2px 避免末行出现滚动条
|
||||||
|
el.style.height = (el.scrollHeight + 2) + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleIme() {
|
||||||
|
imeMode = !imeMode;
|
||||||
|
const imeBar = document.getElementById('ime-bar');
|
||||||
|
const imeInput = document.getElementById('ime-input');
|
||||||
|
const btnIme = document.getElementById('btn-ime');
|
||||||
|
const page = document.getElementById('screen-page');
|
||||||
|
if (imeMode) {
|
||||||
|
if (btnIme) btnIme.classList.add('active');
|
||||||
|
if (imeBar) { imeBar.classList.add('visible'); adjustImeBar(); }
|
||||||
|
if (imeInput) {
|
||||||
|
imeInput.focus();
|
||||||
|
adjustImeBar();
|
||||||
|
startImeWatcher();
|
||||||
|
// iOS 上软键盘弹出后 visualViewport 常要一次滚动才归位,强制滚回顶部触发重排
|
||||||
|
setTimeout(function() { window.scrollTo(0, 0); adjustImeBar(); }, 60);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stopImeWatcher();
|
||||||
|
if (btnIme) btnIme.classList.remove('active');
|
||||||
|
if (imeBar) imeBar.classList.remove('visible');
|
||||||
|
if (imeInput) imeInput.blur();
|
||||||
|
if (page) page.style.paddingBottom = '';
|
||||||
|
updateKeyboardButtons(false); // 收起键盘,便于移动远程光标
|
||||||
|
}
|
||||||
|
updateInputShortcutsPosition();
|
||||||
|
}
|
||||||
|
|
||||||
|
function imeSend() {
|
||||||
|
const input = document.getElementById('ime-input');
|
||||||
|
if (!input) return;
|
||||||
|
const text = input.value;
|
||||||
|
if (text) {
|
||||||
|
sendTextToRemote(text);
|
||||||
|
input.value = ''; // 清空输入框,保持键盘弹出
|
||||||
|
autoGrowImeInput(); // 复位为单行高度
|
||||||
|
input.focus();
|
||||||
|
} else {
|
||||||
|
// 空输入框:向远程发送回车(提交远程已编辑好的消息)
|
||||||
|
sendKeyRaw(13, true); // VK_RETURN
|
||||||
|
sendKeyRaw(13, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 键盘弹出时给 screen-page 加 padding-bottom = 键盘高度,把底部输入条顶到键盘上方。
|
||||||
|
// 与 adjustTermViewport 同款做法:screen-page 是 flex 列,ime-bar 是最后一个 flex 子项。
|
||||||
|
function adjustImeBar() {
|
||||||
|
if (!imeMode) return;
|
||||||
|
const page = document.getElementById('screen-page');
|
||||||
|
if (!page) return;
|
||||||
|
let inset = 0;
|
||||||
|
if (window.visualViewport) {
|
||||||
|
inset = Math.max(0, Math.round(
|
||||||
|
window.innerHeight - window.visualViewport.height - (window.visualViewport.offsetTop || 0)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
const next = inset + 'px';
|
||||||
|
if (page.style.paddingBottom !== next) {
|
||||||
|
page.style.paddingBottom = next;
|
||||||
|
updateInputShortcutsPosition();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 键盘弹出是异步动画,iOS 上 visualViewport 甚至要一次滚动才更新,用 rAF 循环
|
||||||
|
// 在 imeMode 期间持续修正,避免"必须手动滚动网页才归位"的问题。
|
||||||
|
let imeRafId = null;
|
||||||
|
function imeWatcher() {
|
||||||
|
if (!imeMode) { imeRafId = null; return; }
|
||||||
|
adjustImeBar();
|
||||||
|
imeRafId = requestAnimationFrame(imeWatcher);
|
||||||
|
}
|
||||||
|
function startImeWatcher() {
|
||||||
|
if (imeRafId == null) imeRafId = requestAnimationFrame(imeWatcher);
|
||||||
|
}
|
||||||
|
function stopImeWatcher() {
|
||||||
|
if (imeRafId != null) { cancelAnimationFrame(imeRafId); imeRafId = null; }
|
||||||
|
}
|
||||||
|
|
||||||
function showTouchIndicator(x, y) {
|
function showTouchIndicator(x, y) {
|
||||||
touchIndicator.style.left = x + 'px';
|
touchIndicator.style.left = x + 'px';
|
||||||
touchIndicator.style.top = y + 'px';
|
touchIndicator.style.top = y + 'px';
|
||||||
@@ -4102,10 +4300,12 @@
|
|||||||
|
|
||||||
function toggleKeyboard() {
|
function toggleKeyboard() {
|
||||||
// Use focus state captured at touchstart (more reliable than button's active class)
|
// Use focus state captured at touchstart (more reliable than button's active class)
|
||||||
const isOpen = wasKeyboardFocused;
|
// 若 "I" 输入框开着,键盘按钮此时也视为"开启"——取消时一并收起输入框、清掉 "I" 状态。
|
||||||
|
const isOpen = wasKeyboardFocused || imeMode;
|
||||||
wasKeyboardFocused = false; // Reset
|
wasKeyboardFocused = false; // Reset
|
||||||
|
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
|
if (imeMode) toggleIme(); // 关闭 "I" 输入框(内部会 updateKeyboardButtons(false))
|
||||||
mobileKeyboard.blur();
|
mobileKeyboard.blur();
|
||||||
updateKeyboardButtons(false);
|
updateKeyboardButtons(false);
|
||||||
} else {
|
} else {
|
||||||
@@ -4119,6 +4319,8 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
mobileKeyboard.addEventListener('blur', function() {
|
mobileKeyboard.addEventListener('blur', function() {
|
||||||
|
// "I" 组合框聚焦会让 mobile-keyboard 失焦,此时保持快捷键栏可见
|
||||||
|
if (imeMode) return;
|
||||||
// Skip if keyboard button was touched within last 300ms
|
// Skip if keyboard button was touched within last 300ms
|
||||||
if (Date.now() - lastKeyboardBtnTouch < 300) return;
|
if (Date.now() - lastKeyboardBtnTouch < 300) return;
|
||||||
updateKeyboardButtons(false);
|
updateKeyboardButtons(false);
|
||||||
@@ -4193,19 +4395,43 @@
|
|||||||
// Desktop keyboard handling
|
// Desktop keyboard handling
|
||||||
document.addEventListener('keydown', function(e) {
|
document.addEventListener('keydown', function(e) {
|
||||||
if (!document.getElementById('screen-page').classList.contains('active')) return;
|
if (!document.getElementById('screen-page').classList.contains('active')) return;
|
||||||
if (e.target.tagName === 'INPUT') return;
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||||||
// F11 belongs to the local fullscreen toggle UNLESS we're actively
|
// F11 belongs to the local fullscreen toggle UNLESS we're actively
|
||||||
// controlling the remote — see the F11 handler above. Skip here
|
// controlling the remote — see the F11 handler above. Skip here
|
||||||
// to let that one handle it.
|
// to let that one handle it.
|
||||||
if (e.key === 'F11' && !controlEnabled) return;
|
if (e.key === 'F11' && !controlEnabled) return;
|
||||||
|
|
||||||
|
// 文本剪贴板桥(secure context 下 navigator.clipboard 可用)。
|
||||||
|
// Ctrl/Cmd+V:把本地剪贴板文本先灌到远程剪贴板,再合成一次 Ctrl+V 粘贴。
|
||||||
|
// Ctrl/Cmd+C / Ctrl/Cmd+X:照常转发给远程(远程完成拷贝/剪切),随后延迟拉取远程剪贴板回本地。
|
||||||
|
if (controlEnabled && (e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey) {
|
||||||
|
if (e.keyCode === 86) { // Ctrl/Cmd+V
|
||||||
|
e.preventDefault();
|
||||||
|
skipNextCtrlVKeyup = true;
|
||||||
|
pasteToRemote();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// e.keyCode === 67(C)/88(X) 时落到下面的 sendKey 原样转发,转发后再延迟拉取
|
||||||
|
}
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
sendKey(e.keyCode, true, e.altKey);
|
sendKey(e.keyCode, true, e.altKey);
|
||||||
|
|
||||||
|
if (controlEnabled && (e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && (e.keyCode === 67 || e.keyCode === 88)) {
|
||||||
|
scheduleCopyFromRemote();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('keyup', function(e) {
|
document.addEventListener('keyup', function(e) {
|
||||||
if (!document.getElementById('screen-page').classList.contains('active')) return;
|
if (!document.getElementById('screen-page').classList.contains('active')) return;
|
||||||
if (e.target.tagName === 'INPUT') return;
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||||||
if (e.key === 'F11' && !controlEnabled) return;
|
if (e.key === 'F11' && !controlEnabled) return;
|
||||||
|
// 由 pasteToRemote 合成的完整 Ctrl+V 按下:物理 keyup 不再转发,避免 down/up 乱序
|
||||||
|
if (e.keyCode === 86 && skipNextCtrlVKeyup) {
|
||||||
|
skipNextCtrlVKeyup = false;
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
sendKey(e.keyCode, false, e.altKey);
|
sendKey(e.keyCode, false, e.altKey);
|
||||||
});
|
});
|
||||||
@@ -4267,6 +4493,15 @@
|
|||||||
controlEnabled = false;
|
controlEnabled = false;
|
||||||
// Reset keyboard state (blur event will update button state)
|
// Reset keyboard state (blur event will update button state)
|
||||||
mobileKeyboard.blur();
|
mobileKeyboard.blur();
|
||||||
|
// Reset "I" 手机输入法组合框
|
||||||
|
imeMode = false;
|
||||||
|
stopImeWatcher();
|
||||||
|
const imeBar = document.getElementById('ime-bar');
|
||||||
|
if (imeBar) imeBar.classList.remove('visible');
|
||||||
|
const imePage = document.getElementById('screen-page');
|
||||||
|
if (imePage) imePage.style.paddingBottom = '';
|
||||||
|
const btnIme = document.getElementById('btn-ime');
|
||||||
|
if (btnIme) btnIme.classList.remove('active');
|
||||||
// Reset floating toolbar buttons
|
// Reset floating toolbar buttons
|
||||||
const btnMouse = document.getElementById('btn-mouse');
|
const btnMouse = document.getElementById('btn-mouse');
|
||||||
if (btnMouse) btnMouse.classList.remove('active');
|
if (btnMouse) btnMouse.classList.remove('active');
|
||||||
@@ -4449,6 +4684,8 @@
|
|||||||
}
|
}
|
||||||
const shortcutBtns = document.querySelectorAll('.shortcut-btn');
|
const shortcutBtns = document.querySelectorAll('.shortcut-btn');
|
||||||
shortcutBtns.forEach(function(btn) {
|
shortcutBtns.forEach(function(btn) {
|
||||||
|
// "I" 按钮不是按键注入,交由 toggleIme 处理
|
||||||
|
if (btn.id === 'btn-ime') return;
|
||||||
btn.addEventListener('touchstart', function(e) {
|
btn.addEventListener('touchstart', function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const keyCode = parseInt(btn.dataset.key);
|
const keyCode = parseInt(btn.dataset.key);
|
||||||
@@ -4477,6 +4714,56 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// "I" 手机输入法组合框:触摸记时、开关、回车/按钮发送、键盘上浮
|
||||||
|
const btnIme = document.getElementById('btn-ime');
|
||||||
|
if (btnIme) {
|
||||||
|
btnIme.addEventListener('touchstart', function() {
|
||||||
|
lastKeyboardBtnTouch = Date.now();
|
||||||
|
}, { passive: true });
|
||||||
|
btnIme.addEventListener('mousedown', function() {
|
||||||
|
lastKeyboardBtnTouch = Date.now();
|
||||||
|
});
|
||||||
|
btnIme.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleIme();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const imeInput = document.getElementById('ime-input');
|
||||||
|
if (imeInput) {
|
||||||
|
imeInput.addEventListener('keydown', function(e) {
|
||||||
|
if (e.isComposing) return; // 输入法确认候选时一律不转发
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
imeSend();
|
||||||
|
} else if (e.key === 'Backspace' && !imeInput.value) {
|
||||||
|
// 空输入框:删除键改为向远程发送 Backspace
|
||||||
|
e.preventDefault();
|
||||||
|
imeBackspace();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
imeInput.addEventListener('input', autoGrowImeInput);
|
||||||
|
}
|
||||||
|
const imeSendBtn = document.getElementById('ime-send');
|
||||||
|
if (imeSendBtn) {
|
||||||
|
imeSendBtn.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
imeSend();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const imeCollapse = document.getElementById('ime-collapse');
|
||||||
|
if (imeCollapse) {
|
||||||
|
imeCollapse.addEventListener('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleIme();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (window.visualViewport) {
|
||||||
|
window.visualViewport.addEventListener('resize', adjustImeBar);
|
||||||
|
window.visualViewport.addEventListener('scroll', adjustImeBar);
|
||||||
|
}
|
||||||
|
// iOS / 部分浏览器键盘弹出时触发的是 window resize,作为上浮兜底
|
||||||
|
window.addEventListener('resize', adjustImeBar);
|
||||||
} else {
|
} else {
|
||||||
// Desktop: hide keyboard buttons (physical keyboard available)
|
// Desktop: hide keyboard buttons (physical keyboard available)
|
||||||
const btnKeyboard = document.getElementById('btn-keyboard');
|
const btnKeyboard = document.getElementById('btn-keyboard');
|
||||||
|
|||||||
Reference in new issue
Block a user