Files
guru-connect/docs/specs/SPEC-015-notification-overlay.md
azcomputerguru afbf0d81b8
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 8m0s
Build and Test / Build Server (Linux) (push) Successful in 11m26s
Build and Test / Security Audit (push) Successful in 4m37s
Build and Test / Build Summary (push) Successful in 12s
spec: add SPEC-015 Configurable Notification Overlay
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>
2026-05-31 08:40:53 -07:00

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(
&notification_config.message_template,
&notification_config.technician_name,
&notification_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(&notification_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