All checks were successful
Comprehensive specification for on-screen notification when technician connects.
- Semi-transparent topmost window with configurable message, position, duration
- Dashboard admin settings page (enable/disable, message template, position, duration)
- Template variables: {{technician_name}}, {{company}}, {{time}}
- Agent displays overlay on StartStream, auto-hides after duration or manual dismiss
- Database: notification_config singleton table
- Protobuf: NotificationConfig message in StartStream
- Priority: P2, Effort: Medium (3-4 weeks)
- Added to roadmap under Core Remote Control
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1052 lines
37 KiB
Markdown
1052 lines
37 KiB
Markdown
# 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<u16> = 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<u16> = 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<u16>;
|
|
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<u16>;
|
|
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<PgPool>,
|
|
_auth: RequireRole<Admin>,
|
|
) -> Result<Json<NotificationConfig>, 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<PgPool>,
|
|
_auth: RequireRole<Admin>,
|
|
Json(update): Json<UpdateNotificationConfig>,
|
|
) -> Result<Json<NotificationConfig>, 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<NotificationConfig> {
|
|
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 <div>Loading...</div>;
|
|
}
|
|
|
|
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 (
|
|
<div className="p-6 max-w-4xl">
|
|
<h1 className="text-2xl font-bold mb-6">Notification Overlay Settings</h1>
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-6">
|
|
{/* Enable/Disable */}
|
|
<div className="flex items-center gap-2">
|
|
<Switch
|
|
checked={formData.enabled}
|
|
onCheckedChange={(enabled) => setFormData({ ...formData, enabled })}
|
|
/>
|
|
<Label>Enable notification overlay when technician connects</Label>
|
|
</div>
|
|
|
|
{/* Message Template */}
|
|
<div>
|
|
<Label htmlFor="message">Message Template</Label>
|
|
<Textarea
|
|
id="message"
|
|
value={formData.message_template}
|
|
onChange={(e) => setFormData({ ...formData, message_template: e.target.value })}
|
|
rows={3}
|
|
/>
|
|
<p className="text-sm text-gray-500 mt-1">
|
|
Available variables: <code>{"{{technician_name}}"}</code>, <code>{"{{company}}"}</code>, <code>{"{{time}}"}</code>
|
|
</p>
|
|
</div>
|
|
|
|
{/* Duration Slider */}
|
|
<div>
|
|
<Label>Auto-hide duration: {formData.duration_secs} seconds</Label>
|
|
<Slider
|
|
min={5}
|
|
max={60}
|
|
step={5}
|
|
value={[formData.duration_secs]}
|
|
onValueChange={([duration_secs]) => setFormData({ ...formData, duration_secs })}
|
|
/>
|
|
</div>
|
|
|
|
{/* Position Dropdown */}
|
|
<div>
|
|
<Label htmlFor="position">Position on screen</Label>
|
|
<Select
|
|
id="position"
|
|
value={formData.position}
|
|
onChange={(e) => setFormData({ ...formData, position: e.target.value })}
|
|
>
|
|
<option value="top-left">Top Left</option>
|
|
<option value="top-right">Top Right</option>
|
|
<option value="bottom-left">Bottom Left</option>
|
|
<option value="bottom-right">Bottom Right</option>
|
|
<option value="center">Center</option>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Dismissible Checkbox */}
|
|
<div className="flex items-center gap-2">
|
|
<Switch
|
|
checked={formData.dismissible}
|
|
onCheckedChange={(dismissible) => setFormData({ ...formData, dismissible })}
|
|
/>
|
|
<Label>Allow users to manually dismiss (show "X" button)</Label>
|
|
</div>
|
|
|
|
{/* Live Preview */}
|
|
<div className="border rounded p-4 bg-gray-50">
|
|
<Label className="mb-2 block">Live Preview</Label>
|
|
<div className="bg-white border rounded shadow-lg p-4 inline-block">
|
|
<p className="text-sm">{previewMessage}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Save Button */}
|
|
<Button type="submit" disabled={updateMutation.isPending}>
|
|
{updateMutation.isPending ? "Saving..." : "Save Settings"}
|
|
</Button>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
**New file:** `dashboard/src/api/notification.ts`
|
|
|
|
```ts
|
|
import { apiClient } from "./client";
|
|
|
|
export interface NotificationConfig {
|
|
id: string;
|
|
enabled: boolean;
|
|
message_template: string;
|
|
duration_secs: number;
|
|
position: string;
|
|
dismissible: boolean;
|
|
}
|
|
|
|
export const notificationApi = {
|
|
get: async (): Promise<NotificationConfig> => {
|
|
const response = await apiClient.get("/api/notification-config");
|
|
return response.data;
|
|
},
|
|
|
|
update: async (config: Omit<NotificationConfig, "id">): Promise<NotificationConfig> => {
|
|
const response = await apiClient.put("/api/notification-config", config);
|
|
return response.data;
|
|
},
|
|
};
|
|
```
|
|
|
|
**Update `dashboard/src/App.tsx`** to add route:
|
|
|
|
```tsx
|
|
import { NotificationSettingsPage } from "@/features/settings/NotificationSettingsPage";
|
|
|
|
// Add route (admin-only)
|
|
<Route path="/settings/notifications" element={<AdminRoute><NotificationSettingsPage /></AdminRoute>} />
|
|
```
|
|
|
|
**Update `dashboard/src/components/layout/Sidebar.tsx`** to add nav link:
|
|
|
|
```tsx
|
|
<NavLink to="/settings/notifications">
|
|
<BellIcon /> Notification Overlay
|
|
</NavLink>
|
|
```
|
|
|
|
---
|
|
|
|
## Security Considerations
|
|
|
|
### Authentication and Authorization
|
|
- **Admin-only endpoints:** `GET /api/notification-config` and `PUT /api/notification-config` require `RequireRole<Admin>` middleware
|
|
- **No public access:** Notification configuration is not exposed to non-admin users
|
|
- **Viewer authentication:** Technician name comes from authenticated JWT token (viewer's display name)
|
|
|
|
### Input Validation
|
|
- **Message template:** Max 500 characters, plain text only (no HTML rendering on agent to prevent XSS)
|
|
- **Duration:** Validated 5-60 seconds range in database constraint and API handler
|
|
- **Position:** Enum validation against allowed values (`top-left`, `top-right`, `bottom-left`, `bottom-right`, `center`)
|
|
- **Template variable injection:** Agent uses simple string replacement (no eval/script execution)
|
|
|
|
### Data Privacy
|
|
- **No credential exposure:** Message template cannot contain sensitive data (passwords, API keys)
|
|
- **Technician identity only:** Only technician's display name (from JWT) is shown, not email or internal ID
|
|
- **Company name from branding:** Uses server-side branding configuration, not user input
|
|
|
|
### Audit Logging
|
|
- **Configuration changes:** Log to `events` table when admin updates notification settings
|
|
- Event type: `notification_config_updated`
|
|
- Include old/new values (enabled, message_template, duration, position)
|
|
- Logged by admin user ID
|
|
|
|
### Threat Model
|
|
- **Template injection:** Mitigated by plain-text rendering only (no HTML/script execution on agent)
|
|
- **Denial of service:** Duration capped at 60 seconds; single overlay per viewer connection (no spam)
|
|
- **Social engineering:** Notification clearly branded with company name; user sees real technician name from authenticated session
|
|
- **Information disclosure:** No sensitive data in notification; technician name already visible in tray icon/consent dialog
|
|
|
|
---
|
|
|
|
## Testing Strategy
|
|
|
|
### Unit Tests
|
|
|
|
**Agent (`agent/src/notification/mod.rs`):**
|
|
- `test_substitute_variables` - Verify template variable substitution (technician name, company, time)
|
|
- `test_position_from_str` - Validate position parsing (top-left, center, etc.)
|
|
- `test_disabled_config` - Notification not shown when `enabled=false`
|
|
|
|
**Server (`server/src/api/notification.rs`):**
|
|
- `test_validate_duration` - Reject invalid durations (<5 or >60 seconds)
|
|
- `test_validate_position` - Reject invalid position strings
|
|
- `test_admin_only` - Non-admin users get 403 Forbidden on PUT endpoint
|
|
|
|
### Integration Tests
|
|
|
|
**End-to-end flow:**
|
|
1. Admin enables notification via dashboard → `PUT /api/notification-config` with `enabled=true`
|
|
2. Viewer connects to session → Server sends `StartStream` with `NotificationConfig`
|
|
3. Agent receives `StartStream` → Overlay window appears at configured position
|
|
4. Wait for `duration_secs` → Overlay auto-hides
|
|
5. OR: User clicks "X" (if dismissible) → Overlay closes immediately
|
|
|
|
**Multi-monitor test:**
|
|
- Connect to agent with multi-monitor setup
|
|
- Verify overlay appears on primary monitor (v1 behavior)
|
|
|
|
**Backward compatibility test:**
|
|
- Deploy updated server with notification config enabled
|
|
- Connect with older agent (without `NotificationConfig` handling)
|
|
- Verify session works normally, older agent ignores unknown protobuf field
|
|
|
|
### Manual Test Scenarios
|
|
|
|
1. **Enable notification with default settings**
|
|
- Go to Settings → Notification Overlay
|
|
- Enable toggle
|
|
- Connect to session
|
|
- Verify overlay appears top-right, shows technician name/company, auto-hides after 10 seconds
|
|
|
|
2. **Test different positions**
|
|
- Set position to each option (top-left, top-right, bottom-left, bottom-right, center)
|
|
- Connect to session for each
|
|
- Verify overlay appears in correct corner/center
|
|
|
|
3. **Test dismissible vs. non-dismissible**
|
|
- Enable dismissible → Verify "X" button appears, clicking closes overlay
|
|
- Disable dismissible → Verify no "X" button, must wait for auto-hide
|
|
|
|
4. **Test template variables**
|
|
- Set message to "Tech: {{technician_name}} | Company: {{company}} | Time: {{time}}"
|
|
- Connect to session
|
|
- Verify all three variables substituted correctly
|
|
|
|
5. **Test duration range**
|
|
- Set duration to 5 seconds → Verify auto-hide after 5 seconds
|
|
- Set duration to 60 seconds → Verify auto-hide after 60 seconds
|
|
|
|
6. **Test disable notification**
|
|
- Disable toggle
|
|
- Connect to session
|
|
- Verify no overlay appears
|
|
|
|
---
|
|
|
|
## Rollout Plan
|
|
|
|
### Phase 1: Database Migration (Week 1)
|
|
1. Deploy migration `006_notification_config.sql` to production database
|
|
2. Verify singleton row inserted with default config (`enabled=false`)
|
|
|
|
### Phase 2: Server API (Week 2)
|
|
1. Deploy server update with new API endpoints (`GET/PUT /api/notification-config`)
|
|
2. Deploy relay handler changes to include `NotificationConfig` in `StartStream`
|
|
3. Verify API endpoints accessible to admin users
|
|
4. Verify protobuf compilation succeeds
|
|
|
|
### Phase 3: Dashboard UI (Week 3)
|
|
1. Deploy dashboard with new settings page
|
|
2. Verify admin users can access `/settings/notifications`
|
|
3. Test enable/disable, message template, duration slider, position dropdown
|
|
4. Verify live preview renders correctly
|
|
|
|
### Phase 4: Agent Update (Week 3-4)
|
|
1. Build agent with `notification/mod.rs` and `StartStream` handling
|
|
2. Test on Windows 7, 10, 11 (various DPI settings)
|
|
3. Deploy updated agent to test machines
|
|
4. Enable notification config on test environment
|
|
5. Verify overlay appears on viewer connection
|
|
|
|
### Phase 5: Production Rollout (Week 4)
|
|
1. Default config remains `enabled=false` for backward compatibility
|
|
2. Admins opt-in by enabling notification via dashboard
|
|
3. Monitor logs for notification display events
|
|
4. Gather user feedback on overlay placement, duration, messaging
|
|
|
|
### Backward Compatibility
|
|
|
|
- **Older agents:** Gracefully ignore the new `notification_config` field in `StartStream` (protobuf allows optional fields)
|
|
- **Older servers:** If agent is updated but server is not, agent will never receive `NotificationConfig` (no overlay)
|
|
- **Database:** Migration is additive (new table), does not alter existing schema
|
|
- **Default state:** Feature disabled by default (`enabled=false`), admins must explicitly enable
|
|
|
|
---
|
|
|
|
## Effort Estimate & Dependencies
|
|
|
|
### Effort: Medium (3-4 weeks)
|
|
|
|
**Breakdown by component:**
|
|
- **Agent overlay rendering (1.5 weeks):**
|
|
- Windows CreateWindowExW, layered window, alpha blending (0.5 week)
|
|
- Text rendering, multi-monitor support (0.5 week)
|
|
- Auto-hide timer, dismiss button (0.5 week)
|
|
- **Server API (0.5 week):**
|
|
- Database migration, API endpoints (0.25 week)
|
|
- Include NotificationConfig in StartStream relay handler (0.25 week)
|
|
- **Dashboard UI (1 week):**
|
|
- Settings page form (0.5 week)
|
|
- Live preview, template variable hints (0.25 week)
|
|
- Styling, responsive layout (0.25 week)
|
|
- **Testing & QA (0.5 week):**
|
|
- Unit tests, integration tests (0.25 week)
|
|
- Manual testing across Windows versions, multi-monitor (0.25 week)
|
|
- **Documentation (0.5 week):**
|
|
- User guide for admins (how to configure)
|
|
- End-user documentation (what the notification means)
|
|
|
|
### Dependencies
|
|
|
|
**Must be completed first:**
|
|
- None (standalone feature)
|
|
|
|
**Enables future features:**
|
|
- **Session recording notification** - Reuse notification overlay to show "This session is being recorded"
|
|
- **File transfer alerts** - Show overlay when technician initiates file transfer
|
|
- **Clipboard sync notification** - Alert user when clipboard is synced
|
|
- **Multi-monitor selection** - Extend position options to specify which monitor in multi-monitor setups
|
|
|
|
---
|
|
|
|
## Open Questions
|
|
|
|
1. **Logo/image support in v1?**
|
|
- **Current decision:** Text-only in v1 for simplicity and security (no image rendering attack surface)
|
|
- **Future:** v2 could support company logo from branding config (PNG, max 32x32 pixels)
|
|
|
|
2. **Sound alert on connection?**
|
|
- **Current decision:** Visual-only in v1 (no audio)
|
|
- **Future:** Optional sound effect configurable in dashboard settings
|
|
|
|
3. **Notification on disconnect?**
|
|
- **Current decision:** Only on connect (StartStream) in v1
|
|
- **Future:** Optional "Technician disconnected" notification on StopStream
|
|
|
|
4. **Per-technician customization?**
|
|
- **Current decision:** Organization-wide settings in v1 (all technicians use same config)
|
|
- **Future:** Per-role or per-technician message templates (e.g., "Tier 1 Support" vs. "Senior Admin")
|
|
|
|
5. **Multi-language support?**
|
|
- **Current decision:** English-only in v1 (admin sets message template in their language)
|
|
- **Future:** Localization based on agent's Windows locale (detect language, load translated template)
|
|
|
|
6. **Animation effects?**
|
|
- **Current decision:** Instant show/hide in v1 (no fade-in/fade-out)
|
|
- **Future:** Optional fade animations using `SetLayeredWindowAttributes` with gradual alpha change
|
|
|
|
---
|
|
|
|
## References
|
|
|
|
- Related roadmap section: Core Remote Control
|
|
- Existing code:
|
|
- `agent/src/tray/mod.rs` - Tray icon with tooltip (Windows UI patterns)
|
|
- `agent/src/consent/mod.rs` - Windows MessageBox (MB_TOPMOST, MB_SYSTEMMODAL)
|
|
- `agent/src/config.rs` - Configuration management (TOML + embedded config)
|
|
- `proto/guruconnect.proto` - StartStream (line 285), StopStream (line 298)
|
|
- `server/src/session/mod.rs` - Session lifecycle, viewer_count tracking (line 433)
|
|
- `server/migrations/004_v2_secure_session_core.sql` - Database migration pattern
|
|
- `dashboard/src/features/settings/` - Admin settings pages (branding example)
|
|
- External documentation:
|
|
- Windows Layered Windows: https://docs.microsoft.com/en-us/windows/win32/winmsg/window-features#layered-windows
|
|
- SetLayeredWindowAttributes: https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setlayeredwindowattributes
|
|
- CreateWindowExW: https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-createwindowexw
|
|
|
|
---
|
|
|
|
**Next Steps:**
|
|
1. Review specification with team
|
|
2. Refine based on feedback
|
|
3. Move to sprint backlog
|
|
4. Assign to developer
|
|
5. Create tracking issue on Gitea: https://git.azcomputerguru.com/azcomputerguru/guru-connect/issues
|