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:
AZ Computer Guru
2025-12-29 17:51:22 -07:00
parent e3fbba4d6b
commit a8ffa4bd83
9 changed files with 2254 additions and 684 deletions

1911
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@ resolver = "2"
members = [
"agent",
"server",
"viewer",
]
[workspace.package]

66
viewer/Cargo.toml Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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(())
}