Files
remotelink-docker/components/viewer/toolbar.tsx
monoadmin 61edbf59bf Add ScreenConnect-parity features (high + medium)
Viewer:
- Toolbar: Ctrl+Alt+Del, clipboard paste, monitor picker, file transfer, chat, WoL buttons
- Multi-monitor: agent sends monitor_list on connect, viewer can switch via dropdown
- Clipboard sync: agent polls local clipboard → sends to viewer; viewer paste → agent sets remote clipboard
- File transfer panel: drag-drop upload to agent, directory browser, download files from remote
- Chat panel: bidirectional text chat forwarded through relay

Agent:
- Multi-monitor capture with set_monitor/set_quality message handlers
- exec_key_combo for Ctrl+Alt+Del and arbitrary combos
- Clipboard polling via pyperclip (both directions)
- File upload/download/list_files with base64 chunked protocol
- Attended mode (--attended): zenity/kdialog/PowerShell consent dialog before accepting stream
- Auto-update: heartbeat checks version, downloads new binary and exec-replaces self (Linux)
- Reports MAC address on registration (for WoL)

Relay:
- Forwards monitor_list, clipboard_content, file_chunk, file_list, chat_message agent→viewer
- Session recording: when RECORDING_DIR env set, saves JPEG frames as .remrec files
- ALLOWED_ORIGINS CORS now set from NEXT_PUBLIC_APP_URL in docker-compose

Database:
- groups table (id, name, description, created_by)
- machines: group_id, mac_address, notes, tags text[]
- Migration 0003 applied

Dashboard:
- Machines page: search, tag filter, group filter, inline notes/tags/rename editing
- MachineCard: inline tag management, group picker, notes textarea
- Admin page: new Groups tab (create/list/delete groups)
- API: PATCH /api/machines/[id] (name, notes, tags, groupId)
- API: GET/POST/DELETE /api/groups
- API: POST /api/machines/wol (broadcast magic packet)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 23:57:47 -07:00

294 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Maximize2,
Minimize2,
Monitor,
Signal,
SignalLow,
SignalMedium,
Keyboard,
MousePointer2,
Loader2,
CheckCircle2,
RefreshCw,
X,
Clipboard,
FolderOpen,
ChevronDown,
MessageSquare,
Zap,
} from 'lucide-react'
interface MonitorInfo {
index: number
width: number
height: number
primary: boolean
}
interface Session {
id: string
machineId: string | null
machineName: string | null
startedAt: string
}
type ConnectionState = 'connecting' | 'waiting' | 'connected' | 'disconnected' | 'error'
interface ViewerToolbarProps {
session: Session | null
connectionState: ConnectionState
isFullscreen: boolean
quality: 'high' | 'medium' | 'low'
isMuted: boolean
monitors: MonitorInfo[]
activeMonitor: number
onToggleFullscreen: () => void
onQualityChange: (quality: 'high' | 'medium' | 'low') => void
onToggleMute: () => void
onDisconnect: () => void
onReconnect: () => void
onCtrlAltDel: () => void
onClipboardPaste: () => void
onMonitorChange: (index: number) => void
onToggleFileTransfer: () => void
onToggleChat: () => void
onWakeOnLan?: () => void
machineName?: string
}
export function ViewerToolbar({
session,
connectionState,
isFullscreen,
quality,
monitors,
activeMonitor,
onToggleFullscreen,
onQualityChange,
onDisconnect,
onReconnect,
onCtrlAltDel,
onClipboardPaste,
onMonitorChange,
onToggleFileTransfer,
onToggleChat,
onWakeOnLan,
}: ViewerToolbarProps) {
const connected = connectionState === 'connected'
const getQualityIcon = () => {
switch (quality) {
case 'high': return Signal
case 'medium': return SignalMedium
case 'low': return SignalLow
}
}
const getConnectionBadge = () => {
switch (connectionState) {
case 'connecting':
return (
<span className="flex items-center gap-1.5 text-yellow-500 text-xs">
<Loader2 className="h-3 w-3 animate-spin" />
Connecting
</span>
)
case 'waiting':
return (
<span className="flex items-center gap-1.5 text-yellow-500 text-xs">
<span className="h-2 w-2 rounded-full bg-yellow-500 animate-pulse" />
Waiting for agent
</span>
)
case 'connected':
return (
<span className="flex items-center gap-1.5 text-green-500 text-xs">
<span className="h-2 w-2 rounded-full bg-green-500" />
Connected
</span>
)
case 'disconnected':
return (
<span className="flex items-center gap-1.5 text-muted-foreground text-xs">
<span className="h-2 w-2 rounded-full bg-muted-foreground" />
Disconnected
</span>
)
case 'error':
return (
<span className="flex items-center gap-1.5 text-destructive text-xs">
<span className="h-2 w-2 rounded-full bg-destructive" />
Error
</span>
)
}
}
const QualityIcon = getQualityIcon()
return (
<div className="flex items-center justify-between h-12 px-4 bg-card/95 backdrop-blur border-b border-border/50 gap-2">
{/* Left: machine name + status */}
<div className="flex items-center gap-4 min-w-0">
<div className="flex items-center gap-2 min-w-0">
<Monitor className="h-4 w-4 text-primary shrink-0" />
<span className="font-medium text-sm truncate">
{session?.machineName || 'Remote Machine'}
</span>
</div>
<div className="shrink-0">{getConnectionBadge()}</div>
</div>
{/* Right: controls */}
<div className="flex items-center gap-1 shrink-0">
{/* Input indicators */}
<div className="flex items-center gap-1.5 px-2 border-r border-border/50 mr-1">
<MousePointer2 className="h-3.5 w-3.5 text-muted-foreground" />
<Keyboard className="h-3.5 w-3.5 text-muted-foreground" />
</div>
{/* Monitor picker (only shown when multiple monitors) */}
{connected && monitors.length > 1 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 gap-1 px-2 text-xs">
<Monitor className="h-3.5 w-3.5" />
{monitors[activeMonitor]
? `${monitors[activeMonitor].width}×${monitors[activeMonitor].height}`
: 'Monitor'}
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Select Monitor</DropdownMenuLabel>
<DropdownMenuSeparator />
{monitors.map((m) => (
<DropdownMenuItem key={m.index} onClick={() => onMonitorChange(m.index)}>
<Monitor className="mr-2 h-4 w-4" />
{m.primary ? 'Primary' : `Display ${m.index + 1}`}
<span className="ml-auto text-xs text-muted-foreground">{m.width}×{m.height}</span>
{activeMonitor === m.index && <CheckCircle2 className="ml-2 h-4 w-4 text-primary" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Ctrl+Alt+Del */}
{connected && (
<Button
variant="ghost"
size="sm"
className="h-8 px-2 text-xs font-mono"
onClick={onCtrlAltDel}
title="Send Ctrl+Alt+Del"
>
CAD
</Button>
)}
{/* Clipboard paste */}
{connected && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={onClipboardPaste}
title="Paste clipboard to remote"
>
<Clipboard className="h-4 w-4" />
<span className="sr-only">Paste clipboard</span>
</Button>
)}
{/* File transfer */}
{connected && (
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onToggleFileTransfer} title="File transfer">
<FolderOpen className="h-4 w-4" />
<span className="sr-only">File transfer</span>
</Button>
)}
{/* Chat */}
{connected && (
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onToggleChat} title="Session chat">
<MessageSquare className="h-4 w-4" />
<span className="sr-only">Chat</span>
</Button>
)}
{/* Wake-on-LAN (shown when agent is offline) */}
{!connected && onWakeOnLan && (
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onWakeOnLan} title="Wake on LAN">
<Zap className="h-4 w-4 text-yellow-500" />
<span className="sr-only">Wake on LAN</span>
</Button>
)}
{/* Quality selector */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" title="Stream quality">
<QualityIcon className="h-4 w-4" />
<span className="sr-only">Quality</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Stream Quality</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onQualityChange('high')}>
<Signal className="mr-2 h-4 w-4" />
High
{quality === 'high' && <CheckCircle2 className="ml-auto h-4 w-4 text-primary" />}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onQualityChange('medium')}>
<SignalMedium className="mr-2 h-4 w-4" />
Medium
{quality === 'medium' && <CheckCircle2 className="ml-auto h-4 w-4 text-primary" />}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onQualityChange('low')}>
<SignalLow className="mr-2 h-4 w-4" />
Low
{quality === 'low' && <CheckCircle2 className="ml-auto h-4 w-4 text-primary" />}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Fullscreen toggle */}
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onToggleFullscreen} title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}>
{isFullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</Button>
{/* Reconnect */}
{(connectionState === 'disconnected' || connectionState === 'waiting') && (
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onReconnect} title="Reconnect">
<RefreshCw className="h-4 w-4" />
</Button>
)}
{/* Disconnect */}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={onDisconnect}
title="End session"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
)
}