wirenote 0.1.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.
Files changed (60) hide show
  1. wirenote/__init__.py +3 -0
  2. wirenote/calendar/__init__.py +2 -0
  3. wirenote/calendar/ics.py +236 -0
  4. wirenote/capture/__init__.py +2 -0
  5. wirenote/capture/recorder.py +304 -0
  6. wirenote/capture/wav.py +58 -0
  7. wirenote/cli.py +262 -0
  8. wirenote/config/__init__.py +2 -0
  9. wirenote/config/apps.py +158 -0
  10. wirenote/config/migrate.py +130 -0
  11. wirenote/config/perms.py +61 -0
  12. wirenote/config/secrets.py +98 -0
  13. wirenote/config/settings.py +176 -0
  14. wirenote/config/vocabulary.py +32 -0
  15. wirenote/data/wirenote-ui.service +15 -0
  16. wirenote/data/wirenote.desktop +10 -0
  17. wirenote/data/wirenote.service +15 -0
  18. wirenote/detector/__init__.py +2 -0
  19. wirenote/detector/core.py +115 -0
  20. wirenote/detector/daemon.py +743 -0
  21. wirenote/detector/graph.py +102 -0
  22. wirenote/detector/identity.py +205 -0
  23. wirenote/detector/streams.py +69 -0
  24. wirenote/detector/tracker.py +304 -0
  25. wirenote/net/__init__.py +2 -0
  26. wirenote/net/peer.py +101 -0
  27. wirenote/notify/__init__.py +2 -0
  28. wirenote/notify/clipboard.py +43 -0
  29. wirenote/notify/dbus.py +89 -0
  30. wirenote/process/__init__.py +2 -0
  31. wirenote/process/jobs.py +122 -0
  32. wirenote/process/merge.py +87 -0
  33. wirenote/process/models.py +57 -0
  34. wirenote/process/note.py +124 -0
  35. wirenote/process/speakers.py +295 -0
  36. wirenote/process/summarize.py +287 -0
  37. wirenote/process/transcribe.py +97 -0
  38. wirenote/process/worker.py +229 -0
  39. wirenote/tools/__init__.py +2 -0
  40. wirenote/tools/dump_streams.py +595 -0
  41. wirenote/tools/install.py +140 -0
  42. wirenote/tools/setup.py +338 -0
  43. wirenote/ui/__init__.py +2 -0
  44. wirenote/ui/data.py +783 -0
  45. wirenote/ui/launch.py +88 -0
  46. wirenote/ui/server.py +423 -0
  47. wirenote/ui/static/app.css +907 -0
  48. wirenote/ui/static/app.js +1267 -0
  49. wirenote/ui/static/fonts/OFL.txt +93 -0
  50. wirenote/ui/static/fonts/inter-latin-ext.woff2 +0 -0
  51. wirenote/ui/static/fonts/inter-latin.woff2 +0 -0
  52. wirenote/ui/static/icon.svg +1 -0
  53. wirenote/ui/static/icons.svg +31 -0
  54. wirenote/ui/static/index.html +57 -0
  55. wirenote/ui/static/login.html +19 -0
  56. wirenote-0.1.0.dist-info/METADATA +106 -0
  57. wirenote-0.1.0.dist-info/RECORD +60 -0
  58. wirenote-0.1.0.dist-info/WHEEL +4 -0
  59. wirenote-0.1.0.dist-info/entry_points.txt +2 -0
  60. wirenote-0.1.0.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,595 @@
1
+ #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+ # Copyright (c) 2026 Hrishikesh Jadhav
4
+ """M0 stream probe: dump PipeWire capture streams and who owns them.
5
+
6
+ Throwaway script for milestone M0 of docs/dev/PLAN.md. It answers one question: can
7
+ each meeting app's microphone stream be told apart by the lookup order in
8
+ docs/dev/PLAN.md's "Identifying the app" table?
9
+
10
+ Modes:
11
+ snapshot (default) run `pw-dump` once, save it, print every capture stream.
12
+ --monitor run `pw-dump --monitor`, log every update batch with a
13
+ timestamp, and print capture-stream events as they happen.
14
+ Stop with Ctrl-C (or --duration).
15
+
16
+ Files written to --out:
17
+ pw-dump.json snapshot mode: raw `pw-dump` output
18
+ streams.json analysis of every Stream/Input/Audio node
19
+ monitor.jsonl --monitor: one line per update batch
20
+ mirror-running-serial<N>.json --monitor: full graph the moment a capture
21
+ stream (by object.serial) first reached "running"
22
+ mirror-final.json --monitor: graph mirror at exit
23
+
24
+ Process data (/proc) is read while the stream is live, because the process
25
+ may be gone by the time the capture ends.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import datetime as dt
32
+ import json
33
+ import os
34
+ import re
35
+ import signal
36
+ import subprocess
37
+ import sys
38
+ import threading
39
+ import time
40
+ from pathlib import Path
41
+
42
+ CAPTURE_CLASS = "Stream/Input/Audio"
43
+ PARENT_DEPTH = 5
44
+
45
+ # Fixtures get committed, so drop anything that looks like a secret from
46
+ # command lines (Zoom join links carry the meeting password, for example).
47
+ _SECRET_ARG = re.compile(r"(token|pwd|password|passwd|secret|key|auth|sig)", re.I)
48
+ _URL_QUERY = re.compile(r"(\w+://[^\s?#]*)[?#]\S*")
49
+
50
+
51
+ def now_iso() -> str:
52
+ return dt.datetime.now().astimezone().isoformat(timespec="milliseconds")
53
+
54
+
55
+ # --------------------------------------------------------------------------
56
+ # /proc helpers
57
+ # --------------------------------------------------------------------------
58
+
59
+ def redact_arg(arg: str) -> str:
60
+ arg = _URL_QUERY.sub(r"\1?<redacted>", arg)
61
+ if "=" in arg:
62
+ name, _, _value = arg.partition("=")
63
+ if _SECRET_ARG.search(name):
64
+ return f"{name}=<redacted>"
65
+ return arg
66
+
67
+
68
+ def _read(path: str) -> str | None:
69
+ try:
70
+ with open(path, "rb") as f:
71
+ return f.read().decode("utf-8", "replace")
72
+ except OSError:
73
+ return None
74
+
75
+
76
+ def parse_cgroup(text: str | None) -> dict:
77
+ """Pull Flatpak ID, Snap name and systemd unit out of /proc/<pid>/cgroup."""
78
+ out = {"raw": None, "unit": None, "flatpak_id": None, "snap_name": None}
79
+ if not text:
80
+ return out
81
+ # cgroup v2 has a single "0::/path" line; v1 has several, take name=systemd
82
+ # or the unified line.
83
+ path = None
84
+ for line in text.splitlines():
85
+ parts = line.split(":", 2)
86
+ if len(parts) == 3 and (parts[0] == "0" or parts[1] == "name=systemd"):
87
+ path = parts[2]
88
+ if parts[0] == "0":
89
+ break
90
+ out["raw"] = path
91
+ if not path:
92
+ return out
93
+ leaf_units = [seg for seg in path.split("/") if seg.endswith((".scope", ".service"))]
94
+ unit = leaf_units[-1] if leaf_units else None
95
+ out["unit"] = unit
96
+ for seg in reversed(path.split("/")):
97
+ # app-flatpak-<app-id>-<number>.scope
98
+ m = re.match(r"app-flatpak-(.+)-\d+\.scope$", seg)
99
+ if m:
100
+ out["flatpak_id"] = m.group(1)
101
+ break
102
+ # snap.<name>.<app>-<uuid>.scope / snap.<name>.<app>.service
103
+ m = re.match(r"snap\.([^.]+)\.", seg)
104
+ if m:
105
+ out["snap_name"] = m.group(1)
106
+ break
107
+ return out
108
+
109
+
110
+ def proc_info(pid: int) -> dict:
111
+ """What /proc says about one PID. Missing fields mean the read failed."""
112
+ base = f"/proc/{pid}"
113
+ info: dict = {"pid": pid, "alive": os.path.isdir(base)}
114
+ if not info["alive"]:
115
+ return info
116
+ try:
117
+ info["exe"] = os.readlink(f"{base}/exe")
118
+ except OSError as e:
119
+ info["exe"] = None
120
+ info["exe_error"] = e.strerror
121
+ if info.get("exe"):
122
+ # A replaced binary reads as "/path/to/exe (deleted)".
123
+ info["exe_basename"] = os.path.basename(info["exe"].removesuffix(" (deleted)"))
124
+ comm = _read(f"{base}/comm")
125
+ info["comm"] = comm.strip() if comm else None
126
+ cmdline = _read(f"{base}/cmdline")
127
+ if cmdline is not None:
128
+ argv = [a for a in cmdline.split("\0") if a]
129
+ info["cmdline"] = [redact_arg(a) for a in argv]
130
+ info["cgroup"] = parse_cgroup(_read(f"{base}/cgroup"))
131
+ stat = _read(f"{base}/stat")
132
+ if stat:
133
+ # Field 4 (ppid) comes after the parenthesised comm, which may hold spaces.
134
+ rest = stat.rsplit(")", 1)[-1].split()
135
+ info["ppid"] = int(rest[1]) if len(rest) > 1 else None
136
+ return info
137
+
138
+
139
+ def parent_chain(pid: int, depth: int = PARENT_DEPTH) -> list[dict]:
140
+ chain = []
141
+ ppid = proc_info(pid).get("ppid")
142
+ while ppid and ppid > 1 and len(chain) < depth:
143
+ p = proc_info(ppid)
144
+ chain.append({k: p.get(k) for k in
145
+ ("pid", "exe", "exe_basename", "comm", "cmdline", "cgroup")})
146
+ ppid = p.get("ppid")
147
+ return chain
148
+
149
+
150
+ # --------------------------------------------------------------------------
151
+ # Graph mirror and analysis
152
+ # --------------------------------------------------------------------------
153
+
154
+ class Graph:
155
+ """In-memory mirror of the PipeWire object graph, keyed by object id."""
156
+
157
+ def __init__(self) -> None:
158
+ self.objects: dict[int, dict] = {}
159
+
160
+ def apply(self, batch: list[dict]) -> list[tuple[str, dict, dict | None]]:
161
+ """Apply one pw-dump batch. Returns (kind, new_or_removed, previous)."""
162
+ changes = []
163
+ for obj in batch:
164
+ oid = obj["id"]
165
+ prev = self.objects.get(oid)
166
+ if obj.get("info") is None and "type" not in obj:
167
+ # Removal: pw-dump prints {"id": N, "info": null}.
168
+ if prev is not None:
169
+ del self.objects[oid]
170
+ changes.append(("removed", prev or obj, prev))
171
+ else:
172
+ # Updates carry the whole object, so replace rather than merge.
173
+ self.objects[oid] = obj
174
+ changes.append(("added" if prev is None else "changed", obj, prev))
175
+ return changes
176
+
177
+ def props(self, oid: int | None) -> dict:
178
+ obj = self.objects.get(oid) if oid is not None else None
179
+ return ((obj or {}).get("info") or {}).get("props") or {}
180
+
181
+ def capture_nodes(self) -> list[dict]:
182
+ return [o for o in self.objects.values() if is_capture_node(o)]
183
+
184
+ def links_into(self, node_id: int) -> list[dict]:
185
+ out = []
186
+ for o in self.objects.values():
187
+ if o.get("type", "").endswith(":Link"):
188
+ info = o.get("info") or {}
189
+ if info.get("input-node-id") == node_id:
190
+ out.append(o)
191
+ return out
192
+
193
+
194
+ def is_capture_node(obj: dict) -> bool:
195
+ if not obj.get("type", "").endswith(":Node"):
196
+ return False
197
+ props = (obj.get("info") or {}).get("props") or {}
198
+ return props.get("media.class") == CAPTURE_CLASS
199
+
200
+
201
+ def serial_of(obj: dict) -> int | None:
202
+ return ((obj.get("info") or {}).get("props") or {}).get("object.serial")
203
+
204
+
205
+ def node_state(obj: dict | None) -> str | None:
206
+ return ((obj or {}).get("info") or {}).get("state")
207
+
208
+
209
+ def classify_source(media_class: str | None, port_props: dict) -> str:
210
+ if port_props.get("port.monitor") or (media_class or "").startswith("Audio/Sink"):
211
+ return "sink-monitor"
212
+ if (media_class or "").startswith("Audio/Source"):
213
+ return "capture-device"
214
+ if (media_class or "").startswith("Stream/Output/Audio"):
215
+ return "app-stream"
216
+ return "other"
217
+
218
+
219
+ def analyze(graph: Graph, node: dict) -> dict:
220
+ info = node.get("info") or {}
221
+ props = info.get("props") or {}
222
+ nid = node["id"]
223
+ client_id = props.get("client.id")
224
+ cprops = graph.props(client_id)
225
+
226
+ links = []
227
+ for link in graph.links_into(nid):
228
+ li = link.get("info") or {}
229
+ src_id = li.get("output-node-id")
230
+ sprops = graph.props(src_id)
231
+ pprops = graph.props(li.get("output-port-id"))
232
+ links.append({
233
+ "link_id": link["id"],
234
+ "link_state": li.get("state"),
235
+ "source_node_id": src_id,
236
+ "source_node_name": sprops.get("node.name"),
237
+ "source_description": sprops.get("node.description"),
238
+ "source_media_class": sprops.get("media.class"),
239
+ "source_port_name": pprops.get("port.name"),
240
+ "source_kind": classify_source(sprops.get("media.class"), pprops),
241
+ })
242
+
243
+ # Several places claim a PID. pipewire.sec.pid comes from the socket's
244
+ # peer credentials, but for pulse-protocol clients that peer is the
245
+ # pipewire-pulse server itself. application.process.id is self-reported
246
+ # and may be a sandbox-namespace PID inside Flatpak.
247
+ pid_claims = {
248
+ "client.pipewire.sec.pid": cprops.get("pipewire.sec.pid"),
249
+ "client.application.process.id": cprops.get("application.process.id"),
250
+ "node.application.process.id": props.get("application.process.id"),
251
+ }
252
+ pids = []
253
+ for v in pid_claims.values():
254
+ try:
255
+ pid = int(v)
256
+ except (TypeError, ValueError):
257
+ continue
258
+ if pid not in pids:
259
+ pids.append(pid)
260
+
261
+ processes = {}
262
+ for pid in pids:
263
+ p = proc_info(pid)
264
+ p["parents"] = parent_chain(pid) if p.get("alive") else []
265
+ processes[str(pid)] = p
266
+
267
+ app_props = lambda d: {k: v for k, v in d.items()
268
+ if k.startswith(("application.", "pipewire.", "media.", "window."))}
269
+
270
+ result = {
271
+ "node_id": nid,
272
+ "node_serial": props.get("object.serial"),
273
+ "client_api": cprops.get("client.api"),
274
+ "state": info.get("state"),
275
+ "node_name": props.get("node.name"),
276
+ "media_name": props.get("media.name"),
277
+ "node_app_props": app_props(props),
278
+ "client_id": client_id,
279
+ "client_app_props": app_props(cprops),
280
+ "links_in": links,
281
+ "pid_claims": pid_claims,
282
+ "processes": processes,
283
+ }
284
+ result["identity"] = resolve_identity(result)
285
+ return result
286
+
287
+
288
+ def cmdline0(argv: list[str] | None) -> str | None:
289
+ if not argv:
290
+ return None
291
+ # Chrome and Electron rewrite argv into one space-joined string, so the
292
+ # first NUL-separated field can hold the whole command line.
293
+ first = argv[0].split(" ", 1)[0] if len(argv) == 1 else argv[0]
294
+ return os.path.basename(first)
295
+
296
+
297
+ def resolve_identity(a: dict) -> dict:
298
+ """Walk docs/dev/PLAN.md's lookup order and report what each step yields.
299
+
300
+ M0 has no apps.toml yet, so instead of stopping at the first rule match
301
+ this records every step's answer plus the first step that gave anything.
302
+ """
303
+ claims = a["pid_claims"]
304
+ if a.get("client_api") == "pipewire-pulse":
305
+ # sec.pid is the pulse server here; only the self-reported PID is left.
306
+ pid_source = "client.application.process.id"
307
+ else:
308
+ pid_source = "client.pipewire.sec.pid"
309
+ pid = claims.get(pid_source)
310
+ if pid is None:
311
+ pid_source, pid = next(((k, v) for k, v in claims.items() if v is not None),
312
+ (None, None))
313
+ primary = a["processes"].get(str(pid), {}) if pid is not None else {}
314
+ cg = primary.get("cgroup") or {}
315
+ cprops = a["client_app_props"]
316
+ nprops = a["node_app_props"]
317
+
318
+ steps = {
319
+ "1_cgroup": (f"flatpak:{cg['flatpak_id']}" if cg.get("flatpak_id")
320
+ else f"snap:{cg['snap_name']}" if cg.get("snap_name") else None),
321
+ "2_portal_app_id": cprops.get("pipewire.access.portal.app_id"),
322
+ "3_exe": primary.get("exe_basename"),
323
+ "3_cmdline0": cmdline0(primary.get("cmdline")),
324
+ "4_parents": [p.get("exe_basename") or p.get("comm") for p in primary.get("parents", [])],
325
+ "5_binary": cprops.get("application.process.binary") or nprops.get("application.process.binary"),
326
+ "5_app_name": cprops.get("application.name") or nprops.get("application.name"),
327
+ "systemd_unit": cg.get("unit"),
328
+ }
329
+ first = None
330
+ for key in ("1_cgroup", "2_portal_app_id", "3_exe", "3_cmdline0", "5_binary", "5_app_name"):
331
+ if steps[key]:
332
+ first = (key, steps[key])
333
+ break
334
+ # Does the chosen PID look like the stream owner? A mismatch between the
335
+ # self-reported binary and the real exe/comm means the PID is suspect
336
+ # (pulse server PID, sandbox-namespace PID, or a lie).
337
+ claimed = steps["5_binary"]
338
+ pid_check = {
339
+ "pid": pid,
340
+ "pid_source": pid_source,
341
+ "claimed_binary": claimed,
342
+ "exe_basename": primary.get("exe_basename"),
343
+ "comm": primary.get("comm"),
344
+ "matches_claim": bool(claimed) and claimed in (primary.get("exe_basename"),
345
+ primary.get("comm")),
346
+ }
347
+ return {"steps": steps, "first_hit": first, "pid_check": pid_check}
348
+
349
+
350
+ # --------------------------------------------------------------------------
351
+ # Output
352
+ # --------------------------------------------------------------------------
353
+
354
+ def format_analysis(a: dict) -> str:
355
+ ident = a["identity"]
356
+ s = ident["steps"]
357
+ lines = [
358
+ f" node {a['node_id']} (serial {a['node_serial']}) state={a['state']}"
359
+ f" node.name={a['node_name']!r} media.name={a['media_name']!r}",
360
+ f" client {a['client_id']} (api={a['client_api']!r}): app.name={s['5_app_name']!r}"
361
+ f" binary={s['5_binary']!r}",
362
+ f" pid claims: {a['pid_claims']}",
363
+ f" pid check: {ident['pid_check']}",
364
+ ]
365
+ for link in a["links_in"] or [{}]:
366
+ if not link:
367
+ lines.append(" linked from: (no links)")
368
+ continue
369
+ lines.append(
370
+ f" linked from: node {link['source_node_id']} {link['source_node_name']!r}"
371
+ f" [{link['source_media_class']}] port={link['source_port_name']}"
372
+ f" -> {link['source_kind']} (link {link['link_state']})")
373
+ for pid, p in a["processes"].items():
374
+ if not p.get("alive"):
375
+ lines.append(f" pid {pid}: not visible in /proc")
376
+ continue
377
+ cmd = " ".join(p.get("cmdline") or [])
378
+ lines.append(f" pid {pid}: exe={p.get('exe')} comm={p.get('comm')}")
379
+ lines.append(f" cmdline: {cmd[:200]}{'…' if len(cmd) > 200 else ''}")
380
+ lines.append(f" cgroup: {(p.get('cgroup') or {}).get('raw')}")
381
+ for depth, par in enumerate(p.get("parents", []), 1):
382
+ lines.append(f" parent {depth}: pid {par['pid']} exe={par.get('exe')}"
383
+ f" comm={par.get('comm')}")
384
+ lines.append(f" identity steps: {json.dumps(s)}")
385
+ lines.append(f" first hit: {ident['first_hit']}")
386
+ return "\n".join(lines)
387
+
388
+
389
+ def write_json(path: Path, data) -> None:
390
+ tmp = path.with_suffix(path.suffix + ".tmp")
391
+ tmp.write_text(json.dumps(data, indent=2, sort_keys=False))
392
+ tmp.replace(path)
393
+
394
+
395
+ # --------------------------------------------------------------------------
396
+ # Modes
397
+ # --------------------------------------------------------------------------
398
+
399
+ def run_snapshot(out: Path | None) -> int:
400
+ raw = subprocess.run(["pw-dump", "--no-colors"], check=True,
401
+ capture_output=True, text=True).stdout
402
+ graph = Graph()
403
+ graph.apply(json.loads(raw))
404
+ analyses = [analyze(graph, n) for n in sorted(graph.capture_nodes(), key=lambda n: n["id"])]
405
+
406
+ print(f"{now_iso()} {len(analyses)} {CAPTURE_CLASS} node(s)")
407
+ for a in analyses:
408
+ print(format_analysis(a))
409
+ if out:
410
+ out.mkdir(parents=True, exist_ok=True)
411
+ (out / "pw-dump.json").write_text(raw)
412
+ write_json(out / "streams.json", {"captured_at": now_iso(), "mode": "snapshot",
413
+ "streams": analyses})
414
+ print(f"\nwrote {out}/pw-dump.json and {out}/streams.json")
415
+ return 0
416
+
417
+
418
+ def iter_batches(stream):
419
+ """Yield (wall_iso, monotonic, batch) for each top-level JSON array.
420
+
421
+ pw-dump --monitor prints pretty-printed arrays back to back; each one ends
422
+ with "]" alone at column 0, which is where we try to decode.
423
+ """
424
+ buf: list[str] = []
425
+ for line in stream:
426
+ buf.append(line)
427
+ if line.rstrip("\n") != "]":
428
+ continue
429
+ text = "".join(buf)
430
+ try:
431
+ batch = json.loads(text)
432
+ except json.JSONDecodeError:
433
+ continue # a nested "]" at column 0; keep reading
434
+ buf.clear()
435
+ yield now_iso(), time.monotonic(), batch
436
+
437
+
438
+ def describe_change(kind: str, obj: dict, prev: dict | None, graph: Graph) -> str | None:
439
+ """One console line for changes that matter to detection, else None."""
440
+ typ = (obj.get("type") or (prev or {}).get("type") or "")
441
+ if typ.endswith(":Node") and is_capture_node(obj if kind != "removed" else (prev or {})):
442
+ ref = obj if kind != "removed" else prev
443
+ props = ((ref or {}).get("info") or {}).get("props") or {}
444
+ label = f"capture node {obj['id']} {props.get('node.name')!r} ({props.get('application.name')!r})"
445
+ if kind == "added":
446
+ return f"ADD {label} state={node_state(obj)}"
447
+ if kind == "removed":
448
+ return f"REMOVE {label} (last state {node_state(prev)})"
449
+ if node_state(obj) != node_state(prev):
450
+ return f"STATE {label} {node_state(prev)} -> {node_state(obj)}"
451
+ return None
452
+ if typ.endswith(":Link"):
453
+ li = ((obj if kind != "removed" else prev) or {}).get("info") or {}
454
+ dst = li.get("input-node-id")
455
+ if dst is None or not is_capture_node(graph.objects.get(dst, {})):
456
+ return None
457
+ src = li.get("output-node-id")
458
+ sname = graph.props(src).get("node.name")
459
+ if kind == "added":
460
+ return f"LINK+ {src} {sname!r} -> capture node {dst} ({li.get('state')})"
461
+ if kind == "removed":
462
+ return f"LINK- {src} {sname!r} -> capture node {dst}"
463
+ if li.get("state") != ((prev or {}).get("info") or {}).get("state"):
464
+ return f"LINK~ {src} {sname!r} -> capture node {dst} now {li.get('state')}"
465
+ return None
466
+
467
+
468
+ def run_monitor(out: Path | None, duration: float | None) -> int:
469
+ if out:
470
+ out.mkdir(parents=True, exist_ok=True)
471
+ proc = subprocess.Popen(["pw-dump", "--monitor", "--no-colors"],
472
+ stdout=subprocess.PIPE, text=True, bufsize=1)
473
+ graph = Graph()
474
+ log = open(out / "monitor.jsonl", "w") if out else None
475
+ analyses: list[dict] = []
476
+ seen_running: set[int] = set() # object.serial values
477
+ start = time.monotonic()
478
+ stop = {"flag": False}
479
+ lock = threading.Lock() # monitor.jsonl and analyses are shared with read_markers
480
+
481
+ def on_signal(signum, _frame):
482
+ stop["flag"] = True
483
+ proc.send_signal(signal.SIGINT)
484
+
485
+ signal.signal(signal.SIGINT, on_signal)
486
+ signal.signal(signal.SIGTERM, on_signal)
487
+ if duration:
488
+ signal.signal(signal.SIGALRM, on_signal)
489
+ signal.setitimer(signal.ITIMER_REAL, duration)
490
+
491
+ def record(reason: str, node: dict, t: str, mono: float, verbose: bool) -> None:
492
+ a = analyze(graph, node)
493
+ with lock:
494
+ analyses.append({"t": t, "rel_s": round(mono - start, 3), "reason": reason,
495
+ "analysis": a})
496
+ if verbose:
497
+ print(format_analysis(a), flush=True)
498
+
499
+ def read_markers() -> None:
500
+ # Each line typed into the terminal becomes a timestamped marker, so
501
+ # "join", "mute", "leave" line up with the stream events.
502
+ for line in sys.stdin:
503
+ text = line.strip() or "mark"
504
+ t, mono = now_iso(), time.monotonic()
505
+ rel = round(mono - start, 3)
506
+ with lock:
507
+ if log:
508
+ log.write(json.dumps({"t": t, "rel_s": rel, "marker": text}) + "\n")
509
+ log.flush()
510
+ analyses.append({"t": t, "rel_s": rel, "reason": "marker", "marker": text})
511
+ print(f"[{rel:8.3f}] MARK {text}", flush=True)
512
+
513
+ if sys.stdin.isatty():
514
+ threading.Thread(target=read_markers, daemon=True).start()
515
+ print(f"{now_iso()} monitoring PipeWire; Ctrl-C to stop."
516
+ " Type a word + Enter to log a marker (join, mute, unmute, leave).", flush=True)
517
+ try:
518
+ for n, (t, mono, batch) in enumerate(iter_batches(proc.stdout)):
519
+ rel = mono - start
520
+ if log:
521
+ with lock:
522
+ log.write(json.dumps({"t": t, "rel_s": round(rel, 3), "batch": batch}) + "\n")
523
+ log.flush()
524
+ changes = graph.apply(batch)
525
+ if n == 0:
526
+ nodes = graph.capture_nodes()
527
+ print(f"[{rel:8.3f}] initial state: {len(graph.objects)} objects,"
528
+ f" {len(nodes)} capture node(s)", flush=True)
529
+ for node in nodes:
530
+ record("initial", node, t, mono, verbose=True)
531
+ if node_state(node) == "running":
532
+ seen_running.add(serial_of(node))
533
+ continue
534
+
535
+ touched: dict[int, str] = {}
536
+ for kind, obj, prev in changes:
537
+ line = describe_change(kind, obj, prev, graph)
538
+ if line:
539
+ print(f"[{rel:8.3f}] {line}", flush=True)
540
+ if kind == "removed" and prev is not None and is_capture_node(prev):
541
+ with lock:
542
+ analyses.append({"t": t, "rel_s": round(rel, 3), "reason": "removed",
543
+ "node_id": obj["id"],
544
+ "node_serial": prev["info"]["props"].get("object.serial")})
545
+ if kind != "removed" and is_capture_node(obj):
546
+ if kind == "added" or node_state(obj) != node_state(prev):
547
+ touched[obj["id"]] = f"{kind}:{node_state(obj)}"
548
+ if line and line.startswith("LINK") and kind != "removed":
549
+ dst = (obj.get("info") or {}).get("input-node-id")
550
+ touched.setdefault(dst, "link")
551
+
552
+ for nid, reason in touched.items():
553
+ node = graph.objects.get(nid)
554
+ if not node:
555
+ continue
556
+ # PipeWire reuses node ids; the serial is what identifies a stream.
557
+ first_run = node_state(node) == "running" and serial_of(node) not in seen_running
558
+ record(reason, node, t, mono, verbose=first_run)
559
+ if first_run:
560
+ seen_running.add(serial_of(node))
561
+ if out:
562
+ write_json(out / f"mirror-running-serial{serial_of(node)}.json",
563
+ list(graph.objects.values()))
564
+ if stop["flag"]:
565
+ break
566
+ finally:
567
+ if proc.poll() is None:
568
+ proc.send_signal(signal.SIGINT)
569
+ try:
570
+ proc.wait(timeout=3)
571
+ except subprocess.TimeoutExpired:
572
+ proc.kill()
573
+ if log:
574
+ log.close()
575
+ if out:
576
+ write_json(out / "mirror-final.json", list(graph.objects.values()))
577
+ write_json(out / "streams.json", {"captured_at": now_iso(), "mode": "monitor",
578
+ "events": analyses})
579
+ print(f"\nwrote monitor.jsonl, streams.json, mirror-*.json to {out}", flush=True)
580
+ return 0
581
+
582
+
583
+ def main(argv: list[str] | None = None) -> int:
584
+ ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
585
+ ap.add_argument("--monitor", action="store_true", help="follow pw-dump --monitor")
586
+ ap.add_argument("--out", type=Path, help="directory to save fixtures into")
587
+ ap.add_argument("--duration", type=float, help="--monitor: stop after N seconds")
588
+ args = ap.parse_args(argv)
589
+ if args.monitor:
590
+ return run_monitor(args.out, args.duration)
591
+ return run_snapshot(args.out)
592
+
593
+
594
+ if __name__ == "__main__":
595
+ sys.exit(main())