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,140 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (c) 2026 Hrishikesh Jadhav
3
+ """`wirenote setup install` / `uninstall`: systemd user units, the app-menu entry
4
+ and its icon, so nobody copies files by hand.
5
+
6
+ Nothing here needs root: everything goes to the user's own config and data folders.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+ from collections.abc import Callable
15
+ from dataclasses import dataclass
16
+ from importlib.resources import files
17
+ from pathlib import Path
18
+
19
+ from ..config.settings import config_dir, data_dir, load_settings, state_dir
20
+
21
+ UNITS = ("wirenote.service", "wirenote-ui.service")
22
+ OLD_UNITS = ("meetnote.service", "meetnote-ui.service")
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Action:
27
+ kind: str # "write" | "run" | "remove"
28
+ target: str # a path, or a description of the command
29
+ payload: object # file text, or argv
30
+
31
+
32
+ def _units_dir() -> Path:
33
+ return config_dir().parent / "systemd" / "user"
34
+
35
+
36
+ def _xdg_data() -> Path:
37
+ return data_dir().parent
38
+
39
+
40
+ def _desktop_path() -> Path:
41
+ return _xdg_data() / "applications" / "wirenote.desktop"
42
+
43
+
44
+ def _icon_path() -> Path:
45
+ return _xdg_data() / "icons" / "hicolor" / "scalable" / "apps" / "wirenote.svg"
46
+
47
+
48
+ def _exe() -> str:
49
+ found = shutil.which("wirenote")
50
+ return found or str(Path(sys.argv[0]).resolve())
51
+
52
+
53
+ def _run(argv: list[str]) -> int:
54
+ return subprocess.run(argv, check=False).returncode
55
+
56
+
57
+ def _template(name: str, exe: str) -> str:
58
+ quoted = f'"{exe}"' if " " in exe else exe
59
+ return (files("wirenote") / "data" / name).read_text().replace("@EXEC@", quoted)
60
+
61
+
62
+ def _systemctl(*args: str) -> Action:
63
+ argv = ["systemctl", "--user", *args]
64
+ return Action("run", " ".join(argv), argv)
65
+
66
+
67
+ def plan_install(exe: str) -> list[Action]:
68
+ units = _units_dir()
69
+ actions = [Action("write", str(units / u), _template(u, exe)) for u in UNITS]
70
+ actions.append(Action("write", str(_desktop_path()), _template("wirenote.desktop", exe)))
71
+ actions.append(Action("write", str(_icon_path()),
72
+ (files("wirenote") / "ui" / "static" / "icon.svg").read_text()))
73
+ actions.append(_systemctl("daemon-reload"))
74
+ if any((units / u).exists() for u in OLD_UNITS):
75
+ # Before enabling ours: the old UI holds the same port.
76
+ actions.append(_systemctl("disable", "--now", *OLD_UNITS))
77
+ actions += [Action("remove", str(units / u), None) for u in OLD_UNITS]
78
+ actions.append(_systemctl("daemon-reload"))
79
+ actions.append(_systemctl("enable", *UNITS))
80
+ # Restart rather than start, so an update runs the new code.
81
+ actions.append(_systemctl("restart", *UNITS))
82
+ return actions
83
+
84
+
85
+ def _apply(actions: list[Action], dry_run: bool) -> int:
86
+ failed = 0
87
+ for a in actions:
88
+ if a.kind == "write":
89
+ print(f"write {a.target}")
90
+ if dry_run:
91
+ print("".join(f" {line}\n" for line in str(a.payload).splitlines()), end="")
92
+ continue
93
+ path = Path(a.target)
94
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
95
+ path.write_text(str(a.payload))
96
+ elif a.kind == "remove":
97
+ print(f"remove {a.target}")
98
+ if not dry_run:
99
+ Path(a.target).unlink(missing_ok=True)
100
+ else:
101
+ print(f"run {a.target}")
102
+ if not dry_run and _run(list(a.payload)) != 0:
103
+ print(f" ✗ {a.target} failed")
104
+ failed += 1
105
+ return 1 if failed else 0
106
+
107
+
108
+ def install(dry_run: bool = False) -> int:
109
+ rc = _apply(plan_install(_exe()), dry_run)
110
+ if not dry_run and rc == 0:
111
+ print("\n✓ Wirenote runs in the background and starts with your session.")
112
+ print(" Open it from the app menu, or run: wirenote open. Check the setup: wirenote doctor")
113
+ return rc
114
+
115
+
116
+ def uninstall(purge: bool = False, dry_run: bool = False,
117
+ confirm: Callable[[str], str] = input) -> int:
118
+ actions = [_systemctl("disable", "--now", *UNITS)]
119
+ actions += [Action("remove", str(_units_dir() / u), None) for u in UNITS]
120
+ actions += [Action("remove", str(_desktop_path()), None),
121
+ Action("remove", str(_icon_path()), None), _systemctl("daemon-reload")]
122
+ folders: list[Path] = []
123
+ if purge:
124
+ settings = load_settings(config_dir() / "config.toml")
125
+ folders = [config_dir(), state_dir(), data_dir()]
126
+ if not settings.recordings_path().is_relative_to(data_dir()):
127
+ folders.append(settings.recordings_path())
128
+ print("This deletes all recordings, transcripts, settings and voice prints:")
129
+ for f in folders:
130
+ print(f" {f}")
131
+ print(f"Your Markdown notes in {settings.notes_path()} are kept.")
132
+ if not dry_run and confirm("Type DELETE to remove them: ").strip() != "DELETE":
133
+ print("nothing removed")
134
+ return 1
135
+ rc = _apply(actions, dry_run)
136
+ for f in folders:
137
+ print(f"remove {f}")
138
+ if not dry_run:
139
+ shutil.rmtree(f, ignore_errors=True)
140
+ return rc
@@ -0,0 +1,338 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (c) 2026 Hrishikesh Jadhav
3
+ """`wirenote setup`, `wirenote calendar` and `wirenote doctor`.
4
+
5
+ Secrets (API key, calendar links) are read from the terminal, never from the
6
+ command line, so they stay out of shell history, and go to secrets.toml.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import datetime as dt
12
+ import getpass
13
+ import json
14
+ import os
15
+ import shutil
16
+ import subprocess
17
+ import urllib.request
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+
21
+ from ..config.secrets import (calendar_sources, load_secrets, save_secrets, secrets_path,
22
+ set_config_value)
23
+ from ..net.peer import listener_uid, trusted_owner
24
+ from ..config.settings import Settings, config_dir, load_settings, state_dir
25
+
26
+ OK, BAD, WARN = "✓", "✗", "!"
27
+
28
+
29
+ def _settings() -> Settings:
30
+ return load_settings(config_dir() / "config.toml")
31
+
32
+
33
+ def _poke_daemon(command: str) -> None:
34
+ from ..detector.daemon import control_socket_path
35
+ import socket
36
+
37
+ try:
38
+ with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
39
+ s.settimeout(3)
40
+ s.connect(str(control_socket_path()))
41
+ s.sendall(f"{command}\n".encode())
42
+ s.makefile().readline()
43
+ except OSError:
44
+ pass
45
+
46
+
47
+ # setup -------------------------------------------------------------------------
48
+
49
+ def setup_claude() -> int:
50
+ s = _settings()
51
+ print("Summaries through the Claude API: transcripts are sent to Anthropic.")
52
+ print("Check your employer's policy before using this for work calls.\n")
53
+ key = getpass.getpass("Anthropic API key (input hidden, empty to keep the current one): ")
54
+ secrets = load_secrets()
55
+ if key.strip():
56
+ secrets["anthropic_api_key"] = key.strip()
57
+ if not secrets.get("anthropic_api_key") and not os.environ.get("ANTHROPIC_API_KEY"):
58
+ print("no key given")
59
+ return 1
60
+ import anthropic
61
+
62
+ client = anthropic.Anthropic(api_key=secrets.get("anthropic_api_key"))
63
+ try:
64
+ client.models.retrieve(s.anthropic_model) # free; checks key and model
65
+ except anthropic.AuthenticationError:
66
+ print(f"{BAD} the key was rejected; nothing changed")
67
+ return 1
68
+ except anthropic.NotFoundError:
69
+ print(f"{BAD} model {s.anthropic_model} not available to this key; nothing changed")
70
+ return 1
71
+ except anthropic.APIConnectionError as e:
72
+ print(f"{BAD} cannot reach the API ({e}); nothing changed")
73
+ return 1
74
+ save_secrets(secrets)
75
+ set_config_value(config_dir() / "config.toml", "summary_backend", '"anthropic"')
76
+ print(f"{OK} key works with {s.anthropic_model}; saved to {secrets_path()} (mode 600)")
77
+ print(f"{OK} summary_backend = \"anthropic\"; the next note uses it (no restart needed)")
78
+ return 0
79
+
80
+
81
+ def setup_ollama() -> int:
82
+ s = _settings()
83
+ ok, msg = _check_ollama(s)
84
+ print(f"{OK if ok else BAD} {msg}")
85
+ if not ok:
86
+ return 1
87
+ set_config_value(config_dir() / "config.toml", "summary_backend", '"ollama"')
88
+ print(f"{OK} summary_backend = \"ollama\"; the next note uses it (no restart needed)")
89
+ return 0
90
+
91
+
92
+ def setup_consent(text: str | None) -> int:
93
+ if text is None:
94
+ text = input("Consent line (empty to turn it off): ").strip()
95
+ set_config_value(config_dir() / "config.toml", "consent_line", json.dumps(text, ensure_ascii=False))
96
+ print(f"{OK} consent line {'set' if text else 'turned off'}; restart to apply: "
97
+ "systemctl --user restart wirenote")
98
+ return 0
99
+
100
+
101
+ # calendar ----------------------------------------------------------------------
102
+
103
+ def calendar_list() -> int:
104
+ from ..calendar import ics
105
+
106
+ s = _settings()
107
+ sources = calendar_sources(s.calendar_urls)
108
+ if not sources:
109
+ print("no calendar yet; add one with: wirenote calendar add")
110
+ return 1
111
+ now = dt.datetime.now().astimezone()
112
+ events, errors = ics.load(sources, now)
113
+ for err in errors:
114
+ print(f"{BAD} {err}")
115
+ window = set(id(e) for e in ics.in_window(events, now, s.calendar_before_s,
116
+ s.calendar_after_start_s))
117
+ upcoming = sorted((e for e in events if e.end > now), key=lambda e: e.start)
118
+ print(f"{len(sources)} calendar(s), {len(upcoming)} event(s) in the next 24 h:")
119
+ for e in upcoming:
120
+ mark = "→" if id(e) in window else " "
121
+ call = " [call link]" if e.service_links(None) else ""
122
+ print(f" {mark} {e.start.astimezone():%a %H:%M}-{e.end.astimezone():%H:%M} {e.title}{call}")
123
+ if window:
124
+ print("→ in the prompt window now")
125
+ return 1 if errors and not events else 0
126
+
127
+
128
+ def calendar_add() -> int:
129
+ from ..calendar import ics
130
+
131
+ print("Paste the calendar's ICS link (Outlook: Settings > Calendar > Shared calendars >")
132
+ print("Publish a calendar > ICS; Google: Settings > your calendar > Secret address in iCal).")
133
+ print("It is stored in secrets.toml, not in config.toml or the logs.\n")
134
+ url = getpass.getpass("ICS link (input hidden): ").strip()
135
+ if not url:
136
+ print("nothing added")
137
+ return 1
138
+ now = dt.datetime.now().astimezone()
139
+ try:
140
+ events = ics.parse(ics.fetch(url), now)
141
+ except Exception as e: # noqa: BLE001
142
+ print(f"{BAD} could not read that calendar: {ics.describe_error(url, e)}")
143
+ return 1
144
+ secrets = load_secrets()
145
+ urls = list(dict.fromkeys([*secrets.get("calendar_urls", []), url]))
146
+ secrets["calendar_urls"] = urls
147
+ save_secrets(secrets)
148
+ _poke_daemon("refresh-calendar")
149
+ print(f"{OK} calendar added ({len(events)} timed events in the next 24 h); "
150
+ "the daemon picks it up now")
151
+ return 0
152
+
153
+
154
+ def calendar_clear() -> int:
155
+ secrets = load_secrets()
156
+ n = len(secrets.pop("calendar_urls", []))
157
+ save_secrets(secrets)
158
+ _poke_daemon("refresh-calendar")
159
+ print(f"{OK} removed {n} calendar link(s) from {secrets_path()}")
160
+ return 0
161
+
162
+
163
+ # doctor ------------------------------------------------------------------------
164
+
165
+ def _check_ollama(s: Settings) -> tuple[bool, str]:
166
+ from ..process.summarize import pin_local
167
+
168
+ url = s.ollama_url.rstrip("/")
169
+ local = pin_local(url)
170
+ if local:
171
+ url, ip, port = local
172
+ owner = listener_uid(port, ip)
173
+ if owner is not None and not trusted_owner(owner):
174
+ return False, (f"port {port} belongs to another user (uid {owner}), so Wirenote "
175
+ "will not send transcripts there; start your own Ollama or set "
176
+ "ollama_url")
177
+ try:
178
+ with urllib.request.urlopen(f"{url}/api/tags", timeout=5) as r:
179
+ models = [m["name"] for m in json.loads(r.read()).get("models", [])]
180
+ except OSError as e:
181
+ return False, (f"Ollama not reachable at {s.ollama_url} ({e}); "
182
+ "start it: systemctl --user start ollama")
183
+ want = s.ollama_model if ":" in s.ollama_model else f"{s.ollama_model}:latest"
184
+ if want not in models:
185
+ return False, f"Ollama runs, but {s.ollama_model} is not pulled: ollama pull {s.ollama_model}"
186
+ return True, f"Ollama runs with {s.ollama_model}"
187
+
188
+
189
+ @dataclass(frozen=True)
190
+ class Check:
191
+ status: str # "ok" | "warn" | "bad"
192
+ message: str
193
+ fix: str | None = None
194
+
195
+
196
+ def _ok(cond: bool, ok_msg: str, bad_msg: str, fix: str | None = None,
197
+ warn: bool = False) -> Check:
198
+ if cond:
199
+ return Check("ok", ok_msg)
200
+ return Check("warn" if warn else "bad", bad_msg, fix)
201
+
202
+
203
+ def _daemon_checks() -> list[Check]:
204
+ try:
205
+ status = json.loads((state_dir() / "status.json").read_text())
206
+ except (OSError, ValueError):
207
+ return [Check("bad", "the background service has never run",
208
+ "wirenote setup install")]
209
+ alive = bool(status.get("running") and Path(f"/proc/{status.get('pid')}").exists())
210
+ out = [_ok(alive, "background service running", "background service not running",
211
+ "systemctl --user start wirenote")]
212
+ out.append(_ok(bool(status.get("notifications")), "desktop notifications work",
213
+ "desktop notifications unavailable"))
214
+ if status.get("notifications") and status.get("notification_buttons") is False:
215
+ out.append(Check("warn", "notifications here have no buttons: record from the Wirenote "
216
+ "window or with `wirenote record`"))
217
+ failed = (status.get("jobs") or {}).get("failed", 0)
218
+ out.append(_ok(not failed, "no failed note jobs", f"{failed} failed note job(s)",
219
+ "wirenote jobs", warn=True))
220
+ return out
221
+
222
+
223
+ def _pipewire_checks() -> list[Check]:
224
+ try:
225
+ dump = json.loads(subprocess.run(["pw-dump"], capture_output=True, timeout=10,
226
+ check=True).stdout)
227
+ from ..detector.graph import Graph
228
+ g = Graph()
229
+ g.apply(dump)
230
+ out = [Check("ok", f"PipeWire: mic {g.default_node('default.audio.source')}, "
231
+ f"output {g.default_node('default.audio.sink')}")]
232
+ except (OSError, subprocess.SubprocessError, ValueError) as e:
233
+ out = [Check("bad", f"PipeWire not readable: {e}")]
234
+ out.append(_ok(shutil.which("pw-record") is not None, "pw-record installed",
235
+ "pw-record missing", "install your distribution's pipewire tools"))
236
+ out.append(_ok(shutil.which("ffmpeg") is not None, "ffmpeg installed",
237
+ "ffmpeg missing: the web app cannot play recordings",
238
+ "install ffmpeg", warn=True))
239
+ return out
240
+
241
+
242
+ def _model_checks(s: Settings) -> list[Check]:
243
+ from ..process.models import EMB_REPO, SEG_FILE, SEG_REPO, hub_file, whisper_path
244
+
245
+ out = []
246
+ try:
247
+ whisper_path(s.whisper_model, local_only=True)
248
+ out.append(Check("ok", f"Whisper model {s.whisper_model} is downloaded"))
249
+ except Exception: # noqa: BLE001
250
+ out.append(Check("warn", f"Whisper model {s.whisper_model} downloads on the first note "
251
+ "(about 1.6 GB for large-v3-turbo)"))
252
+ if s.diarize:
253
+ try:
254
+ hub_file(SEG_REPO, SEG_FILE, local_only=True)
255
+ hub_file(EMB_REPO, s.diarize_model, local_only=True)
256
+ out.append(Check("ok", "speaker separation models are downloaded"))
257
+ except Exception: # noqa: BLE001
258
+ out.append(Check("warn", "speaker separation models download on the first note "
259
+ "(about 32 MB)"))
260
+ return out
261
+
262
+
263
+ def _summary_checks(s: Settings) -> list[Check]:
264
+ if s.summary_backend == "ollama":
265
+ ok, msg = _check_ollama(s)
266
+ return [Check("ok" if ok else "bad", msg)]
267
+ if s.summary_backend == "anthropic":
268
+ has_key = bool(load_secrets().get("anthropic_api_key") or os.environ.get("ANTHROPIC_API_KEY"))
269
+ return [_ok(has_key, f"Claude API ({s.anthropic_model}): key set; transcripts are "
270
+ "sent to Anthropic",
271
+ f"Claude API ({s.anthropic_model}): no key", "wirenote setup claude")]
272
+ return [Check("warn", "summaries are off (summary_backend = \"none\")")]
273
+
274
+
275
+ def _calendar_checks(s: Settings) -> list[Check]:
276
+ sources = calendar_sources(s.calendar_urls)
277
+ if not sources:
278
+ return [Check("warn", "no calendar (optional): titles and invitees come from it",
279
+ "wirenote calendar add")]
280
+ from ..calendar import ics
281
+ events, errors = ics.load(sources, dt.datetime.now().astimezone())
282
+ return [_ok(not errors, f"{len(sources)} calendar(s), {len(events)} events around now",
283
+ f"{len(errors)} of {len(sources)} calendar(s) failed: {errors[0] if errors else ''}")]
284
+
285
+
286
+ def _desktop_checks(s: Settings) -> list[Check]:
287
+ from ..notify.clipboard import available_tools
288
+
289
+ desktop = os.environ.get("XDG_CURRENT_DESKTOP") or "unknown"
290
+ out = [Check("ok", f"desktop: {desktop}")]
291
+ if s.consent_line and "KDE" not in desktop.upper() and not available_tools():
292
+ out.append(Check("warn", "no clipboard tool for the consent line",
293
+ "install wl-clipboard (Wayland) or xclip (X11)"))
294
+ return out
295
+
296
+
297
+ def _file_checks(s: Settings) -> list[Check]:
298
+ from ..calendar.ics import SCHEME
299
+ from ..config.perms import loose_files, private_paths
300
+
301
+ out = [Check("ok", "consent line set") if s.consent_line else
302
+ Check("warn", "no consent line (optional)", 'wirenote setup consent "…"')]
303
+ rec_root = s.recordings_path()
304
+ rec_root.mkdir(mode=0o700, parents=True, exist_ok=True)
305
+ free_gb = shutil.disk_usage(rec_root).free / 1e9
306
+ out.append(_ok(free_gb > 5, f"{free_gb:.0f} GB free for recordings (about 0.23 GB per hour)",
307
+ f"only {free_gb:.1f} GB free for recordings"))
308
+ notes = s.notes_path()
309
+ notes.mkdir(mode=0o700, parents=True, exist_ok=True)
310
+ out.append(_ok(os.access(notes, os.W_OK), f"notes go to {notes}", f"cannot write to {notes}"))
311
+ loose = loose_files(*private_paths(s))
312
+ out.append(_ok(not loose, "recordings, notes and settings are private to you",
313
+ f"{len(loose)} file(s) or folder(s) readable by other users",
314
+ "wirenote doctor --fix"))
315
+ if any(SCHEME.match(str(u)) for u in s.calendar_urls):
316
+ out.append(Check("warn", "calendar link in config.toml: links are secret, keep them "
317
+ "in secrets.toml",
318
+ "wirenote calendar add, then remove it from config.toml"))
319
+ return out
320
+
321
+
322
+ def checks(s: Settings) -> list[Check]:
323
+ return [*_daemon_checks(), *_pipewire_checks(), *_model_checks(s), *_summary_checks(s),
324
+ *_calendar_checks(s), *_desktop_checks(s), *_file_checks(s)]
325
+
326
+
327
+ def doctor(fix: bool = False) -> int:
328
+ s = _settings()
329
+ if fix:
330
+ from ..config.perms import fix_modes, private_paths
331
+ print(f"{OK} made {fix_modes(*private_paths(s))} file(s) and folder(s) private")
332
+ found = checks(s)
333
+ for c in found:
334
+ mark = {"ok": OK, "warn": WARN, "bad": BAD}[c.status]
335
+ print(f"{mark} {c.message}" + (f": {c.fix}" if c.fix else ""))
336
+ problems = sum(c.status == "bad" for c in found)
337
+ print("\nall good" if not problems else f"\n{problems} problem(s)")
338
+ return 1 if problems else 0
@@ -0,0 +1,2 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (c) 2026 Hrishikesh Jadhav