Add native viewer with low-level keyboard hooks
- New viewer crate for Windows native remote desktop viewing - Implements WH_KEYBOARD_LL hook for Win key, Alt+Tab capture - WebSocket client for server communication - softbuffer rendering for frame display - Zstd decompression for compressed frames - Mouse and keyboard input forwarding 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
1911
Cargo.lock
generated
1911
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ resolver = "2"
|
||||
members = [
|
||||
"agent",
|
||||
"server",
|
||||
"viewer",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
|
||||
66
viewer/Cargo.toml
Normal file
66
viewer/Cargo.toml
Normal file
@@ -0,0 +1,66 @@
|
||||
[package]
|
||||
name = "guruconnect-viewer"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
authors = ["AZ Computer Guru"]
|
||||
description = "GuruConnect Native Remote Desktop Viewer"
|
||||
|
||||
[dependencies]
|
||||
# Async runtime
|
||||
tokio = { version = "1", features = ["full", "sync", "time", "rt-multi-thread", "macros"] }
|
||||
|
||||
# WebSocket
|
||||
tokio-tungstenite = { version = "0.24", features = ["native-tls"] }
|
||||
futures-util = "0.3"
|
||||
url = "2"
|
||||
|
||||
# Windowing
|
||||
winit = { version = "0.30", features = ["rwh_06"] }
|
||||
softbuffer = "0.4"
|
||||
raw-window-handle = "0.6"
|
||||
|
||||
# Compression
|
||||
zstd = "0.13"
|
||||
|
||||
# Protocol (protobuf)
|
||||
prost = "0.13"
|
||||
prost-types = "0.13"
|
||||
bytes = "1"
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
# Logging
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
# Error handling
|
||||
anyhow = "1"
|
||||
thiserror = "1"
|
||||
|
||||
# UUID
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
|
||||
# CLI args
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
# Windows APIs for low-level keyboard hooks
|
||||
windows = { version = "0.58", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
"Win32_UI_Input_KeyboardAndMouse",
|
||||
"Win32_System_LibraryLoader",
|
||||
"Win32_System_Threading",
|
||||
]}
|
||||
|
||||
[build-dependencies]
|
||||
prost-build = "0.13"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
opt-level = "z"
|
||||
strip = true
|
||||
panic = "abort"
|
||||
9
viewer/build.rs
Normal file
9
viewer/build.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
use std::io::Result;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
println!("cargo:rerun-if-changed=../proto/guruconnect.proto");
|
||||
|
||||
prost_build::compile_protos(&["../proto/guruconnect.proto"], &["../proto/"])?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
173
viewer/src/input.rs
Normal file
173
viewer/src/input.rs
Normal file
@@ -0,0 +1,173 @@
|
||||
//! Low-level keyboard hook for capturing all keys including Win key
|
||||
|
||||
use crate::InputEvent;
|
||||
#[cfg(windows)]
|
||||
use crate::proto;
|
||||
use anyhow::Result;
|
||||
use tokio::sync::mpsc;
|
||||
#[cfg(windows)]
|
||||
use tracing::trace;
|
||||
|
||||
#[cfg(windows)]
|
||||
use windows::{
|
||||
Win32::Foundation::{LPARAM, LRESULT, WPARAM},
|
||||
Win32::UI::WindowsAndMessaging::{
|
||||
CallNextHookEx, DispatchMessageW, GetMessageW, PeekMessageW, SetWindowsHookExW,
|
||||
TranslateMessage, UnhookWindowsHookEx, HHOOK, KBDLLHOOKSTRUCT, MSG, PM_REMOVE,
|
||||
WH_KEYBOARD_LL, WM_KEYDOWN, WM_KEYUP, WM_SYSKEYDOWN, WM_SYSKEYUP,
|
||||
},
|
||||
};
|
||||
|
||||
#[cfg(windows)]
|
||||
use std::sync::OnceLock;
|
||||
|
||||
#[cfg(windows)]
|
||||
static INPUT_TX: OnceLock<mpsc::Sender<InputEvent>> = OnceLock::new();
|
||||
|
||||
#[cfg(windows)]
|
||||
static mut HOOK_HANDLE: HHOOK = HHOOK(std::ptr::null_mut());
|
||||
|
||||
/// Virtual key codes for special keys
|
||||
#[cfg(windows)]
|
||||
mod vk {
|
||||
pub const VK_LWIN: u32 = 0x5B;
|
||||
pub const VK_RWIN: u32 = 0x5C;
|
||||
pub const VK_APPS: u32 = 0x5D;
|
||||
pub const VK_LSHIFT: u32 = 0xA0;
|
||||
pub const VK_RSHIFT: u32 = 0xA1;
|
||||
pub const VK_LCONTROL: u32 = 0xA2;
|
||||
pub const VK_RCONTROL: u32 = 0xA3;
|
||||
pub const VK_LMENU: u32 = 0xA4; // Left Alt
|
||||
pub const VK_RMENU: u32 = 0xA5; // Right Alt
|
||||
pub const VK_TAB: u32 = 0x09;
|
||||
pub const VK_ESCAPE: u32 = 0x1B;
|
||||
pub const VK_SNAPSHOT: u32 = 0x2C; // Print Screen
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub struct KeyboardHook {
|
||||
_hook: HHOOK,
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
impl KeyboardHook {
|
||||
pub fn new(input_tx: mpsc::Sender<InputEvent>) -> Result<Self> {
|
||||
// Store the sender globally for the hook callback
|
||||
INPUT_TX.set(input_tx).map_err(|_| anyhow::anyhow!("Input TX already set"))?;
|
||||
|
||||
unsafe {
|
||||
let hook = SetWindowsHookExW(
|
||||
WH_KEYBOARD_LL,
|
||||
Some(keyboard_hook_proc),
|
||||
None,
|
||||
0,
|
||||
)?;
|
||||
|
||||
HOOK_HANDLE = hook;
|
||||
Ok(Self { _hook: hook })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
impl Drop for KeyboardHook {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
if !HOOK_HANDLE.0.is_null() {
|
||||
let _ = UnhookWindowsHookEx(HOOK_HANDLE);
|
||||
HOOK_HANDLE = HHOOK(std::ptr::null_mut());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
unsafe extern "system" fn keyboard_hook_proc(
|
||||
code: i32,
|
||||
wparam: WPARAM,
|
||||
lparam: LPARAM,
|
||||
) -> LRESULT {
|
||||
if code >= 0 {
|
||||
let kb_struct = &*(lparam.0 as *const KBDLLHOOKSTRUCT);
|
||||
let vk_code = kb_struct.vkCode;
|
||||
let scan_code = kb_struct.scanCode;
|
||||
|
||||
let is_down = wparam.0 as u32 == WM_KEYDOWN || wparam.0 as u32 == WM_SYSKEYDOWN;
|
||||
let is_up = wparam.0 as u32 == WM_KEYUP || wparam.0 as u32 == WM_SYSKEYUP;
|
||||
|
||||
if is_down || is_up {
|
||||
// Check if this is a key we want to intercept (Win key, Alt+Tab, etc.)
|
||||
let should_intercept = matches!(
|
||||
vk_code,
|
||||
vk::VK_LWIN | vk::VK_RWIN | vk::VK_APPS
|
||||
);
|
||||
|
||||
// Send the key event to the remote
|
||||
if let Some(tx) = INPUT_TX.get() {
|
||||
let event = proto::KeyEvent {
|
||||
down: is_down,
|
||||
key_type: proto::KeyEventType::KeyVk as i32,
|
||||
vk_code,
|
||||
scan_code,
|
||||
unicode: String::new(),
|
||||
modifiers: Some(get_current_modifiers()),
|
||||
};
|
||||
|
||||
let _ = tx.try_send(InputEvent::Key(event));
|
||||
trace!("Key hook: vk={:#x} scan={} down={}", vk_code, scan_code, is_down);
|
||||
}
|
||||
|
||||
// For Win key, consume the event so it doesn't open Start menu locally
|
||||
if should_intercept {
|
||||
return LRESULT(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CallNextHookEx(HOOK_HANDLE, code, wparam, lparam)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn get_current_modifiers() -> proto::Modifiers {
|
||||
use windows::Win32::UI::Input::KeyboardAndMouse::GetAsyncKeyState;
|
||||
|
||||
unsafe {
|
||||
proto::Modifiers {
|
||||
ctrl: GetAsyncKeyState(0x11) < 0, // VK_CONTROL
|
||||
alt: GetAsyncKeyState(0x12) < 0, // VK_MENU
|
||||
shift: GetAsyncKeyState(0x10) < 0, // VK_SHIFT
|
||||
meta: GetAsyncKeyState(0x5B) < 0 || GetAsyncKeyState(0x5C) < 0, // VK_LWIN/RWIN
|
||||
caps_lock: GetAsyncKeyState(0x14) & 1 != 0, // VK_CAPITAL
|
||||
num_lock: GetAsyncKeyState(0x90) & 1 != 0, // VK_NUMLOCK
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pump Windows message queue (required for hooks to work)
|
||||
#[cfg(windows)]
|
||||
pub fn pump_messages() {
|
||||
unsafe {
|
||||
let mut msg = MSG::default();
|
||||
while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() {
|
||||
let _ = TranslateMessage(&msg);
|
||||
DispatchMessageW(&msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Non-Windows stubs
|
||||
#[cfg(not(windows))]
|
||||
#[allow(dead_code)]
|
||||
pub struct KeyboardHook;
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[allow(dead_code)]
|
||||
impl KeyboardHook {
|
||||
pub fn new(_input_tx: mpsc::Sender<InputEvent>) -> Result<Self> {
|
||||
Ok(Self)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[allow(dead_code)]
|
||||
pub fn pump_messages() {}
|
||||
167
viewer/src/main.rs
Normal file
167
viewer/src/main.rs
Normal file
@@ -0,0 +1,167 @@
|
||||
//! GuruConnect Native Viewer
|
||||
//!
|
||||
//! Native remote desktop viewer with full keyboard capture including Win key.
|
||||
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod proto;
|
||||
mod transport;
|
||||
mod render;
|
||||
mod input;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
use tracing::{info, error, warn, Level};
|
||||
use tracing_subscriber::FmtSubscriber;
|
||||
|
||||
/// GuruConnect Native Viewer
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "guruconnect-viewer")]
|
||||
#[command(about = "Native remote desktop viewer with full keyboard capture")]
|
||||
struct Args {
|
||||
/// Server URL (e.g., wss://connect.azcomputerguru.com/ws/viewer)
|
||||
#[arg(short, long, default_value = "wss://connect.azcomputerguru.com/ws/viewer")]
|
||||
server: String,
|
||||
|
||||
/// Session ID to connect to
|
||||
#[arg(short = 'i', long)]
|
||||
session_id: String,
|
||||
|
||||
/// API key for authentication
|
||||
#[arg(short, long, default_value = "dev-key")]
|
||||
api_key: String,
|
||||
|
||||
/// Enable verbose logging
|
||||
#[arg(short, long)]
|
||||
verbose: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ViewerEvent {
|
||||
Connected,
|
||||
Disconnected(String),
|
||||
Frame(render::FrameData),
|
||||
CursorPosition(i32, i32, bool),
|
||||
CursorShape(proto::CursorShape),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum InputEvent {
|
||||
Mouse(proto::MouseEvent),
|
||||
Key(proto::KeyEvent),
|
||||
SpecialKey(proto::SpecialKeyEvent),
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args = Args::parse();
|
||||
|
||||
// Initialize logging
|
||||
let level = if args.verbose { Level::DEBUG } else { Level::INFO };
|
||||
FmtSubscriber::builder()
|
||||
.with_max_level(level)
|
||||
.with_target(false)
|
||||
.init();
|
||||
|
||||
info!("GuruConnect Viewer starting");
|
||||
info!("Server: {}", args.server);
|
||||
info!("Session: {}", args.session_id);
|
||||
|
||||
// Create channels for communication between components
|
||||
let (viewer_tx, viewer_rx) = mpsc::channel::<ViewerEvent>(100);
|
||||
let (input_tx, input_rx) = mpsc::channel::<InputEvent>(100);
|
||||
|
||||
// Run the viewer
|
||||
let rt = tokio::runtime::Runtime::new()?;
|
||||
rt.block_on(async {
|
||||
run_viewer(args, viewer_tx, viewer_rx, input_tx, input_rx).await
|
||||
})
|
||||
}
|
||||
|
||||
async fn run_viewer(
|
||||
args: Args,
|
||||
viewer_tx: mpsc::Sender<ViewerEvent>,
|
||||
viewer_rx: mpsc::Receiver<ViewerEvent>,
|
||||
input_tx: mpsc::Sender<InputEvent>,
|
||||
mut input_rx: mpsc::Receiver<InputEvent>,
|
||||
) -> Result<()> {
|
||||
// Connect to server
|
||||
let ws_url = format!("{}?session_id={}", args.server, args.session_id);
|
||||
info!("Connecting to {}", ws_url);
|
||||
|
||||
let (ws_sender, mut ws_receiver) = transport::connect(&ws_url, &args.api_key).await?;
|
||||
let ws_sender = Arc::new(Mutex::new(ws_sender));
|
||||
|
||||
info!("Connected to server");
|
||||
let _ = viewer_tx.send(ViewerEvent::Connected).await;
|
||||
|
||||
// Clone sender for input forwarding
|
||||
let ws_sender_input = ws_sender.clone();
|
||||
|
||||
// Spawn task to forward input events to server
|
||||
let input_task = tokio::spawn(async move {
|
||||
while let Some(event) = input_rx.recv().await {
|
||||
let msg = match event {
|
||||
InputEvent::Mouse(m) => proto::Message {
|
||||
payload: Some(proto::message::Payload::MouseEvent(m)),
|
||||
},
|
||||
InputEvent::Key(k) => proto::Message {
|
||||
payload: Some(proto::message::Payload::KeyEvent(k)),
|
||||
},
|
||||
InputEvent::SpecialKey(s) => proto::Message {
|
||||
payload: Some(proto::message::Payload::SpecialKey(s)),
|
||||
},
|
||||
};
|
||||
|
||||
if let Err(e) = transport::send_message(&ws_sender_input, &msg).await {
|
||||
error!("Failed to send input: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Spawn task to receive messages from server
|
||||
let viewer_tx_recv = viewer_tx.clone();
|
||||
let receive_task = tokio::spawn(async move {
|
||||
while let Some(msg) = ws_receiver.recv().await {
|
||||
match msg.payload {
|
||||
Some(proto::message::Payload::VideoFrame(frame)) => {
|
||||
if let Some(proto::video_frame::Encoding::Raw(raw)) = frame.encoding {
|
||||
let frame_data = render::FrameData {
|
||||
width: raw.width as u32,
|
||||
height: raw.height as u32,
|
||||
data: raw.data,
|
||||
compressed: raw.compressed,
|
||||
is_keyframe: raw.is_keyframe,
|
||||
};
|
||||
let _ = viewer_tx_recv.send(ViewerEvent::Frame(frame_data)).await;
|
||||
}
|
||||
}
|
||||
Some(proto::message::Payload::CursorPosition(pos)) => {
|
||||
let _ = viewer_tx_recv.send(ViewerEvent::CursorPosition(
|
||||
pos.x, pos.y, pos.visible
|
||||
)).await;
|
||||
}
|
||||
Some(proto::message::Payload::CursorShape(shape)) => {
|
||||
let _ = viewer_tx_recv.send(ViewerEvent::CursorShape(shape)).await;
|
||||
}
|
||||
Some(proto::message::Payload::Disconnect(d)) => {
|
||||
warn!("Server disconnected: {}", d.reason);
|
||||
let _ = viewer_tx_recv.send(ViewerEvent::Disconnected(d.reason)).await;
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Run the window (this blocks until window closes)
|
||||
render::run_window(viewer_rx, input_tx).await?;
|
||||
|
||||
// Cleanup
|
||||
input_task.abort();
|
||||
receive_task.abort();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
4
viewer/src/proto.rs
Normal file
4
viewer/src/proto.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
//! Protocol buffer definitions
|
||||
|
||||
// Include generated protobuf code
|
||||
include!(concat!(env!("OUT_DIR"), "/guruconnect.rs"));
|
||||
507
viewer/src/render.rs
Normal file
507
viewer/src/render.rs
Normal file
@@ -0,0 +1,507 @@
|
||||
//! Window rendering and frame display
|
||||
|
||||
use crate::{ViewerEvent, InputEvent, proto};
|
||||
#[cfg(windows)]
|
||||
use crate::input;
|
||||
use anyhow::Result;
|
||||
use std::num::NonZeroU32;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, error, info, warn};
|
||||
use winit::{
|
||||
application::ApplicationHandler,
|
||||
dpi::LogicalSize,
|
||||
event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent},
|
||||
event_loop::{ActiveEventLoop, ControlFlow, EventLoop},
|
||||
keyboard::{KeyCode, PhysicalKey},
|
||||
window::{Window, WindowId},
|
||||
};
|
||||
|
||||
/// Frame data received from server
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FrameData {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub data: Vec<u8>,
|
||||
pub compressed: bool,
|
||||
pub is_keyframe: bool,
|
||||
}
|
||||
|
||||
struct ViewerApp {
|
||||
window: Option<Arc<Window>>,
|
||||
surface: Option<softbuffer::Surface<Arc<Window>, Arc<Window>>>,
|
||||
frame_buffer: Vec<u32>,
|
||||
frame_width: u32,
|
||||
frame_height: u32,
|
||||
viewer_rx: mpsc::Receiver<ViewerEvent>,
|
||||
input_tx: mpsc::Sender<InputEvent>,
|
||||
mouse_x: i32,
|
||||
mouse_y: i32,
|
||||
#[cfg(windows)]
|
||||
keyboard_hook: Option<input::KeyboardHook>,
|
||||
}
|
||||
|
||||
impl ViewerApp {
|
||||
fn new(
|
||||
viewer_rx: mpsc::Receiver<ViewerEvent>,
|
||||
input_tx: mpsc::Sender<InputEvent>,
|
||||
) -> Self {
|
||||
Self {
|
||||
window: None,
|
||||
surface: None,
|
||||
frame_buffer: Vec::new(),
|
||||
frame_width: 0,
|
||||
frame_height: 0,
|
||||
viewer_rx,
|
||||
input_tx,
|
||||
mouse_x: 0,
|
||||
mouse_y: 0,
|
||||
#[cfg(windows)]
|
||||
keyboard_hook: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn process_frame(&mut self, frame: FrameData) {
|
||||
let data = if frame.compressed {
|
||||
// Decompress zstd
|
||||
match zstd::decode_all(frame.data.as_slice()) {
|
||||
Ok(decompressed) => decompressed,
|
||||
Err(e) => {
|
||||
error!("Failed to decompress frame: {}", e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
frame.data
|
||||
};
|
||||
|
||||
// Convert BGRA to ARGB (softbuffer expects 0RGB format on little-endian)
|
||||
let pixel_count = (frame.width * frame.height) as usize;
|
||||
if data.len() < pixel_count * 4 {
|
||||
error!("Frame data too small: {} < {}", data.len(), pixel_count * 4);
|
||||
return;
|
||||
}
|
||||
|
||||
// Resize frame buffer if needed
|
||||
if self.frame_width != frame.width || self.frame_height != frame.height {
|
||||
self.frame_width = frame.width;
|
||||
self.frame_height = frame.height;
|
||||
self.frame_buffer.resize(pixel_count, 0);
|
||||
|
||||
// Resize window to match frame
|
||||
if let Some(window) = &self.window {
|
||||
let _ = window.request_inner_size(LogicalSize::new(frame.width, frame.height));
|
||||
}
|
||||
}
|
||||
|
||||
// Convert BGRA to 0RGB (ignore alpha, swap B and R)
|
||||
for i in 0..pixel_count {
|
||||
let offset = i * 4;
|
||||
let b = data[offset] as u32;
|
||||
let g = data[offset + 1] as u32;
|
||||
let r = data[offset + 2] as u32;
|
||||
// 0RGB format: 0x00RRGGBB
|
||||
self.frame_buffer[i] = (r << 16) | (g << 8) | b;
|
||||
}
|
||||
|
||||
// Request redraw
|
||||
if let Some(window) = &self.window {
|
||||
window.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&mut self) {
|
||||
let Some(surface) = &mut self.surface else { return };
|
||||
let Some(window) = &self.window else { return };
|
||||
|
||||
if self.frame_buffer.is_empty() || self.frame_width == 0 || self.frame_height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let size = window.inner_size();
|
||||
if size.width == 0 || size.height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Resize surface if needed
|
||||
let width = NonZeroU32::new(size.width).unwrap();
|
||||
let height = NonZeroU32::new(size.height).unwrap();
|
||||
|
||||
if let Err(e) = surface.resize(width, height) {
|
||||
error!("Failed to resize surface: {}", e);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut buffer = match surface.buffer_mut() {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
error!("Failed to get surface buffer: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Simple nearest-neighbor scaling
|
||||
let scale_x = self.frame_width as f32 / size.width as f32;
|
||||
let scale_y = self.frame_height as f32 / size.height as f32;
|
||||
|
||||
for y in 0..size.height {
|
||||
for x in 0..size.width {
|
||||
let src_x = ((x as f32 * scale_x) as u32).min(self.frame_width - 1);
|
||||
let src_y = ((y as f32 * scale_y) as u32).min(self.frame_height - 1);
|
||||
let src_idx = (src_y * self.frame_width + src_x) as usize;
|
||||
let dst_idx = (y * size.width + x) as usize;
|
||||
|
||||
if src_idx < self.frame_buffer.len() && dst_idx < buffer.len() {
|
||||
buffer[dst_idx] = self.frame_buffer[src_idx];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = buffer.present() {
|
||||
error!("Failed to present buffer: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
fn send_mouse_event(&self, event_type: proto::MouseEventType, x: i32, y: i32) {
|
||||
let event = proto::MouseEvent {
|
||||
x,
|
||||
y,
|
||||
buttons: Some(proto::MouseButtons::default()),
|
||||
wheel_delta_x: 0,
|
||||
wheel_delta_y: 0,
|
||||
event_type: event_type as i32,
|
||||
};
|
||||
|
||||
let _ = self.input_tx.try_send(InputEvent::Mouse(event));
|
||||
}
|
||||
|
||||
fn send_mouse_button(&self, button: MouseButton, state: ElementState) {
|
||||
let event_type = match state {
|
||||
ElementState::Pressed => proto::MouseEventType::MouseDown,
|
||||
ElementState::Released => proto::MouseEventType::MouseUp,
|
||||
};
|
||||
|
||||
let mut buttons = proto::MouseButtons::default();
|
||||
match button {
|
||||
MouseButton::Left => buttons.left = true,
|
||||
MouseButton::Right => buttons.right = true,
|
||||
MouseButton::Middle => buttons.middle = true,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let event = proto::MouseEvent {
|
||||
x: self.mouse_x,
|
||||
y: self.mouse_y,
|
||||
buttons: Some(buttons),
|
||||
wheel_delta_x: 0,
|
||||
wheel_delta_y: 0,
|
||||
event_type: event_type as i32,
|
||||
};
|
||||
|
||||
let _ = self.input_tx.try_send(InputEvent::Mouse(event));
|
||||
}
|
||||
|
||||
fn send_mouse_wheel(&self, delta_x: i32, delta_y: i32) {
|
||||
let event = proto::MouseEvent {
|
||||
x: self.mouse_x,
|
||||
y: self.mouse_y,
|
||||
buttons: Some(proto::MouseButtons::default()),
|
||||
wheel_delta_x: delta_x,
|
||||
wheel_delta_y: delta_y,
|
||||
event_type: proto::MouseEventType::MouseWheel as i32,
|
||||
};
|
||||
|
||||
let _ = self.input_tx.try_send(InputEvent::Mouse(event));
|
||||
}
|
||||
|
||||
fn send_key_event(&self, key: PhysicalKey, state: ElementState) {
|
||||
let vk_code = match key {
|
||||
PhysicalKey::Code(code) => keycode_to_vk(code),
|
||||
_ => return,
|
||||
};
|
||||
|
||||
let event = proto::KeyEvent {
|
||||
down: state == ElementState::Pressed,
|
||||
key_type: proto::KeyEventType::KeyVk as i32,
|
||||
vk_code,
|
||||
scan_code: 0,
|
||||
unicode: String::new(),
|
||||
modifiers: Some(proto::Modifiers::default()),
|
||||
};
|
||||
|
||||
let _ = self.input_tx.try_send(InputEvent::Key(event));
|
||||
}
|
||||
|
||||
fn screen_to_frame_coords(&self, x: f64, y: f64) -> (i32, i32) {
|
||||
let Some(window) = &self.window else {
|
||||
return (x as i32, y as i32);
|
||||
};
|
||||
|
||||
let size = window.inner_size();
|
||||
if size.width == 0 || size.height == 0 || self.frame_width == 0 || self.frame_height == 0 {
|
||||
return (x as i32, y as i32);
|
||||
}
|
||||
|
||||
// Scale from window coordinates to frame coordinates
|
||||
let scale_x = self.frame_width as f64 / size.width as f64;
|
||||
let scale_y = self.frame_height as f64 / size.height as f64;
|
||||
|
||||
let frame_x = (x * scale_x) as i32;
|
||||
let frame_y = (y * scale_y) as i32;
|
||||
|
||||
(frame_x, frame_y)
|
||||
}
|
||||
}
|
||||
|
||||
impl ApplicationHandler for ViewerApp {
|
||||
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
||||
if self.window.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let window_attrs = Window::default_attributes()
|
||||
.with_title("GuruConnect Viewer")
|
||||
.with_inner_size(LogicalSize::new(1280, 720));
|
||||
|
||||
let window = Arc::new(event_loop.create_window(window_attrs).unwrap());
|
||||
|
||||
// Create software rendering surface
|
||||
let context = softbuffer::Context::new(window.clone()).unwrap();
|
||||
let surface = softbuffer::Surface::new(&context, window.clone()).unwrap();
|
||||
|
||||
self.window = Some(window.clone());
|
||||
self.surface = Some(surface);
|
||||
|
||||
// Install keyboard hook
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let input_tx = self.input_tx.clone();
|
||||
match input::KeyboardHook::new(input_tx) {
|
||||
Ok(hook) => {
|
||||
info!("Keyboard hook installed");
|
||||
self.keyboard_hook = Some(hook);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to install keyboard hook: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("Window created");
|
||||
}
|
||||
|
||||
fn window_event(&mut self, event_loop: &ActiveEventLoop, _: WindowId, event: WindowEvent) {
|
||||
// Check for incoming viewer events (non-blocking)
|
||||
while let Ok(viewer_event) = self.viewer_rx.try_recv() {
|
||||
match viewer_event {
|
||||
ViewerEvent::Frame(frame) => {
|
||||
self.process_frame(frame);
|
||||
}
|
||||
ViewerEvent::Connected => {
|
||||
info!("Connected to remote session");
|
||||
}
|
||||
ViewerEvent::Disconnected(reason) => {
|
||||
warn!("Disconnected: {}", reason);
|
||||
event_loop.exit();
|
||||
}
|
||||
ViewerEvent::CursorPosition(_x, _y, _visible) => {
|
||||
// Could update cursor display here
|
||||
}
|
||||
ViewerEvent::CursorShape(_shape) => {
|
||||
// Could update cursor shape here
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match event {
|
||||
WindowEvent::CloseRequested => {
|
||||
info!("Window close requested");
|
||||
event_loop.exit();
|
||||
}
|
||||
WindowEvent::RedrawRequested => {
|
||||
self.render();
|
||||
}
|
||||
WindowEvent::Resized(size) => {
|
||||
debug!("Window resized to {}x{}", size.width, size.height);
|
||||
if let Some(window) = &self.window {
|
||||
window.request_redraw();
|
||||
}
|
||||
}
|
||||
WindowEvent::CursorMoved { position, .. } => {
|
||||
let (x, y) = self.screen_to_frame_coords(position.x, position.y);
|
||||
self.mouse_x = x;
|
||||
self.mouse_y = y;
|
||||
self.send_mouse_event(proto::MouseEventType::MouseMove, x, y);
|
||||
}
|
||||
WindowEvent::MouseInput { state, button, .. } => {
|
||||
self.send_mouse_button(button, state);
|
||||
}
|
||||
WindowEvent::MouseWheel { delta, .. } => {
|
||||
let (dx, dy) = match delta {
|
||||
MouseScrollDelta::LineDelta(x, y) => (x as i32 * 120, y as i32 * 120),
|
||||
MouseScrollDelta::PixelDelta(pos) => (pos.x as i32, pos.y as i32),
|
||||
};
|
||||
self.send_mouse_wheel(dx, dy);
|
||||
}
|
||||
WindowEvent::KeyboardInput { event, .. } => {
|
||||
// Note: This handles keys that aren't captured by the low-level hook
|
||||
// The hook handles Win key and other special keys
|
||||
if !event.repeat {
|
||||
self.send_key_event(event.physical_key, event.state);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
|
||||
// Keep checking for events
|
||||
event_loop.set_control_flow(ControlFlow::Poll);
|
||||
|
||||
// Process Windows messages for keyboard hook
|
||||
#[cfg(windows)]
|
||||
input::pump_messages();
|
||||
|
||||
// Request redraw periodically to check for new frames
|
||||
if let Some(window) = &self.window {
|
||||
window.request_redraw();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the viewer window
|
||||
pub async fn run_window(
|
||||
viewer_rx: mpsc::Receiver<ViewerEvent>,
|
||||
input_tx: mpsc::Sender<InputEvent>,
|
||||
) -> Result<()> {
|
||||
let event_loop = EventLoop::new()?;
|
||||
let mut app = ViewerApp::new(viewer_rx, input_tx);
|
||||
|
||||
event_loop.run_app(&mut app)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Convert winit KeyCode to Windows virtual key code
|
||||
fn keycode_to_vk(code: KeyCode) -> u32 {
|
||||
match code {
|
||||
// Letters
|
||||
KeyCode::KeyA => 0x41,
|
||||
KeyCode::KeyB => 0x42,
|
||||
KeyCode::KeyC => 0x43,
|
||||
KeyCode::KeyD => 0x44,
|
||||
KeyCode::KeyE => 0x45,
|
||||
KeyCode::KeyF => 0x46,
|
||||
KeyCode::KeyG => 0x47,
|
||||
KeyCode::KeyH => 0x48,
|
||||
KeyCode::KeyI => 0x49,
|
||||
KeyCode::KeyJ => 0x4A,
|
||||
KeyCode::KeyK => 0x4B,
|
||||
KeyCode::KeyL => 0x4C,
|
||||
KeyCode::KeyM => 0x4D,
|
||||
KeyCode::KeyN => 0x4E,
|
||||
KeyCode::KeyO => 0x4F,
|
||||
KeyCode::KeyP => 0x50,
|
||||
KeyCode::KeyQ => 0x51,
|
||||
KeyCode::KeyR => 0x52,
|
||||
KeyCode::KeyS => 0x53,
|
||||
KeyCode::KeyT => 0x54,
|
||||
KeyCode::KeyU => 0x55,
|
||||
KeyCode::KeyV => 0x56,
|
||||
KeyCode::KeyW => 0x57,
|
||||
KeyCode::KeyX => 0x58,
|
||||
KeyCode::KeyY => 0x59,
|
||||
KeyCode::KeyZ => 0x5A,
|
||||
|
||||
// Numbers
|
||||
KeyCode::Digit0 => 0x30,
|
||||
KeyCode::Digit1 => 0x31,
|
||||
KeyCode::Digit2 => 0x32,
|
||||
KeyCode::Digit3 => 0x33,
|
||||
KeyCode::Digit4 => 0x34,
|
||||
KeyCode::Digit5 => 0x35,
|
||||
KeyCode::Digit6 => 0x36,
|
||||
KeyCode::Digit7 => 0x37,
|
||||
KeyCode::Digit8 => 0x38,
|
||||
KeyCode::Digit9 => 0x39,
|
||||
|
||||
// Function keys
|
||||
KeyCode::F1 => 0x70,
|
||||
KeyCode::F2 => 0x71,
|
||||
KeyCode::F3 => 0x72,
|
||||
KeyCode::F4 => 0x73,
|
||||
KeyCode::F5 => 0x74,
|
||||
KeyCode::F6 => 0x75,
|
||||
KeyCode::F7 => 0x76,
|
||||
KeyCode::F8 => 0x77,
|
||||
KeyCode::F9 => 0x78,
|
||||
KeyCode::F10 => 0x79,
|
||||
KeyCode::F11 => 0x7A,
|
||||
KeyCode::F12 => 0x7B,
|
||||
|
||||
// Special keys
|
||||
KeyCode::Escape => 0x1B,
|
||||
KeyCode::Tab => 0x09,
|
||||
KeyCode::CapsLock => 0x14,
|
||||
KeyCode::ShiftLeft => 0x10,
|
||||
KeyCode::ShiftRight => 0x10,
|
||||
KeyCode::ControlLeft => 0x11,
|
||||
KeyCode::ControlRight => 0x11,
|
||||
KeyCode::AltLeft => 0x12,
|
||||
KeyCode::AltRight => 0x12,
|
||||
KeyCode::Space => 0x20,
|
||||
KeyCode::Enter => 0x0D,
|
||||
KeyCode::Backspace => 0x08,
|
||||
KeyCode::Delete => 0x2E,
|
||||
KeyCode::Insert => 0x2D,
|
||||
KeyCode::Home => 0x24,
|
||||
KeyCode::End => 0x23,
|
||||
KeyCode::PageUp => 0x21,
|
||||
KeyCode::PageDown => 0x22,
|
||||
|
||||
// Arrow keys
|
||||
KeyCode::ArrowUp => 0x26,
|
||||
KeyCode::ArrowDown => 0x28,
|
||||
KeyCode::ArrowLeft => 0x25,
|
||||
KeyCode::ArrowRight => 0x27,
|
||||
|
||||
// Numpad
|
||||
KeyCode::NumLock => 0x90,
|
||||
KeyCode::Numpad0 => 0x60,
|
||||
KeyCode::Numpad1 => 0x61,
|
||||
KeyCode::Numpad2 => 0x62,
|
||||
KeyCode::Numpad3 => 0x63,
|
||||
KeyCode::Numpad4 => 0x64,
|
||||
KeyCode::Numpad5 => 0x65,
|
||||
KeyCode::Numpad6 => 0x66,
|
||||
KeyCode::Numpad7 => 0x67,
|
||||
KeyCode::Numpad8 => 0x68,
|
||||
KeyCode::Numpad9 => 0x69,
|
||||
KeyCode::NumpadAdd => 0x6B,
|
||||
KeyCode::NumpadSubtract => 0x6D,
|
||||
KeyCode::NumpadMultiply => 0x6A,
|
||||
KeyCode::NumpadDivide => 0x6F,
|
||||
KeyCode::NumpadDecimal => 0x6E,
|
||||
KeyCode::NumpadEnter => 0x0D,
|
||||
|
||||
// Punctuation
|
||||
KeyCode::Semicolon => 0xBA,
|
||||
KeyCode::Equal => 0xBB,
|
||||
KeyCode::Comma => 0xBC,
|
||||
KeyCode::Minus => 0xBD,
|
||||
KeyCode::Period => 0xBE,
|
||||
KeyCode::Slash => 0xBF,
|
||||
KeyCode::Backquote => 0xC0,
|
||||
KeyCode::BracketLeft => 0xDB,
|
||||
KeyCode::Backslash => 0xDC,
|
||||
KeyCode::BracketRight => 0xDD,
|
||||
KeyCode::Quote => 0xDE,
|
||||
|
||||
// Other
|
||||
KeyCode::PrintScreen => 0x2C,
|
||||
KeyCode::ScrollLock => 0x91,
|
||||
KeyCode::Pause => 0x13,
|
||||
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
100
viewer/src/transport.rs
Normal file
100
viewer/src/transport.rs
Normal file
@@ -0,0 +1,100 @@
|
||||
//! WebSocket transport for viewer-server communication
|
||||
|
||||
use crate::proto;
|
||||
use anyhow::{anyhow, Result};
|
||||
use bytes::Bytes;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use prost::Message as ProstMessage;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio_tungstenite::{
|
||||
connect_async,
|
||||
tungstenite::protocol::Message as WsMessage,
|
||||
MaybeTlsStream, WebSocketStream,
|
||||
};
|
||||
use tokio::net::TcpStream;
|
||||
use tracing::{debug, error, trace};
|
||||
|
||||
pub type WsSender = futures_util::stream::SplitSink<
|
||||
WebSocketStream<MaybeTlsStream<TcpStream>>,
|
||||
WsMessage,
|
||||
>;
|
||||
|
||||
pub type WsReceiver = futures_util::stream::SplitStream<
|
||||
WebSocketStream<MaybeTlsStream<TcpStream>>,
|
||||
>;
|
||||
|
||||
/// Receiver wrapper that parses protobuf messages
|
||||
pub struct MessageReceiver {
|
||||
inner: WsReceiver,
|
||||
}
|
||||
|
||||
impl MessageReceiver {
|
||||
pub async fn recv(&mut self) -> Option<proto::Message> {
|
||||
loop {
|
||||
match self.inner.next().await {
|
||||
Some(Ok(WsMessage::Binary(data))) => {
|
||||
match proto::Message::decode(Bytes::from(data)) {
|
||||
Ok(msg) => return Some(msg),
|
||||
Err(e) => {
|
||||
error!("Failed to decode message: {}", e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Ok(WsMessage::Close(_))) => {
|
||||
debug!("WebSocket closed");
|
||||
return None;
|
||||
}
|
||||
Some(Ok(WsMessage::Ping(_))) => {
|
||||
trace!("Received ping");
|
||||
continue;
|
||||
}
|
||||
Some(Ok(WsMessage::Pong(_))) => {
|
||||
trace!("Received pong");
|
||||
continue;
|
||||
}
|
||||
Some(Ok(_)) => continue,
|
||||
Some(Err(e)) => {
|
||||
error!("WebSocket error: {}", e);
|
||||
return None;
|
||||
}
|
||||
None => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect to the GuruConnect server
|
||||
pub async fn connect(url: &str, api_key: &str) -> Result<(WsSender, MessageReceiver)> {
|
||||
// Add API key to URL
|
||||
let full_url = if url.contains('?') {
|
||||
format!("{}&api_key={}", url, api_key)
|
||||
} else {
|
||||
format!("{}?api_key={}", url, api_key)
|
||||
};
|
||||
|
||||
debug!("Connecting to {}", full_url);
|
||||
|
||||
let (ws_stream, _) = connect_async(&full_url)
|
||||
.await
|
||||
.map_err(|e| anyhow!("Failed to connect: {}", e))?;
|
||||
|
||||
let (sender, receiver) = ws_stream.split();
|
||||
|
||||
Ok((sender, MessageReceiver { inner: receiver }))
|
||||
}
|
||||
|
||||
/// Send a protobuf message over the WebSocket
|
||||
pub async fn send_message(
|
||||
sender: &Arc<Mutex<WsSender>>,
|
||||
msg: &proto::Message,
|
||||
) -> Result<()> {
|
||||
let mut buf = Vec::with_capacity(msg.encoded_len());
|
||||
msg.encode(&mut buf)?;
|
||||
|
||||
let mut sender = sender.lock().await;
|
||||
sender.send(WsMessage::Binary(buf)).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user