Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .cursor/commands/openspec-apply.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,25 @@ id: openspec-apply
category: OpenSpec
description: Implement an approved OpenSpec change and keep tasks in sync.
---

<!-- OPENSPEC:START -->

**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/<id>/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.
4. Update the checklist after all work is done so each task is marked `- [x]` and reflects reality.
5. Reference `openspec list` or `openspec show <item>` when additional context is required.

**Reference**

- Use `openspec show <id> --json --deltas-only` if you need additional context from the proposal while implementing.
<!-- OPENSPEC:END -->
5 changes: 5 additions & 0 deletions .cursor/commands/openspec-archive.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,17 @@ id: openspec-archive
category: OpenSpec
description: Archive a deployed OpenSpec change and update specs.
---

<!-- OPENSPEC:START -->

**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 `<ChangeId>` 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.
Expand All @@ -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 <id>` 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.
<!-- OPENSPEC:END -->
5 changes: 5 additions & 0 deletions .cursor/commands/openspec-proposal.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,19 @@ id: openspec-proposal
category: OpenSpec
description: Scaffold a new OpenSpec change and validate strictly.
---

<!-- OPENSPEC:START -->

**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.
- Identify any vague or ambiguous details and ask the necessary follow-up questions before editing files.
- 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/<id>/`.
3. Map the change into concrete capabilities or requirements, breaking multi-scope efforts into distinct spec deltas with clear relationships and sequencing.
Expand All @@ -22,6 +26,7 @@ description: Scaffold a new OpenSpec change and validate strictly.
7. Validate with `openspec validate <id> --strict --no-interactive` and resolve every issue before sharing the proposal.

**Reference**

- Use `openspec show <id> --json --deltas-only` or `openspec show <spec> --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 <keyword>`, `ls`, or direct file reads so proposals align with current implementation realities.
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,5 @@ dist-ssr

.yarn/install-state.gz

openspec/changes
openspec/changes
CLAUDE.md
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,15 @@ 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)
task test # Run tests
```

**Core (Rust):**

```bash
cd core
cargo build
Expand All @@ -34,13 +36,15 @@ cargo fmt # Format code
```

**Tauri App:**

```bash
cd tauri
task dev # Dev mode with hot reload
task build # Production build
```

**Web App:**

```bash
cd web-app
yarn dev
Expand Down Expand Up @@ -69,16 +73,19 @@ Pre-commit hooks enforce all formatting automatically.
- Cross-platform: macOS, Windows, Linux all supported

<!-- OPENSPEC:START -->

# 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
Expand Down
9 changes: 9 additions & 0 deletions core/AGENTS.md
Original file line number Diff line number Diff line change
@@ -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).
Binary file added core/resources/pencil.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions core/socket_lib/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -120,6 +125,7 @@ pub enum Message {
ControllerCursorEnabled(bool),
LivekitServerUrl(String),
SentryMetadata(SentryMetadata),
DrawingEnabled(DrawingEnabled),
}

#[derive(Debug)]
Expand Down
70 changes: 65 additions & 5 deletions core/src/graphics/draw.rs
Original file line number Diff line number Diff line change
@@ -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('#');

Expand All @@ -27,13 +30,15 @@ fn color_from_hex(hex: &str) -> Color {
struct DrawPath {
path_id: u64,
points: Vec<Position>,
finished_at: Option<Instant>,
}

impl DrawPath {
pub fn new(path_id: u64, point: Position) -> Self {
Self {
path_id,
points: vec![point],
finished_at: None,
}
}
}
Expand All @@ -44,6 +49,7 @@ pub struct Draw {
completed_cache: Cache,
mode: DrawingMode,
color: Color,
auto_clear: bool,
}

impl std::fmt::Debug for Draw {
Expand All @@ -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,
}
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -134,6 +142,42 @@ impl Draw {
self.completed_cache.clear();
}

pub fn clear_expired_paths(&mut self) -> Vec<u64> {
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| {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -299,6 +348,17 @@ impl DrawManager {
}
}

pub fn update_auto_clear(&mut self) -> Vec<u64> {
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<Geometry> {
let mut geometries = Vec::with_capacity(self.draws.len() + 1);
Expand Down
14 changes: 12 additions & 2 deletions core/src/graphics/graphics_context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<u64> {
self.iced_renderer.update_auto_clear()
}
}

impl Drop for GraphicsContext<'_> {
Expand Down
8 changes: 6 additions & 2 deletions core/src/graphics/iced_canvas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u64> {
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) {
Expand Down
Loading
Loading