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.
- wirenote/__init__.py +3 -0
- wirenote/calendar/__init__.py +2 -0
- wirenote/calendar/ics.py +236 -0
- wirenote/capture/__init__.py +2 -0
- wirenote/capture/recorder.py +304 -0
- wirenote/capture/wav.py +58 -0
- wirenote/cli.py +262 -0
- wirenote/config/__init__.py +2 -0
- wirenote/config/apps.py +158 -0
- wirenote/config/migrate.py +130 -0
- wirenote/config/perms.py +61 -0
- wirenote/config/secrets.py +98 -0
- wirenote/config/settings.py +176 -0
- wirenote/config/vocabulary.py +32 -0
- wirenote/data/wirenote-ui.service +15 -0
- wirenote/data/wirenote.desktop +10 -0
- wirenote/data/wirenote.service +15 -0
- wirenote/detector/__init__.py +2 -0
- wirenote/detector/core.py +115 -0
- wirenote/detector/daemon.py +743 -0
- wirenote/detector/graph.py +102 -0
- wirenote/detector/identity.py +205 -0
- wirenote/detector/streams.py +69 -0
- wirenote/detector/tracker.py +304 -0
- wirenote/net/__init__.py +2 -0
- wirenote/net/peer.py +101 -0
- wirenote/notify/__init__.py +2 -0
- wirenote/notify/clipboard.py +43 -0
- wirenote/notify/dbus.py +89 -0
- wirenote/process/__init__.py +2 -0
- wirenote/process/jobs.py +122 -0
- wirenote/process/merge.py +87 -0
- wirenote/process/models.py +57 -0
- wirenote/process/note.py +124 -0
- wirenote/process/speakers.py +295 -0
- wirenote/process/summarize.py +287 -0
- wirenote/process/transcribe.py +97 -0
- wirenote/process/worker.py +229 -0
- wirenote/tools/__init__.py +2 -0
- wirenote/tools/dump_streams.py +595 -0
- wirenote/tools/install.py +140 -0
- wirenote/tools/setup.py +338 -0
- wirenote/ui/__init__.py +2 -0
- wirenote/ui/data.py +783 -0
- wirenote/ui/launch.py +88 -0
- wirenote/ui/server.py +423 -0
- wirenote/ui/static/app.css +907 -0
- wirenote/ui/static/app.js +1267 -0
- wirenote/ui/static/fonts/OFL.txt +93 -0
- wirenote/ui/static/fonts/inter-latin-ext.woff2 +0 -0
- wirenote/ui/static/fonts/inter-latin.woff2 +0 -0
- wirenote/ui/static/icon.svg +1 -0
- wirenote/ui/static/icons.svg +31 -0
- wirenote/ui/static/index.html +57 -0
- wirenote/ui/static/login.html +19 -0
- wirenote-0.1.0.dist-info/METADATA +106 -0
- wirenote-0.1.0.dist-info/RECORD +60 -0
- wirenote-0.1.0.dist-info/WHEEL +4 -0
- wirenote-0.1.0.dist-info/entry_points.txt +2 -0
- 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())
|