diff --git a/docs/FEATURE_ROADMAP.md b/docs/FEATURE_ROADMAP.md index f015d55..cdecb08 100644 --- a/docs/FEATURE_ROADMAP.md +++ b/docs/FEATURE_ROADMAP.md @@ -63,6 +63,7 @@ Bringing GC to parity with GuruRMM's release engineering. Full plan: [SPEC-001]( - [~] React/TS web viewer (`dashboard/src/components/RemoteViewer.tsx`) — embeddable session viewer - [ ] **Headless Linux mode (direct TTY access)** — P2 — Terminal-based remote access for Linux servers without GUI. PTY spawn (`openpty`), xterm.js web viewer, full ANSI/VT100 support. Enables server management, container debugging, emergency recovery via GuruConnect dashboard with audit logging. SSH replacement with centralized auth. ([SPEC-012](specs/SPEC-012-headless-linux-tty.md)) - [ ] **Windows session selection and backstage mode** — P2 — Enumerate and switch between Windows user sessions (Terminal Services/RDP/Fast User Switching) and access Session 0 (backstage) for system-level admin tasks. ScreenConnect parity: session selector shows all logged-on users, instant switching without reconnect. Backstage mode provides terminal/command interface for services management without disrupting any user desktop. Critical for multi-user server environments. ([SPEC-013](specs/SPEC-013-session-selection-and-backstage.md)) +- [ ] **Configurable notification overlay on viewer connection** — P2 — Display a semi-transparent on-screen notification when a technician connects, showing technician name and company. Dashboard-configurable message template (supports `{{technician_name}}`, `{{company}}`, `{{time}}`), duration (5-60s), position (top-left/right, bottom-left/right, center), and dismissible behavior. Increases transparency and user awareness during remote support sessions. Compliance-friendly for privacy policies requiring user notification. ([SPEC-015](specs/SPEC-015-notification-overlay.md)) - [ ] Multi-monitor switching — P2 - [ ] File transfer — P3 (out of scope for native-remote-control v1) - [ ] Session recording — P3 (out of scope for native-remote-control v1) diff --git a/docs/specs/SPEC-015-notification-overlay.md b/docs/specs/SPEC-015-notification-overlay.md new file mode 100644 index 0000000..e8128ed --- /dev/null +++ b/docs/specs/SPEC-015-notification-overlay.md @@ -0,0 +1,1051 @@ +# SPEC-015: Configurable Notification Overlay on Viewer Connection + +**Status:** Proposed +**Priority:** P2 +**Requested By:** Mike (2026-05-31) +**Estimated Effort:** Medium (3-4 weeks) + +--- + +## Overview + +Display a configurable on-screen notification overlay to the end user when a remote technician connects to their machine. The notification appears as a semi-transparent topmost window showing the technician's name and company, with configurable duration, position, and dismissal behavior. + +**Use Cases:** +- Transparency and user awareness during attended support sessions +- Compliance with privacy policies requiring user notification +- Professional branding during remote support (technician name, company) +- Reduce user confusion/anxiety when they see remote mouse movement + +**Success Criteria:** +- Notification appears within 1 second of viewer connection (StartStream message) +- User can see technician name and company clearly +- Notification auto-hides after configured duration OR can be manually dismissed +- Works correctly across multi-monitor setups +- Admin can configure all aspects via dashboard settings page + +--- + +## Scope + +### Included in v1 +- **Agent-side overlay rendering:** Semi-transparent topmost window with text (technician name, company, custom message) +- **Server-side configuration:** Database table + API endpoints for notification settings +- **Dashboard admin UI:** Settings page to enable/disable, configure message template, duration, position +- **Protobuf integration:** Extend `StartStream` message to include `NotificationConfig` +- **Template variables:** Support `{{technician_name}}`, `{{company}}`, `{{time}}` in message template +- **Position options:** Top-left, top-right, bottom-left, bottom-right, center +- **Auto-hide timer:** Configurable 5-60 seconds +- **Manual dismiss:** Optional "X" button to close overlay +- **Multi-monitor support:** Show on primary monitor (v1 default) + +### Explicitly Out of Scope +- Custom logo/image in notification (use text-based company branding) +- Animated entrance/exit effects (instant show/fade) +- Sound alerts (visual only) +- Per-technician customization (organization-wide settings only) +- Multi-monitor placement choice (always primary in v1; per-monitor selection deferred to v2) +- HTML rendering in message template (plain text only for security) +- Notification when viewer disconnects (only on connect) + +### Success Criteria +- Notification displays within 1 second of `StartStream` +- Text is readable at default Windows DPI settings (100-150%) +- Overlay doesn't interfere with input injection or screen capture +- Configuration changes apply to new sessions immediately (no agent restart) +- Backward compatibility: older agents gracefully ignore the new protobuf field + +--- + +## Architecture + +### Components + +**Agent (Windows):** +- New module `agent/src/notification/mod.rs` - Overlay window rendering and lifecycle +- Listens for `StartStream` messages from relay server +- Extracts `NotificationConfig` from message and displays overlay +- Auto-hides after `duration_secs` or on manual dismiss +- Logs notification events at INFO level + +**Server (Rust/Axum):** +- New table `notification_config` (singleton, similar to `branding_config`) +- New API endpoints: + - `GET /api/notification-config` (admin-only) - Fetch current config + - `PUT /api/notification-config` (admin-only) - Update config +- Include `NotificationConfig` proto message in `StartStream` when sending to agent +- Migration: `006_notification_config.sql` + +**Dashboard (React/TypeScript):** +- New page `dashboard/src/features/settings/NotificationSettingsPage.tsx` (admin-only route) +- Settings form: + - Enable/disable toggle + - Message template textarea (with variable hints: `{{technician_name}}`, `{{company}}`, `{{time}}`) + - Duration slider (5-60 seconds) + - Position dropdown (top-left, top-right, bottom-left, bottom-right, center) + - Dismissible checkbox (show "X" button) + - Live preview panel + +**Protobuf (`proto/guruconnect.proto`):** +- New message `NotificationConfig` +- Add optional `NotificationConfig notification_config = 4` field to `StartStream` + +### Data Flow + +1. **Admin configures notification** (dashboard) + - Admin opens Settings → Notification Overlay + - Configures message template, duration, position, dismissibility + - Saves via `PUT /api/notification-config` + - Server updates `notification_config` table (singleton row) + +2. **Viewer connects to session** (runtime) + - Viewer requests session via dashboard + - Server sends `StartStream` message to agent, including current `NotificationConfig` + - Agent receives `StartStream`, checks if `notification_config.enabled == true` + - Agent spawns overlay window with configured message/position/duration + - Overlay auto-hides after `duration_secs` OR user clicks "X" (if dismissible) + +3. **Configuration changes apply immediately** + - Next `StartStream` message includes updated config (no agent restart needed) + +### Database Schema + +**New table:** `notification_config` (singleton) + +```sql +CREATE TABLE notification_config ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + enabled BOOLEAN NOT NULL DEFAULT false, + message_template TEXT NOT NULL DEFAULT '{{technician_name}} from {{company}} is now connected.', + duration_secs INTEGER NOT NULL DEFAULT 10, -- 5-60 seconds + position TEXT NOT NULL DEFAULT 'top-right', -- top-left|top-right|bottom-left|bottom-right|center + dismissible BOOLEAN NOT NULL DEFAULT true, -- Show "X" button + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + CONSTRAINT check_duration CHECK (duration_secs >= 5 AND duration_secs <= 60), + CONSTRAINT check_position CHECK (position IN ('top-left', 'top-right', 'bottom-left', 'bottom-right', 'center')) +); + +-- Singleton pattern: only one config row allowed +CREATE UNIQUE INDEX notification_config_singleton ON notification_config ((true)); + +-- Insert default config +INSERT INTO notification_config (enabled, message_template, duration_secs, position, dismissible) +VALUES (false, '{{technician_name}} from {{company}} is now connected.', 10, 'top-right', true); +``` + +### Protobuf Changes + +**File:** `proto/guruconnect.proto` + +Add new message (near line 340, after `UpdateStatus`): + +```protobuf +// ============================================================================ +// Notification Overlay Configuration +// ============================================================================ + +// Configuration for the on-screen notification shown when a technician connects. +// Sent from server to agent in the StartStream message. The agent displays the +// overlay if enabled=true, substitutes template variables ({{technician_name}}, +// {{company}}, {{time}}), and auto-hides after duration_secs or on manual dismiss. +message NotificationConfig { + bool enabled = 1; // Show notification overlay + string message_template = 2; // Template with {{technician_name}}, {{company}}, {{time}} + int32 duration_secs = 3; // Auto-hide after N seconds (5-60) + string position = 4; // top-left|top-right|bottom-left|bottom-right|center + bool dismissible = 5; // Show "X" button for manual dismiss + string technician_name = 6; // Filled by server (viewer's display name) + string company = 7; // Filled by server (branding config or default) +} +``` + +**Update `StartStream` message** (line 285): + +```protobuf +message StartStream { + string viewer_id = 1; // ID of viewer requesting stream + int32 display_id = 2; // Which display to stream (0 = primary) + VideoCodec video_codec = 3; // Negotiated codec + NotificationConfig notification_config = 4; // ADDED: notification overlay config +} +``` + +**Update `Message` wrapper** (line 411): + +```protobuf +message Message { + oneof payload { + // ... existing fields ... + + // Notification configuration (sent with StartStream) + NotificationConfig notification_config = 85; // NEW: for future standalone updates + } +} +``` + +--- + +## Implementation Details + +### Agent (`agent/src/notification/mod.rs`) + +**New file:** `agent/src/notification/mod.rs` + +```rust +//! On-screen notification overlay when a technician connects. +//! +//! Displays a semi-transparent topmost window with customizable message, +//! position, and auto-hide timer. Supports template variables: +//! - {{technician_name}} - Name of the connecting technician +//! - {{company}} - Organization name +//! - {{time}} - Current time (HH:MM format) + +use anyhow::Result; +use std::time::Duration; +use tracing::info; +use windows::Win32::{ + Foundation::*, + Graphics::Gdi::*, + UI::WindowsAndMessaging::*, +}; + +/// Notification overlay position on screen +#[derive(Debug, Clone, Copy)] +pub enum NotificationPosition { + TopLeft, + TopRight, + BottomLeft, + BottomRight, + Center, +} + +impl NotificationPosition { + pub fn from_str(s: &str) -> Self { + match s { + "top-left" => Self::TopLeft, + "top-right" => Self::TopRight, + "bottom-left" => Self::BottomLeft, + "bottom-right" => Self::BottomRight, + "center" => Self::Center, + _ => Self::TopRight, // Default + } + } +} + +/// Notification overlay configuration +#[derive(Debug, Clone)] +pub struct NotificationConfig { + pub enabled: bool, + pub message: String, // Already substituted + pub duration: Duration, + pub position: NotificationPosition, + pub dismissible: bool, +} + +/// Show the notification overlay window. +/// +/// Creates a topmost, layered window with semi-transparent background. +/// Auto-hides after `config.duration` or when user clicks "X" (if dismissible). +#[cfg(windows)] +pub fn show_notification(config: NotificationConfig) -> Result<()> { + if !config.enabled { + return Ok(()); + } + + info!( + message = %config.message, + duration_secs = ?config.duration.as_secs(), + position = ?config.position, + "Showing notification overlay" + ); + + // Spawn a thread to create and manage the overlay window + std::thread::spawn(move || { + if let Err(e) = show_notification_window(&config) { + tracing::error!(error = %e, "Failed to show notification window"); + } + }); + + Ok(()) +} + +#[cfg(windows)] +fn show_notification_window(config: &NotificationConfig) -> Result<()> { + use std::ffi::OsStr; + use std::os::windows::ffi::OsStrExt; + + const WINDOW_WIDTH: i32 = 400; + const WINDOW_HEIGHT: i32 = 100; + const PADDING: i32 = 20; + + // Get primary monitor dimensions + let screen_width = unsafe { GetSystemMetrics(SM_CXSCREEN) }; + let screen_height = unsafe { GetSystemMetrics(SM_CYSCREEN) }; + + // Calculate position based on config + let (x, y) = match config.position { + NotificationPosition::TopLeft => (PADDING, PADDING), + NotificationPosition::TopRight => (screen_width - WINDOW_WIDTH - PADDING, PADDING), + NotificationPosition::BottomLeft => (PADDING, screen_height - WINDOW_HEIGHT - PADDING), + NotificationPosition::BottomRight => ( + screen_width - WINDOW_WIDTH - PADDING, + screen_height - WINDOW_HEIGHT - PADDING, + ), + NotificationPosition::Center => ( + (screen_width - WINDOW_WIDTH) / 2, + (screen_height - WINDOW_HEIGHT) / 2, + ), + }; + + // Register window class + let class_name: Vec = OsStr::new("GuruConnectNotification") + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + + let wc = WNDCLASSW { + lpfnWndProc: Some(notification_wndproc), + hInstance: unsafe { GetModuleHandleW(None)? }, + lpszClassName: PCWSTR(class_name.as_ptr()), + hCursor: unsafe { LoadCursorW(None, IDC_ARROW)? }, + hbrBackground: unsafe { CreateSolidBrush(COLORREF(0x00F0F0F0)) }, // Light gray + ..Default::default() + }; + + unsafe { RegisterClassW(&wc) }; + + // Create the window + let window_style = WS_POPUP | if config.dismissible { WS_SYSMENU } else { WS_DISABLED }; + let ex_style = WS_EX_TOPMOST | WS_EX_LAYERED | WS_EX_TOOLWINDOW; + + let hwnd = unsafe { + CreateWindowExW( + ex_style, + PCWSTR(class_name.as_ptr()), + windows::core::w!("GuruConnect Notification"), + window_style, + x, + y, + WINDOW_WIDTH, + WINDOW_HEIGHT, + None, + None, + wc.hInstance, + None, + )? + }; + + // Set 90% opacity (230/255) + unsafe { + SetLayeredWindowAttributes(hwnd, COLORREF(0), 230, LWA_ALPHA)?; + } + + // Store message text in window user data for WM_PAINT + let message_wide: Vec = OsStr::new(&config.message) + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let message_ptr = Box::into_raw(Box::new(message_wide)) as isize; + unsafe { SetWindowLongPtrW(hwnd, GWLP_USERDATA, message_ptr) }; + + // Show the window + unsafe { ShowWindow(hwnd, SW_SHOW) }; + + // Set auto-hide timer + unsafe { + SetTimer(hwnd, 1, config.duration.as_millis() as u32, None); + } + + // Message loop + let mut msg = MSG::default(); + unsafe { + while GetMessageW(&mut msg, None, 0, 0).into() { + TranslateMessage(&msg); + DispatchMessageW(&msg); + } + } + + Ok(()) +} + +#[cfg(windows)] +unsafe extern "system" fn notification_wndproc( + hwnd: HWND, + msg: u32, + wparam: WPARAM, + lparam: LPARAM, +) -> LRESULT { + match msg { + WM_PAINT => { + let mut ps = PAINTSTRUCT::default(); + let hdc = BeginPaint(hwnd, &mut ps); + + // Get message text from window user data + let message_ptr = GetWindowLongPtrW(hwnd, GWLP_USERDATA) as *const Vec; + if !message_ptr.is_null() { + let message = &*message_ptr; + + // Draw text centered + let mut rect = RECT::default(); + GetClientRect(hwnd, &mut rect).ok(); + DrawTextW( + hdc, + message, + &mut rect, + DT_CENTER | DT_VCENTER | DT_WORDBREAK, + ); + } + + EndPaint(hwnd, &ps); + LRESULT(0) + } + WM_TIMER => { + // Auto-hide timer expired + DestroyWindow(hwnd).ok(); + LRESULT(0) + } + WM_CLOSE | WM_DESTROY => { + // Clean up message text allocation + let message_ptr = GetWindowLongPtrW(hwnd, GWLP_USERDATA) as *mut Vec; + if !message_ptr.is_null() { + drop(Box::from_raw(message_ptr)); + } + PostQuitMessage(0); + LRESULT(0) + } + _ => DefWindowProcW(hwnd, msg, wparam, lparam), + } +} + +/// Substitute template variables in notification message +pub fn substitute_variables( + template: &str, + technician_name: &str, + company: &str, +) -> String { + use chrono::Local; + + let now = Local::now(); + let time_str = now.format("%H:%M").to_string(); + + template + .replace("{{technician_name}}", technician_name) + .replace("{{company}}", company) + .replace("{{time}}", &time_str) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_substitute_variables() { + let template = "{{technician_name}} from {{company}} connected at {{time}}."; + let result = substitute_variables(template, "John Doe", "Acme Corp"); + assert!(result.contains("John Doe")); + assert!(result.contains("Acme Corp")); + assert!(result.contains(":")); // Time contains ":" + } +} +``` + +**Integrate into agent/src/session/mod.rs:** + +```rust +// Around line 300, in handle_start_stream() +if let Some(notification_config) = msg.notification_config { + if notification_config.enabled { + let message = crate::notification::substitute_variables( + ¬ification_config.message_template, + ¬ification_config.technician_name, + ¬ification_config.company, + ); + + let config = crate::notification::NotificationConfig { + enabled: true, + message, + duration: Duration::from_secs(notification_config.duration_secs as u64), + position: crate::notification::NotificationPosition::from_str(¬ification_config.position), + dismissible: notification_config.dismissible, + }; + + crate::notification::show_notification(config)?; + } +} +``` + +**Add to agent/src/main.rs:** + +```rust +mod notification; +``` + +### Server (`server/src/`) + +**New migration:** `server/migrations/006_notification_config.sql` + +```sql +-- Notification overlay configuration (singleton table) +CREATE TABLE notification_config ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + enabled BOOLEAN NOT NULL DEFAULT false, + message_template TEXT NOT NULL DEFAULT '{{technician_name}} from {{company}} is now connected.', + duration_secs INTEGER NOT NULL DEFAULT 10, + position TEXT NOT NULL DEFAULT 'top-right', + dismissible BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + CONSTRAINT check_duration CHECK (duration_secs >= 5 AND duration_secs <= 60), + CONSTRAINT check_position CHECK (position IN ('top-left', 'top-right', 'bottom-left', 'bottom-right', 'center')) +); + +CREATE UNIQUE INDEX notification_config_singleton ON notification_config ((true)); + +-- Insert default configuration +INSERT INTO notification_config (enabled, message_template, duration_secs, position, dismissible) +VALUES (false, '{{technician_name}} from {{company}} is now connected.', 10, 'top-right', true); +``` + +**New module:** `server/src/api/notification.rs` + +```rust +//! Notification configuration API endpoints + +use axum::{extract::State, http::StatusCode, Json}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use uuid::Uuid; +use crate::auth::RequireRole; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NotificationConfig { + pub id: Uuid, + pub enabled: bool, + pub message_template: String, + pub duration_secs: i32, + pub position: String, + pub dismissible: bool, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateNotificationConfig { + pub enabled: bool, + pub message_template: String, + pub duration_secs: i32, + pub position: String, + pub dismissible: bool, +} + +/// GET /api/notification-config - Get current notification configuration (admin-only) +pub async fn get_notification_config( + State(pool): State, + _auth: RequireRole, +) -> Result, StatusCode> { + let config = sqlx::query_as!( + NotificationConfig, + "SELECT id, enabled, message_template, duration_secs, position, dismissible + FROM notification_config + LIMIT 1" + ) + .fetch_one(&pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json(config)) +} + +/// PUT /api/notification-config - Update notification configuration (admin-only) +pub async fn update_notification_config( + State(pool): State, + _auth: RequireRole, + Json(update): Json, +) -> Result, StatusCode> { + // Validate duration + if update.duration_secs < 5 || update.duration_secs > 60 { + return Err(StatusCode::BAD_REQUEST); + } + + // Validate position + if !["top-left", "top-right", "bottom-left", "bottom-right", "center"] + .contains(&update.position.as_str()) + { + return Err(StatusCode::BAD_REQUEST); + } + + let config = sqlx::query_as!( + NotificationConfig, + "UPDATE notification_config + SET enabled = $1, + message_template = $2, + duration_secs = $3, + position = $4, + dismissible = $5, + updated_at = NOW() + RETURNING id, enabled, message_template, duration_secs, position, dismissible", + update.enabled, + update.message_template, + update.duration_secs, + update.position, + update.dismissible, + ) + .fetch_one(&pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json(config)) +} +``` + +**Update `server/src/main.rs`** to register routes: + +```rust +use crate::api::notification::{get_notification_config, update_notification_config}; + +// Around line 700, add routes +.route("/api/notification-config", get(get_notification_config).put(update_notification_config)) +``` + +**Update `server/src/relay/mod.rs`** to include notification config in `StartStream`: + +```rust +// Around line 550, when sending StartStream to agent +let notification_config = fetch_notification_config(&state.db).await?; + +let notification_proto = if notification_config.enabled { + Some(proto::NotificationConfig { + enabled: true, + message_template: notification_config.message_template.clone(), + duration_secs: notification_config.duration_secs, + position: notification_config.position.clone(), + dismissible: notification_config.dismissible, + technician_name: viewer_user.display_name.clone(), + company: branding_config.company_name.clone(), + }) +} else { + None +}; + +let start_stream = proto::StartStream { + viewer_id: viewer_id.to_string(), + display_id: 0, + video_codec: negotiated_codec as i32, + notification_config: notification_proto, +}; +``` + +**New helper function in `server/src/db/mod.rs`:** + +```rust +pub async fn fetch_notification_config(pool: &PgPool) -> Result { + sqlx::query_as!( + NotificationConfig, + "SELECT id, enabled, message_template, duration_secs, position, dismissible + FROM notification_config + LIMIT 1" + ) + .fetch_one(pool) + .await +} +``` + +### Dashboard (`dashboard/src/features/settings/NotificationSettingsPage.tsx`) + +**New file:** `dashboard/src/features/settings/NotificationSettingsPage.tsx` + +```tsx +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { notificationApi } from "@/api/notification"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; +import { Select } from "@/components/ui/select"; +import { Slider } from "@/components/ui/slider"; + +export function NotificationSettingsPage() { + const queryClient = useQueryClient(); + const { data: config, isLoading } = useQuery({ + queryKey: ["notification-config"], + queryFn: notificationApi.get, + }); + + const updateMutation = useMutation({ + mutationFn: notificationApi.update, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["notification-config"] }); + }, + }); + + const [formData, setFormData] = useState(config); + + if (isLoading || !config) { + return
Loading...
; + } + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + updateMutation.mutate(formData); + }; + + const previewMessage = formData.message_template + .replace("{{technician_name}}", "John Doe") + .replace("{{company}}", "Acme Corp") + .replace("{{time}}", new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })); + + return ( +
+

Notification Overlay Settings

+ +
+ {/* Enable/Disable */} +
+ setFormData({ ...formData, enabled })} + /> + +
+ + {/* Message Template */} +
+ +