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
wirenote/ui/launch.py ADDED
@@ -0,0 +1,88 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (c) 2026 Hrishikesh Jadhav
3
+ """Open the web UI through a one-time login link.
4
+
5
+ A launcher (the app menu, `wirenote open`, "Open note") writes a random code as
6
+ an empty file in a folder only this user can read, and opens
7
+ /login?code=…; the server accepts a code only if that file exists, belongs to
8
+ its own user and is fresh. Another user on the machine cannot make one.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import re
15
+ import secrets
16
+ import subprocess
17
+ import time
18
+ from pathlib import Path
19
+ from urllib.parse import quote
20
+
21
+ from ..net.peer import listener_uid
22
+
23
+ CODE = re.compile(r"^[A-Za-z0-9_-]{32}$")
24
+ MAX_AGE_S = 60.0
25
+
26
+
27
+ def runtime_dir() -> Path:
28
+ return Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}"))
29
+
30
+
31
+ def login_dir() -> Path:
32
+ d = runtime_dir() / "wirenote" / "login"
33
+ d.mkdir(mode=0o700, parents=True, exist_ok=True)
34
+ os.chmod(d.parent, 0o700)
35
+ os.chmod(d, 0o700)
36
+ return d
37
+
38
+
39
+ def _sweep(d: Path) -> None:
40
+ """Drop codes nobody redeemed."""
41
+ for f in d.iterdir():
42
+ try:
43
+ if time.time() - f.lstat().st_mtime > MAX_AGE_S:
44
+ f.unlink()
45
+ except OSError:
46
+ pass
47
+
48
+
49
+ def new_login_code() -> str:
50
+ d = login_dir()
51
+ _sweep(d)
52
+ code = secrets.token_urlsafe(24)
53
+ os.close(os.open(d / code, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600))
54
+ return code
55
+
56
+
57
+ def redeem_login_code(code: str) -> bool:
58
+ """True once for a fresh code of this user's; the code is used up either way."""
59
+ if not CODE.match(code or ""):
60
+ return False
61
+ path = login_dir() / code
62
+ try:
63
+ st = path.lstat()
64
+ path.unlink()
65
+ except OSError:
66
+ return False
67
+ return st.st_uid == os.getuid() and time.time() - st.st_mtime <= MAX_AGE_S
68
+
69
+
70
+ def login_url(port: int, route: str = "#/") -> str:
71
+ return (f"http://127.0.0.1:{port}/login?code={new_login_code()}"
72
+ f"&next={quote(route, safe='')}")
73
+
74
+
75
+ def _xdg_open(target: str) -> None:
76
+ subprocess.Popen(["xdg-open", target], start_new_session=True,
77
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
78
+
79
+
80
+ def open_ui(port: int, route: str = "#/", fallback: str | None = None) -> bool:
81
+ """Open the UI if this user's server listens on port; else open fallback, if any.
82
+ Never opens a server that belongs to someone else."""
83
+ if listener_uid(port) == os.getuid():
84
+ _xdg_open(login_url(port, route))
85
+ return True
86
+ if fallback:
87
+ _xdg_open(fallback)
88
+ return False
wirenote/ui/server.py ADDED
@@ -0,0 +1,423 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (c) 2026 Hrishikesh Jadhav
3
+ """`wirenote ui`: the local web app on 127.0.0.1 (docs/dev/ui-design.md).
4
+
5
+ Stdlib only. Localhost only, Host header checked (DNS rebinding). Every
6
+ connection must come from this user (other accounts on the machine reach
7
+ 127.0.0.1 too), and every request needs the session cookie, which only a
8
+ one-time login link from a launcher hands out (launch.py). Writes also need the
9
+ token embedded in the page, so another site open in the browser
10
+ cannot change or delete anything.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import hmac
17
+ import json
18
+ import mimetypes
19
+ import os
20
+ import re
21
+ import secrets
22
+ import socket
23
+ import subprocess
24
+ import sys
25
+ from http import HTTPStatus
26
+ from http.cookies import CookieError, SimpleCookie
27
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
28
+ from pathlib import Path
29
+ from urllib.parse import parse_qs, unquote, urlsplit
30
+
31
+ from .. import __version__
32
+ from ..config.settings import Settings
33
+ from ..net.peer import tcp_owner
34
+ from .data import Invalid, NotFound, Store
35
+ from .launch import redeem_login_code
36
+
37
+ STATIC = Path(__file__).parent / "static"
38
+ mimetypes.add_type("font/woff2", ".woff2")
39
+ mimetypes.add_type("text/javascript", ".js")
40
+
41
+ ROUTE_CALL = re.compile(r"^/api/calls/([^/]+)(/[a-z-]+)?(?:/(\d+))?$")
42
+ NEXT_ROUTE = re.compile(r"^#/[\w/?=.&%-]*$")
43
+ COOKIE = "wirenote_session"
44
+
45
+
46
+ def _session_key(state_dir: Path) -> str:
47
+ """One key per install, kept across restarts so an open tab stays logged in."""
48
+ path = state_dir / "ui.key"
49
+ try:
50
+ key = path.read_text().strip()
51
+ if key:
52
+ return key
53
+ except FileNotFoundError:
54
+ pass
55
+ state_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
56
+ key = secrets.token_urlsafe(32)
57
+ tmp = path.with_suffix(".tmp")
58
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
59
+ with os.fdopen(fd, "w") as f:
60
+ f.write(key + "\n")
61
+ tmp.replace(path)
62
+ return key
63
+
64
+
65
+ class App:
66
+ def __init__(self, settings: Settings, state_dir: Path, port: int) -> None:
67
+ self.store = Store(settings, state_dir)
68
+ self.session_key = _session_key(state_dir)
69
+ # Derived from the persistent key, so an open tab's writes still work after a
70
+ # restart; a page can only learn it from index.html, which needs the cookie.
71
+ self.token = hmac.new(self.session_key.encode(), b"wirenote-write-token",
72
+ hashlib.sha256).hexdigest()[:32]
73
+ self.port = port
74
+ self.hosts = {f"127.0.0.1:{port}", f"localhost:{port}"}
75
+
76
+ def index_html(self) -> bytes:
77
+ html = (STATIC / "index.html").read_text()
78
+ return html.replace("{{TOKEN}}", self.token).replace("{{VERSION}}", __version__).encode()
79
+
80
+
81
+ def make_handler(app: App):
82
+ class Handler(BaseHTTPRequestHandler):
83
+ server_version = "wirenote"
84
+ protocol_version = "HTTP/1.1"
85
+
86
+ def log_message(self, fmt, *args): # quiet; errors still go to stderr
87
+ pass
88
+
89
+ # Plumbing ----------------------------------------------------------
90
+
91
+ def _send(self, status: int, body: bytes, ctype: str, extra: dict | None = None):
92
+ self.send_response(status)
93
+ self.send_header("Content-Type", ctype)
94
+ self.send_header("Content-Length", str(len(body)))
95
+ self.send_header("Cache-Control", "no-store")
96
+ self.send_header("X-Content-Type-Options", "nosniff")
97
+ self.send_header("Referrer-Policy", "no-referrer")
98
+ # Other sites must not embed or probe our responses (<audio src=…>).
99
+ self.send_header("Cross-Origin-Resource-Policy", "same-origin")
100
+ for k, v in (extra or {}).items():
101
+ self.send_header(k, v)
102
+ if self.close_connection:
103
+ self.send_header("Connection", "close")
104
+ self.end_headers()
105
+ if self.command != "HEAD":
106
+ self.wfile.write(body)
107
+
108
+ def _json(self, data, status: int = 200):
109
+ self._send(status, json.dumps(data, ensure_ascii=False).encode(),
110
+ "application/json; charset=utf-8")
111
+
112
+ def _error(self, status: int, message: str):
113
+ self._json({"error": message}, status)
114
+
115
+ def _drain(self) -> None:
116
+ """Read a refused request's body so keep-alive stays in step; a body too big
117
+ (or a bad length) cannot be skipped, so that connection ends here."""
118
+ try:
119
+ n = int(self.headers.get("Content-Length") or 0)
120
+ except ValueError:
121
+ n = -1
122
+ if 0 <= n <= 1 << 20:
123
+ if n:
124
+ self.rfile.read(n)
125
+ else:
126
+ self.close_connection = True
127
+
128
+ def _refuse(self, status: int, message: str) -> bool:
129
+ self._drain()
130
+ self._error(status, message)
131
+ return False
132
+
133
+ def _from_me(self) -> bool:
134
+ """The client socket belongs to this user; unknown counts as someone else."""
135
+ return tcp_owner(app.port, self.client_address[1]) == os.getuid()
136
+
137
+ def _session_ok(self) -> bool:
138
+ try:
139
+ jar = SimpleCookie(self.headers.get("Cookie", ""))
140
+ except CookieError:
141
+ return False
142
+ morsel = jar.get(COOKIE)
143
+ return bool(morsel) and secrets.compare_digest(morsel.value, app.session_key)
144
+
145
+ def _allowed(self, write: bool) -> bool:
146
+ if not self._from_me():
147
+ self.close_connection = True
148
+ return self._refuse(HTTPStatus.FORBIDDEN, "not your server")
149
+ if self.headers.get("Host") not in app.hosts:
150
+ return self._refuse(HTTPStatus.FORBIDDEN, "bad host")
151
+ if self.headers.get("Sec-Fetch-Site") == "cross-site":
152
+ return self._refuse(HTTPStatus.FORBIDDEN, "cross-site request")
153
+ if write and not self._session_ok():
154
+ return self._refuse(HTTPStatus.UNAUTHORIZED, "not logged in; open Wirenote from the app menu")
155
+ if write:
156
+ origin = self.headers.get("Origin")
157
+ if origin and origin not in {f"http://{h}" for h in app.hosts}:
158
+ self._drain()
159
+ self._error(HTTPStatus.FORBIDDEN, "bad origin")
160
+ return False
161
+ if not secrets.compare_digest(self.headers.get("X-Wirenote-Token", ""), app.token):
162
+ self._drain()
163
+ self._error(HTTPStatus.FORBIDDEN, "missing token; reload the page")
164
+ return False
165
+ return True
166
+
167
+ def _body(self) -> dict:
168
+ if hasattr(self, "_payload"):
169
+ return self._payload
170
+ n = int(self.headers.get("Content-Length") or 0)
171
+ if n > 1 << 20:
172
+ raise Invalid("request too large")
173
+ raw = self.rfile.read(n) if n else b"{}"
174
+ try:
175
+ data = json.loads(raw or b"{}")
176
+ except ValueError as e:
177
+ raise Invalid("bad JSON") from e
178
+ if not isinstance(data, dict):
179
+ raise Invalid("expected an object")
180
+ self._payload = data
181
+ return data
182
+
183
+ def _dispatch(self, fn):
184
+ try:
185
+ fn()
186
+ except NotFound as e:
187
+ self._error(HTTPStatus.NOT_FOUND, f"not found: {e}")
188
+ except Invalid as e:
189
+ self._error(HTTPStatus.BAD_REQUEST, str(e))
190
+ except (BrokenPipeError, ConnectionResetError):
191
+ pass
192
+ except Exception as e: # noqa: BLE001 - keep serving; report the error
193
+ # The query can hold search words: log the path only.
194
+ print(f"ui: {self.command} {urlsplit(self.path).path}: {e!r}",
195
+ file=sys.stderr, flush=True)
196
+ self._error(HTTPStatus.INTERNAL_SERVER_ERROR, "internal error")
197
+
198
+ # GET ---------------------------------------------------------------
199
+
200
+ def do_HEAD(self):
201
+ self.do_GET()
202
+
203
+ def do_GET(self):
204
+ if not self._allowed(write=False):
205
+ return
206
+ self._dispatch(self._get)
207
+
208
+ def _login_page(self, status: int):
209
+ self._send(status, (STATIC / "login.html").read_bytes(), "text/html; charset=utf-8",
210
+ {"Content-Security-Policy": "default-src 'none'; style-src 'self'; "
211
+ "img-src 'self'; frame-ancestors 'none'"})
212
+
213
+ def _login(self, query: str):
214
+ q = parse_qs(query)
215
+ if not redeem_login_code(q.get("code", [""])[0]):
216
+ return self._login_page(HTTPStatus.FORBIDDEN)
217
+ nxt = q.get("next", ["#/"])[0]
218
+ self._send(HTTPStatus.SEE_OTHER, b"", "text/plain", {
219
+ "Location": "/" + (nxt if NEXT_ROUTE.match(nxt) else "#/"),
220
+ "Set-Cookie": f"{COOKIE}={app.session_key}; HttpOnly; SameSite=Strict; Path=/"})
221
+
222
+ def _get(self):
223
+ url = urlsplit(self.path)
224
+ path = url.path
225
+ if path == "/login":
226
+ return self._login(url.query)
227
+ if path.startswith("/static/"):
228
+ return self._static(path.removeprefix("/static/"))
229
+ if not self._session_ok():
230
+ if path in ("/", "/index.html"):
231
+ return self._login_page(HTTPStatus.UNAUTHORIZED)
232
+ return self._error(HTTPStatus.UNAUTHORIZED, "not logged in; open Wirenote from the app menu")
233
+ if path in ("/", "/index.html"):
234
+ return self._send(200, app.index_html(), "text/html; charset=utf-8", {
235
+ "Content-Security-Policy":
236
+ "default-src 'self'; media-src 'self' blob:; img-src 'self' data:; "
237
+ "style-src 'self'; script-src 'self'; frame-ancestors 'none'; "
238
+ "base-uri 'none'; form-action 'self'"})
239
+ if path == "/api/status":
240
+ return self._json(app.store.status())
241
+ if path == "/api/calls":
242
+ q = parse_qs(url.query).get("q", [""])[0]
243
+ return self._json({"calls": app.store.calls(q)})
244
+ if path == "/api/upcoming":
245
+ return self._json({"events": app.store.upcoming()})
246
+ if path == "/api/actions":
247
+ return self._json({"items": app.store.action_items()})
248
+ if path == "/api/asks":
249
+ return self._json({"asks": app.store.asks()})
250
+ if path == "/api/settings":
251
+ return self._json(app.store.settings_info())
252
+ if path == "/api/doctor":
253
+ return self._json(app.store.doctor())
254
+ m = ROUTE_CALL.match(path)
255
+ if m and not m.group(2):
256
+ return self._json(app.store.call(m.group(1)))
257
+ if m and m.group(2) == "/audio":
258
+ return self._file(app.store.mixed_audio(m.group(1)), "audio/ogg")
259
+ raise NotFound(path)
260
+
261
+ def _static(self, rel: str):
262
+ target = (STATIC / rel).resolve()
263
+ if STATIC.resolve() not in target.parents or not target.is_file():
264
+ raise NotFound(rel)
265
+ ctype = mimetypes.guess_type(target.name)[0] or "application/octet-stream"
266
+ if ctype.startswith("text/") or ctype == "text/javascript":
267
+ ctype += "; charset=utf-8"
268
+ self._send(200, target.read_bytes(), ctype)
269
+
270
+ def _file(self, path: Path, ctype: str):
271
+ """Serve with HTTP Range, so the audio player can seek."""
272
+ size = path.stat().st_size
273
+ rng = re.match(r"bytes=(\d*)-(\d*)$", self.headers.get("Range", ""))
274
+ if not rng:
275
+ return self._send(200, path.read_bytes(), ctype, {"Accept-Ranges": "bytes"})
276
+ start = int(rng.group(1)) if rng.group(1) else max(0, size - int(rng.group(2) or 0))
277
+ end = int(rng.group(2)) if rng.group(1) and rng.group(2) else size - 1
278
+ end = min(end, size - 1)
279
+ if start > end:
280
+ return self._send(416, b"", ctype, {"Content-Range": f"bytes */{size}"})
281
+ with path.open("rb") as f:
282
+ f.seek(start)
283
+ body = f.read(end - start + 1)
284
+ self._send(206, body, ctype, {"Accept-Ranges": "bytes",
285
+ "Content-Range": f"bytes {start}-{end}/{size}"})
286
+
287
+ # POST / DELETE -----------------------------------------------------
288
+
289
+ def _read_body_first(self, handler):
290
+ # Every route must consume its body: on a kept-alive connection unread
291
+ # bytes would be taken as the start of the next request.
292
+ def run():
293
+ # One handler serves every request on a connection: drop the last body.
294
+ self.__dict__.pop("_payload", None)
295
+ self._body()
296
+ handler()
297
+ return run
298
+
299
+ def do_POST(self):
300
+ if self._allowed(write=True):
301
+ self._dispatch(self._read_body_first(self._post))
302
+
303
+ def do_PUT(self):
304
+ if self._allowed(write=True):
305
+ self._dispatch(self._read_body_first(self._put))
306
+
307
+ def do_DELETE(self):
308
+ if self._allowed(write=True):
309
+ self._dispatch(self._read_body_first(self._delete))
310
+
311
+ def _put(self):
312
+ if urlsplit(self.path).path != "/api/settings":
313
+ raise NotFound(self.path)
314
+ body = self._body()
315
+ return self._json(app.store.update_settings(body.get("changes"),
316
+ body.get("confirm_cloud") is True))
317
+
318
+ def _post(self):
319
+ path = urlsplit(self.path).path
320
+ store = app.store
321
+ if path == "/api/stop":
322
+ return self._json(_control("stop"))
323
+ if path == "/api/record":
324
+ return self._json(_control("record-now"))
325
+ if path == "/api/ask":
326
+ return self._json(store.ask_all(str(self._body().get("question", ""))))
327
+ if path == "/api/vocabulary":
328
+ terms = self._body().get("terms", [])
329
+ if not isinstance(terms, list):
330
+ raise Invalid("terms must be a list")
331
+ return self._json({"terms": store.set_vocabulary(terms)})
332
+ if path == "/api/settings/anthropic-key":
333
+ return self._json(store.set_api_key(str(self._body().get("key", ""))))
334
+ if path == "/api/calendars":
335
+ return self._json({"calendars": store.add_calendar(str(self._body().get("url", "")))})
336
+ m = ROUTE_CALL.match(path)
337
+ if not m:
338
+ raise NotFound(path)
339
+ rec_id, action, index = m.group(1), m.group(2), m.group(3)
340
+ body = self._body()
341
+ if action == "/resummarize":
342
+ return self._json({"job": store.requeue(rec_id, summary_only=True)})
343
+ if action == "/retranscribe":
344
+ return self._json({"job": store.requeue(rec_id, summary_only=False)})
345
+ if action == "/ask":
346
+ return self._json(store.ask(rec_id, str(body.get("question", ""))))
347
+ if action == "/notes":
348
+ store.save_my_notes(rec_id, str(body.get("text", "")))
349
+ return self._json({"ok": True})
350
+ if action == "/speaker":
351
+ store.rename_speaker(rec_id, str(body.get("label", "")), str(body.get("name", "")),
352
+ bool(body.get("remember")), str(body.get("merge_into", "")))
353
+ return self._json({"ok": True})
354
+ if action == "/followup":
355
+ return self._json(store.followup(rec_id))
356
+ if action == "/rename":
357
+ store.rename(rec_id, str(body.get("title", "")))
358
+ return self._json({"ok": True})
359
+ if action == "/action" and index is not None:
360
+ store.set_action_done(rec_id, int(index), bool(body.get("done")))
361
+ return self._json({"ok": True})
362
+ if action == "/open":
363
+ subprocess.Popen(["xdg-open", str(store.note_path(rec_id))],
364
+ start_new_session=True, stdout=subprocess.DEVNULL,
365
+ stderr=subprocess.DEVNULL)
366
+ return self._json({"ok": True})
367
+ if action == "/folder":
368
+ subprocess.Popen(["xdg-open", str(store.rec_dir(rec_id))],
369
+ start_new_session=True, stdout=subprocess.DEVNULL,
370
+ stderr=subprocess.DEVNULL)
371
+ return self._json({"ok": True})
372
+ raise NotFound(path)
373
+
374
+ def _delete(self):
375
+ path = urlsplit(self.path).path
376
+ if path.startswith("/api/voices/"):
377
+ app.store.forget_voice(unquote(path.removeprefix("/api/voices/")))
378
+ return self._json({"ok": True})
379
+ if path.startswith("/api/calendars/"):
380
+ cal_id = path.removeprefix("/api/calendars/")
381
+ return self._json({"calendars": app.store.remove_calendar(cal_id)})
382
+ m = ROUTE_CALL.match(path)
383
+ if not m:
384
+ raise NotFound(self.path)
385
+ if m.group(2) == "/audio":
386
+ app.store.delete_audio(m.group(1))
387
+ elif not m.group(2):
388
+ app.store.delete_call(m.group(1))
389
+ else:
390
+ raise NotFound(self.path)
391
+ self._json({"ok": True})
392
+
393
+ return Handler
394
+
395
+
396
+ def _control(command: str) -> dict:
397
+ from ..detector.daemon import control_socket_path
398
+
399
+ try:
400
+ with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
401
+ s.settimeout(5)
402
+ s.connect(str(control_socket_path()))
403
+ s.sendall(f"{command}\n".encode())
404
+ return json.loads(s.makefile().readline())
405
+ except (OSError, ValueError) as e:
406
+ return {"ok": False, "error": f"daemon not reachable: {e}"}
407
+
408
+
409
+ def serve(settings: Settings, state_dir: Path, port: int, open_browser: bool = False) -> None:
410
+ app = App(settings, state_dir, port)
411
+ httpd = ThreadingHTTPServer(("127.0.0.1", port), make_handler(app))
412
+ httpd.daemon_threads = True
413
+ print(f"wirenote ui on http://127.0.0.1:{port}/ (open it with `wirenote open`)",
414
+ file=sys.stderr, flush=True)
415
+ if open_browser:
416
+ from .launch import open_ui
417
+ open_ui(port)
418
+ try:
419
+ httpd.serve_forever()
420
+ except KeyboardInterrupt:
421
+ pass
422
+ finally:
423
+ httpd.server_close()