Add Python agent, WebSocket relay, real viewer, enrollment tokens

- WebSocket relay service (FastAPI) bridges agents and viewers
- Python agent with screen capture (mss), input control (pynput),
  script execution, and auto-reconnect
- Windows service wrapper, PyInstaller spec, NSIS installer for
  silent mass deployment (RemoteLink-Setup.exe /S /SERVER= /ENROLL=)
- Enrollment token system: admin generates tokens, agents self-register
- Real WebSocket viewer replaces simulated canvas
- Linux agent binary served from /downloads/remotelink-agent-linux
- DB migration 0002: viewer_token on sessions, enrollment_tokens table
- Sign-up pages cleaned up (invite-only redirect)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
monoadmin
2026-04-10 16:25:10 -07:00
parent b2be19ed14
commit e16a2fa978
28 changed files with 1953 additions and 343 deletions

View File

@@ -11,7 +11,34 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { UserPlus, Copy, Check, Trash2, Clock, CheckCircle2, Loader2 } from 'lucide-react'
import {
UserPlus, Copy, Check, Trash2, Clock, CheckCircle2,
Loader2, KeyRound, Ban, Infinity, Hash,
} from 'lucide-react'
// ── Shared helper ─────────────────────────────────────────────────────────────
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false)
return (
<Button
variant="ghost"
size="sm"
className="h-7 px-2"
onClick={async () => {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}}
>
{copied
? <Check className="h-3.5 w-3.5 text-green-500" />
: <Copy className="h-3.5 w-3.5" />}
</Button>
)
}
// ── Invites ───────────────────────────────────────────────────────────────────
interface Invite {
id: string
@@ -28,30 +55,14 @@ function inviteStatus(invite: Invite): 'used' | 'expired' | 'pending' {
return 'pending'
}
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false)
const handleCopy = async () => {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<Button variant="ghost" size="sm" onClick={handleCopy} className="h-7 px-2">
{copied ? <Check className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />}
</Button>
)
}
export default function AdminPage() {
function InvitesSection() {
const [invites, setInvites] = useState<Invite[]>([])
const [email, setEmail] = useState('')
const [isLoading, setIsLoading] = useState(true)
const [isCreating, setIsCreating] = useState(false)
const [error, setError] = useState<string | null>(null)
const [successEmail, setSuccessEmail] = useState<string | null>(null)
const [newToken, setNewToken] = useState<string | null>(null)
const [successEmail, setSuccessEmail] = useState<string | null>(null)
const fetchInvites = useCallback(async () => {
try {
@@ -63,17 +74,12 @@ export default function AdminPage() {
}
}, [])
useEffect(() => {
fetchInvites()
}, [fetchInvites])
useEffect(() => { fetchInvites() }, [fetchInvites])
const handleCreate = async (e: React.FormEvent) => {
const handleCreate = async (e: React.SyntheticEvent<HTMLFormElement>) => {
e.preventDefault()
setError(null)
setSuccessEmail(null)
setNewToken(null)
setError(null); setSuccessEmail(null); setNewToken(null)
setIsCreating(true)
try {
const res = await fetch('/api/invites', {
method: 'POST',
@@ -81,12 +87,7 @@ export default function AdminPage() {
body: JSON.stringify({ email }),
})
const data = await res.json()
if (!res.ok) {
setError(data.error)
return
}
if (!res.ok) { setError(data.error); return }
setSuccessEmail(email)
setNewToken(data.invite.token)
setEmail('')
@@ -99,95 +100,48 @@ export default function AdminPage() {
}
const handleDelete = async (id: string) => {
const res = await fetch('/api/invites', {
await fetch('/api/invites', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }),
})
if (res.ok) await fetchInvites()
await fetchInvites()
}
const inviteUrl = (token: string) =>
`${window.location.origin}/auth/invite/${token}`
const inviteUrl = (token: string) => `${window.location.origin}/auth/invite/${token}`
const statusBadge = (invite: Invite) => {
const status = inviteStatus(invite)
if (status === 'used')
return (
<span className="flex items-center gap-1 text-xs text-green-500">
<CheckCircle2 className="h-3 w-3" /> Used
</span>
)
if (status === 'expired')
return (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="h-3 w-3" /> Expired
</span>
)
return (
<span className="flex items-center gap-1 text-xs text-primary">
<Clock className="h-3 w-3" /> Pending
</span>
)
const s = inviteStatus(invite)
if (s === 'used') return <span className="flex items-center gap-1 text-xs text-green-500"><CheckCircle2 className="h-3 w-3" /> Used</span>
if (s === 'expired') return <span className="flex items-center gap-1 text-xs text-muted-foreground"><Clock className="h-3 w-3" /> Expired</span>
return <span className="flex items-center gap-1 text-xs text-primary"><Clock className="h-3 w-3" /> Pending</span>
}
return (
<div className="space-y-6 max-w-2xl">
<div>
<h2 className="text-2xl font-bold">Admin</h2>
<p className="text-muted-foreground">Manage user invitations</p>
</div>
{/* Create invite */}
<div className="space-y-4">
<Card className="border-border/50">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<UserPlus className="h-5 w-5" />
Invite user
</CardTitle>
<CardDescription>
Send an invite link to a new user. Links expire after 7 days.
</CardDescription>
<CardTitle className="flex items-center gap-2"><UserPlus className="h-5 w-5" />Invite user</CardTitle>
<CardDescription>Send an invite link to a new user. Links expire after 7 days.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleCreate} className="space-y-4">
<div className="grid gap-2">
<Label htmlFor="email">Email address</Label>
<div className="flex gap-2">
<Input
id="email"
type="email"
placeholder="user@company.com"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="bg-secondary/50"
/>
<Input id="email" type="email" placeholder="user@company.com" required value={email}
onChange={(e) => setEmail(e.target.value)} className="bg-secondary/50" />
<Button type="submit" disabled={isCreating}>
{isCreating ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Send invite'
)}
{isCreating ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Send invite'}
</Button>
</div>
</div>
{error && (
<div className="rounded-md bg-destructive/10 border border-destructive/20 p-3">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
{error && <div className="rounded-md bg-destructive/10 border border-destructive/20 p-3"><p className="text-sm text-destructive">{error}</p></div>}
{newToken && successEmail && (
<div className="rounded-md bg-green-500/10 border border-green-500/20 p-3 space-y-2">
<p className="text-sm text-green-500 font-medium">
Invite created for {successEmail}
</p>
<p className="text-sm text-green-500 font-medium">Invite created for {successEmail}</p>
<div className="flex items-center gap-2">
<code className="text-xs text-muted-foreground truncate flex-1 bg-muted/50 px-2 py-1 rounded">
{inviteUrl(newToken)}
</code>
<code className="text-xs text-muted-foreground truncate flex-1 bg-muted/50 px-2 py-1 rounded">{inviteUrl(newToken)}</code>
<CopyButton text={inviteUrl(newToken)} />
</div>
</div>
@@ -196,7 +150,6 @@ export default function AdminPage() {
</CardContent>
</Card>
{/* Invites list */}
<Card className="border-border/50">
<CardHeader>
<CardTitle>Invitations</CardTitle>
@@ -204,41 +157,26 @@ export default function AdminPage() {
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
<div className="flex justify-center py-8"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : invites.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No invitations yet
</p>
<p className="text-sm text-muted-foreground text-center py-8">No invitations yet</p>
) : (
<div className="space-y-2">
{invites.map((invite) => {
const status = inviteStatus(invite)
return (
<div
key={invite.id}
className="flex items-center justify-between p-3 rounded-lg bg-muted/30"
>
<div key={invite.id} className="flex items-center justify-between p-3 rounded-lg bg-muted/30">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{invite.email}</p>
<div className="flex items-center gap-3 mt-0.5">
{statusBadge(invite)}
<span className="text-xs text-muted-foreground">
{new Date(invite.created_at).toLocaleDateString()}
</span>
<span className="text-xs text-muted-foreground">{new Date(invite.created_at).toLocaleDateString()}</span>
</div>
</div>
<div className="flex items-center gap-1 ml-3">
{status === 'pending' && (
<CopyButton text={inviteUrl(invite.token)} />
)}
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-muted-foreground hover:text-destructive"
onClick={() => handleDelete(invite.id)}
>
{status === 'pending' && <CopyButton text={inviteUrl(invite.token)} />}
<Button variant="ghost" size="sm" className="h-7 px-2 text-muted-foreground hover:text-destructive"
onClick={() => handleDelete(invite.id)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
@@ -252,3 +190,269 @@ export default function AdminPage() {
</div>
)
}
// ── Enrollment tokens ─────────────────────────────────────────────────────────
interface EnrollmentToken {
id: string
token: string
label: string | null
created_at: string
expires_at: string | null
used_count: number
max_uses: number | null
revoked_at: string | null
}
function tokenStatus(t: EnrollmentToken): 'active' | 'revoked' | 'expired' | 'exhausted' {
if (t.revoked_at) return 'revoked'
if (t.expires_at && new Date(t.expires_at) < new Date()) return 'expired'
if (t.max_uses !== null && t.used_count >= t.max_uses) return 'exhausted'
return 'active'
}
function EnrollmentSection() {
const [tokens, setTokens] = useState<EnrollmentToken[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isCreating, setIsCreating] = useState(false)
const [error, setError] = useState<string | null>(null)
const [newToken, setNewToken] = useState<EnrollmentToken | null>(null)
// form state
const [label, setLabel] = useState('')
const [expiresInDays, setExpiresInDays] = useState('')
const [maxUses, setMaxUses] = useState('')
const fetchTokens = useCallback(async () => {
try {
const res = await fetch('/api/enrollment-tokens')
const data = await res.json()
if (res.ok) setTokens(data.tokens)
} finally {
setIsLoading(false)
}
}, [])
useEffect(() => { fetchTokens() }, [fetchTokens])
const handleCreate = async (e: React.SyntheticEvent<HTMLFormElement>) => {
e.preventDefault()
setError(null); setNewToken(null)
setIsCreating(true)
try {
const res = await fetch('/api/enrollment-tokens', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
label: label || undefined,
expiresInDays: expiresInDays ? parseInt(expiresInDays) : undefined,
maxUses: maxUses ? parseInt(maxUses) : undefined,
}),
})
const data = await res.json()
if (!res.ok) { setError(data.error); return }
setNewToken(data.token)
setLabel(''); setExpiresInDays(''); setMaxUses('')
await fetchTokens()
} catch {
setError('Network error. Please try again.')
} finally {
setIsCreating(false)
}
}
const handleRevoke = async (id: string) => {
await fetch('/api/enrollment-tokens', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }),
})
await fetchTokens()
}
const statusBadge = (t: EnrollmentToken) => {
const s = tokenStatus(t)
const styles: Record<string, string> = {
active: 'text-green-500',
revoked: 'text-destructive',
expired: 'text-muted-foreground',
exhausted: 'text-yellow-500',
}
const labels: Record<string, string> = {
active: 'Active',
revoked: 'Revoked',
expired: 'Expired',
exhausted: 'Exhausted',
}
return <span className={`text-xs font-medium ${styles[s]}`}>{labels[s]}</span>
}
const agentCommand = (token: string) =>
`python agent.py --server ${window.location.origin} --enroll ${token}`
return (
<div className="space-y-4">
<Card className="border-border/50">
<CardHeader>
<CardTitle className="flex items-center gap-2"><KeyRound className="h-5 w-5" />Create enrollment token</CardTitle>
<CardDescription>
Tokens let agents self-register. Share the token or use it in a silent installer for mass deployment.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleCreate} className="space-y-4">
<div className="grid gap-2">
<Label htmlFor="token-label">Label <span className="text-muted-foreground font-normal">(optional)</span></Label>
<Input id="token-label" placeholder="e.g. Office PCs batch 1"
value={label} onChange={(e) => setLabel(e.target.value)} className="bg-secondary/50" />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-2">
<Label htmlFor="expires">Expires after <span className="text-muted-foreground font-normal">(days, blank = never)</span></Label>
<Input id="expires" type="number" min="1" placeholder="Never"
value={expiresInDays} onChange={(e) => setExpiresInDays(e.target.value)} className="bg-secondary/50" />
</div>
<div className="grid gap-2">
<Label htmlFor="max-uses">Max uses <span className="text-muted-foreground font-normal">(blank = unlimited)</span></Label>
<Input id="max-uses" type="number" min="1" placeholder="Unlimited"
value={maxUses} onChange={(e) => setMaxUses(e.target.value)} className="bg-secondary/50" />
</div>
</div>
{error && <div className="rounded-md bg-destructive/10 border border-destructive/20 p-3"><p className="text-sm text-destructive">{error}</p></div>}
<Button type="submit" disabled={isCreating}>
{isCreating ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Generate token'}
</Button>
</form>
{newToken && (
<div className="mt-4 rounded-md bg-green-500/10 border border-green-500/20 p-4 space-y-3">
<p className="text-sm text-green-500 font-medium">Token created</p>
<div>
<p className="text-xs text-muted-foreground mb-1">Token</p>
<div className="flex items-center gap-2">
<code className="text-xs bg-muted/50 px-2 py-1 rounded flex-1 truncate font-mono">{newToken.token}</code>
<CopyButton text={newToken.token} />
</div>
</div>
<div>
<p className="text-xs text-muted-foreground mb-1">Agent command</p>
<div className="flex items-center gap-2">
<code className="text-xs bg-muted/50 px-2 py-1 rounded flex-1 truncate font-mono">{agentCommand(newToken.token)}</code>
<CopyButton text={agentCommand(newToken.token)} />
</div>
</div>
<div>
<p className="text-xs text-muted-foreground mb-1">Silent Windows installer</p>
<div className="flex items-center gap-2">
<code className="text-xs bg-muted/50 px-2 py-1 rounded flex-1 truncate font-mono">
{`RemoteLink-Setup.exe /S /SERVER=${window.location.origin} /ENROLL=${newToken.token}`}
</code>
<CopyButton text={`RemoteLink-Setup.exe /S /SERVER=${window.location.origin} /ENROLL=${newToken.token}`} />
</div>
</div>
</div>
)}
</CardContent>
</Card>
<Card className="border-border/50">
<CardHeader>
<CardTitle>Enrollment tokens</CardTitle>
<CardDescription>Manage tokens used to register new agents</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex justify-center py-8"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : tokens.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">No enrollment tokens yet</p>
) : (
<div className="space-y-2">
{tokens.map((t) => {
const status = tokenStatus(t)
const isActive = status === 'active'
return (
<div key={t.id} className={`flex items-center justify-between p-3 rounded-lg bg-muted/30 ${!isActive ? 'opacity-60' : ''}`}>
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center gap-2">
<p className="text-sm font-medium truncate">{t.label || <span className="text-muted-foreground italic">Unlabelled</span>}</p>
{statusBadge(t)}
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="font-mono truncate max-w-[160px]">{t.token}</span>
<span className="flex items-center gap-1">
<Hash className="h-3 w-3" />
{t.used_count} use{t.used_count !== 1 ? 's' : ''}
{t.max_uses !== null ? ` / ${t.max_uses}` : ''}
</span>
{t.expires_at ? (
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{new Date(t.expires_at) > new Date()
? `Expires ${new Date(t.expires_at).toLocaleDateString()}`
: `Expired ${new Date(t.expires_at).toLocaleDateString()}`}
</span>
) : (
<span className="flex items-center gap-1"><Infinity className="h-3 w-3" /> No expiry</span>
)}
</div>
</div>
<div className="flex items-center gap-1 ml-3 shrink-0">
{isActive && <CopyButton text={t.token} />}
{isActive && (
<Button variant="ghost" size="sm" className="h-7 px-2 text-muted-foreground hover:text-destructive"
onClick={() => handleRevoke(t.id)} title="Revoke token">
<Ban className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
</div>
)
}
// ── Page ──────────────────────────────────────────────────────────────────────
type Tab = 'invites' | 'enrollment'
export default function AdminPage() {
const [tab, setTab] = useState<Tab>('invites')
return (
<div className="space-y-6 max-w-2xl">
<div>
<h2 className="text-2xl font-bold">Admin</h2>
<p className="text-muted-foreground">Manage users and agent deployment</p>
</div>
{/* Tab bar */}
<div className="flex gap-1 p-1 rounded-lg bg-muted/50 w-fit">
{(['invites', 'enrollment'] as Tab[]).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`px-4 py-1.5 rounded-md text-sm font-medium transition-colors ${
tab === t
? 'bg-background shadow-sm text-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{t === 'invites' ? 'User invites' : 'Agent enrollment'}
</button>
))}
</div>
{tab === 'invites' ? <InvitesSection /> : <EnrollmentSection />}
</div>
)
}

View File

@@ -42,7 +42,7 @@ export default function ConnectPage() {
return
}
router.push(`/viewer/${data.sessionId}`)
router.push(`/viewer/${data.sessionId}?token=${data.viewerToken}`)
} catch {
setError('Network error. Please try again.')
setIsConnecting(false)

View File

@@ -1,14 +1,7 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import {
Download,
Monitor,
Apple,
Terminal,
Shield,
Cpu,
HardDrive
} from 'lucide-react'
import { Download, Monitor, Apple, Terminal, Shield, Cpu, HardDrive, Clock } from 'lucide-react'
import Link from 'next/link'
const platforms = [
{
@@ -16,43 +9,35 @@ const platforms = [
icon: Monitor,
description: 'Windows 10/11 (64-bit)',
filename: 'RemoteLink-Setup.exe',
size: '45 MB',
available: true,
downloadPath: null,
available: false,
note: 'Coming soon — build on Windows with PyInstaller + NSIS',
},
{
name: 'macOS',
icon: Apple,
description: 'macOS 11+ (Apple Silicon & Intel)',
filename: 'RemoteLink.dmg',
size: '52 MB',
available: true,
filename: 'remotelink-agent-macos',
downloadPath: null,
available: false,
note: 'Coming soon — build on macOS with PyInstaller',
},
{
name: 'Linux',
icon: Terminal,
description: 'Ubuntu, Debian, Fedora, Arch',
filename: 'remotelink-agent.AppImage',
size: '48 MB',
description: 'x86_64 — Ubuntu, Debian, Fedora, Arch',
filename: 'remotelink-agent-linux',
downloadPath: '/downloads/remotelink-agent-linux',
available: true,
size: '19 MB',
note: null,
},
]
const features = [
{
icon: Shield,
title: 'Secure',
description: 'End-to-end encryption for all connections',
},
{
icon: Cpu,
title: 'Lightweight',
description: 'Minimal system resource usage',
},
{
icon: HardDrive,
title: 'Portable',
description: 'No installation required on Windows',
},
{ icon: Shield, title: 'Secure', description: 'All traffic routed through your own relay server' },
{ icon: Cpu, title: 'Lightweight', description: 'Single binary, minimal CPU and memory usage' },
{ icon: HardDrive, title: 'Portable', description: 'Run once with no install, or deploy as a service' },
]
export default function DownloadPage() {
@@ -61,17 +46,17 @@ export default function DownloadPage() {
<div className="text-center">
<h2 className="text-2xl font-bold mb-2">Download RemoteLink Agent</h2>
<p className="text-muted-foreground max-w-xl mx-auto text-balance">
Install the agent on machines you want to control remotely.
The agent runs in the background and enables secure connections.
Install the agent on machines you want to control remotely.
It connects back to your server and waits for a viewer session.
</p>
</div>
<div className="grid gap-4 md:grid-cols-3">
{platforms.map((platform) => (
<Card key={platform.name} className="border-border/50">
<Card key={platform.name} className={`border-border/50 ${!platform.available ? 'opacity-60' : ''}`}>
<CardHeader className="text-center pb-2">
<div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-xl bg-primary/10">
<platform.icon className="h-7 w-7 text-primary" />
<div className={`mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-xl ${platform.available ? 'bg-primary/10' : 'bg-muted'}`}>
<platform.icon className={`h-7 w-7 ${platform.available ? 'text-primary' : 'text-muted-foreground'}`} />
</div>
<CardTitle>{platform.name}</CardTitle>
<CardDescription>{platform.description}</CardDescription>
@@ -79,15 +64,26 @@ export default function DownloadPage() {
<CardContent className="space-y-4">
<div className="text-center text-sm text-muted-foreground">
<p className="font-mono">{platform.filename}</p>
<p>{platform.size}</p>
{platform.available && 'size' in platform && <p>{platform.size}</p>}
</div>
<Button
className="w-full"
disabled={!platform.available}
>
<Download className="mr-2 h-4 w-4" />
Download
</Button>
{platform.available && platform.downloadPath ? (
<Button className="w-full" asChild>
<a href={platform.downloadPath} download={platform.filename}>
<Download className="mr-2 h-4 w-4" />
Download
</a>
</Button>
) : (
<Button className="w-full" disabled>
<Clock className="mr-2 h-4 w-4" />
Coming soon
</Button>
)}
{platform.note && (
<p className="text-xs text-muted-foreground text-center">{platform.note}</p>
)}
</CardContent>
</Card>
))}
@@ -118,30 +114,27 @@ export default function DownloadPage() {
</CardHeader>
<CardContent className="space-y-6">
<div>
<h4 className="font-medium mb-2">Windows</h4>
<h4 className="font-medium mb-2">Linux run once (portable)</h4>
<ol className="list-decimal list-inside space-y-1 text-sm text-muted-foreground">
<li>Download and run RemoteLink-Setup.exe</li>
<li>Follow the installation wizard</li>
<li>The agent will start automatically and appear in your system tray</li>
<li>Click the tray icon to generate a session code</li>
<li>Download <code className="bg-muted px-1 rounded">remotelink-agent-linux</code></li>
<li>Make it executable: <code className="bg-muted px-1 rounded">chmod +x remotelink-agent-linux</code></li>
<li>Get an enrollment token from <Link href="/dashboard/admin" className="text-primary underline underline-offset-2">Admin Agent enrollment</Link></li>
<li>Run: <code className="bg-muted px-1 rounded">./remotelink-agent-linux --server https://your-server --enroll YOUR_TOKEN --run-once</code></li>
</ol>
</div>
<div>
<h4 className="font-medium mb-2">macOS</h4>
<h4 className="font-medium mb-2">Linux permanent install (reconnects on reboot)</h4>
<ol className="list-decimal list-inside space-y-1 text-sm text-muted-foreground">
<li>Download and open RemoteLink.dmg</li>
<li>Drag RemoteLink to your Applications folder</li>
<li>Open RemoteLink from Applications</li>
<li>Grant accessibility permissions when prompted</li>
<li>Run without <code className="bg-muted px-1 rounded">--run-once</code> config is saved to <code className="bg-muted px-1 rounded">/etc/remotelink/agent.json</code></li>
<li>Create a systemd service or add to crontab with <code className="bg-muted px-1 rounded">@reboot</code></li>
</ol>
</div>
<div>
<h4 className="font-medium mb-2">Linux</h4>
<h4 className="font-medium mb-2">Windows silent mass deploy (coming soon)</h4>
<ol className="list-decimal list-inside space-y-1 text-sm text-muted-foreground">
<li>Download the AppImage file</li>
<li>Make it executable: <code className="bg-muted px-1 rounded">chmod +x remotelink-agent.AppImage</code></li>
<li>Run the AppImage</li>
<li>The agent will appear in your system tray</li>
<li>Build <code className="bg-muted px-1 rounded">RemoteLink-Setup.exe</code> on a Windows machine using the NSIS installer script in the agent source</li>
<li>Deploy silently: <code className="bg-muted px-1 rounded">RemoteLink-Setup.exe /S /SERVER=https://your-server /ENROLL=YOUR_TOKEN</code></li>
<li>The installer registers a Windows Service that auto-starts on boot</li>
</ol>
</div>
</CardContent>