dev-recall 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,230 @@
1
+ """Process and port tracker using psutil.
2
+
3
+ Polls every 30 s to detect:
4
+ - Dev server processes starting/stopping (by process name allowlist)
5
+ - Listening TCP ports opening/closing in the dev-server range
6
+
7
+ Emits PORT_OPEN / PORT_CLOSE events when a listening port appears or
8
+ disappears for a tracked process.
9
+
10
+ Gracefully becomes a no-op when psutil is not installed.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import threading
17
+ import time
18
+ from datetime import datetime, timezone
19
+ from typing import Callable, Optional
20
+
21
+ from recall.models import Event, EventType, Source
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ try:
26
+ import psutil # type: ignore
27
+
28
+ _PSUTIL_AVAILABLE = True
29
+ except ImportError:
30
+ _PSUTIL_AVAILABLE = False
31
+
32
+ # Process names that indicate a dev server or interesting background service.
33
+ # Matched as a substring of the process name or first cmdline argument.
34
+ _DEV_PROCESS_NAMES = {
35
+ "node",
36
+ "npm",
37
+ "npx",
38
+ "yarn",
39
+ "pnpm",
40
+ "bun",
41
+ "deno",
42
+ "next",
43
+ "vite",
44
+ "webpack",
45
+ "parcel",
46
+ "rollup",
47
+ "esbuild",
48
+ "turbopack",
49
+ "python",
50
+ "python3",
51
+ "uvicorn",
52
+ "gunicorn",
53
+ "hypercorn",
54
+ "fastapi",
55
+ "flask",
56
+ "django",
57
+ "starlette",
58
+ "tornado",
59
+ "aiohttp",
60
+ "ruby",
61
+ "rails",
62
+ "puma",
63
+ "unicorn",
64
+ "thin",
65
+ "java",
66
+ "mvn",
67
+ "gradle",
68
+ "go",
69
+ "air",
70
+ "gin",
71
+ "fiber",
72
+ "php",
73
+ "artisan",
74
+ "symfony",
75
+ "cargo",
76
+ "rust-analyzer",
77
+ "dotnet",
78
+ }
79
+
80
+ # Only track ports in this range (typical dev / ephemeral service ports).
81
+ _PORT_MIN = 1024
82
+ _PORT_MAX = 65535
83
+
84
+ # Ports to ignore (databases, system services, etc.)
85
+ _PORT_IGNORE = {
86
+ 3306, # MySQL
87
+ 5432, # PostgreSQL
88
+ 6379, # Redis
89
+ 27017, # MongoDB
90
+ 11211, # Memcached
91
+ 2181, # ZooKeeper
92
+ 2375, # Docker daemon (unencrypted)
93
+ 2376, # Docker daemon (TLS)
94
+ 22, # SSH
95
+ 25, # SMTP
96
+ 53, # DNS
97
+ 80, # HTTP (production)
98
+ 443, # HTTPS (production)
99
+ }
100
+
101
+ _POLL_INTERVAL = 30 # seconds
102
+
103
+
104
+ def _now_ts() -> tuple[str, str]:
105
+ now = datetime.now(timezone.utc)
106
+ return now.strftime("%Y-%m-%dT%H:%M:%SZ"), now.strftime("%Y-%m-%d")
107
+
108
+
109
+ def _is_dev_process(proc: "psutil.Process") -> bool: # type: ignore[name-defined]
110
+ """Return True if the process looks like a dev server."""
111
+ try:
112
+ name = proc.name().lower()
113
+ if name in _DEV_PROCESS_NAMES:
114
+ return True
115
+ # Also check the first cmdline argument (e.g. "python manage.py runserver")
116
+ cmdline = proc.cmdline()
117
+ if cmdline:
118
+ exe_base = cmdline[0].rsplit("/", 1)[-1].lower()
119
+ if exe_base in _DEV_PROCESS_NAMES:
120
+ return True
121
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
122
+ pass
123
+ return False
124
+
125
+
126
+ def _snapshot_ports() -> dict[int, str]:
127
+ """Return {port: process_name} for all listening TCP sockets owned by dev processes."""
128
+ result: dict[int, str] = {}
129
+ try:
130
+ connections = psutil.net_connections(kind="tcp")
131
+ except psutil.AccessDenied:
132
+ return result
133
+
134
+ for conn in connections:
135
+ if conn.status != psutil.CONN_LISTEN:
136
+ continue
137
+ port = conn.laddr.port
138
+ if port < _PORT_MIN or port > _PORT_MAX or port in _PORT_IGNORE:
139
+ continue
140
+ pid = conn.pid
141
+ if pid is None:
142
+ continue
143
+ try:
144
+ proc = psutil.Process(pid)
145
+ if _is_dev_process(proc):
146
+ result[port] = proc.name()
147
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
148
+ continue
149
+ return result
150
+
151
+
152
+ class ProcessCollector:
153
+ """Poll psutil every 30 s and emit PORT_OPEN / PORT_CLOSE events."""
154
+
155
+ def __init__(self, event_callback: Callable[[Event], None]) -> None:
156
+ self._cb = event_callback
157
+ self._thread: Optional[threading.Thread] = None
158
+ self._stop_event = threading.Event()
159
+
160
+ def start(self) -> None:
161
+ if not _PSUTIL_AVAILABLE:
162
+ logger.debug("psutil unavailable — process/port tracking disabled")
163
+ return
164
+
165
+ self._thread = threading.Thread(
166
+ target=self._run,
167
+ daemon=True,
168
+ name="dev-recall-process",
169
+ )
170
+ self._thread.start()
171
+ logger.info("ProcessCollector started (poll interval %ds)", _POLL_INTERVAL)
172
+
173
+ def stop(self) -> None:
174
+ self._stop_event.set()
175
+
176
+ # ------------------------------------------------------------------
177
+ # Internal
178
+ # ------------------------------------------------------------------
179
+
180
+ def _run(self) -> None:
181
+ prev: dict[int, str] = {}
182
+ try:
183
+ prev = _snapshot_ports()
184
+ except Exception:
185
+ logger.exception("ProcessCollector initial snapshot failed")
186
+
187
+ while not self._stop_event.wait(_POLL_INTERVAL):
188
+ try:
189
+ curr = _snapshot_ports()
190
+ self._diff(prev, curr)
191
+ prev = curr
192
+ except Exception:
193
+ logger.exception("ProcessCollector poll failed")
194
+
195
+ def _diff(self, prev: dict[int, str], curr: dict[int, str]) -> None:
196
+ ts, date = _now_ts()
197
+
198
+ # Newly opened ports
199
+ for port, proc_name in curr.items():
200
+ if port not in prev:
201
+ raw = {"port": port, "process_name": proc_name}
202
+ event = Event(
203
+ timestamp=ts,
204
+ date=date,
205
+ event_type=EventType.PORT_OPEN,
206
+ source=Source.PROCESS_TRACKER,
207
+ content="",
208
+ raw_data=raw,
209
+ )
210
+ from recall.models import build_content
211
+
212
+ event.content = build_content(EventType.PORT_OPEN, raw)
213
+ self._cb(event)
214
+
215
+ # Closed ports
216
+ for port, proc_name in prev.items():
217
+ if port not in curr:
218
+ raw = {"port": port, "process_name": proc_name}
219
+ event = Event(
220
+ timestamp=ts,
221
+ date=date,
222
+ event_type=EventType.PORT_CLOSE,
223
+ source=Source.PROCESS_TRACKER,
224
+ content="",
225
+ raw_data=raw,
226
+ )
227
+ from recall.models import build_content
228
+
229
+ event.content = build_content(EventType.PORT_CLOSE, raw)
230
+ self._cb(event)
@@ -0,0 +1,229 @@
1
+ """Linux session event collector via D-Bus logind.
2
+
3
+ Subscribes to org.freedesktop.login1.Manager signals:
4
+ - PrepareForSleep(before: bool) → SYSTEM_SUSPEND / SYSTEM_RESUME
5
+ - org.freedesktop.login1.Session Lock / Unlock → SESSION_LOCK / SESSION_UNLOCK
6
+
7
+ Uses dbus-next with an asyncio event loop running in a daemon thread.
8
+
9
+ Gracefully becomes a no-op when dbus-next is not installed or the D-Bus
10
+ session bus is not available.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import logging
17
+ import os
18
+ import time
19
+ import threading
20
+ from datetime import datetime, timezone
21
+ from typing import Callable, Optional
22
+
23
+ from recall.models import Event, EventType, Source
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ try:
28
+ from dbus_next.aio import MessageBus # type: ignore
29
+ from dbus_next import BusType, Message, MessageType # type: ignore
30
+
31
+ _DBUS_AVAILABLE = True
32
+ except ImportError:
33
+ _DBUS_AVAILABLE = False
34
+
35
+
36
+ def _now_ts() -> tuple[str, str]:
37
+ now = datetime.now(timezone.utc)
38
+ return now.strftime("%Y-%m-%dT%H:%M:%SZ"), now.strftime("%Y-%m-%d")
39
+
40
+
41
+ class LinuxSessionCollector:
42
+ """Collect screen lock/unlock and system sleep/resume events from D-Bus."""
43
+
44
+ def __init__(self, event_callback: Callable[[Event], None]) -> None:
45
+ self._cb = event_callback
46
+ self._thread: Optional[threading.Thread] = None
47
+ self._loop: Optional[asyncio.AbstractEventLoop] = None
48
+ self._lock_time: Optional[float] = None
49
+ self._suspend_time: Optional[float] = None
50
+
51
+ def start(self) -> None:
52
+ if not _DBUS_AVAILABLE:
53
+ logger.debug("dbus-next unavailable — session event tracking disabled")
54
+ return
55
+
56
+ # Only works with a session D-Bus socket
57
+ if not os.environ.get("DBUS_SESSION_BUS_ADDRESS") and not os.path.exists(
58
+ "/run/user/%d/bus" % os.getuid()
59
+ ):
60
+ logger.debug("No D-Bus session bus — session event tracking disabled")
61
+ return
62
+
63
+ self._thread = threading.Thread(
64
+ target=self._run,
65
+ daemon=True,
66
+ name="devmem-session",
67
+ )
68
+ self._thread.start()
69
+
70
+ def stop(self) -> None:
71
+ if self._loop is not None:
72
+ self._loop.call_soon_threadsafe(self._loop.stop)
73
+
74
+ # ------------------------------------------------------------------
75
+ # Internal
76
+ # ------------------------------------------------------------------
77
+
78
+ def _run(self) -> None:
79
+ self._loop = asyncio.new_event_loop()
80
+ try:
81
+ self._loop.run_until_complete(self._subscribe())
82
+ except Exception:
83
+ logger.exception("LinuxSessionCollector crashed")
84
+
85
+ async def _subscribe(self) -> None:
86
+ try:
87
+ bus = await MessageBus(bus_type=BusType.SYSTEM).connect()
88
+ except Exception:
89
+ logger.debug("Could not connect to system D-Bus — session tracking disabled")
90
+ return
91
+
92
+ # ----------------------------------------------------------------
93
+ # Subscribe to PrepareForSleep (system suspend/resume)
94
+ # ----------------------------------------------------------------
95
+ await bus.call(
96
+ Message(
97
+ destination="org.freedesktop.DBus",
98
+ path="/org/freedesktop/DBus",
99
+ interface="org.freedesktop.DBus",
100
+ member="AddMatch",
101
+ signature="s",
102
+ body=[
103
+ "type='signal',"
104
+ "sender='org.freedesktop.login1',"
105
+ "interface='org.freedesktop.login1.Manager',"
106
+ "member='PrepareForSleep'"
107
+ ],
108
+ )
109
+ )
110
+
111
+ # ----------------------------------------------------------------
112
+ # Subscribe to session Lock/Unlock
113
+ # ----------------------------------------------------------------
114
+ await bus.call(
115
+ Message(
116
+ destination="org.freedesktop.DBus",
117
+ path="/org/freedesktop/DBus",
118
+ interface="org.freedesktop.DBus",
119
+ member="AddMatch",
120
+ signature="s",
121
+ body=[
122
+ "type='signal',"
123
+ "sender='org.freedesktop.login1',"
124
+ "interface='org.freedesktop.login1.Session',"
125
+ "member='Lock'"
126
+ ],
127
+ )
128
+ )
129
+ await bus.call(
130
+ Message(
131
+ destination="org.freedesktop.DBus",
132
+ path="/org/freedesktop/DBus",
133
+ interface="org.freedesktop.DBus",
134
+ member="AddMatch",
135
+ signature="s",
136
+ body=[
137
+ "type='signal',"
138
+ "sender='org.freedesktop.login1',"
139
+ "interface='org.freedesktop.login1.Session',"
140
+ "member='Unlock'"
141
+ ],
142
+ )
143
+ )
144
+
145
+ logger.info("LinuxSessionCollector subscribed to D-Bus logind signals")
146
+
147
+ bus.add_message_handler(self._handle_message)
148
+
149
+ # Keep running until stop() is called
150
+ await asyncio.get_event_loop().run_in_executor(None, self._thread.join if self._thread else lambda: None)
151
+
152
+ def _handle_message(self, message: "Message") -> None: # type: ignore[name-defined]
153
+ if message.message_type != MessageType.SIGNAL:
154
+ return
155
+ member = message.member
156
+ if member == "PrepareForSleep":
157
+ before = message.body[0] if message.body else True
158
+ self._on_prepare_for_sleep(before)
159
+ elif member == "Lock":
160
+ self._on_session_lock()
161
+ elif member == "Unlock":
162
+ self._on_session_unlock()
163
+
164
+ def _on_prepare_for_sleep(self, before: bool) -> None:
165
+ ts, date = _now_ts()
166
+ if before:
167
+ # Going to sleep
168
+ self._suspend_time = time.monotonic()
169
+ event = Event(
170
+ timestamp=ts,
171
+ date=date,
172
+ event_type=EventType.SYSTEM_SUSPEND,
173
+ source=Source.LINUX_SESSION,
174
+ content="",
175
+ raw_data={},
176
+ )
177
+ else:
178
+ # Waking up
179
+ raw: dict = {}
180
+ if self._suspend_time is not None:
181
+ raw["suspended_seconds"] = round(time.monotonic() - self._suspend_time)
182
+ self._suspend_time = None
183
+ event = Event(
184
+ timestamp=ts,
185
+ date=date,
186
+ event_type=EventType.SYSTEM_RESUME,
187
+ source=Source.LINUX_SESSION,
188
+ content="",
189
+ raw_data=raw,
190
+ )
191
+ from recall.models import build_content
192
+
193
+ event.content = build_content(event.event_type, event.raw_data)
194
+ self._cb(event)
195
+
196
+ def _on_session_lock(self) -> None:
197
+ self._lock_time = time.monotonic()
198
+ ts, date = _now_ts()
199
+ event = Event(
200
+ timestamp=ts,
201
+ date=date,
202
+ event_type=EventType.SESSION_LOCK,
203
+ source=Source.LINUX_SESSION,
204
+ content="",
205
+ raw_data={},
206
+ )
207
+ from recall.models import build_content
208
+
209
+ event.content = build_content(EventType.SESSION_LOCK, event.raw_data)
210
+ self._cb(event)
211
+
212
+ def _on_session_unlock(self) -> None:
213
+ ts, date = _now_ts()
214
+ raw: dict = {}
215
+ if self._lock_time is not None:
216
+ raw["locked_seconds"] = round(time.monotonic() - self._lock_time)
217
+ self._lock_time = None
218
+ event = Event(
219
+ timestamp=ts,
220
+ date=date,
221
+ event_type=EventType.SESSION_UNLOCK,
222
+ source=Source.LINUX_SESSION,
223
+ content="",
224
+ raw_data=raw,
225
+ )
226
+ from recall.models import build_content
227
+
228
+ event.content = build_content(EventType.SESSION_UNLOCK, event.raw_data)
229
+ self._cb(event)
@@ -0,0 +1,199 @@
1
+ """Linux window/app tracker using libwnck (X11/XWayland).
2
+
3
+ Tracks application open/close, window focus changes, and virtual workspace
4
+ switches via Wnck signals on a GLib main loop in a daemon thread.
5
+
6
+ Gracefully becomes a no-op when:
7
+ - PyGObject / libwnck is not installed
8
+ - No X display is available (pure Wayland without XWayland)
9
+ - Wnck.Screen.get_default() returns None
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import os
16
+ import threading
17
+ import time
18
+ from datetime import datetime, timezone
19
+ from typing import Callable, Optional
20
+
21
+ from recall.models import Event, EventType, Source
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ try:
26
+ import gi
27
+
28
+ gi.require_version("Wnck", "3.0")
29
+ gi.require_version("GLib", "2.0")
30
+ from gi.repository import GLib, Wnck # type: ignore
31
+
32
+ _WNCK_AVAILABLE = True
33
+ except Exception:
34
+ _WNCK_AVAILABLE = False
35
+
36
+
37
+ def _now_ts() -> tuple[str, str]:
38
+ """Return (ISO timestamp, YYYY-MM-DD)."""
39
+ now = datetime.now(timezone.utc)
40
+ return now.strftime("%Y-%m-%dT%H:%M:%SZ"), now.strftime("%Y-%m-%d")
41
+
42
+
43
+ class LinuxWindowCollector:
44
+ """Collect application and window events from the X11/XWayland session."""
45
+
46
+ def __init__(self, event_callback: Callable[[Event], None]) -> None:
47
+ self._cb = event_callback
48
+ self._thread: Optional[threading.Thread] = None
49
+ self._loop: Optional["GLib.MainLoop"] = None # type: ignore[name-defined]
50
+ # Track app open times for duration calculation
51
+ self._app_open_times: dict[int, float] = {}
52
+ # Track active workspace name for switch events
53
+ self._current_workspace: Optional[str] = None
54
+
55
+ def start(self) -> None:
56
+ if not _WNCK_AVAILABLE:
57
+ logger.debug("libwnck unavailable — window tracking disabled")
58
+ return
59
+
60
+ display = os.environ.get("DISPLAY", "")
61
+ if not display:
62
+ logger.debug("No DISPLAY — window tracking disabled")
63
+ return
64
+
65
+ self._thread = threading.Thread(
66
+ target=self._run,
67
+ daemon=True,
68
+ name="devmem-window",
69
+ )
70
+ self._thread.start()
71
+
72
+ def stop(self) -> None:
73
+ if self._loop is not None:
74
+ self._loop.quit()
75
+
76
+ # ------------------------------------------------------------------
77
+ # Internal
78
+ # ------------------------------------------------------------------
79
+
80
+ def _run(self) -> None:
81
+ try:
82
+ self._loop = GLib.MainLoop()
83
+
84
+ # Wnck.Screen.get_default() must be called inside the GLib loop thread
85
+ GLib.idle_add(self._setup_wnck)
86
+ self._loop.run()
87
+ except Exception:
88
+ logger.exception("LinuxWindowCollector loop crashed")
89
+
90
+ def _setup_wnck(self) -> bool:
91
+ try:
92
+ screen = Wnck.Screen.get_default()
93
+ if screen is None:
94
+ logger.debug("Wnck.Screen.get_default() returned None — disabling window tracking")
95
+ if self._loop:
96
+ self._loop.quit()
97
+ return False
98
+
99
+ screen.force_update()
100
+
101
+ # Seed current workspace
102
+ ws = screen.get_active_workspace()
103
+ self._current_workspace = ws.get_name() if ws else None
104
+
105
+ screen.connect("application-opened", self._on_app_opened)
106
+ screen.connect("application-closed", self._on_app_closed)
107
+ screen.connect("active-window-changed", self._on_window_focus)
108
+ screen.connect("active-workspace-changed", self._on_workspace_changed)
109
+ logger.info("Wnck window tracking active on DISPLAY=%s", os.environ.get("DISPLAY"))
110
+ except Exception:
111
+ logger.exception("Wnck setup failed")
112
+ return False # remove idle callback
113
+
114
+ def _on_app_opened(self, screen: "Wnck.Screen", app: "Wnck.Application") -> None: # type: ignore[name-defined]
115
+ xid = app.get_xid()
116
+ self._app_open_times[xid] = time.monotonic()
117
+ ts, date = _now_ts()
118
+ name = app.get_name() or ""
119
+ event = Event(
120
+ timestamp=ts,
121
+ date=date,
122
+ event_type=EventType.APP_OPEN,
123
+ source=Source.LINUX_WINDOW,
124
+ content="",
125
+ raw_data={"app_name": name, "xid": xid},
126
+ )
127
+ from recall.models import build_content
128
+
129
+ event.content = build_content(EventType.APP_OPEN, event.raw_data)
130
+ self._cb(event)
131
+
132
+ def _on_app_closed(self, screen: "Wnck.Screen", app: "Wnck.Application") -> None: # type: ignore[name-defined]
133
+ xid = app.get_xid()
134
+ ts, date = _now_ts()
135
+ name = app.get_name() or ""
136
+ raw: dict = {"app_name": name, "xid": xid}
137
+ open_t = self._app_open_times.pop(xid, None)
138
+ if open_t is not None:
139
+ raw["duration_seconds"] = round(time.monotonic() - open_t)
140
+ event = Event(
141
+ timestamp=ts,
142
+ date=date,
143
+ event_type=EventType.APP_CLOSE,
144
+ source=Source.LINUX_WINDOW,
145
+ content="",
146
+ raw_data=raw,
147
+ )
148
+ from recall.models import build_content
149
+
150
+ event.content = build_content(EventType.APP_CLOSE, event.raw_data)
151
+ self._cb(event)
152
+
153
+ def _on_window_focus(
154
+ self,
155
+ screen: "Wnck.Screen", # type: ignore[name-defined]
156
+ prev_window: Optional["Wnck.Window"], # type: ignore[name-defined]
157
+ ) -> None:
158
+ window = screen.get_active_window()
159
+ if window is None:
160
+ return
161
+ ts, date = _now_ts()
162
+ title = window.get_name() or ""
163
+ app = window.get_application()
164
+ app_name = app.get_name() if app else ""
165
+ event = Event(
166
+ timestamp=ts,
167
+ date=date,
168
+ event_type=EventType.WINDOW_FOCUS,
169
+ source=Source.LINUX_WINDOW,
170
+ content="",
171
+ raw_data={"window_title": title, "app_name": app_name},
172
+ )
173
+ from recall.models import build_content
174
+
175
+ event.content = build_content(EventType.WINDOW_FOCUS, event.raw_data)
176
+ self._cb(event)
177
+
178
+ def _on_workspace_changed(
179
+ self,
180
+ screen: "Wnck.Screen", # type: ignore[name-defined]
181
+ prev_ws: Optional["Wnck.Workspace"], # type: ignore[name-defined]
182
+ ) -> None:
183
+ from_name = prev_ws.get_name() if prev_ws else (self._current_workspace or "")
184
+ ws = screen.get_active_workspace()
185
+ to_name = ws.get_name() if ws else ""
186
+ self._current_workspace = to_name
187
+ ts, date = _now_ts()
188
+ event = Event(
189
+ timestamp=ts,
190
+ date=date,
191
+ event_type=EventType.WORKSPACE_SWITCH,
192
+ source=Source.LINUX_WINDOW,
193
+ content="",
194
+ raw_data={"from_workspace": from_name, "to_workspace": to_name},
195
+ )
196
+ from recall.models import build_content
197
+
198
+ event.content = build_content(EventType.WORKSPACE_SWITCH, event.raw_data)
199
+ self._cb(event)