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.
- idevicetail/__init__.py +15 -0
- idevicetail/__main__.py +4 -0
- idevicetail/bus.py +89 -0
- idevicetail/cli.py +426 -0
- idevicetail/config.py +72 -0
- idevicetail/device_names.py +94 -0
- idevicetail/discovery.py +209 -0
- idevicetail/engine_agent.py +245 -0
- idevicetail/engine_device.py +425 -0
- idevicetail/exporter.py +92 -0
- idevicetail/filesink.py +135 -0
- idevicetail/manager.py +457 -0
- idevicetail/models.py +150 -0
- idevicetail/normalize.py +365 -0
- idevicetail/server.py +574 -0
- idevicetail/store.py +236 -0
- idevicetail/web/app.js +341 -0
- idevicetail/web/index.html +111 -0
- idevicetail/web/styles.css +146 -0
- idevicetail-0.1.0.dist-info/METADATA +130 -0
- idevicetail-0.1.0.dist-info/RECORD +25 -0
- idevicetail-0.1.0.dist-info/WHEEL +5 -0
- idevicetail-0.1.0.dist-info/entry_points.txt +2 -0
- idevicetail-0.1.0.dist-info/licenses/LICENSE +27 -0
- idevicetail-0.1.0.dist-info/top_level.txt +1 -0
idevicetail/__init__.py
ADDED
|
@@ -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"
|
idevicetail/__main__.py
ADDED
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)
|