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

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 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)

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(
            &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:

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-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


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