# 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 */}