diff --git a/.cursor/commands/openspec-apply.md b/.cursor/commands/openspec-apply.md index 99a91480..f6b81e83 100644 --- a/.cursor/commands/openspec-apply.md +++ b/.cursor/commands/openspec-apply.md @@ -4,14 +4,18 @@ id: openspec-apply category: OpenSpec description: Implement an approved OpenSpec change and keep tasks in sync. --- + + **Guardrails** + - Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required. - Keep changes tightly scoped to the requested outcome. - Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications. **Steps** Track these steps as TODOs and complete them one by one. + 1. Read `changes//proposal.md`, `design.md` (if present), and `tasks.md` to confirm scope and acceptance criteria. 2. Work through tasks sequentially, keeping edits minimal and focused on the requested change. 3. Confirm completion before updating statuses—make sure every item in `tasks.md` is finished. @@ -19,5 +23,6 @@ Track these steps as TODOs and complete them one by one. 5. Reference `openspec list` or `openspec show ` when additional context is required. **Reference** + - Use `openspec show --json --deltas-only` if you need additional context from the proposal while implementing. diff --git a/.cursor/commands/openspec-archive.md b/.cursor/commands/openspec-archive.md index 013eed49..2d5cd6f2 100644 --- a/.cursor/commands/openspec-archive.md +++ b/.cursor/commands/openspec-archive.md @@ -4,13 +4,17 @@ id: openspec-archive category: OpenSpec description: Archive a deployed OpenSpec change and update specs. --- + + **Guardrails** + - Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required. - Keep changes tightly scoped to the requested outcome. - Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications. **Steps** + 1. Determine the change ID to archive: - If this prompt already includes a specific change ID (for example inside a `` block populated by slash-command arguments), use that value after trimming whitespace. - If the conversation references a change loosely (for example by title or summary), run `openspec list` to surface likely IDs, share the relevant candidates, and confirm which one the user intends. @@ -22,6 +26,7 @@ description: Archive a deployed OpenSpec change and update specs. 5. Validate with `openspec validate --strict --no-interactive` and inspect with `openspec show ` if anything looks off. **Reference** + - Use `openspec list` to confirm change IDs before archiving. - Inspect refreshed specs with `openspec list --specs` and address any validation issues before handing off. diff --git a/.cursor/commands/openspec-proposal.md b/.cursor/commands/openspec-proposal.md index 55e981a0..a9cdbd55 100644 --- a/.cursor/commands/openspec-proposal.md +++ b/.cursor/commands/openspec-proposal.md @@ -4,8 +4,11 @@ id: openspec-proposal category: OpenSpec description: Scaffold a new OpenSpec change and validate strictly. --- + + **Guardrails** + - Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required. - Keep changes tightly scoped to the requested outcome. - Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications. @@ -13,6 +16,7 @@ description: Scaffold a new OpenSpec change and validate strictly. - Do not write any code during the proposal stage. Only create design documents (proposal.md, tasks.md, design.md, and spec deltas). Implementation happens in the apply stage after approval. **Steps** + 1. Review `openspec/project.md`, run `openspec list` and `openspec list --specs`, and inspect related code or docs (e.g., via `rg`/`ls`) to ground the proposal in current behaviour; note any gaps that require clarification. 2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, and `design.md` (when needed) under `openspec/changes//`. 3. Map the change into concrete capabilities or requirements, breaking multi-scope efforts into distinct spec deltas with clear relationships and sequencing. @@ -22,6 +26,7 @@ description: Scaffold a new OpenSpec change and validate strictly. 7. Validate with `openspec validate --strict --no-interactive` and resolve every issue before sharing the proposal. **Reference** + - Use `openspec show --json --deltas-only` or `openspec show --type spec` to inspect details when validation fails. - Search existing requirements with `rg -n "Requirement:|Scenario:" openspec/specs` before writing new ones. - Explore the codebase with `rg `, `ls`, or direct file reads so proposals align with current implementation realities. diff --git a/.gitignore b/.gitignore index ca64364f..5da10fbd 100644 --- a/.gitignore +++ b/.gitignore @@ -28,4 +28,5 @@ dist-ssr .yarn/install-state.gz -openspec/changes \ No newline at end of file +openspec/changes +CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md index 4b3b2bc6..ef8be13d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,7 @@ All commands use [Taskfile](https://taskfile.dev). Run `task --list` in any dire Avoid running the following commands as an agent, as this is preferred to run from a user in their terminal and navigate in the Desktop app. **Backend (Go):** + ```bash cd backend task run # Run with hot reload (Air) @@ -26,6 +27,7 @@ task test # Run tests ``` **Core (Rust):** + ```bash cd core cargo build @@ -34,6 +36,7 @@ cargo fmt # Format code ``` **Tauri App:** + ```bash cd tauri task dev # Dev mode with hot reload @@ -41,6 +44,7 @@ task build # Production build ``` **Web App:** + ```bash cd web-app yarn dev @@ -69,16 +73,19 @@ Pre-commit hooks enforce all formatting automatically. - Cross-platform: macOS, Windows, Linux all supported + # OpenSpec Instructions These instructions are for AI assistants working in this project. Always open `@/openspec/AGENTS.md` when the request: + - Mentions planning or proposals (words like proposal, spec, change, plan) - Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work - Sounds ambiguous and you need the authoritative spec before coding Use `@/openspec/AGENTS.md` to learn: + - How to create and apply change proposals - Spec format and conventions - Project structure and guidelines diff --git a/core/AGENTS.md b/core/AGENTS.md index 540f0de9..97a0d839 100644 --- a/core/AGENTS.md +++ b/core/AGENTS.md @@ -1,26 +1,35 @@ # Core Agent Guide ## Scope + This directory contains the Rust screen capture and remote control engine (`hopp_core`) plus supporting crates (`socket_lib`, `sentry_utils`). ## Key Commands + - See `Taskfile.yml` in this directory for the full, up-to-date command list. +## Building +- Verify your changes by running `task build_dev` + ## Formatting & Linting + - `cargo fmt` is required (pre-commit formats staged Rust files). - CI runs `cargo fmt --all -- --check` and `cargo clippy -D warnings`. - Rust edition: 2021. ## Testing + - Do not run core tests as an agent. - For validation, only use build commands (see `Taskfile.yml`). - Full testing details live in `tests/README.md`. ## Conventions & Structure + - Platform-specific modules live in `src/**/{linux,macos,windows}.rs`. - Core subsystems: `capture/`, `graphics/`, `input/`, `room_service/`. - Logging uses `env_logger`; set `RUST_LOG=hopp_core=info` (use `debug` only when needed). ## Related Docs + - `README.md` for architecture and diagrams. - `tests/README.md` for test setup and commands (manual only). diff --git a/core/resources/pencil.png b/core/resources/pencil.png new file mode 100644 index 00000000..34b24cf5 Binary files /dev/null and b/core/resources/pencil.png differ diff --git a/core/socket_lib/src/lib.rs b/core/socket_lib/src/lib.rs index 188261b1..6741ee16 100644 --- a/core/socket_lib/src/lib.rs +++ b/core/socket_lib/src/lib.rs @@ -108,6 +108,11 @@ pub struct SentryMetadata { pub app_version: String, } +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct DrawingEnabled { + pub permanent: bool, +} + #[derive(Debug, Serialize, Deserialize)] pub enum Message { GetAvailableContent, @@ -120,6 +125,7 @@ pub enum Message { ControllerCursorEnabled(bool), LivekitServerUrl(String), SentryMetadata(SentryMetadata), + DrawingEnabled(DrawingEnabled), } #[derive(Debug)] diff --git a/core/src/graphics/draw.rs b/core/src/graphics/draw.rs index e5bc7fe9..90a0d4cc 100644 --- a/core/src/graphics/draw.rs +++ b/core/src/graphics/draw.rs @@ -1,10 +1,13 @@ use std::collections::HashMap; +use std::time::{Duration, Instant}; use iced::widget::canvas::{path, stroke, Cache, Frame, Geometry, Stroke}; use iced::{Color, Point, Rectangle, Renderer}; use crate::{room_service::DrawingMode, utils::geometry::Position}; +const PATH_EXPIRY_DURATION: Duration = Duration::from_secs(3); + fn color_from_hex(hex: &str) -> Color { let hex = hex.trim_start_matches('#'); @@ -27,6 +30,7 @@ fn color_from_hex(hex: &str) -> Color { struct DrawPath { path_id: u64, points: Vec, + finished_at: Option, } impl DrawPath { @@ -34,6 +38,7 @@ impl DrawPath { Self { path_id, points: vec![point], + finished_at: None, } } } @@ -44,6 +49,7 @@ pub struct Draw { completed_cache: Cache, mode: DrawingMode, color: Color, + auto_clear: bool, } impl std::fmt::Debug for Draw { @@ -58,13 +64,14 @@ impl std::fmt::Debug for Draw { } impl Draw { - pub fn new(color: &str) -> Self { + pub fn new(color: &str, auto_clear: bool) -> Self { Self { in_progress_path: None, completed_paths: Vec::new(), completed_cache: Cache::new(), mode: DrawingMode::Disabled, color: color_from_hex(color), + auto_clear, } } @@ -104,8 +111,9 @@ impl Draw { return; } - if let Some(in_progress_path) = self.in_progress_path.take() { + if let Some(mut in_progress_path) = self.in_progress_path.take() { log::info!("finish_path: finishing path {}", in_progress_path.path_id); + in_progress_path.finished_at = Some(Instant::now()); self.completed_paths.push(in_progress_path); self.completed_cache.clear(); } else { @@ -134,6 +142,42 @@ impl Draw { self.completed_cache.clear(); } + pub fn clear_expired_paths(&mut self) -> Vec { + if !self.auto_clear { + return Vec::new(); + } + + // Only clear in non-permanent mode + if let DrawingMode::Draw(settings) = &self.mode { + if settings.permanent { + return Vec::new(); + } + } else { + return Vec::new(); + } + + let now = Instant::now(); + let mut removed_ids = Vec::new(); + + self.completed_paths.retain(|path| { + if let Some(finished_at) = path.finished_at { + let should_keep = now.duration_since(finished_at) < PATH_EXPIRY_DURATION; + if !should_keep { + removed_ids.push(path.path_id); + } + should_keep + } else { + true + } + }); + + if !removed_ids.is_empty() { + self.completed_cache.clear(); + } + + removed_ids + } + /// Returns cached geometry for completed paths. pub fn draw_completed(&self, renderer: &Renderer, bounds: Rectangle) -> Geometry { self.completed_cache.draw(renderer, bounds.size(), |frame| { @@ -209,9 +253,14 @@ impl DrawManager { } /// Adds a new participant with their color. - pub fn add_participant(&mut self, sid: String, color: &str) { - log::info!("DrawManager::add_participant: sid={} color={}", sid, color); - self.draws.insert(sid, Draw::new(color)); + pub fn add_participant(&mut self, sid: String, color: &str, auto_clear: bool) { + log::info!( + "DrawManager::add_participant: sid={} color={} auto_clear={}", + sid, + color, + auto_clear + ); + self.draws.insert(sid, Draw::new(color, auto_clear)); } /// Removes a participant and their drawing data. @@ -299,6 +348,17 @@ impl DrawManager { } } + pub fn update_auto_clear(&mut self) -> Vec { + let mut removed_path_ids = Vec::new(); + + for draw in self.draws.values_mut() { + let removed = draw.clear_expired_paths(); + removed_path_ids.extend(removed); + } + + removed_path_ids + } + /// Renders all draws and returns the geometries. pub fn draw(&self, renderer: &Renderer, bounds: Rectangle) -> Vec { let mut geometries = Vec::with_capacity(self.draws.len() + 1); diff --git a/core/src/graphics/graphics_context.rs b/core/src/graphics/graphics_context.rs index 2211bebb..2a5b526b 100644 --- a/core/src/graphics/graphics_context.rs +++ b/core/src/graphics/graphics_context.rs @@ -444,8 +444,10 @@ impl<'a> GraphicsContext<'a> { /// # Arguments /// * `sid` - Session ID identifying the participant /// * `color` - Hex color string for the participant's drawings - pub fn add_draw_participant(&mut self, sid: String, color: &str) { - self.iced_renderer.add_draw_participant(sid, color); + /// * `auto_clear` - Whether to automatically clear paths after 3 seconds (for local participant) + pub fn add_draw_participant(&mut self, sid: String, color: &str, auto_clear: bool) { + self.iced_renderer + .add_draw_participant(sid, color, auto_clear); } /// Removes a participant from the draw manager. @@ -509,6 +511,14 @@ impl<'a> GraphicsContext<'a> { pub fn draw_clear_all_paths(&mut self, sid: &str) { self.iced_renderer.draw_clear_all_paths(sid); } + + /// Updates auto-clear for all participants and returns removed path IDs. + /// + /// # Returns + /// A vector of removed path IDs + pub fn update_auto_clear(&mut self) -> Vec { + self.iced_renderer.update_auto_clear() + } } impl Drop for GraphicsContext<'_> { diff --git a/core/src/graphics/iced_canvas.rs b/core/src/graphics/iced_canvas.rs index 56fc96a1..c7769489 100644 --- a/core/src/graphics/iced_canvas.rs +++ b/core/src/graphics/iced_canvas.rs @@ -67,8 +67,12 @@ impl OverlaySurface { .into() } - pub fn add_draw_participant(&mut self, sid: String, color: &str) { - self.draws.add_participant(sid, color); + pub fn update_auto_clear(&mut self) -> Vec { + self.draws.update_auto_clear() + } + + pub fn add_draw_participant(&mut self, sid: String, color: &str, auto_clear: bool) { + self.draws.add_participant(sid, color, auto_clear); } pub fn remove_draw_participant(&mut self, sid: &str) { diff --git a/core/src/graphics/iced_renderer.rs b/core/src/graphics/iced_renderer.rs index 361cc425..bb585eae 100644 --- a/core/src/graphics/iced_renderer.rs +++ b/core/src/graphics/iced_renderer.rs @@ -101,8 +101,9 @@ impl IcedRenderer { wgpu_renderer.present(None, frame.texture.format(), view, &self.viewport); } - pub fn add_draw_participant(&mut self, sid: String, color: &str) { - self.overlay_surface.add_draw_participant(sid, color); + pub fn add_draw_participant(&mut self, sid: String, color: &str, auto_clear: bool) { + self.overlay_surface + .add_draw_participant(sid, color, auto_clear); } pub fn remove_draw_participant(&mut self, sid: &str) { @@ -132,4 +133,8 @@ impl IcedRenderer { pub fn draw_clear_all_paths(&mut self, sid: &str) { self.overlay_surface.draw_clear_all_paths(sid); } + + pub fn update_auto_clear(&mut self) -> Vec { + self.overlay_surface.update_auto_clear() + } } diff --git a/core/src/input/mouse.rs b/core/src/input/mouse.rs index eca4f91f..2b8c7bd6 100644 --- a/core/src/input/mouse.rs +++ b/core/src/input/mouse.rs @@ -1331,6 +1331,10 @@ impl CursorController { log::error!("trigger_render: error sending redraw event: {e:?}"); } } + + pub fn is_controllers_enabled(&self) -> bool { + self.controllers_cursors_enabled + } } impl Drop for CursorController { diff --git a/core/src/input/mouse_macos.rs b/core/src/input/mouse_macos.rs index 28d070c7..d7ac5509 100644 --- a/core/src/input/mouse_macos.rs +++ b/core/src/input/mouse_macos.rs @@ -61,6 +61,7 @@ impl MouseObserver { CGEventType::LeftMouseDown, CGEventType::RightMouseDown, CGEventType::MouseMoved, + CGEventType::LeftMouseDragged, CGEventType::ScrollWheel, ], move |_a, _b, d| { @@ -78,7 +79,7 @@ impl MouseObserver { } match d.get_type() { - CGEventType::MouseMoved => { + CGEventType::MouseMoved | CGEventType::LeftMouseDragged => { log::debug!("Mouse moved event received"); let mut sharer_cursor = internal.lock().unwrap(); diff --git a/core/src/lib.rs b/core/src/lib.rs index 00932b61..9a4ca168 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -27,6 +27,7 @@ pub(crate) mod window_manager; use capture::capturer::{poll_stream, Capturer}; use graphics::graphics_context::GraphicsContext; +use image::GenericImageView; use input::clipboard::ClipboardController; use input::keyboard::{KeyboardController, KeyboardLayout}; use input::mouse::CursorController; @@ -107,6 +108,7 @@ struct RemoteControl<'a> { cursor_controller: CursorController, keyboard_controller: KeyboardController, clipboard_controller: Option, + pencil_cursor: winit::window::CustomCursor, } /// The main application struct that manages the entire remote desktop control session. @@ -164,6 +166,7 @@ pub struct Application<'a> { socket: CursorSocket, room_service: Option, event_loop_proxy: EventLoopProxy, + local_drawing: LocalDrawing, window_manager: Option, } @@ -173,6 +176,36 @@ pub enum ApplicationError { RoomServiceError(#[from] std::io::Error), } +#[derive(Debug)] +struct LocalDrawing { + enabled: bool, + permanent: bool, + left_mouse_pressed: bool, + current_path_id: u64, + last_cursor_position: Option, + last_redraw_time: std::time::Instant, + previous_controllers_enabled: bool, + cursor_set_times: u32, +} + +impl LocalDrawing { + fn reset(&mut self) { + self.enabled = false; + self.permanent = false; + self.left_mouse_pressed = false; + self.current_path_id = 0; + self.last_cursor_position = None; + self.last_redraw_time = std::time::Instant::now(); + self.previous_controllers_enabled = false; + } +} + +impl fmt::Display for LocalDrawing { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "LocalDrawing: enabled: {} permanent: {} left_mouse_pressed: {} current_path_id: {} last_cursor_position: {:?} last_redraw_time: {:?} previous_controllers_enabled: {}", self.enabled, self.permanent, self.left_mouse_pressed, self.current_path_id, self.last_cursor_position, self.last_redraw_time, self.previous_controllers_enabled) + } +} + impl<'a> Application<'a> { /// Creates a new Application instance with the specified configuration. /// @@ -212,6 +245,16 @@ impl<'a> Application<'a> { socket, room_service: None, event_loop_proxy, + local_drawing: LocalDrawing { + enabled: false, + permanent: false, + left_mouse_pressed: false, + current_path_id: 0, + last_cursor_position: None, + last_redraw_time: std::time::Instant::now(), + previous_controllers_enabled: false, + cursor_set_times: 0, + }, window_manager: None, }) } @@ -259,6 +302,7 @@ impl<'a> Application<'a> { /// - Begins streaming captured content via LiveKit fn screenshare( &mut self, + event_loop: &ActiveEventLoop, screenshare_input: ScreenShareMessage, monitors: Vec, ) -> Result<(), ServerError> { @@ -335,7 +379,11 @@ impl<'a> Application<'a> { let monitor = screen_capturer.get_selected_monitor(&monitors, screenshare_input.content.id); drop(screen_capturer); - let res = self.create_overlay_window(monitor, screenshare_input.accessibility_permission); + let res = self.create_overlay_window( + event_loop, + monitor, + screenshare_input.accessibility_permission, + ); if let Err(e) = res { self.stop_screenshare(); log::error!("screenshare: error creating overlay window: {e:?}"); @@ -366,6 +414,7 @@ impl<'a> Application<'a> { fn create_overlay_window( &mut self, + event_loop: &ActiveEventLoop, selected_monitor: MonitorHandle, accessibility_permission: bool, ) -> Result<(), ServerError> { @@ -405,6 +454,40 @@ impl<'a> Application<'a> { } }; + // Add local participant to draw manager with auto-clear enabled + graphics_context.add_draw_participant("local".to_string(), "#FFDF20", true); + + // Load pencil cursor image once during window creation + let pencil_path = format!("{}/pencil.png", self.textures_path); + let pencil_image = image::open(&pencil_path).map_err(|e| { + log::error!("create_overlay_window: Failed to load pencil.png: {e:?}"); + ServerError::GfxCreationError + })?; + let mut rgba = pencil_image.to_rgba8(); + for pixel in rgba.chunks_exact_mut(4) { + let a = pixel[3] as f32 / 255.0; + pixel[0] = (pixel[0] as f32 * a) as u8; + pixel[1] = (pixel[1] as f32 * a) as u8; + pixel[2] = (pixel[2] as f32 * a) as u8; + } + let (width, height) = pencil_image.dimensions(); + let hotspot_x = 0; // Pencil tip at top-left + let hotspot_y = height.saturating_sub(1); // Bottom of image (pencil tip) + + let custom_cursor_source = winit::window::CustomCursor::from_rgba( + rgba.into_raw(), + width as u16, + height as u16, + hotspot_x as u16, + hotspot_y as u16, + ) + .map_err(|e| { + log::error!("create_overlay_window: Failed to create cursor source: {e:?}"); + ServerError::GfxCreationError + })?; + + let pencil_cursor = event_loop.create_custom_cursor(custom_cursor_source); + /* Hardcode window frame to zero as we only support displays for now.*/ let window_frame = Frame::default(); let scaled = { @@ -460,8 +543,12 @@ impl<'a> Application<'a> { cursor_controller: cursor_controller.unwrap(), keyboard_controller: KeyboardController::::new(), clipboard_controller, + pencil_cursor, }); + // Reset local drawing state on start of screenshare. + self.local_drawing.reset(); + #[cfg(target_os = "linux")] { /* We can't support the overlay surface on linux yet. */ @@ -669,7 +756,7 @@ impl<'a> ApplicationHandler for Application<'a> { .available_monitors() .collect::>(); - let result_message = match self.screenshare(data, monitors) { + let result_message = match self.screenshare(event_loop, data, monitors) { Ok(_) => Ok(()), Err(e) => { log::error!("user_event: Screen share failed: {e:?}"); @@ -741,7 +828,7 @@ impl<'a> ApplicationHandler for Application<'a> { } // Add participant to draw manager with their color if let Some(color) = remote_control.cursor_controller.get_participant_color(&sid) { - remote_control.gfx.add_draw_participant(sid, color); + remote_control.gfx.add_draw_participant(sid, color, false); } } UserEvent::ParticipantDisconnected(participant) => { @@ -955,6 +1042,95 @@ impl<'a> ApplicationHandler for Application<'a> { .cursor_controller .trigger_click_animation(position, sid.as_str()); } + UserEvent::LocalDrawingEnabled(drawing_enabled) => { + log::debug!("user_event: LocalDrawingEnabled: {:?}", drawing_enabled); + if self.remote_control.is_none() { + log::warn!("user_event: remote control is none local drawing enabled"); + return; + } + + let remote_control = &mut self.remote_control.as_mut().unwrap(); + if !self.local_drawing.enabled { + let window = remote_control.gfx.window(); + + // Enable cursor hittest so we can receive mouse events + if let Err(e) = window.set_cursor_hittest(true) { + log::error!("user_event: Failed to enable cursor hittest: {e:?}"); + return; + } + + // Enable drawing mode + self.local_drawing.enabled = true; + self.local_drawing.permanent = drawing_enabled.permanent; + + // Reset cursor set times counter + self.local_drawing.cursor_set_times = 0; + + // Store the current controller state before disabling + self.local_drawing.previous_controllers_enabled = + remote_control.cursor_controller.is_controllers_enabled(); + + // Disable remote control + remote_control + .cursor_controller + .set_controllers_enabled(false); + remote_control.keyboard_controller.set_enabled(false); + + remote_control.gfx.set_drawing_mode( + "local", + room_service::DrawingMode::Draw(room_service::DrawSettings { + permanent: drawing_enabled.permanent, + }), + ); + + log::info!( + "Local drawing mode enabled (permanent: {})", + drawing_enabled.permanent + ); + } else { + // Disable drawing mode + self.local_drawing.enabled = false; + self.local_drawing.left_mouse_pressed = false; + self.local_drawing.last_cursor_position = None; + + // Clear all local drawing paths + remote_control.gfx.draw_clear_all_paths("local"); + let cursor_controller = &mut remote_control.cursor_controller; + remote_control.gfx.draw(cursor_controller); + + // Send LiveKit event to clear all paths + if let Some(room_service) = &self.room_service { + room_service.publish_draw_clear_all_paths(); + } + + let window = remote_control.gfx.window(); + + // Restore default cursor + window.set_cursor(winit::window::Cursor::Icon( + winit::window::CursorIcon::Default, + )); + + // Disable cursor hittest + if let Err(e) = window.set_cursor_hittest(false) { + log::error!("user_event: Failed to disable cursor hittest: {e:?}"); + } + + // Restore remote control to previous state + remote_control + .cursor_controller + .set_controllers_enabled(self.local_drawing.previous_controllers_enabled); + remote_control + .keyboard_controller + .set_enabled(self.local_drawing.previous_controllers_enabled); + + // Set drawing mode to disabled for local participant + remote_control + .gfx + .set_drawing_mode("local", room_service::DrawingMode::Disabled); + + log::info!("Local drawing mode disabled"); + } + } } } @@ -990,8 +1166,176 @@ impl<'a> ApplicationHandler for Application<'a> { return; } let remote_control = &mut self.remote_control.as_mut().unwrap(); - let cursor_controller = &mut remote_control.cursor_controller; - remote_control.gfx.draw(cursor_controller); + + // Update auto-clear and send events for cleared paths + let cleared_path_ids = remote_control.gfx.update_auto_clear(); + if !cleared_path_ids.is_empty() && self.room_service.is_some() { + self.room_service + .as_ref() + .unwrap() + .publish_draw_clear_paths(cleared_path_ids); + } + + if !self.local_drawing.enabled { + let cursor_controller = &mut remote_control.cursor_controller; + remote_control.gfx.draw(cursor_controller); + } else { + if self.local_drawing.last_redraw_time.elapsed() + > std::time::Duration::from_millis(20) + { + if self.local_drawing.cursor_set_times < 500 { + let window = remote_control.gfx.window(); + window.focus_window(); + window.set_cursor_visible(false); + window.set_cursor_visible(true); + window.set_cursor(remote_control.pencil_cursor.clone()); + self.local_drawing.cursor_set_times += 1; + } + let cursor_controller = &mut remote_control.cursor_controller; + remote_control.gfx.draw(cursor_controller); + self.local_drawing.last_redraw_time = std::time::Instant::now(); + } + remote_control.gfx.window().request_redraw(); + } + } + WindowEvent::MouseInput { state, button, .. } => { + if self.local_drawing.enabled { + if button == winit::event::MouseButton::Left { + if state == winit::event::ElementState::Pressed { + self.local_drawing.left_mouse_pressed = true; + // Start a new path if we have a cursor position + if let Some(position) = self.local_drawing.last_cursor_position { + if let Some(remote_control) = &mut self.remote_control { + self.local_drawing.current_path_id += 1; + remote_control.gfx.draw_start( + "local", + position, + self.local_drawing.current_path_id, + ); + remote_control.cursor_controller.trigger_render(); + + // Send LiveKit event + if let Some(room_service) = &self.room_service { + let overlay_window = + remote_control.cursor_controller.get_overlay_window(); + let normalized_point = overlay_window + .get_local_percentage_from_pixel( + position.x, position.y, + ); + room_service.publish_draw_start( + room_service::DrawPathPoint { + point: room_service::ClientPoint { + x: normalized_point.x, + y: normalized_point.y, + }, + path_id: self.local_drawing.current_path_id, + }, + ); + } + + log::debug!( + "Local draw_start at {:?} with path_id {}", + position, + self.local_drawing.current_path_id + ); + } + } + } else { + self.local_drawing.left_mouse_pressed = false; + // End the current path + if let Some(position) = self.local_drawing.last_cursor_position { + if let Some(remote_control) = &mut self.remote_control { + remote_control.gfx.draw_end("local", position); + remote_control.cursor_controller.trigger_render(); + + // Send LiveKit event + if let Some(room_service) = &self.room_service { + let overlay_window = + remote_control.cursor_controller.get_overlay_window(); + let normalized_point = overlay_window + .get_local_percentage_from_pixel( + position.x, position.y, + ); + room_service.publish_draw_end(room_service::ClientPoint { + x: normalized_point.x, + y: normalized_point.y, + }); + } + + log::debug!("Local draw_end at {:?}", position); + } + } + } + } else if button == winit::event::MouseButton::Right + && state == winit::event::ElementState::Pressed + { + if let Some(remote_control) = &mut self.remote_control { + // Clear all local drawing paths + remote_control.gfx.draw_clear_all_paths("local"); + remote_control.cursor_controller.trigger_render(); + + // Send LiveKit event to clear all paths + if let Some(room_service) = &self.room_service { + room_service.publish_draw_clear_all_paths(); + } + log::debug!("Local draw_clear_all_paths on right click"); + } + } + } + } + WindowEvent::CursorMoved { position, .. } => { + if self.local_drawing.enabled { + let display_scale = if let Some(remote_control) = &mut self.remote_control { + remote_control + .cursor_controller + .get_overlay_window() + .get_display_scale() + } else { + 1.0 + }; + // Convert physical position to our Position type + let pos = Position { + x: position.x / display_scale, + y: position.y / display_scale, + }; + self.local_drawing.last_cursor_position = Some(pos); + + // If we're actively drawing, add a point + if self.local_drawing.left_mouse_pressed { + if let Some(remote_control) = &mut self.remote_control { + remote_control.gfx.draw_add_point("local", pos); + remote_control.cursor_controller.trigger_render(); + + // Send LiveKit event + if let Some(room_service) = &self.room_service { + let overlay_window = + remote_control.cursor_controller.get_overlay_window(); + let normalized_point = + overlay_window.get_local_percentage_from_pixel(pos.x, pos.y); + room_service.publish_draw_add_point(room_service::ClientPoint { + x: normalized_point.x, + y: normalized_point.y, + }); + } + } + } + } + } + WindowEvent::KeyboardInput { event, .. } => { + if self.local_drawing.enabled && event.state == winit::event::ElementState::Pressed + { + if let winit::keyboard::Key::Named(winit::keyboard::NamedKey::Escape) = + event.logical_key + { + // Disable drawing mode + let _ = self + .event_loop_proxy + .send_event(UserEvent::LocalDrawingEnabled( + socket_lib::DrawingEnabled { permanent: false }, + )); + log::debug!("Escape pressed, disabling local drawing"); + } + } } _ => {} } @@ -1060,6 +1404,7 @@ pub enum UserEvent { DrawClearPath(u64, String), DrawClearAllPaths(String), ClickAnimationFromParticipant(room_service::ClientPoint, String), + LocalDrawingEnabled(socket_lib::DrawingEnabled), } pub struct RenderEventLoop { @@ -1155,6 +1500,7 @@ impl RenderEventLoop { Message::ControllerCursorEnabled(enabled) => { UserEvent::ControllerCursorEnabled(enabled) } + Message::DrawingEnabled(permanent) => UserEvent::LocalDrawingEnabled(permanent), // Ping is on purpose empty. We use it only for stopping the above receive to timeout. Message::Ping => { continue; diff --git a/core/src/overlay_window.rs b/core/src/overlay_window.rs index 85bcae09..f05e91a8 100644 --- a/core/src/overlay_window.rs +++ b/core/src/overlay_window.rs @@ -262,6 +262,13 @@ impl OverlayWindow { y: y * self.display_info.display_extent.height / self.display_info.display_scale, } } + + pub fn get_local_percentage_from_pixel(&self, x: f64, y: f64) -> Position { + Position { + x: (x * self.display_info.display_scale) / self.display_info.display_extent.width, + y: (y * self.display_info.display_scale) / self.display_info.display_extent.height, + } + } } impl fmt::Display for OverlayWindow { diff --git a/core/src/room_service.rs b/core/src/room_service.rs index 7bdda978..12f3659d 100644 --- a/core/src/room_service.rs +++ b/core/src/room_service.rs @@ -20,6 +20,7 @@ const TOPIC_REMOTE_CONTROL_ENABLED: &str = "remote_control_enabled"; const TOPIC_PARTICIPANT_IN_CONTROL: &str = "participant_in_control"; const TOPIC_TICK_RESPONSE: &str = "tick_response"; const VIDEO_TRACK_NAME: &str = "screen_share"; +const TOPIC_DRAW: &str = "draw"; const MAX_FRAMERATE: f64 = 40.0; // Bitrate constants (in bits per second) @@ -55,6 +56,11 @@ enum RoomServiceCommand { TickResponse(u128), IterateParticipants, PublishParticipantInControl(String), + PublishDrawStart(DrawPathPoint), + PublishDrawAddPoint(ClientPoint), + PublishDrawEnd(ClientPoint), + PublishDrawClearPaths(Vec), + PublishDrawClearAllPaths, } #[derive(Debug)] @@ -335,6 +341,56 @@ impl RoomService { log::error!("publish_participant_in_control: Failed to send command: {e:?}"); } } + + pub fn publish_draw_start(&self, point: DrawPathPoint) { + log::debug!("publish_draw_start: {:?}", point); + let res = self + .service_command_tx + .send(RoomServiceCommand::PublishDrawStart(point)); + if let Err(e) = res { + log::error!("publish_draw_start: Error sending command: {e:?}"); + } + } + + pub fn publish_draw_add_point(&self, point: ClientPoint) { + log::debug!("publish_draw_add_point: {:?}", point); + let res = self + .service_command_tx + .send(RoomServiceCommand::PublishDrawAddPoint(point)); + if let Err(e) = res { + log::error!("publish_draw_add_point: Error sending command: {e:?}"); + } + } + + pub fn publish_draw_end(&self, point: ClientPoint) { + log::debug!("publish_draw_end: {:?}", point); + let res = self + .service_command_tx + .send(RoomServiceCommand::PublishDrawEnd(point)); + if let Err(e) = res { + log::error!("publish_draw_end: Error sending command: {e:?}"); + } + } + + pub fn publish_draw_clear_paths(&self, path_ids: Vec) { + log::debug!("publish_draw_clear_paths: {:?}", path_ids); + let res = self + .service_command_tx + .send(RoomServiceCommand::PublishDrawClearPaths(path_ids)); + if let Err(e) = res { + log::error!("publish_draw_clear_paths: Error sending command: {e:?}"); + } + } + + pub fn publish_draw_clear_all_paths(&self) { + log::debug!("publish_draw_clear_all_paths"); + let res = self + .service_command_tx + .send(RoomServiceCommand::PublishDrawClearAllPaths); + if let Err(e) = res { + log::error!("publish_draw_clear_all_paths: Error sending command: {e:?}"); + } + } } /// Handles room service commands in an async loop. @@ -661,6 +717,130 @@ async fn room_service_commands( ); } } + RoomServiceCommand::PublishDrawStart(point) => { + let room = inner.room.lock().await; + if room.is_none() { + log::warn!("room_service_commands: Room doesn't exist"); + continue; + } + let room = room.as_ref().unwrap(); + let local_participant = room.local_participant(); + let event = ClientEvent::DrawStart(point); + let payload = serde_json::to_vec(&event).unwrap(); + let res = local_participant + .publish_data(DataPacket { + payload, + reliable: true, + topic: Some(TOPIC_DRAW.to_string()), + ..Default::default() + }) + .await; + + if let Err(e) = res { + log::error!("room_service_commands: Failed to publish draw start: {e:?}"); + } + } + RoomServiceCommand::PublishDrawAddPoint(point) => { + let room = inner.room.lock().await; + if room.is_none() { + log::warn!("room_service_commands: Room doesn't exist"); + continue; + } + let room = room.as_ref().unwrap(); + let local_participant = room.local_participant(); + let event = ClientEvent::DrawAddPoint(point); + let payload = serde_json::to_vec(&event).unwrap(); + let res = local_participant + .publish_data(DataPacket { + payload, + reliable: false, + topic: Some(TOPIC_DRAW.to_string()), + ..Default::default() + }) + .await; + + if let Err(e) = res { + log::error!("room_service_commands: Failed to publish draw add point: {e:?}"); + } + } + RoomServiceCommand::PublishDrawEnd(point) => { + let room = inner.room.lock().await; + if room.is_none() { + log::warn!("room_service_commands: Room doesn't exist"); + continue; + } + let room = room.as_ref().unwrap(); + let local_participant = room.local_participant(); + let event = ClientEvent::DrawEnd(point); + let payload = serde_json::to_vec(&event).unwrap(); + let res = local_participant + .publish_data(DataPacket { + payload, + reliable: true, + topic: Some(TOPIC_DRAW.to_string()), + ..Default::default() + }) + .await; + + if let Err(e) = res { + log::error!("room_service_commands: Failed to publish draw end: {e:?}"); + } + } + RoomServiceCommand::PublishDrawClearPaths(path_ids) => { + let room = inner.room.lock().await; + if room.is_none() { + log::warn!("room_service_commands: Room doesn't exist"); + continue; + } + let room = room.as_ref().unwrap(); + let local_participant = room.local_participant(); + + // Send individual DrawClearPath events for each path ID + for path_id in path_ids { + let event = ClientEvent::DrawClearPath { path_id }; + let payload = serde_json::to_vec(&event).unwrap(); + let res = local_participant + .publish_data(DataPacket { + payload, + reliable: true, + topic: Some(TOPIC_DRAW.to_string()), + ..Default::default() + }) + .await; + + if let Err(e) = res { + log::error!( + "room_service_commands: Failed to publish draw clear path {}: {e:?}", + path_id + ); + } + } + } + RoomServiceCommand::PublishDrawClearAllPaths => { + let room = inner.room.lock().await; + if room.is_none() { + log::warn!("room_service_commands: Room doesn't exist"); + continue; + } + let room = room.as_ref().unwrap(); + let local_participant = room.local_participant(); + let event = ClientEvent::DrawClearAllPaths; + let payload = serde_json::to_vec(&event).unwrap(); + let res = local_participant + .publish_data(DataPacket { + payload, + reliable: true, + topic: Some(TOPIC_DRAW.to_string()), + ..Default::default() + }) + .await; + + if let Err(e) = res { + log::error!( + "room_service_commands: Failed to publish draw clear all paths: {e:?}" + ); + } + } } } } diff --git a/core/tests/src/local_drawing.rs b/core/tests/src/local_drawing.rs new file mode 100644 index 00000000..f9ef0419 --- /dev/null +++ b/core/tests/src/local_drawing.rs @@ -0,0 +1,45 @@ +use crate::screenshare_client; +use socket_lib::{DrawingEnabled, Message}; +use std::{io, time::Duration}; + +pub fn test_local_drawing_permanent() -> io::Result<()> { + println!("\n=== TEST: Local Drawing (Permanent) ==="); + + // Start screenshare session + let (mut socket, _) = screenshare_client::start_screenshare_session()?; + + // Enable permanent drawing mode + socket.send_message(Message::DrawingEnabled(DrawingEnabled { permanent: true }))?; + println!("Permanent drawing enabled. Draw with mouse, press Escape to exit."); + println!("You have 15 seconds to test drawing..."); + + // Wait for user to test manually + std::thread::sleep(Duration::from_secs(15)); + + // Stop screenshare + screenshare_client::stop_screenshare_session(&mut socket)?; + + println!("Test completed."); + Ok(()) +} + +pub fn test_local_drawing_non_permanent() -> io::Result<()> { + println!("\n=== TEST: Local Drawing (Non-Permanent) ==="); + + // Start screenshare session + let (mut socket, _) = screenshare_client::start_screenshare_session()?; + + // Enable non-permanent drawing mode + socket.send_message(Message::DrawingEnabled(DrawingEnabled { permanent: false }))?; + println!("Non-permanent drawing enabled. Draw with mouse, press Escape to exit."); + println!("You have 15 seconds to test drawing..."); + + // Wait for user to test manually + std::thread::sleep(Duration::from_secs(15)); + + // Stop screenshare + screenshare_client::stop_screenshare_session(&mut socket)?; + + println!("Test completed."); + Ok(()) +} diff --git a/core/tests/src/main.rs b/core/tests/src/main.rs index 93cb4ef6..10515bab 100644 --- a/core/tests/src/main.rs +++ b/core/tests/src/main.rs @@ -3,6 +3,7 @@ use std::io; mod events; mod livekit_utils; +mod local_drawing; mod remote_clipboard; mod remote_cursor; mod remote_drawing; @@ -44,6 +45,20 @@ enum Commands { #[arg(value_enum)] test_type: DrawingTest, }, + /// Test local drawing functionality (sharer drawing) + LocalDrawing { + /// Type of local drawing test to run + #[arg(value_enum)] + test_type: LocalDrawingTest, + }, +} + +#[derive(Clone, ValueEnum, Debug)] +enum LocalDrawingTest { + /// Test local drawing with permanent mode ON + Permanent, + /// Test local drawing with permanent mode OFF + NonPermanent, } #[derive(Clone, ValueEnum, Debug)] @@ -244,6 +259,19 @@ async fn main() -> io::Result<()> { } println!("Drawing test finished."); } + Commands::LocalDrawing { test_type } => { + match test_type { + LocalDrawingTest::Permanent => { + println!("Running local drawing test (permanent)..."); + local_drawing::test_local_drawing_permanent()?; + } + LocalDrawingTest::NonPermanent => { + println!("Running local drawing test (non-permanent)..."); + local_drawing::test_local_drawing_non_permanent()?; + } + } + println!("Local drawing test finished."); + } } Ok(()) diff --git a/openspec/AGENTS.md b/openspec/AGENTS.md index 6c1703ee..8de8eb8a 100644 --- a/openspec/AGENTS.md +++ b/openspec/AGENTS.md @@ -15,14 +15,17 @@ Instructions for AI coding assistants using OpenSpec for spec-driven development ## Three-Stage Workflow ### Stage 1: Creating Changes + Create proposal when you need to: + - Add features or functionality - Make breaking changes (API, schema) -- Change architecture or patterns +- Change architecture or patterns - Optimize performance (changes behavior) - Update security patterns Triggers (examples): + - "Help me create a change proposal" - "Help me plan a change" - "Help me create a proposal" @@ -30,10 +33,12 @@ Triggers (examples): - "I want to create a spec" Loose matching guidance: + - Contains one of: `proposal`, `change`, `spec` - With one of: `create`, `plan`, `make`, `start`, `help` Skip proposal for: + - Bug fixes (restore intended behavior) - Typos, formatting, comments - Dependency updates (non-breaking) @@ -41,13 +46,16 @@ Skip proposal for: - Tests for existing behavior **Workflow** + 1. Review `openspec/project.md`, `openspec list`, and `openspec list --specs` to understand current context. 2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, optional `design.md`, and spec deltas under `openspec/changes//`. 3. Draft spec deltas using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement. 4. Run `openspec validate --strict --no-interactive` and resolve any issues before sharing the proposal. ### Stage 2: Implementing Changes + Track these steps as TODOs and complete them one by one. + 1. **Read proposal.md** - Understand what's being built 2. **Read design.md** (if exists) - Review technical decisions 3. **Read tasks.md** - Get implementation checklist @@ -57,7 +65,9 @@ Track these steps as TODOs and complete them one by one. 7. **Approval gate** - Do not start implementation until the proposal is reviewed and approved ### Stage 3: Archiving Changes + After deployment, create separate PR to: + - Move `changes/[name]/` → `changes/archive/YYYY-MM-DD-[name]/` - Update `specs/` if capabilities changed - Use `openspec archive --skip-specs --yes` for tooling-only changes (always pass the change ID explicitly) @@ -66,6 +76,7 @@ After deployment, create separate PR to: ## Before Any Task **Context Checklist:** + - [ ] Read relevant specs in `specs/[capability]/spec.md` - [ ] Check pending changes in `changes/` for conflicts - [ ] Read `openspec/project.md` for conventions @@ -73,12 +84,14 @@ After deployment, create separate PR to: - [ ] Run `openspec list --specs` to see existing capabilities **Before Creating Specs:** + - Always check if capability already exists - Prefer modifying existing specs over creating duplicates - Use `openspec show [spec]` to review current state - If request is ambiguous, ask 1–2 clarifying questions before scaffolding ### Search Guidance + - Enumerate specs: `openspec spec list --long` (or `--json` for scripts) - Enumerate changes: `openspec list` (or `openspec change list --json` - deprecated but available) - Show details: @@ -147,7 +160,7 @@ openspec/ ``` New request? ├─ Bug fix restoring spec behavior? → Fix directly -├─ Typo/format/comment? → Fix directly +├─ Typo/format/comment? → Fix directly ├─ New feature/capability? → Create proposal ├─ Breaking change? → Create proposal ├─ Architecture change? → Create proposal @@ -159,45 +172,60 @@ New request? 1. **Create directory:** `changes/[change-id]/` (kebab-case, verb-led, unique) 2. **Write proposal.md:** + ```markdown # Change: [Brief description of change] ## Why + [1-2 sentences on problem/opportunity] ## What Changes + - [Bullet list of changes] - [Mark breaking changes with **BREAKING**] ## Impact + - Affected specs: [list capabilities] - Affected code: [key files/systems] ``` 3. **Create spec deltas:** `specs/[capability]/spec.md` + ```markdown ## ADDED Requirements + ### Requirement: New Feature + The system SHALL provide... #### Scenario: Success case + - **WHEN** user performs action - **THEN** expected result ## MODIFIED Requirements + ### Requirement: Existing Feature + [Complete modified requirement] ## REMOVED Requirements + ### Requirement: Old Feature + **Reason**: [Why removing] **Migration**: [How to handle] ``` + If multiple capabilities are affected, create multiple delta files under `changes/[change-id]/specs//spec.md`—one per capability. 4. **Create tasks.md:** + ```markdown ## 1. Implementation + - [ ] 1.1 Create database schema - [ ] 1.2 Implement API endpoint - [ ] 1.3 Add frontend component @@ -205,32 +233,40 @@ If multiple capabilities are affected, create multiple delta files under `change ``` 5. **Create design.md when needed:** -Create `design.md` if any of the following apply; otherwise omit it: + Create `design.md` if any of the following apply; otherwise omit it: + - Cross-cutting change (multiple services/modules) or a new architectural pattern - New external dependency or significant data model changes - Security, performance, or migration complexity - Ambiguity that benefits from technical decisions before coding Minimal `design.md` skeleton: + ```markdown ## Context + [Background, constraints, stakeholders] ## Goals / Non-Goals + - Goals: [...] - Non-Goals: [...] ## Decisions + - Decision: [What and why] - Alternatives considered: [Options + rationale] ## Risks / Trade-offs + - [Risk] → Mitigation ## Migration Plan + [Steps, rollback] ## Open Questions + - [...] ``` @@ -239,22 +275,27 @@ Minimal `design.md` skeleton: ### Critical: Scenario Formatting **CORRECT** (use #### headers): + ```markdown #### Scenario: User login success + - **WHEN** valid credentials provided - **THEN** return JWT token ``` **WRONG** (don't use bullets or bold): + ```markdown -- **Scenario: User login** ❌ -**Scenario**: User login ❌ -### Scenario: User login ❌ +- **Scenario: User login** ❌ + **Scenario**: User login ❌ + +### Scenario: User login ❌ ``` Every requirement MUST have at least one scenario. ### Requirement Wording + - Use SHALL/MUST for normative requirements (avoid should/may unless intentionally non-normative) ### Delta Operations @@ -267,6 +308,7 @@ Every requirement MUST have at least one scenario. Headers matched with `trim(header)` - whitespace ignored. #### When to use ADDED vs MODIFIED + - ADDED: Introduces a new capability or sub-capability that can stand alone as a requirement. Prefer ADDED when the change is orthogonal (e.g., adding "Slash Command Configuration") rather than altering the semantics of an existing requirement. - MODIFIED: Changes the behavior, scope, or acceptance criteria of an existing requirement. Always paste the full, updated requirement content (header + all scenarios). The archiver will replace the entire requirement with what you provide here; partial deltas will drop previous details. - RENAMED: Use when only the name changes. If you also change behavior, use RENAMED (name) plus MODIFIED (content) referencing the new name. @@ -274,14 +316,17 @@ Headers matched with `trim(header)` - whitespace ignored. Common pitfall: Using MODIFIED to add a new concern without including the previous text. This causes loss of detail at archive time. If you aren’t explicitly changing the existing requirement, add a new requirement under ADDED instead. Authoring a MODIFIED requirement correctly: -1) Locate the existing requirement in `openspec/specs//spec.md`. -2) Copy the entire requirement block (from `### Requirement: ...` through its scenarios). -3) Paste it under `## MODIFIED Requirements` and edit to reflect the new behavior. -4) Ensure the header text matches exactly (whitespace-insensitive) and keep at least one `#### Scenario:`. + +1. Locate the existing requirement in `openspec/specs//spec.md`. +2. Copy the entire requirement block (from `### Requirement: ...` through its scenarios). +3. Paste it under `## MODIFIED Requirements` and edit to reflect the new behavior. +4. Ensure the header text matches exactly (whitespace-insensitive) and keep at least one `#### Scenario:`. Example for RENAMED: + ```markdown ## RENAMED Requirements + - FROM: `### Requirement: Login` - TO: `### Requirement: User Authentication` ``` @@ -291,14 +336,17 @@ Example for RENAMED: ### Common Errors **"Change must have at least one delta"** + - Check `changes/[name]/specs/` exists with .md files - Verify files have operation prefixes (## ADDED Requirements) **"Requirement must have at least one scenario"** + - Check scenarios use `#### Scenario:` format (4 hashtags) - Don't use bullet points or bold for scenario headers **Silent scenario parsing failures** + - Exact format required: `#### Scenario: Name` - Debug with: `openspec show [change] --json --deltas-only` @@ -360,73 +408,88 @@ openspec/changes/add-2fa-notify/ ``` auth/spec.md + ```markdown ## ADDED Requirements + ### Requirement: Two-Factor Authentication + ... ``` notifications/spec.md + ```markdown ## ADDED Requirements + ### Requirement: OTP Email Notification + ... ``` ## Best Practices ### Simplicity First + - Default to <100 lines of new code - Single-file implementations until proven insufficient - Avoid frameworks without clear justification - Choose boring, proven patterns ### Complexity Triggers + Only add complexity with: + - Performance data showing current solution too slow - Concrete scale requirements (>1000 users, >100MB data) - Multiple proven use cases requiring abstraction ### Clear References + - Use `file.ts:42` format for code locations - Reference specs as `specs/auth/spec.md` - Link related changes and PRs ### Capability Naming + - Use verb-noun: `user-auth`, `payment-capture` - Single purpose per capability - 10-minute understandability rule - Split if description needs "AND" ### Change ID Naming + - Use kebab-case, short and descriptive: `add-two-factor-auth` - Prefer verb-led prefixes: `add-`, `update-`, `remove-`, `refactor-` - Ensure uniqueness; if taken, append `-2`, `-3`, etc. ## Tool Selection Guide -| Task | Tool | Why | -|------|------|-----| -| Find files by pattern | Glob | Fast pattern matching | -| Search code content | Grep | Optimized regex search | -| Read specific files | Read | Direct file access | +| Task | Tool | Why | +| --------------------- | ---- | ------------------------ | +| Find files by pattern | Glob | Fast pattern matching | +| Search code content | Grep | Optimized regex search | +| Read specific files | Read | Direct file access | | Explore unknown scope | Task | Multi-step investigation | ## Error Recovery ### Change Conflicts + 1. Run `openspec list` to see active changes 2. Check for overlapping specs 3. Coordinate with change owners 4. Consider combining proposals ### Validation Failures + 1. Run with `--strict` flag 2. Check JSON output for details 3. Verify spec file format 4. Ensure scenarios properly formatted ### Missing Context + 1. Read project.md first 2. Check related specs 3. Review recent archives @@ -435,17 +498,20 @@ Only add complexity with: ## Quick Reference ### Stage Indicators + - `changes/` - Proposed, not yet built - `specs/` - Built and deployed - `archive/` - Completed changes ### File Purposes + - `proposal.md` - Why and what - `tasks.md` - Implementation steps - `design.md` - Technical decisions - `spec.md` - Requirements and behavior ### CLI Essentials + ```bash openspec list # What's in progress? openspec show [item] # View details diff --git a/openspec/project.md b/openspec/project.md index ca37d85e..0bc1b78d 100644 --- a/openspec/project.md +++ b/openspec/project.md @@ -1,7 +1,9 @@ # Project Context ## Purpose + Hopp is an open-source **pair programming** app with: + - High-quality, low-latency screen sharing (WebRTC) - Multi-user rooms (“mob programming”) - Remote control (mouse/keyboard) and remote cursors @@ -10,9 +12,11 @@ Hopp is an open-source **pair programming** app with: The desktop app is built with **Tauri** and a separate Rust “core process” (`hopp_core`). WebRTC infrastructure is powered by **LiveKit**. ## Tech Stack + This repo is a monorepo (Yarn workspaces) with multiple runtimes. ### Monorepo / Tooling + - **Yarn 4** workspaces (`packageManager: yarn@4.9.2`) with `nodeLinker: node-modules` - **Node.js**: `v20` (`.nvmrc`) - **Taskfile** (`task`) is the primary dev/build entrypoint across workspaces @@ -22,6 +26,7 @@ This repo is a monorepo (Yarn workspaces) with multiple runtimes. - Go: `golangci-lint` + `gofmt` (via `.golangcli.yml`) ### Backend (`/backend`) + - **Go 1.25** (`go.mod`) - HTTP framework: **Echo** - Datastores: @@ -37,6 +42,7 @@ This repo is a monorepo (Yarn workspaces) with multiple runtimes. - Realtime: **Gorilla WebSocket**, **LiveKit server SDK** ### Desktop App (`/tauri`) + - **Tauri 2** (Rust backend + Vite frontend) - Rust crate: `tauri/src-tauri` (Tauri plugins: updater, autostart, global shortcut, deep-link, etc.) - Frontend: **React + TypeScript + Tailwind CSS** (Vite) @@ -44,6 +50,7 @@ This repo is a monorepo (Yarn workspaces) with multiple runtimes. - Updater: GitHub Releases (`latest.json` endpoint in `tauri.conf.json`) ### Core Process (`/core`) + - **Rust** “core engine” (`hopp_core`) handling: - Screen capture + streaming integration with LiveKit - Remote cursor rendering overlay @@ -51,6 +58,7 @@ This repo is a monorepo (Yarn workspaces) with multiple runtimes. - The Tauri app launches the core process and communicates over a socket. ### Web App (`/web-app`) + - **React + TypeScript** (Vite) - Routing: React Router (`react-router-dom`) - Data fetching: TanStack React Query (and generated OpenAPI types) @@ -58,11 +66,13 @@ This repo is a monorepo (Yarn workspaces) with multiple runtimes. - Build output is bundled as a single-file asset and injected into the backend’s `backend/web/*.html` ### Documentation (`/docs`) + - **Astro + Starlight** (Tailwind) ## Project Conventions ### Code Style + - **Prettier** is the source of truth for JS/TS formatting (`.prettierrc`, 120 cols). - **Rust** must be formatted with `cargo fmt` (pre-commit hook runs per crate: `core/`, `tauri/src-tauri/`). - **Go** must be formatted with `gofmt` and pass `golangci-lint` (enabled linters are `govet`, `ineffassign`, `unused`, `staticcheck`). @@ -74,6 +84,7 @@ This repo is a monorepo (Yarn workspaces) with multiple runtimes. - Prefer `new URL()` / `URLSearchParams` for building URLs and query params over string concatenation. ### Architecture Patterns + - **Three-layer product shape**: - `core/`: low-level capture/remote-control engine (Rust) - `tauri/`: desktop shell + UI (Tauri + React) @@ -86,6 +97,7 @@ This repo is a monorepo (Yarn workspaces) with multiple runtimes. - Web app assets are built and injected into `backend/web/` for backend-driven pages. ### Testing Strategy + - **Backend (Go)**: - Integration tests live under `backend/test/integration/`. - **Core (Rust)**: @@ -94,11 +106,13 @@ This repo is a monorepo (Yarn workspaces) with multiple runtimes. - Linting + typechecking + manual QA are currently the primary guardrails (no dedicated unit test runner is configured in package manifests). ### Git Workflow + - Use feature branches and open PRs against the default branch. - Expect pre-commit hooks to run on commit (Prettier, `cargo fmt`, `golangci-lint`). - Keep commits focused and descriptive; avoid mixing formatting-only changes with behavioral changes unless necessary. ## Domain Context + - **Rooms**: sessions where multiple participants can join for pairing/mobbing. - **Sharer vs controller**: - The **sharer** is streaming their screen and can allow remote control. @@ -109,6 +123,7 @@ This repo is a monorepo (Yarn workspaces) with multiple runtimes. - Tauri provides tray/window surfaces; `hopp_core` does capture + overlay rendering and interacts with OS APIs. ## Important Constraints + - **Cross-platform desktop**: macOS/Windows/Linux constraints and platform APIs matter (capture, overlays, input simulation). - **Local HTTPS requirement**: backend local dev uses mkcert-generated certs for HTTPS/websocket flows (WebKit requirements). - **Fixed dev port expectations**: Tauri dev expects a fixed Vite dev port (default `1420`). @@ -116,6 +131,7 @@ This repo is a monorepo (Yarn workspaces) with multiple runtimes. - **License**: AGPL-3.0-only (see root `package.json` / repo license). ## External Dependencies + - **LiveKit** (self-hosted or cloud) for WebRTC rooms/streaming - **PostgreSQL** database - **Redis** for pub/sub and realtime events diff --git a/tauri/src-tauri/src/app_state.rs b/tauri/src-tauri/src/app_state.rs index 667feb36..24362b11 100644 --- a/tauri/src-tauri/src/app_state.rs +++ b/tauri/src-tauri/src/app_state.rs @@ -65,6 +65,9 @@ struct AppStateInternal { /// The user's preferred interaction mode for screen sharing sessions pub last_mode: Option, + + /// Whether drawing mode should persist until right-click (permanent mode) + pub drawing_permanent: Option, } /// Legacy version of the application state structure. @@ -87,6 +90,7 @@ impl Default for AppStateInternal { /// - Hopp server URL: none /// - Feedback disabled: false /// - Last mode: none + /// - Drawing permanent: none fn default() -> Self { AppStateInternal { tray_notification: true, @@ -96,6 +100,7 @@ impl Default for AppStateInternal { hopp_server_url: None, feedback_disabled: false, last_mode: None, + drawing_permanent: None, } } } @@ -353,6 +358,23 @@ impl AppState { } } + /// Gets whether drawing mode should persist until right-click (permanent mode). + /// Returns false if the setting has not been configured. + pub fn drawing_permanent(&self) -> bool { + let _lock = self.lock.lock().unwrap(); + self.state.drawing_permanent.unwrap_or(false) + } + + /// Updates the drawing permanent setting and saves to disk. + pub fn set_drawing_permanent(&mut self, permanent: bool) { + log::info!("set_drawing_permanent: {permanent}"); + let _lock = self.lock.lock().unwrap(); + self.state.drawing_permanent = Some(permanent); + if !self.save() { + log::error!("set_drawing_permanent: Failed to save app state"); + } + } + /// Saves the current state to disk. /// /// # Returns diff --git a/tauri/src-tauri/src/main.rs b/tauri/src-tauri/src/main.rs index 56db8120..b563908a 100644 --- a/tauri/src-tauri/src/main.rs +++ b/tauri/src-tauri/src/main.rs @@ -3,7 +3,9 @@ use hopp::sounds::{self, SoundConfig}; use log::LevelFilter; -use socket_lib::{CaptureContent, Content, Extent, Message, ScreenShareMessage, SentryMetadata}; +use socket_lib::{ + CaptureContent, Content, DrawingEnabled, Extent, Message, ScreenShareMessage, SentryMetadata, +}; use tauri::Manager; use tauri::{ menu::{MenuBuilder, MenuItemBuilder}, @@ -449,6 +451,43 @@ fn set_last_mode(app: tauri::AppHandle, mode: StoredMode) { data.app_state.set_last_mode(mode); } +#[tauri::command] +fn get_drawing_permanent(app: tauri::AppHandle) -> bool { + log::info!("get_drawing_permanent"); + let data = app.state::>(); + let data = data.lock().unwrap(); + let value = data.app_state.drawing_permanent(); + log::info!("get_drawing_permanent: {value}"); + value +} + +#[tauri::command] +fn set_drawing_permanent(app: tauri::AppHandle, permanent: bool) { + log::info!("set_drawing_permanent: {permanent}"); + let data = app.state::>(); + let mut data = data.lock().unwrap(); + data.app_state.set_drawing_permanent(permanent); +} + +#[tauri::command] +fn enable_drawing(app: tauri::AppHandle, permanent: bool) { + log::info!("enable_drawing: permanent={permanent}"); + let data = app.state::>(); + let mut data = data.lock().unwrap(); + let res = data + .socket + .send_message(Message::DrawingEnabled(DrawingEnabled { permanent })); + if let Err(e) = res { + log::error!("enable_drawing: failed to send message: {e:?}"); + } + drop(data); + + // Hide main window + if let Some(window) = app.get_webview_window("main") { + let _ = window.hide(); + } +} + #[tauri::command] fn minimize_main_window(app: tauri::AppHandle) { log::info!("minimize_main_window"); @@ -1024,6 +1063,9 @@ fn main() { get_last_used_mic, set_last_mode, get_last_mode, + get_drawing_permanent, + set_drawing_permanent, + enable_drawing, minimize_main_window, set_livekit_url, get_livekit_url, diff --git a/tauri/src/components/ui/call-center.tsx b/tauri/src/components/ui/call-center.tsx index 3de57d0e..81950603 100644 --- a/tauri/src/components/ui/call-center.tsx +++ b/tauri/src/components/ui/call-center.tsx @@ -1,9 +1,16 @@ import { formatDistanceToNow } from "date-fns"; import { LuMicOff, LuVideo, LuVideoOff, LuScreenShare, LuScreenShareOff } from "react-icons/lu"; +import { PiScribbleLoopBold } from "react-icons/pi"; import useStore, { CallState, ParticipantRole } from "@/store/store"; import { useKrispNoiseFilter } from "@livekit/components-react/krisp"; import { Separator } from "@/components/ui/separator"; import { ToggleIconButton } from "@/components/ui/toggle-icon-button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, + DropdownMenuCheckboxItem, +} from "@/components/ui/dropdown-menu"; import { useLocalParticipant, useMediaDeviceSelect, @@ -31,6 +38,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp import { CustomIcons } from "@/components/ui/icons"; import clsx from "clsx"; import { ChevronDownIcon } from "@radix-ui/react-icons"; +import { MoreHorizontal } from "lucide-react"; import { HiOutlinePhoneXMark } from "react-icons/hi2"; import toast from "react-hot-toast"; import ListenToRemoteAudio from "./listen-to-remote-audio"; @@ -213,6 +221,7 @@ export function ConnectedActions() { )} + {callTokens?.role === ParticipantRole.SHARER && } + + Enable drawing + + + + + + + + + + + Drawing options + + + e.preventDefault()} + align="start" + className="w-auto min-w-[200px]" + > + + Persist until right click + + + + + ); +} + function MicrophoneIcon() { const [retry, setRetry] = useState(0); const { updateCallTokens, callTokens } = useStore(); diff --git a/tauri/src/windows/window-utils.ts b/tauri/src/windows/window-utils.ts index 8179c2f2..b6d845f3 100644 --- a/tauri/src/windows/window-utils.ts +++ b/tauri/src/windows/window-utils.ts @@ -236,6 +236,18 @@ const setLastMode = async (mode: TStoredMode): Promise => { return await invoke("set_last_mode", { mode }); }; +const getDrawingPermanent = async (): Promise => { + return await invoke("get_drawing_permanent"); +}; + +const setDrawingPermanent = async (permanent: boolean): Promise => { + return await invoke("set_drawing_permanent", { permanent }); +}; + +const enableDrawing = async (permanent: boolean): Promise => { + await invoke("enable_drawing", { permanent }); +}; + const minimizeMainWindow = async () => { return await invoke("minimize_main_window"); }; @@ -353,6 +365,9 @@ export const tauriUtils = { setLastUsedMic, getLastMode, setLastMode, + getDrawingPermanent, + setDrawingPermanent, + enableDrawing, minimizeMainWindow, setLivekitUrl, getLivekitUrl,