iDeviceTail 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.
@@ -0,0 +1,15 @@
1
+ """iDeviceTail — wireless iOS/iPadOS log collection.
2
+
3
+ Two capture engines feed one normalized log bus:
4
+
5
+ * ``engine_device`` — real system logs / crash reports / sysdiagnose via the
6
+ ``pymobiledevice3`` CLI over Wi-Fi (requires a one-time USB pairing + Trust and,
7
+ on iOS 16+, Developer Mode).
8
+ * ``engine_agent`` — app-level logs streamed by the bundled Swift agent app
9
+ (``IDeviceTailKit``) over a framed TCP protocol. No pairing, no cable, no
10
+ Developer Mode; limited to the agent's own process by the iOS sandbox.
11
+
12
+ See ``docs/FEASIBILITY.md`` for exactly what iOS does and does not allow.
13
+ """
14
+
15
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from idevicetail.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
idevicetail/bus.py ADDED
@@ -0,0 +1,89 @@
1
+ """In-process log fan-out.
2
+
3
+ Every :class:`~idevicetail.models.LogRecord` from either engine is published here.
4
+ Consumers (the WebSocket server, the SQLite writer, exporters) each ``subscribe``
5
+ and get an independent asyncio queue.
6
+
7
+ Backpressure policy: each subscriber queue is bounded. If a consumer falls
8
+ behind, the *oldest* item in that consumer's queue is dropped to make room for
9
+ the newest — a slow browser tab can never stall the capture pipeline. Dropped
10
+ counts are tracked so the UI can show "N lines dropped".
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ from collections import deque
17
+ from typing import Any
18
+
19
+ from .models import LogRecord
20
+
21
+
22
+ class Subscription:
23
+ __slots__ = ("queue", "dropped", "_bus")
24
+
25
+ def __init__(self, bus: "LogBus", maxsize: int) -> None:
26
+ self.queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=maxsize)
27
+ self.dropped = 0
28
+ self._bus = bus
29
+
30
+ async def get(self) -> dict[str, Any]:
31
+ return await self.queue.get()
32
+
33
+ def close(self) -> None:
34
+ self._bus._subs.discard(self)
35
+
36
+
37
+ class LogBus:
38
+ def __init__(self, ring_size: int = 200_000) -> None:
39
+ self._subs: set[Subscription] = set()
40
+ self._ring: deque[dict[str, Any]] = deque(maxlen=ring_size)
41
+ self._seq = 0
42
+ self.total_published = 0
43
+
44
+ # -- producers ---------------------------------------------------------
45
+ def publish(self, rec: LogRecord) -> None:
46
+ self._seq += 1
47
+ rec.seq = self._seq
48
+ wire = rec.to_wire()
49
+ self._ring.append(wire)
50
+ self.total_published += 1
51
+ for sub in list(self._subs):
52
+ _offer(sub, wire)
53
+
54
+ def publish_many(self, recs: list[LogRecord]) -> None:
55
+ for r in recs:
56
+ self.publish(r)
57
+
58
+ # -- consumers -------------------------------------------------------
59
+ def subscribe(self, maxsize: int = 20_000) -> Subscription:
60
+ sub = Subscription(self, maxsize)
61
+ self._subs.add(sub)
62
+ return sub
63
+
64
+ def snapshot(self, limit: int | None = None) -> list[dict[str, Any]]:
65
+ if limit is None or limit >= len(self._ring):
66
+ return list(self._ring)
67
+ return list(self._ring)[-limit:]
68
+
69
+ @property
70
+ def seq(self) -> int:
71
+ return self._seq
72
+
73
+
74
+ def _offer(sub: Subscription, wire: dict[str, Any]) -> None:
75
+ try:
76
+ sub.queue.put_nowait(wire)
77
+ return
78
+ except asyncio.QueueFull:
79
+ pass
80
+ # Drop oldest, retry once.
81
+ try:
82
+ sub.queue.get_nowait()
83
+ sub.dropped += 1
84
+ except asyncio.QueueEmpty: # pragma: no cover - race
85
+ pass
86
+ try:
87
+ sub.queue.put_nowait(wire)
88
+ except asyncio.QueueFull: # pragma: no cover - consumer is fully wedged
89
+ sub.dropped += 1
idevicetail/cli.py ADDED
@@ -0,0 +1,426 @@
1
+ """Command-line entry point.
2
+
3
+ idevicetail serve # discovery + agent listener + web UI (the main mode)
4
+ idevicetail doctor # environment / connectivity check
5
+ idevicetail discover [--secs N] # list devices seen via Bonjour + pymobiledevice3
6
+ idevicetail devices # pymobiledevice3 device list only
7
+ idevicetail stream <UDID> [--oslog] [--tunnel] [--rsd HOST PORT] [--process NAME]
8
+ idevicetail crash-pull <UDID> <DIR>
9
+ idevicetail sysdiagnose <UDID> [<DIR>]
10
+ idevicetail pair <UDID> | wifi-sync <UDID> [--off] | devmode <UDID>
11
+ idevicetail export --format ndjson|csv|text [--db PATH] [-o FILE]
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import asyncio
18
+ import socket
19
+ import sys
20
+ import time
21
+ from pathlib import Path
22
+
23
+ from . import __version__
24
+ from .config import Config
25
+
26
+
27
+ def main(argv: list[str] | None = None) -> int:
28
+ p = argparse.ArgumentParser(prog="idevicetail", description=__doc__,
29
+ formatter_class=argparse.RawDescriptionHelpFormatter)
30
+ p.add_argument("--version", action="version", version=f"idevicetail {__version__}")
31
+ sub = p.add_subparsers(dest="cmd", required=True)
32
+
33
+ s = sub.add_parser("serve", help="run the full service (default use)")
34
+ s.add_argument("--host", default=None)
35
+ s.add_argument("--port", type=int, default=None)
36
+ s.add_argument("--agent-port", type=int, default=None)
37
+ s.add_argument("--data-dir", default=None)
38
+ s.add_argument("--no-store", action="store_true")
39
+ s.add_argument("--no-apple-discovery", action="store_true")
40
+ s.add_argument("--bind-policy", choices=["lan", "any", "local"], default=None)
41
+ s.add_argument("--no-open", action="store_true", help="don't open the web UI in a browser")
42
+
43
+ sub.add_parser("doctor", help="check environment and ports")
44
+
45
+ d = sub.add_parser("discover", help="list discoverable devices")
46
+ d.add_argument("--secs", type=float, default=8.0)
47
+
48
+ sub.add_parser("devices", help="pymobiledevice3 device list")
49
+
50
+ st = sub.add_parser("stream", help="print a live log stream for one device")
51
+ st.add_argument("udid")
52
+ st.add_argument("--oslog", action="store_true", help="os_trace firehose (needs tunnel on iOS 17.4+)")
53
+ st.add_argument("--tunnel", action="store_true", help="let pymobiledevice3 bring up a userspace tunnel")
54
+ st.add_argument("--rsd", nargs=2, metavar=("HOST", "PORT"), help="use an existing RSD tunnel")
55
+ st.add_argument("--process", default=None)
56
+ st.add_argument("--raw", action="store_true", help="print unparsed lines")
57
+
58
+ cp = sub.add_parser("crash-pull")
59
+ cp.add_argument("udid")
60
+ cp.add_argument("dest")
61
+ cp.add_argument("--erase", action="store_true")
62
+
63
+ sd = sub.add_parser("sysdiagnose")
64
+ sd.add_argument("udid")
65
+ sd.add_argument("dest", nargs="?", default="./sysdiagnose")
66
+
67
+ for name in ("pair", "devmode"):
68
+ q = sub.add_parser(name)
69
+ q.add_argument("udid")
70
+ ws = sub.add_parser("wifi-sync")
71
+ ws.add_argument("udid")
72
+ ws.add_argument("--off", action="store_true")
73
+
74
+ ex = sub.add_parser("export")
75
+ ex.add_argument("--format", choices=["ndjson", "csv", "text"], default="ndjson")
76
+ ex.add_argument("--db", default=None)
77
+ ex.add_argument("-o", "--out", default=None)
78
+ ex.add_argument("--device-id", default=None)
79
+ ex.add_argument("--level", default="debug")
80
+ ex.add_argument("-q", "--query", default=None)
81
+
82
+ args = p.parse_args(argv)
83
+ try:
84
+ return _DISPATCH[args.cmd](args)
85
+ except KeyboardInterrupt:
86
+ return 130
87
+
88
+
89
+ # -- commands -----------------------------------------------------------
90
+ def _serve(args) -> int:
91
+ cfg = Config()
92
+ if args.host is not None:
93
+ cfg.host = args.host
94
+ if args.port is not None:
95
+ cfg.port = args.port
96
+ if args.agent_port is not None:
97
+ cfg.agent_port = args.agent_port
98
+ if args.data_dir is not None:
99
+ cfg.data_dir = Path(args.data_dir)
100
+ if args.no_store:
101
+ cfg.store_enabled = False
102
+ if args.no_apple_discovery:
103
+ cfg.discover_apple = False
104
+ if args.bind_policy is not None:
105
+ cfg.bind_policy = args.bind_policy
106
+
107
+ from .server import run
108
+
109
+ url = f"http://localhost:{cfg.port}"
110
+
111
+ # Idempotent: if another idevicetail is already serving this port, don't start
112
+ # a second one (and don't open a second browser tab) — just point at it.
113
+ if _http_ok(f"{url}/healthz"):
114
+ print(f"idevicetail is already running at {url}")
115
+ if not args.no_open:
116
+ _open_browser(url)
117
+ return 0
118
+ if not _port_free(cfg.host, cfg.port):
119
+ print(f"port {cfg.port} is in use by something else — pick another with --port")
120
+ return 1
121
+
122
+ print(f"idevicetail {__version__} — Ctrl-C to stop")
123
+ if not args.no_open:
124
+ _schedule_browser(url)
125
+ try:
126
+ asyncio.run(run(cfg))
127
+ except KeyboardInterrupt:
128
+ pass
129
+ return 0
130
+
131
+
132
+ def _doctor(args) -> int:
133
+ ok = True
134
+ print(f"idevicetail {__version__}")
135
+ print(f"python {sys.version.split()[0]} ({sys.executable})")
136
+ if sys.version_info < (3, 10):
137
+ ok = False
138
+ print(" ! Python 3.10+ required")
139
+
140
+ for mod in ("aiohttp", "zeroconf", "aiosqlite"):
141
+ try:
142
+ m = __import__(mod)
143
+ print(f"{mod:<12} {getattr(m, '__version__', 'ok')}")
144
+ except Exception as e: # noqa: BLE001
145
+ ok = False
146
+ print(f"{mod:<12} MISSING ({e})")
147
+
148
+ cfg = Config()
149
+ eng = _engine(cfg)
150
+ ver = asyncio.run(eng.version())
151
+ if ver:
152
+ print(f"pymobiledevice3 {ver} (Engine A available)")
153
+ else:
154
+ print("pymobiledevice3 not found (Engine A disabled; Engine B still works)")
155
+ print(' install with: pip install "pymobiledevice3>=4.14"')
156
+ if sys.version_info >= (3, 14):
157
+ print(f" NOTE: you are on Python {sys.version.split()[0]}. Some pymobiledevice3")
158
+ print(" deps (lzfse/pylzss) have no wheels for it yet and need a C compiler.")
159
+ print(" Easiest fix: install 3.13 or 3.12 and build the venv with that:")
160
+ print(" py install 3.12 && py -3.12 -m venv .venv")
161
+ print(' .venv\\Scripts\\python -m pip install -e ".[device]"')
162
+
163
+ for port in (cfg.port, cfg.agent_port):
164
+ free = _port_free(cfg.host, port)
165
+ print(f"port {port:<6} {'free' if free else 'IN USE'}")
166
+ ok &= free
167
+
168
+ if sys.platform == "win32":
169
+ print("\nWindows firewall: allow python.exe on Private networks, or run:")
170
+ print(f' netsh advfirewall firewall add rule name="idevicetail" dir=in action=allow '
171
+ f'protocol=TCP localport={cfg.port},{cfg.agent_port} profile=private')
172
+ print("\nOK" if ok else "\nsome checks failed (see above)")
173
+ return 0 if ok else 1
174
+
175
+
176
+ def _discover(args) -> int:
177
+ async def go() -> None:
178
+ from .discovery import Discovery
179
+
180
+ seen: dict[str, dict] = {}
181
+
182
+ def on_dev(d) -> None:
183
+ seen[d.id] = d.to_wire()
184
+ print(f" + {d.name:<28} {','.join(sorted(d.transports)):<24} {d.address}")
185
+
186
+ disc = Discovery(on_device=on_dev, on_lost=lambda k: None)
187
+ await disc.start()
188
+ print(f"listening {args.secs:.0f}s for _apple-mobdev2._tcp and _idevtail._tcp ...")
189
+ # also do a pymd pass
190
+ try:
191
+ for pd in await _engine(Config()).list_devices():
192
+ tag = "pymd-network" if pd.wireless else "pymd-usb"
193
+ print(f" + {pd.name or pd.udid:<28} {tag:<24} {pd.address} iOS {pd.os_version}")
194
+ except Exception as e: # noqa: BLE001
195
+ print(f" (pymobiledevice3 list unavailable: {e})")
196
+ await asyncio.sleep(args.secs)
197
+ await disc.stop()
198
+ print(f"\n{len(seen)} device(s) via Bonjour")
199
+
200
+ asyncio.run(go())
201
+ return 0
202
+
203
+
204
+ def _devices(args) -> int:
205
+ async def go() -> None:
206
+ devs = await _engine(Config()).list_devices()
207
+ if not devs:
208
+ print("no devices (USB or Wi-Fi). Is the device unlocked / paired / Wi-Fi sync on?")
209
+ return
210
+ for d in devs:
211
+ print(f"{d.udid} {d.name!r} {d.model} iOS {d.os_version} "
212
+ f"[{d.connection_type or '?'}] {d.address}")
213
+
214
+ asyncio.run(go())
215
+ return 0
216
+
217
+
218
+ def _stream(args) -> int:
219
+ import json as _json
220
+
221
+ from .normalize import oslog_json_to_record, parse_syslog_line
222
+
223
+ async def go() -> None:
224
+ eng = _engine(Config())
225
+ rsd = (args.rsd[0], int(args.rsd[1])) if args.rsd else None
226
+ mode = "oslog" if args.oslog else "syslog"
227
+ print(f"# streaming {mode} from {args.udid} (Ctrl-C to stop)", file=sys.stderr)
228
+ backoff = 1.0
229
+ while True:
230
+ got = False
231
+ try:
232
+ async for line in eng.stream(args.udid, mode=mode, rsd=rsd,
233
+ use_tunnel=args.tunnel, process=args.process,
234
+ on_stderr=lambda s: print(f"# {s}", file=sys.stderr)):
235
+ got = True
236
+ if args.raw:
237
+ sys.stdout.write(line)
238
+ continue
239
+ s = line.strip()
240
+ if not s:
241
+ continue
242
+ rec = None
243
+ if s[0] == "{":
244
+ try:
245
+ rec = oslog_json_to_record(_json.loads(s),
246
+ device_id=args.udid, device_name=args.udid)
247
+ except ValueError:
248
+ rec = None
249
+ if rec is None:
250
+ rec = parse_syslog_line(line, device_id=args.udid, device_name=args.udid)
251
+ if rec:
252
+ t = time.strftime("%H:%M:%S", time.localtime(rec.ts))
253
+ if rec.subsystem and rec.category:
254
+ lbl = f"[{rec.subsystem}:{rec.category}] "
255
+ elif rec.subsystem:
256
+ lbl = f"[{rec.subsystem}] "
257
+ else:
258
+ lbl = ""
259
+ print(f"{t} {rec.process:<20.20} {rec.level.value.upper():<7} {lbl}{rec.message}")
260
+ except KeyboardInterrupt:
261
+ return
262
+ except (BrokenPipeError, OSError):
263
+ return # downstream consumer (head/grep/…) went away — stop quietly
264
+ except Exception as e: # noqa: BLE001
265
+ print(f"# stream error: {e}", file=sys.stderr)
266
+ backoff = 1.0 if got else min(backoff * 2, 30)
267
+ try:
268
+ print(f"# reconnecting in {backoff:.0f}s ...", file=sys.stderr)
269
+ except OSError:
270
+ return
271
+ await asyncio.sleep(backoff)
272
+
273
+ try:
274
+ asyncio.run(go())
275
+ except KeyboardInterrupt:
276
+ pass
277
+ return 0
278
+
279
+
280
+ def _crash_pull(args) -> int:
281
+ async def go() -> None:
282
+ files = await _engine(Config()).pull_crashes(args.udid, Path(args.dest), erase=args.erase)
283
+ print(f"{len(files)} new file(s) -> {args.dest}")
284
+ for f in files:
285
+ print(f" {f}")
286
+
287
+ asyncio.run(go())
288
+ return 0
289
+
290
+
291
+ def _sysdiagnose(args) -> int:
292
+ async def go() -> None:
293
+ print("triggering sysdiagnose (this can take 5-10 minutes; keep the device unlocked) ...")
294
+ archive = await _engine(Config()).sysdiagnose(args.udid, Path(args.dest))
295
+ print(f"archive: {archive}")
296
+
297
+ asyncio.run(go())
298
+ return 0
299
+
300
+
301
+ def _pair(args) -> int:
302
+ rc, out = asyncio.run(_engine(Config()).pair(args.udid))
303
+ print(out)
304
+ print("Now tap 'Trust' on the device and re-run if needed.")
305
+ return rc
306
+
307
+
308
+ def _wifi_sync(args) -> int:
309
+ rc, out = asyncio.run(_engine(Config()).enable_wifi_sync(args.udid, not args.off))
310
+ print(out or f"wifi-sync {'off' if args.off else 'on'} (rc={rc})")
311
+ return rc
312
+
313
+
314
+ def _devmode(args) -> int:
315
+ rc, out = asyncio.run(_engine(Config()).enable_developer_mode(args.udid))
316
+ print(out)
317
+ print("The device will reboot; after unlock, confirm 'Turn On' under Settings > Privacy & Security > Developer Mode.")
318
+ return rc
319
+
320
+
321
+ def _export(args) -> int:
322
+ from .exporter import write_file
323
+ from .store import Store
324
+
325
+ async def go() -> int:
326
+ cfg = Config()
327
+ db = Path(args.db) if args.db else cfg.db_path
328
+ if not db.exists():
329
+ print(f"no database at {db}", file=sys.stderr)
330
+ return 1
331
+ store = Store(db)
332
+ await store.open()
333
+ from .models import Level
334
+ rank = {lv.value: lv.rank for lv in Level}.get(args.level, 0)
335
+ rows = await store.query(device_id=args.device_id, level_min=rank,
336
+ text=args.query, limit=5_000_000, order="asc")
337
+ await store.close()
338
+ out = args.out or f"idevicetail-export.{ 'txt' if args.format=='text' else args.format}"
339
+ n = write_file(rows, out, args.format)
340
+ print(f"wrote {n} record(s) -> {out}")
341
+ return 0
342
+
343
+ return asyncio.run(go())
344
+
345
+
346
+ # -- helpers ----------------------------------------------------------
347
+ def _engine(cfg: Config):
348
+ from .engine_device import DeviceEngine
349
+
350
+ return DeviceEngine(cfg.pymd_bin)
351
+
352
+
353
+ def _port_free(host: str, port: int) -> bool:
354
+ h = "127.0.0.1" if host in ("0.0.0.0", "::", "") else host
355
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
356
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
357
+ try:
358
+ s.bind((h, port))
359
+ return True
360
+ except OSError:
361
+ return False
362
+ finally:
363
+ s.close()
364
+
365
+
366
+ def _http_ok(url: str, timeout: float = 0.6) -> bool:
367
+ """True if a GET to `url` returns 2xx — used to detect an already-running instance."""
368
+ import urllib.request
369
+
370
+ try:
371
+ with urllib.request.urlopen(url, timeout=timeout) as r: # noqa: S310 (localhost only)
372
+ return 200 <= r.status < 300
373
+ except Exception:
374
+ return False
375
+
376
+
377
+ def _open_browser(url: str) -> None:
378
+ """Open the default browser exactly once. `os.startfile` (ShellExecute) is
379
+ the reliable single-tab path on Windows; `webbrowser` elsewhere."""
380
+ import os as _os
381
+
382
+ try:
383
+ if sys.platform == "win32" and hasattr(_os, "startfile"):
384
+ _os.startfile(url) # type: ignore[attr-defined]
385
+ else:
386
+ import webbrowser
387
+
388
+ webbrowser.open(url, new=2)
389
+ except Exception:
390
+ pass
391
+
392
+
393
+ _browser_opened = False
394
+
395
+
396
+ def _schedule_browser(url: str, delay: float = 1.5) -> None:
397
+ import threading
398
+
399
+ def _go() -> None:
400
+ global _browser_opened
401
+ if _browser_opened:
402
+ return
403
+ _browser_opened = True
404
+ _open_browser(url)
405
+
406
+ threading.Timer(delay, _go).start()
407
+ print(f"opening {url} in your browser …")
408
+
409
+
410
+ _DISPATCH = {
411
+ "serve": _serve,
412
+ "doctor": _doctor,
413
+ "discover": _discover,
414
+ "devices": _devices,
415
+ "stream": _stream,
416
+ "crash-pull": _crash_pull,
417
+ "sysdiagnose": _sysdiagnose,
418
+ "pair": _pair,
419
+ "wifi-sync": _wifi_sync,
420
+ "devmode": _devmode,
421
+ "export": _export,
422
+ }
423
+
424
+
425
+ if __name__ == "__main__":
426
+ raise SystemExit(main())
idevicetail/config.py ADDED
@@ -0,0 +1,72 @@
1
+ """Runtime configuration. Everything is overridable by CLI flag or environment
2
+ variable (``IDEVICETAIL_*``). No config file is required for normal use."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+
10
+
11
+ def _env(name: str, default: str) -> str:
12
+ return os.environ.get(f"IDEVICETAIL_{name}", default)
13
+
14
+
15
+ def _env_int(name: str, default: int) -> int:
16
+ try:
17
+ return int(os.environ.get(f"IDEVICETAIL_{name}", str(default)))
18
+ except ValueError:
19
+ return default
20
+
21
+
22
+ def default_data_dir() -> Path:
23
+ # Keep app data next to the repo by default so it is easy to find / wipe.
24
+ return Path(_env("DATA_DIR", str(Path.cwd() / "data")))
25
+
26
+
27
+ @dataclass(slots=True)
28
+ class Config:
29
+ # --- Web UI + browser WebSocket + REST (project-hub "backend" port) ---
30
+ host: str = field(default_factory=lambda: _env("HOST", "0.0.0.0"))
31
+ port: int = field(default_factory=lambda: _env_int("PORT", 3017))
32
+
33
+ # --- Reserved "frontend" port (single-process app; kept for the port registry) ---
34
+ frontend_port: int = field(default_factory=lambda: _env_int("FRONTEND_PORT", 3016))
35
+
36
+ # --- Engine B: framed TCP listener the iOS agent connects to (infra port) ---
37
+ agent_host: str = field(default_factory=lambda: _env("AGENT_HOST", "0.0.0.0"))
38
+ agent_port: int = field(default_factory=lambda: _env_int("AGENT_PORT", 45455))
39
+
40
+ # --- Bind policy -----------------------------------------------------------
41
+ # "lan" -> refuse connections whose source IP is not private/loopback
42
+ # "any" -> accept anything that reaches the socket
43
+ # "local" -> bind only to 127.0.0.1 (no network at all)
44
+ bind_policy: str = field(default_factory=lambda: _env("BIND_POLICY", "lan"))
45
+
46
+ # --- Discovery -----------------------------------------------------------
47
+ discover_apple: bool = field(default_factory=lambda: _env("DISCOVER_APPLE", "1") != "0")
48
+ discover_agent: bool = field(default_factory=lambda: _env("DISCOVER_AGENT", "1") != "0")
49
+
50
+ # --- Storage -----------------------------------------------------------
51
+ store_enabled: bool = field(default_factory=lambda: _env("STORE", "1") != "0")
52
+ data_dir: Path = field(default_factory=default_data_dir)
53
+ db_flush_rows: int = 500
54
+ db_flush_seconds: float = 1.0
55
+
56
+ # --- In-memory ring buffer served to late-joining UI clients ---
57
+ ring_size: int = field(default_factory=lambda: _env_int("RING_SIZE", 200_000))
58
+
59
+ # --- Engine A -----------------------------------------------------------
60
+ pymd_bin: str = field(default_factory=lambda: _env("PYMD", "pymobiledevice3"))
61
+ device_reconnect_min: float = 1.0
62
+ device_reconnect_max: float = 30.0
63
+
64
+ @property
65
+ def db_path(self) -> Path:
66
+ return self.data_dir / "idevicetail.sqlite"
67
+
68
+ def ensure_dirs(self) -> None:
69
+ self.data_dir.mkdir(parents=True, exist_ok=True)
70
+ (self.data_dir / "crashes").mkdir(exist_ok=True)
71
+ (self.data_dir / "sysdiagnose").mkdir(exist_ok=True)
72
+ (self.data_dir / "exports").mkdir(exist_ok=True)