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>
37 KiB
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
StartStreammessage to includeNotificationConfig - 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
StartStreammessages from relay server - Extracts
NotificationConfigfrom message and displays overlay - Auto-hides after
duration_secsor on manual dismiss - Logs notification events at INFO level
Server (Rust/Axum):
- New table
notification_config(singleton, similar tobranding_config) - New API endpoints:
GET /api/notification-config(admin-only) - Fetch current configPUT /api/notification-config(admin-only) - Update config
- Include
NotificationConfigproto message inStartStreamwhen 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 = 4field toStartStream
Data Flow
-
Admin configures notification (dashboard)
- Admin opens Settings → Notification Overlay
- Configures message template, duration, position, dismissibility
- Saves via
PUT /api/notification-config - Server updates
notification_configtable (singleton row)
-
Viewer connects to session (runtime)
- Viewer requests session via dashboard
- Server sends
StartStreammessage to agent, including currentNotificationConfig - Agent receives
StartStream, checks ifnotification_config.enabled == true - Agent spawns overlay window with configured message/position/duration
- Overlay auto-hides after
duration_secsOR user clicks "X" (if dismissible)
-
Configuration changes apply immediately
- Next
StartStreammessage includes updated config (no agent restart needed)
- Next
Database Schema
New table: notification_config (singleton)
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):
// ============================================================================
// 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):
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):
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
//! 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:
// 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:
mod notification;
Server (server/src/)
New migration: server/migrations/006_notification_config.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
//! 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:
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:
// 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:
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
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
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:
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:
<NavLink to="/settings/notifications">
<BellIcon /> Notification Overlay
</NavLink>
Security Considerations
Authentication and Authorization
- Admin-only endpoints:
GET /api/notification-configandPUT /api/notification-configrequireRequireRole<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
eventstable when admin updates notification settings- Event type:
notification_config_updated - Include old/new values (enabled, message_template, duration, position)
- Logged by admin user ID
- Event type:
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 whenenabled=false
Server (server/src/api/notification.rs):
test_validate_duration- Reject invalid durations (<5 or >60 seconds)test_validate_position- Reject invalid position stringstest_admin_only- Non-admin users get 403 Forbidden on PUT endpoint
Integration Tests
End-to-end flow:
- Admin enables notification via dashboard →
PUT /api/notification-configwithenabled=true - Viewer connects to session → Server sends
StartStreamwithNotificationConfig - Agent receives
StartStream→ Overlay window appears at configured position - Wait for
duration_secs→ Overlay auto-hides - 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
NotificationConfighandling) - Verify session works normally, older agent ignores unknown protobuf field
Manual Test Scenarios
-
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
-
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
-
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
-
Test template variables
- Set message to "Tech: {{technician_name}} | Company: {{company}} | Time: {{time}}"
- Connect to session
- Verify all three variables substituted correctly
-
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
-
Test disable notification
- Disable toggle
- Connect to session
- Verify no overlay appears
Rollout Plan
Phase 1: Database Migration (Week 1)
- Deploy migration
006_notification_config.sqlto production database - Verify singleton row inserted with default config (
enabled=false)
Phase 2: Server API (Week 2)
- Deploy server update with new API endpoints (
GET/PUT /api/notification-config) - Deploy relay handler changes to include
NotificationConfiginStartStream - Verify API endpoints accessible to admin users
- Verify protobuf compilation succeeds
Phase 3: Dashboard UI (Week 3)
- Deploy dashboard with new settings page
- Verify admin users can access
/settings/notifications - Test enable/disable, message template, duration slider, position dropdown
- Verify live preview renders correctly
Phase 4: Agent Update (Week 3-4)
- Build agent with
notification/mod.rsandStartStreamhandling - Test on Windows 7, 10, 11 (various DPI settings)
- Deploy updated agent to test machines
- Enable notification config on test environment
- Verify overlay appears on viewer connection
Phase 5: Production Rollout (Week 4)
- Default config remains
enabled=falsefor backward compatibility - Admins opt-in by enabling notification via dashboard
- Monitor logs for notification display events
- Gather user feedback on overlay placement, duration, messaging
Backward Compatibility
- Older agents: Gracefully ignore the new
notification_configfield inStartStream(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
-
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)
-
Sound alert on connection?
- Current decision: Visual-only in v1 (no audio)
- Future: Optional sound effect configurable in dashboard settings
-
Notification on disconnect?
- Current decision: Only on connect (StartStream) in v1
- Future: Optional "Technician disconnected" notification on StopStream
-
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")
-
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)
-
Animation effects?
- Current decision: Instant show/hide in v1 (no fade-in/fade-out)
- Future: Optional fade animations using
SetLayeredWindowAttributeswith 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 patterndashboard/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:
- Review specification with team
- Refine based on feedback
- Move to sprint backlog
- Assign to developer
- Create tracking issue on Gitea: https://git.azcomputerguru.com/azcomputerguru/guru-connect/issues