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,743 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (c) 2026 Hrishikesh Jadhav
|
|
3
|
+
"""asyncio shell around Detector: pw-dump child, 1 s tick, notifications, logs,
|
|
4
|
+
the recorder, the processing worker, the calendar and the control socket.
|
|
5
|
+
|
|
6
|
+
Clicking Record starts a two-track recording (capture/recorder.py). When the
|
|
7
|
+
recording stops, a job is queued and a worker process turns it into a note
|
|
8
|
+
(process/worker.py). See docs/dev/m2-m4-design.md.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import ctypes
|
|
15
|
+
import dataclasses
|
|
16
|
+
import datetime as dt
|
|
17
|
+
import fcntl
|
|
18
|
+
import hashlib
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import signal
|
|
22
|
+
import sys
|
|
23
|
+
import time
|
|
24
|
+
import traceback
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from ..calendar import ics
|
|
28
|
+
from ..capture.recorder import (Recording, RecordingMeta, read_manifest, recover,
|
|
29
|
+
spawn_pw_record, write_manifest)
|
|
30
|
+
from ..config.apps import AppRules
|
|
31
|
+
from ..config.secrets import calendar_sources
|
|
32
|
+
from ..config.settings import Settings, config_dir, load_settings
|
|
33
|
+
from ..notify import clipboard
|
|
34
|
+
from ..notify.dbus import CLOSED_BY_CALL, Notifier, watch_sleep
|
|
35
|
+
from ..process.jobs import ACTIVE, Job, Jobs
|
|
36
|
+
from .core import Detector, DetectorEvent, Identified
|
|
37
|
+
from .graph import BatchFramer
|
|
38
|
+
from .identity import LiveProc
|
|
39
|
+
from .tracker import (MANUAL_KEY, Note, ShowNudge, ShowPrompt, StartRecording, State,
|
|
40
|
+
StopRecording, Transition, WithdrawPrompt)
|
|
41
|
+
|
|
42
|
+
PW_DUMP = ("pw-dump", "--monitor", "--no-colors")
|
|
43
|
+
STATUS_EVERY_S = 5.0
|
|
44
|
+
HOUSEKEEPING_EVERY_S = 6 * 3600.0
|
|
45
|
+
JOB_RETRY_DELAY_S = 60.0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def now_iso() -> str:
|
|
49
|
+
return dt.datetime.now().astimezone().isoformat(timespec="milliseconds")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def room_hash(room: str | None) -> str | None:
|
|
53
|
+
"""Enough to group a call's events in the log without writing its name."""
|
|
54
|
+
return hashlib.sha256(room.encode()).hexdigest()[:8] if room else None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def control_socket_path() -> Path:
|
|
58
|
+
if os.environ.get("WIRENOTE_SOCKET"): # a second instance, e.g. for tests
|
|
59
|
+
return Path(os.environ["WIRENOTE_SOCKET"])
|
|
60
|
+
return Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")) / "wirenote.sock"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _worker_setup() -> None:
|
|
64
|
+
"""Worker child: low priority, and it dies with the daemon so a restart never
|
|
65
|
+
runs two workers on one job."""
|
|
66
|
+
os.nice(10)
|
|
67
|
+
try:
|
|
68
|
+
ctypes.CDLL("libc.so.6", use_errno=True).prctl(1, signal.SIGTERM) # PR_SET_PDEATHSIG
|
|
69
|
+
except OSError:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class EventLog:
|
|
74
|
+
"""detector.jsonl: one JSON object per event. A short line goes to stderr (journal)."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, path: Path) -> None:
|
|
77
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
78
|
+
self._f = path.open("a", buffering=1)
|
|
79
|
+
|
|
80
|
+
def write(self, event: str, **data) -> None:
|
|
81
|
+
rec = {"t": now_iso(), "event": event, **data}
|
|
82
|
+
self._f.write(json.dumps(rec, default=str) + "\n")
|
|
83
|
+
short = " ".join(f"{k}={v}" for k, v in data.items() if k not in ("evidence", "traceback"))
|
|
84
|
+
print(f"{event} {short}", file=sys.stderr, flush=True)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class PersistentState:
|
|
88
|
+
"""state.json: dismiss counts and apps the user chose to always ignore."""
|
|
89
|
+
|
|
90
|
+
def __init__(self, path: Path) -> None:
|
|
91
|
+
self.path = path
|
|
92
|
+
try:
|
|
93
|
+
data = json.loads(path.read_text())
|
|
94
|
+
except (OSError, ValueError):
|
|
95
|
+
data = {}
|
|
96
|
+
self.dismisses: dict[str, int] = data.get("dismisses", {})
|
|
97
|
+
self.ignored: set[str] = set(data.get("ignored", []))
|
|
98
|
+
|
|
99
|
+
def save(self, dismisses: dict[str, int], ignored: set[str]) -> None:
|
|
100
|
+
self.dismisses, self.ignored = dict(dismisses), set(ignored)
|
|
101
|
+
tmp = self.path.with_suffix(".tmp")
|
|
102
|
+
tmp.write_text(json.dumps({"dismisses": self.dismisses,
|
|
103
|
+
"ignored": sorted(self.ignored)}, indent=2))
|
|
104
|
+
tmp.replace(self.path)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclasses.dataclass
|
|
108
|
+
class Shown:
|
|
109
|
+
"""A notification we put up: what it is for, and whether it was answered."""
|
|
110
|
+
kind: str # "prompt", "recording", "nudge" or "note"
|
|
111
|
+
key: str
|
|
112
|
+
handled: bool = False
|
|
113
|
+
path: str | None = None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class Daemon:
|
|
117
|
+
def __init__(self, settings: Settings, rules: AppRules, state_dir: Path) -> None:
|
|
118
|
+
self.settings = settings
|
|
119
|
+
self.state_dir = state_dir
|
|
120
|
+
state_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
121
|
+
self.log = EventLog(state_dir / "detector.jsonl")
|
|
122
|
+
self.state = PersistentState(state_dir / "state.json")
|
|
123
|
+
self.detector = Detector(settings, rules, LiveProc(), own_pids={os.getpid()},
|
|
124
|
+
ignored=self.state.ignored, dismisses=self.state.dismisses)
|
|
125
|
+
self.detector.calendar_hint = self._calendar_hint
|
|
126
|
+
self.notifier = Notifier()
|
|
127
|
+
self.notify_ok = False
|
|
128
|
+
self.jobs = Jobs(state_dir / "jobs.db")
|
|
129
|
+
self.recording: Recording | None = None
|
|
130
|
+
self.spawn = spawn_pw_record # tests swap in fake pw-record children
|
|
131
|
+
self.calendar_events: list[ics.Event] = []
|
|
132
|
+
self._prompt_events: dict[str, ics.Event] = {} # app key -> event shown in its prompt
|
|
133
|
+
self._shown: dict[int, Shown] = {} # notification id -> purpose
|
|
134
|
+
self._prompt_ids: dict[str, int] = {} # app key -> prompt notification id
|
|
135
|
+
self._recording_id: int | None = None
|
|
136
|
+
self._ops: asyncio.Queue = asyncio.Queue() # notification calls, run in order
|
|
137
|
+
self._rec_ops: asyncio.Queue = asyncio.Queue() # recorder calls, run in order
|
|
138
|
+
self._capture_tick_queued = False
|
|
139
|
+
self._worker: asyncio.subprocess.Process | None = None
|
|
140
|
+
self._worker_kick = asyncio.Event()
|
|
141
|
+
self._calendar_kick = asyncio.Event()
|
|
142
|
+
self._last_status = 0.0
|
|
143
|
+
self._stopping = asyncio.Event()
|
|
144
|
+
|
|
145
|
+
# Lifecycle -------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
async def run(self) -> None:
|
|
148
|
+
lock = self._acquire_lock()
|
|
149
|
+
self.log.write("start", pid=os.getpid(),
|
|
150
|
+
rules=[r.key for r in self.detector.rules.rules],
|
|
151
|
+
ignored=sorted(self.detector.ignored),
|
|
152
|
+
summary_backend=self.settings.summary_backend,
|
|
153
|
+
calendars=len(calendar_sources(self.settings.calendar_urls)))
|
|
154
|
+
try:
|
|
155
|
+
self.notifier.on_action = self._on_action
|
|
156
|
+
self.notifier.on_closed = self._on_closed
|
|
157
|
+
await self.notifier.connect()
|
|
158
|
+
self.notify_ok = True
|
|
159
|
+
self.log.write("notifications", server=self.notifier.server,
|
|
160
|
+
actions=self.notifier.has_actions)
|
|
161
|
+
if not self.notifier.has_actions:
|
|
162
|
+
self.log.write("warning", message="notification server has no action buttons; "
|
|
163
|
+
"use `wirenote record` / `wirenote stop`")
|
|
164
|
+
except Exception as e: # noqa: BLE001 - keep detecting without notifications
|
|
165
|
+
self.log.write("error", message=f"notifications unavailable: {e!r}")
|
|
166
|
+
try:
|
|
167
|
+
await watch_sleep(self._on_sleep)
|
|
168
|
+
except Exception as e: # noqa: BLE001
|
|
169
|
+
self.log.write("warning", message=f"sleep/wake signals unavailable: {e!r}")
|
|
170
|
+
self._recover()
|
|
171
|
+
|
|
172
|
+
loop = asyncio.get_running_loop()
|
|
173
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
174
|
+
loop.add_signal_handler(sig, self._stopping.set)
|
|
175
|
+
self._write_status(force=True) # `status` works before anything uses the mic
|
|
176
|
+
|
|
177
|
+
loops = [self._pw_loop(), self._tick_loop(), self._op_loop(), self._rec_loop(),
|
|
178
|
+
self._worker_loop(), self._housekeeping_loop(), self._control_loop(),
|
|
179
|
+
self._calendar_loop()]
|
|
180
|
+
tasks = [asyncio.create_task(c) for c in loops]
|
|
181
|
+
stop = asyncio.create_task(self._stopping.wait())
|
|
182
|
+
done, _ = await asyncio.wait([*tasks, stop], return_when=asyncio.FIRST_COMPLETED)
|
|
183
|
+
# The loops never return; one that finished crashed. Exit non-zero so
|
|
184
|
+
# systemd restarts us instead of running on without detection.
|
|
185
|
+
crashed = [t for t in done if t is not stop and not t.cancelled() and t.exception()]
|
|
186
|
+
for t in crashed:
|
|
187
|
+
exc = t.exception()
|
|
188
|
+
self.log.write("error", message=f"internal error: {exc!r}",
|
|
189
|
+
traceback="".join(traceback.format_exception(exc)))
|
|
190
|
+
self.log.write("stop", reason="crash" if crashed else "signal")
|
|
191
|
+
for task in [*tasks, stop]:
|
|
192
|
+
task.cancel()
|
|
193
|
+
await asyncio.gather(*tasks, stop, return_exceptions=True)
|
|
194
|
+
if self.recording is not None:
|
|
195
|
+
await self._finish_recording("wirenote stopped")
|
|
196
|
+
for nid in list(self._shown):
|
|
197
|
+
await self._safe_close(nid)
|
|
198
|
+
self._write_status(force=True, running=False)
|
|
199
|
+
try:
|
|
200
|
+
control_socket_path().unlink()
|
|
201
|
+
except OSError:
|
|
202
|
+
pass
|
|
203
|
+
lock.close()
|
|
204
|
+
if crashed:
|
|
205
|
+
raise SystemExit(1)
|
|
206
|
+
|
|
207
|
+
def _acquire_lock(self):
|
|
208
|
+
f = (self.state_dir / "daemon.lock").open("w")
|
|
209
|
+
try:
|
|
210
|
+
fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
211
|
+
except BlockingIOError:
|
|
212
|
+
raise SystemExit("wirenote is already running (lock held in "
|
|
213
|
+
f"{self.state_dir / 'daemon.lock'})")
|
|
214
|
+
f.write(str(os.getpid()))
|
|
215
|
+
f.flush()
|
|
216
|
+
return f
|
|
217
|
+
|
|
218
|
+
def _recover(self) -> None:
|
|
219
|
+
"""Queue recordings a crash left open, and jobs a dead worker left mid-way."""
|
|
220
|
+
for rec_dir in recover(self.settings.recordings_path()):
|
|
221
|
+
job = self.jobs.add(rec_dir)
|
|
222
|
+
self.log.write("recovered", recording=rec_dir.name, job=job)
|
|
223
|
+
for job in self.jobs.requeue_interrupted():
|
|
224
|
+
self.log.write("job_interrupted", job=job.id, state=job.state, attempts=job.attempts)
|
|
225
|
+
|
|
226
|
+
# Inputs ----------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
async def _pw_loop(self) -> None:
|
|
229
|
+
backoff = 1.0
|
|
230
|
+
while True:
|
|
231
|
+
try:
|
|
232
|
+
proc = await asyncio.create_subprocess_exec(
|
|
233
|
+
*PW_DUMP, stdout=asyncio.subprocess.PIPE, limit=1 << 22)
|
|
234
|
+
except OSError as e:
|
|
235
|
+
self.log.write("error", message=f"cannot start pw-dump: {e!r}",
|
|
236
|
+
restart_in_s=backoff)
|
|
237
|
+
await asyncio.sleep(backoff)
|
|
238
|
+
backoff = min(backoff * 2, 30.0)
|
|
239
|
+
continue
|
|
240
|
+
framer = BatchFramer()
|
|
241
|
+
try:
|
|
242
|
+
async for raw in proc.stdout:
|
|
243
|
+
batch = framer.feed(raw.decode("utf-8", "replace"))
|
|
244
|
+
if batch is not None:
|
|
245
|
+
self._handle(self.detector.apply_batch(batch, time.monotonic()))
|
|
246
|
+
backoff = 1.0
|
|
247
|
+
finally:
|
|
248
|
+
if proc.returncode is None:
|
|
249
|
+
proc.terminate()
|
|
250
|
+
rc = await proc.wait()
|
|
251
|
+
self.log.write("pw_dump_exit", returncode=rc, restart_in_s=backoff)
|
|
252
|
+
self.detector.reset_graph()
|
|
253
|
+
await asyncio.sleep(backoff)
|
|
254
|
+
backoff = min(backoff * 2, 30.0)
|
|
255
|
+
|
|
256
|
+
async def _tick_loop(self) -> None:
|
|
257
|
+
while True:
|
|
258
|
+
await asyncio.sleep(self.settings.reconcile_s)
|
|
259
|
+
self._handle(self.detector.tick(time.monotonic()))
|
|
260
|
+
if self.recording is not None and not self._capture_tick_queued:
|
|
261
|
+
self._capture_tick_queued = True
|
|
262
|
+
self._rec_ops.put_nowait((self._capture_tick, None))
|
|
263
|
+
|
|
264
|
+
def _on_sleep(self, going_to_sleep: bool) -> None:
|
|
265
|
+
now = time.monotonic()
|
|
266
|
+
if going_to_sleep:
|
|
267
|
+
self.log.write("sleep")
|
|
268
|
+
self._handle(self.detector.tracker.sleep(now))
|
|
269
|
+
else:
|
|
270
|
+
self.log.write("resume")
|
|
271
|
+
self._handle(self.detector.tracker.resume(now, self.detector.observations()))
|
|
272
|
+
|
|
273
|
+
def _on_action(self, nid: int, action: str) -> None:
|
|
274
|
+
shown = self._shown.get(nid)
|
|
275
|
+
if shown is None or shown.handled:
|
|
276
|
+
return
|
|
277
|
+
if shown.kind == "note" and action in ("open", "default"):
|
|
278
|
+
shown.handled = True
|
|
279
|
+
self._open_note(shown.key, shown.path)
|
|
280
|
+
return
|
|
281
|
+
shown.handled = True
|
|
282
|
+
now = time.monotonic()
|
|
283
|
+
self.log.write("click", notification=shown.kind, app=shown.key, action=action)
|
|
284
|
+
if shown.kind == "prompt" and action == "record":
|
|
285
|
+
self._handle(self.detector.tracker.record(shown.key, now))
|
|
286
|
+
elif shown.kind == "prompt" and action == "ignore":
|
|
287
|
+
self._handle(self.detector.tracker.ignore(shown.key, now))
|
|
288
|
+
elif shown.kind == "recording" and action == "stop":
|
|
289
|
+
self._handle(self.detector.tracker.stop(now))
|
|
290
|
+
elif shown.kind == "nudge" and action == "always-ignore":
|
|
291
|
+
self.detector.ignored.add(shown.key)
|
|
292
|
+
self.log.write("always_ignore", app=shown.key)
|
|
293
|
+
elif shown.kind == "nudge" and action == "keep":
|
|
294
|
+
pass
|
|
295
|
+
else:
|
|
296
|
+
shown.handled = False # e.g. a click on the body ("default"): wait for a button
|
|
297
|
+
return
|
|
298
|
+
self._save_state()
|
|
299
|
+
|
|
300
|
+
def _on_closed(self, nid: int, reason: int) -> None:
|
|
301
|
+
shown = self._shown.pop(nid, None)
|
|
302
|
+
if shown is None:
|
|
303
|
+
return
|
|
304
|
+
if shown.kind == "prompt":
|
|
305
|
+
self._prompt_ids.pop(shown.key, None)
|
|
306
|
+
if shown.kind == "recording" and self._recording_id == nid:
|
|
307
|
+
self._recording_id = None
|
|
308
|
+
if shown.handled or reason == CLOSED_BY_CALL or shown.kind == "note":
|
|
309
|
+
return
|
|
310
|
+
self.log.write("notification_closed", notification=shown.kind, app=shown.key,
|
|
311
|
+
reason=reason)
|
|
312
|
+
if shown.kind == "prompt":
|
|
313
|
+
# docs/dev/PLAN.md › Prompt: a close without an action counts as Ignore.
|
|
314
|
+
self._handle(self.detector.tracker.ignore(shown.key, time.monotonic()))
|
|
315
|
+
self._save_state()
|
|
316
|
+
|
|
317
|
+
# Outputs ---------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
def _handle(self, events: list[DetectorEvent]) -> None:
|
|
320
|
+
for e in events:
|
|
321
|
+
if isinstance(e, Identified):
|
|
322
|
+
if e.key is None:
|
|
323
|
+
self.log.write("unmatched", client=e.client_serial, evidence=e.evidence)
|
|
324
|
+
else:
|
|
325
|
+
self.log.write("identified", app=e.key, kind=e.kind, by=e.matched_by,
|
|
326
|
+
client=e.client_serial, evidence=e.evidence)
|
|
327
|
+
if not e.evidence.get("pid_ok"):
|
|
328
|
+
self.log.write("pid_mismatch", client=e.client_serial, evidence=e.evidence)
|
|
329
|
+
elif isinstance(e, Transition):
|
|
330
|
+
self.log.write("transition", app=e.key, old=str(e.old), new=str(e.new),
|
|
331
|
+
reason=e.reason)
|
|
332
|
+
elif isinstance(e, Note):
|
|
333
|
+
self.log.write("note", app=e.key, message=e.message)
|
|
334
|
+
elif isinstance(e, ShowPrompt):
|
|
335
|
+
event = self._calendar_event(e.service)
|
|
336
|
+
if event:
|
|
337
|
+
self._prompt_events[e.key] = event
|
|
338
|
+
self.log.write("prompt", app=e.key, kind=e.kind, service=e.service,
|
|
339
|
+
room_hash=room_hash(e.room), calendar=event is not None)
|
|
340
|
+
self._ops.put_nowait((self._show_prompt, (e, event)))
|
|
341
|
+
elif isinstance(e, WithdrawPrompt):
|
|
342
|
+
self._prompt_events.pop(e.key, None)
|
|
343
|
+
self.log.write("withdraw", app=e.key)
|
|
344
|
+
self._ops.put_nowait((self._withdraw, e))
|
|
345
|
+
elif isinstance(e, StartRecording):
|
|
346
|
+
self.log.write("record_start", app=e.key)
|
|
347
|
+
self._rec_ops.put_nowait((self._start_capture, e))
|
|
348
|
+
elif isinstance(e, StopRecording):
|
|
349
|
+
self.log.write("record_stop", app=e.key, reason=e.reason,
|
|
350
|
+
duration_s=e.duration_s)
|
|
351
|
+
self._rec_ops.put_nowait((self._stop_capture, e))
|
|
352
|
+
elif isinstance(e, ShowNudge):
|
|
353
|
+
self.log.write("nudge", app=e.key, dismisses=e.dismisses)
|
|
354
|
+
self._ops.put_nowait((self._show_nudge, e))
|
|
355
|
+
self._write_status(force=bool(events))
|
|
356
|
+
|
|
357
|
+
async def _op_loop(self) -> None:
|
|
358
|
+
while True:
|
|
359
|
+
fn, arg = await self._ops.get()
|
|
360
|
+
if not self.notify_ok:
|
|
361
|
+
continue
|
|
362
|
+
try:
|
|
363
|
+
await fn(arg)
|
|
364
|
+
except Exception as e: # noqa: BLE001 - one failed call must not stop the daemon
|
|
365
|
+
self.log.write("error", message=f"notification call failed: {e!r}")
|
|
366
|
+
|
|
367
|
+
async def _show_prompt(self, arg: tuple[ShowPrompt, ics.Event | None]) -> None:
|
|
368
|
+
e, event = arg
|
|
369
|
+
if event:
|
|
370
|
+
summary = event.title
|
|
371
|
+
body = f"{e.name} is using your mic. Record this call?"
|
|
372
|
+
elif e.service:
|
|
373
|
+
summary = f"{e.service} call in {e.name}"
|
|
374
|
+
body = e.room or ""
|
|
375
|
+
else:
|
|
376
|
+
summary = f"{e.name} is using your mic"
|
|
377
|
+
body = "Record this call?"
|
|
378
|
+
if not self.notifier.has_actions:
|
|
379
|
+
# No buttons on this desktop's notifications.
|
|
380
|
+
body = (body + " " if body else "") + \
|
|
381
|
+
"Record from the Wirenote window or run `wirenote record`."
|
|
382
|
+
nid = await self.notifier.notify(summary, body, [("record", "Record"), ("ignore", "Ignore")])
|
|
383
|
+
self._shown[nid] = Shown("prompt", e.key)
|
|
384
|
+
self._prompt_ids[e.key] = nid
|
|
385
|
+
|
|
386
|
+
async def _withdraw(self, e: WithdrawPrompt) -> None:
|
|
387
|
+
nid = self._prompt_ids.pop(e.key, None)
|
|
388
|
+
if nid is not None:
|
|
389
|
+
await self._safe_close(nid)
|
|
390
|
+
|
|
391
|
+
async def _show_recording(self, rec: Recording) -> None:
|
|
392
|
+
m = rec.meta
|
|
393
|
+
what = (m.calendar or {}).get("title") or (f"{m.service} call" if m.service else m.name)
|
|
394
|
+
nid = await self.notifier.notify(f"Recording {what}",
|
|
395
|
+
"Mic and system audio. Stop ends the recording.",
|
|
396
|
+
[("stop", "Stop")])
|
|
397
|
+
self._shown[nid] = Shown("recording", m.app)
|
|
398
|
+
self._recording_id = nid
|
|
399
|
+
|
|
400
|
+
async def _show_stopped(self, arg: tuple[StopRecording, int | None]) -> None:
|
|
401
|
+
e, job_id = arg
|
|
402
|
+
if self._recording_id is not None:
|
|
403
|
+
await self._safe_close(self._recording_id)
|
|
404
|
+
self._recording_id = None
|
|
405
|
+
body = f"{e.reason} after {e.duration_s / 60:.1f} min."
|
|
406
|
+
if job_id is not None:
|
|
407
|
+
body += " Writing the note now." if self._worker is None else " Note queued."
|
|
408
|
+
await self.notifier.notify("Recording saved", body, timeout_ms=8000)
|
|
409
|
+
|
|
410
|
+
async def _show_nudge(self, e: ShowNudge) -> None:
|
|
411
|
+
nid = await self.notifier.notify(
|
|
412
|
+
f"Ignored {e.name} {e.dismisses} times",
|
|
413
|
+
f"Stop asking when {e.name} uses the mic?",
|
|
414
|
+
[("always-ignore", f"Always ignore {e.name}"), ("keep", "Keep asking")])
|
|
415
|
+
self._shown[nid] = Shown("nudge", e.key)
|
|
416
|
+
|
|
417
|
+
async def _show_message(self, arg: tuple[str, str]) -> None:
|
|
418
|
+
summary, body = arg
|
|
419
|
+
await self.notifier.notify(summary, body, timeout_ms=10000, urgency=2)
|
|
420
|
+
|
|
421
|
+
async def _show_note_ready(self, job: Job) -> None:
|
|
422
|
+
title = Path(job.note).stem.split(" ", 2)[-1] if job.note else ""
|
|
423
|
+
nid = await self.notifier.notify("Meeting note ready", title, [("open", "Open note")],
|
|
424
|
+
timeout_ms=0)
|
|
425
|
+
self._shown[nid] = Shown("note", job.recording.name, path=job.note)
|
|
426
|
+
|
|
427
|
+
async def _safe_close(self, nid: int) -> None:
|
|
428
|
+
self._shown.pop(nid, None)
|
|
429
|
+
try:
|
|
430
|
+
await self.notifier.close(nid)
|
|
431
|
+
except Exception: # noqa: BLE001 - already gone
|
|
432
|
+
pass
|
|
433
|
+
|
|
434
|
+
def _open_note(self, rec_id: str, path: str | None) -> None:
|
|
435
|
+
"""The call in the web UI if this user's UI runs (docs/dev/ui-design.md), else the
|
|
436
|
+
Markdown file. Never a UI on the port that belongs to someone else."""
|
|
437
|
+
from ..ui.launch import open_ui
|
|
438
|
+
|
|
439
|
+
try:
|
|
440
|
+
if not open_ui(self.settings.ui_port, f"#/call/{rec_id}", fallback=path):
|
|
441
|
+
self.log.write("warning", message="web UI not running (or not ours); "
|
|
442
|
+
"opened the Markdown note")
|
|
443
|
+
except OSError as e:
|
|
444
|
+
self.log.write("error", message=f"cannot open note: {e!r}")
|
|
445
|
+
|
|
446
|
+
# Recording -------------------------------------------------------------
|
|
447
|
+
|
|
448
|
+
async def _rec_loop(self) -> None:
|
|
449
|
+
while True:
|
|
450
|
+
fn, arg = await self._rec_ops.get()
|
|
451
|
+
try:
|
|
452
|
+
await fn(arg)
|
|
453
|
+
except Exception as e: # noqa: BLE001 - keep detecting; the error is logged
|
|
454
|
+
self.log.write("error", message=f"recorder call failed: {e!r}",
|
|
455
|
+
traceback=traceback.format_exc())
|
|
456
|
+
|
|
457
|
+
def _mic_target(self, key: str) -> str | None:
|
|
458
|
+
"""The source the app records from; None (follow the default) if that is the default."""
|
|
459
|
+
sources = self.detector.app_sources.get(key, ())
|
|
460
|
+
default = self.detector.graph.default_node("default.audio.source")
|
|
461
|
+
if not sources or default in sources:
|
|
462
|
+
return None
|
|
463
|
+
return sources[0]
|
|
464
|
+
|
|
465
|
+
async def _start_capture(self, e: StartRecording) -> None:
|
|
466
|
+
if self.recording is not None:
|
|
467
|
+
self.log.write("warning", message=f"already recording {self.recording.id}")
|
|
468
|
+
return
|
|
469
|
+
t = self.detector.tracker.apps.get(e.key)
|
|
470
|
+
obs = t.obs if t else None
|
|
471
|
+
event = self._prompt_events.pop(e.key, None)
|
|
472
|
+
if event is None and e.key == MANUAL_KEY:
|
|
473
|
+
event = self._calendar_event(None)
|
|
474
|
+
meta = RecordingMeta(app=e.key, name=e.name, service=obs.service if obs else None,
|
|
475
|
+
room=obs.room if obs else None,
|
|
476
|
+
calendar=event.as_meta() if event else None)
|
|
477
|
+
rec = Recording(self.settings.recordings_path(), meta,
|
|
478
|
+
node_prefix=self.settings.own_node_prefix,
|
|
479
|
+
heartbeat_s=self.settings.heartbeat_s, spawn=self.spawn,
|
|
480
|
+
system_target=self.settings.system_target)
|
|
481
|
+
target = self._mic_target(e.key)
|
|
482
|
+
try:
|
|
483
|
+
await rec.start(target)
|
|
484
|
+
except Exception as err: # noqa: BLE001
|
|
485
|
+
self.log.write("error", message=f"recording failed to start: {err!r}")
|
|
486
|
+
self._ops.put_nowait((self._show_message, ("Recording failed", str(err))))
|
|
487
|
+
self._handle(self.detector.tracker.stop(time.monotonic(), "capture failed"))
|
|
488
|
+
return
|
|
489
|
+
self.recording = rec
|
|
490
|
+
self.log.write("capture_start", recording=rec.id, mic_target=target or "default",
|
|
491
|
+
dir=str(rec.dir))
|
|
492
|
+
if self._worker is not None and self._worker.returncode is None:
|
|
493
|
+
self._worker.send_signal(signal.SIGSTOP) # no transcription during a call
|
|
494
|
+
self.log.write("job_paused")
|
|
495
|
+
self._write_status(force=True) # the app opens the live page from recording_id
|
|
496
|
+
self._ops.put_nowait((self._show_recording, rec))
|
|
497
|
+
if self.settings.consent_line:
|
|
498
|
+
self._ops.put_nowait((self._copy_consent, self.settings.consent_line))
|
|
499
|
+
|
|
500
|
+
async def _copy_consent(self, text: str) -> None:
|
|
501
|
+
"""Consent line to the clipboard (Klipper, wl-copy, xclip or xsel); if none
|
|
502
|
+
works, the notification shows it so it can be copied by hand."""
|
|
503
|
+
tool = await clipboard.copy(text, self.notifier.copy_to_clipboard)
|
|
504
|
+
if tool is None:
|
|
505
|
+
self.log.write("warning", message="no clipboard tool worked; showing the consent line")
|
|
506
|
+
await self.notifier.notify("Copy this into the call chat:", text, timeout_ms=0)
|
|
507
|
+
return
|
|
508
|
+
self.log.write("consent_copied", tool=tool)
|
|
509
|
+
await self.notifier.notify("Consent line copied",
|
|
510
|
+
"Paste it into the call chat before you continue.",
|
|
511
|
+
timeout_ms=10000)
|
|
512
|
+
|
|
513
|
+
async def _stop_capture(self, e: StopRecording) -> None:
|
|
514
|
+
if self.recording is None:
|
|
515
|
+
self.log.write("note", app=e.key, message="stop: no recording was running")
|
|
516
|
+
self._ops.put_nowait((self._show_stopped, (e, None)))
|
|
517
|
+
return
|
|
518
|
+
job_id = await self._finish_recording(e.reason)
|
|
519
|
+
self._ops.put_nowait((self._show_stopped, (e, job_id)))
|
|
520
|
+
|
|
521
|
+
async def _finish_recording(self, reason: str) -> int:
|
|
522
|
+
rec, self.recording = self.recording, None
|
|
523
|
+
await rec.stop(reason)
|
|
524
|
+
job_id = self.jobs.add(rec.dir)
|
|
525
|
+
self.log.write("capture_stop", recording=rec.id, reason=reason, job=job_id,
|
|
526
|
+
segments=len(rec.segments))
|
|
527
|
+
if self._worker is not None and self._worker.returncode is None:
|
|
528
|
+
self._worker.send_signal(signal.SIGCONT)
|
|
529
|
+
self.log.write("job_resumed")
|
|
530
|
+
self._worker_kick.set()
|
|
531
|
+
self._write_status(force=True)
|
|
532
|
+
return job_id
|
|
533
|
+
|
|
534
|
+
async def _capture_tick(self, _arg) -> None:
|
|
535
|
+
self._capture_tick_queued = False
|
|
536
|
+
rec = self.recording
|
|
537
|
+
if rec is None:
|
|
538
|
+
return
|
|
539
|
+
for ev in await rec.heartbeat():
|
|
540
|
+
self.log.write(f"track_{ev.kind}", recording=rec.id, track=ev.track, detail=ev.detail)
|
|
541
|
+
if ev.kind in ("stalled", "gave_up"):
|
|
542
|
+
other = "system audio" if ev.track == "mic" else "mic"
|
|
543
|
+
self._ops.put_nowait((self._show_message, (
|
|
544
|
+
f"{'Mic' if ev.track == 'mic' else 'System audio'} track stopped",
|
|
545
|
+
f"{ev.detail}. The {other} track keeps recording.")))
|
|
546
|
+
if self.detector.app_sources.get(rec.meta.app):
|
|
547
|
+
target = self._mic_target(rec.meta.app)
|
|
548
|
+
if await rec.retarget_mic(target):
|
|
549
|
+
self.log.write("mic_moved", recording=rec.id, target=target or "default")
|
|
550
|
+
|
|
551
|
+
# Processing ------------------------------------------------------------
|
|
552
|
+
|
|
553
|
+
async def _worker_loop(self) -> None:
|
|
554
|
+
while True:
|
|
555
|
+
job = self.jobs.next_queued()
|
|
556
|
+
if job is None or self.recording is not None:
|
|
557
|
+
self._worker_kick.clear()
|
|
558
|
+
try:
|
|
559
|
+
await asyncio.wait_for(self._worker_kick.wait(), 30)
|
|
560
|
+
except asyncio.TimeoutError:
|
|
561
|
+
pass
|
|
562
|
+
continue
|
|
563
|
+
self.log.write("job_start", job=job.id, recording=job.recording.name,
|
|
564
|
+
summary_only=job.summary_only)
|
|
565
|
+
self._write_status(force=True)
|
|
566
|
+
self._worker = await asyncio.create_subprocess_exec(
|
|
567
|
+
sys.executable, "-m", "wirenote.cli", "process", str(job.id),
|
|
568
|
+
stdin=asyncio.subprocess.DEVNULL, preexec_fn=_worker_setup)
|
|
569
|
+
rc = await self._worker.wait()
|
|
570
|
+
self._worker = None
|
|
571
|
+
job = self.jobs.get(job.id)
|
|
572
|
+
# The worker records its own failures and exits 0; anything else crashed it.
|
|
573
|
+
if job.state in ACTIVE or rc != 0:
|
|
574
|
+
job = self.jobs.fail(job.id, f"worker exited with {rc}")
|
|
575
|
+
self.log.write("job_end", job=job.id, state=job.state, attempts=job.attempts,
|
|
576
|
+
note=bool(job.note), error=(job.error or "")[-300:] or None)
|
|
577
|
+
self._write_status(force=True)
|
|
578
|
+
if job.state == "done":
|
|
579
|
+
self._ops.put_nowait((self._show_note_ready, job))
|
|
580
|
+
elif job.state == "failed":
|
|
581
|
+
self._ops.put_nowait((self._show_message, (
|
|
582
|
+
"Meeting note failed",
|
|
583
|
+
f"{job.recording.name}: see `wirenote jobs`. Audio is kept.")))
|
|
584
|
+
elif job.state == "queued":
|
|
585
|
+
await asyncio.sleep(JOB_RETRY_DELAY_S)
|
|
586
|
+
|
|
587
|
+
async def _housekeeping_loop(self) -> None:
|
|
588
|
+
"""docs/dev/PLAN.md › Processing: delete audio audio_retention_days after its note."""
|
|
589
|
+
while True:
|
|
590
|
+
try:
|
|
591
|
+
self._expire_audio()
|
|
592
|
+
except Exception as e: # noqa: BLE001
|
|
593
|
+
self.log.write("error", message=f"housekeeping failed: {e!r}")
|
|
594
|
+
await asyncio.sleep(HOUSEKEEPING_EVERY_S)
|
|
595
|
+
|
|
596
|
+
def _expire_audio(self) -> None:
|
|
597
|
+
root = self.settings.recordings_path()
|
|
598
|
+
if not root.is_dir() or self.settings.audio_retention_days <= 0:
|
|
599
|
+
return
|
|
600
|
+
cutoff = dt.datetime.now().astimezone() - dt.timedelta(
|
|
601
|
+
days=self.settings.audio_retention_days)
|
|
602
|
+
for rec_dir in root.iterdir():
|
|
603
|
+
try:
|
|
604
|
+
m = read_manifest(rec_dir)
|
|
605
|
+
except (OSError, ValueError):
|
|
606
|
+
continue
|
|
607
|
+
written = m.get("note_written")
|
|
608
|
+
if not written or m.get("audio_deleted") or dt.datetime.fromisoformat(written) > cutoff:
|
|
609
|
+
continue
|
|
610
|
+
for wav_file in rec_dir.glob("*.wav"):
|
|
611
|
+
wav_file.unlink()
|
|
612
|
+
m["audio_deleted"] = now_iso()
|
|
613
|
+
write_manifest(rec_dir, m)
|
|
614
|
+
self.log.write("audio_deleted", recording=rec_dir.name)
|
|
615
|
+
|
|
616
|
+
# Calendar --------------------------------------------------------------
|
|
617
|
+
|
|
618
|
+
async def _calendar_loop(self) -> None:
|
|
619
|
+
"""Sources are re-read every round, so `wirenote calendar add` needs no restart."""
|
|
620
|
+
while True:
|
|
621
|
+
sources = calendar_sources(self.settings.calendar_urls)
|
|
622
|
+
if sources:
|
|
623
|
+
now = dt.datetime.now().astimezone()
|
|
624
|
+
events, errors = await asyncio.to_thread(ics.load, sources, now)
|
|
625
|
+
for err in errors:
|
|
626
|
+
self.log.write("warning", message=err)
|
|
627
|
+
if events or not errors:
|
|
628
|
+
self.calendar_events = events
|
|
629
|
+
self.log.write("calendar", sources=len(sources), events=len(events),
|
|
630
|
+
errors=len(errors))
|
|
631
|
+
else:
|
|
632
|
+
self.calendar_events = []
|
|
633
|
+
self._calendar_kick.clear()
|
|
634
|
+
try:
|
|
635
|
+
await asyncio.wait_for(self._calendar_kick.wait(), self.settings.calendar_refresh_s)
|
|
636
|
+
except asyncio.TimeoutError:
|
|
637
|
+
pass
|
|
638
|
+
|
|
639
|
+
def _calendar_window(self) -> list[ics.Event]:
|
|
640
|
+
return ics.in_window(self.calendar_events, dt.datetime.now().astimezone(),
|
|
641
|
+
self.settings.calendar_before_s, self.settings.calendar_after_start_s)
|
|
642
|
+
|
|
643
|
+
def _calendar_hint(self) -> bool:
|
|
644
|
+
return bool(self.calendar_events) and ics.has_call_link(self._calendar_window())
|
|
645
|
+
|
|
646
|
+
def _calendar_event(self, service: str | None) -> ics.Event | None:
|
|
647
|
+
return ics.pick(self._calendar_window(), service) if self.calendar_events else None
|
|
648
|
+
|
|
649
|
+
# Control socket --------------------------------------------------------
|
|
650
|
+
|
|
651
|
+
async def _control_loop(self) -> None:
|
|
652
|
+
path = control_socket_path()
|
|
653
|
+
try:
|
|
654
|
+
path.unlink()
|
|
655
|
+
except FileNotFoundError:
|
|
656
|
+
pass
|
|
657
|
+
try:
|
|
658
|
+
server = await asyncio.start_unix_server(self._on_control, path=str(path))
|
|
659
|
+
except OSError as e:
|
|
660
|
+
# Detection and recording work without it; only `wirenote stop/record` and
|
|
661
|
+
# the app's Record/Stop buttons need the socket.
|
|
662
|
+
self.log.write("error", message=f"control socket unavailable at {path}: {e!r}")
|
|
663
|
+
await asyncio.Event().wait()
|
|
664
|
+
os.chmod(path, 0o600)
|
|
665
|
+
async with server:
|
|
666
|
+
await server.serve_forever()
|
|
667
|
+
|
|
668
|
+
async def _on_control(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
669
|
+
try:
|
|
670
|
+
cmd = (await asyncio.wait_for(reader.readline(), 5)).decode().strip()
|
|
671
|
+
reply = self._control(cmd)
|
|
672
|
+
writer.write((json.dumps(reply) + "\n").encode())
|
|
673
|
+
await writer.drain()
|
|
674
|
+
except (asyncio.TimeoutError, ConnectionError, UnicodeDecodeError):
|
|
675
|
+
pass
|
|
676
|
+
finally:
|
|
677
|
+
writer.close()
|
|
678
|
+
|
|
679
|
+
def _control(self, cmd: str) -> dict:
|
|
680
|
+
now = time.monotonic()
|
|
681
|
+
tracker = self.detector.tracker
|
|
682
|
+
if cmd == "stop":
|
|
683
|
+
key = tracker.recording_key
|
|
684
|
+
if key is None:
|
|
685
|
+
return {"ok": False, "error": "not recording"}
|
|
686
|
+
self.log.write("control", command="stop", app=key)
|
|
687
|
+
self._handle(tracker.stop(now, "stopped by you"))
|
|
688
|
+
return {"ok": True, "app": key}
|
|
689
|
+
if cmd == "record":
|
|
690
|
+
prompted = [k for k, t in tracker.apps.items() if t.state is State.PROMPTED]
|
|
691
|
+
if len(prompted) != 1:
|
|
692
|
+
return {"ok": False, "error": "no prompt showing" if not prompted
|
|
693
|
+
else f"several prompts: {', '.join(prompted)}"}
|
|
694
|
+
key = prompted[0]
|
|
695
|
+
self.log.write("control", command="record", app=key)
|
|
696
|
+
nid = self._prompt_ids.pop(key, None)
|
|
697
|
+
if nid is not None:
|
|
698
|
+
self._shown[nid].handled = True
|
|
699
|
+
self._ops.put_nowait((self._safe_close, nid))
|
|
700
|
+
self._handle(tracker.record(key, now))
|
|
701
|
+
return {"ok": True, "app": key}
|
|
702
|
+
if cmd == "record-now":
|
|
703
|
+
if tracker.recording_key is not None:
|
|
704
|
+
return {"ok": False, "error": f"already recording {tracker.recording_key}"}
|
|
705
|
+
self.log.write("control", command="record-now")
|
|
706
|
+
self._handle(tracker.start_manual(now))
|
|
707
|
+
return {"ok": True, "app": MANUAL_KEY}
|
|
708
|
+
if cmd == "refresh-calendar":
|
|
709
|
+
self._calendar_kick.set()
|
|
710
|
+
return {"ok": True, "app": None}
|
|
711
|
+
if cmd == "reload-settings":
|
|
712
|
+
# The UI's Settings page changed config.toml; keep running on the old
|
|
713
|
+
# settings if the file cannot be read.
|
|
714
|
+
try:
|
|
715
|
+
self.settings = load_settings(config_dir() / "config.toml")
|
|
716
|
+
except (OSError, ValueError) as e:
|
|
717
|
+
self.log.write("error", message=f"config.toml not reloaded: {type(e).__name__}")
|
|
718
|
+
return {"ok": False, "error": "config.toml could not be read; kept the old settings"}
|
|
719
|
+
self.log.write("control", command="reload-settings")
|
|
720
|
+
return {"ok": True, "app": None}
|
|
721
|
+
return {"ok": False, "error": f"unknown command {cmd!r}"}
|
|
722
|
+
|
|
723
|
+
# State -----------------------------------------------------------------
|
|
724
|
+
|
|
725
|
+
def _save_state(self) -> None:
|
|
726
|
+
self.state.save(self.detector.tracker.dismisses, self.detector.ignored)
|
|
727
|
+
|
|
728
|
+
def _write_status(self, force: bool = False, running: bool = True) -> None:
|
|
729
|
+
now = time.monotonic()
|
|
730
|
+
apps = self.detector.tracker.snapshot(now)
|
|
731
|
+
if not force and not (apps and now - self._last_status >= STATUS_EVERY_S):
|
|
732
|
+
return
|
|
733
|
+
self._last_status = now
|
|
734
|
+
status = {"updated": now_iso(), "pid": os.getpid(), "running": running,
|
|
735
|
+
"notifications": self.notify_ok,
|
|
736
|
+
"notification_buttons": self.notifier.has_actions if self.notify_ok else None,
|
|
737
|
+
"recording": self.detector.tracker.recording_key,
|
|
738
|
+
"recording_id": self.recording.id if self.recording else None,
|
|
739
|
+
"jobs": self.jobs.counts(), "apps": apps}
|
|
740
|
+
path = self.state_dir / "status.json"
|
|
741
|
+
tmp = path.with_suffix(".tmp")
|
|
742
|
+
tmp.write_text(json.dumps(status, indent=2))
|
|
743
|
+
tmp.replace(path)
|