ios-decrypt-hub 0.2.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.
idh/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """IOSDecryptHub discovery and MCP gateway."""
2
+
3
+ __version__ = "0.2.0"
idh/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
idh/cli.py ADDED
@@ -0,0 +1,244 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ import time
7
+ import webbrowser
8
+ from collections.abc import Callable, Sequence
9
+
10
+ from . import __version__
11
+ from .discovery import DiscoveryRegistry, discover
12
+ from .gateway import GatewayServer, TransparentProxy, inventory, resolve_target, serve_stdio
13
+ from .models import AppEndpoint
14
+
15
+
16
+ def _manual_endpoints(values: Sequence[str]) -> list[AppEndpoint]:
17
+ endpoints: list[AppEndpoint] = []
18
+ for value in values:
19
+ endpoints.append(AppEndpoint.from_url(value))
20
+ return endpoints
21
+
22
+
23
+ def _add_discovery_options(parser: argparse.ArgumentParser) -> None:
24
+ parser.add_argument(
25
+ "--endpoint",
26
+ action="append",
27
+ default=[],
28
+ metavar="URL",
29
+ help="手动加入 HTTP 地址,可重复使用",
30
+ )
31
+ parser.add_argument(
32
+ "--no-discovery", action="store_true", help="禁用 UDP 发现,仅使用 --endpoint"
33
+ )
34
+
35
+
36
+ def build_parser() -> argparse.ArgumentParser:
37
+ parser = argparse.ArgumentParser(prog="idh", description="IOSDecryptHub 发现与 MCP 网关")
38
+ parser.add_argument("--version", action="version", version=f"idh {__version__}")
39
+ subparsers = parser.add_subparsers(dest="command")
40
+
41
+ devices = subparsers.add_parser("devices", help="列出局域网中的设备和 App")
42
+ devices.add_argument("--timeout", type=float, default=1.5, help="发现等待秒数,默认 1.5")
43
+ devices.add_argument("--json", action="store_true", help="输出 JSON")
44
+ _add_discovery_options(devices)
45
+
46
+ watch = subparsers.add_parser("watch", help="持续观察设备上下线")
47
+ watch.add_argument("--interval", type=float, default=0.5, help="刷新间隔秒数")
48
+ watch.add_argument("--json", action="store_true", help="逐行输出 JSON 事件")
49
+ _add_discovery_options(watch)
50
+
51
+ open_command = subparsers.add_parser("open", help="在浏览器中打开 App Web 面板")
52
+ open_command.add_argument("target", help="序号、Bundle ID、App 名称或服务名")
53
+ open_command.add_argument("--timeout", type=float, default=1.5)
54
+ _add_discovery_options(open_command)
55
+
56
+ mcp = subparsers.add_parser("mcp", help="启动 stdio MCP 网关")
57
+ mcp.add_argument("--target", help="透明代理到指定 App;省略时启动聚合网关")
58
+ mcp.add_argument("--startup-timeout", type=float, default=1.5, help="启动时发现等待秒数")
59
+ _add_discovery_options(mcp)
60
+
61
+ call = subparsers.add_parser("call", help="一次性调用指定 App 的 MCP 工具")
62
+ call.add_argument("target", help="优先使用 devices 返回的 target_id")
63
+ call.add_argument("tool_name", help="远端 MCP 工具名称")
64
+ call.add_argument("--arguments", default="{}", metavar="JSON", help="工具参数 JSON 对象")
65
+ call.add_argument("--allow-mutation", action="store_true", help="允许调用修改远端状态的工具")
66
+ call.add_argument("--timeout", type=float, default=1.5, help="发现等待秒数,默认 1.5")
67
+ call.add_argument("--json", action="store_true", help="输出完整 MCP 工具结果 JSON")
68
+ _add_discovery_options(call)
69
+ return parser
70
+
71
+
72
+ def _normalize_argv(argv: Sequence[str]) -> list[str]:
73
+ values = list(argv)
74
+ if values and values[0] == "--devices":
75
+ return ["devices", *values[1:]]
76
+ if values and values[0] == "--watch":
77
+ return ["watch", *values[1:]]
78
+ return values
79
+
80
+
81
+ def _print_inventory(data: dict[str, object]) -> None:
82
+ devices = data.get("devices", [])
83
+ if not devices:
84
+ print("未发现 IOSDecryptHub App")
85
+ return
86
+ for device in devices:
87
+ print(f"{device['name']}")
88
+ for app in device["apps"]:
89
+ status = app["properties"].get("status", "discovered")
90
+ bundle = app["bundle"] or "-"
91
+ print(f" [{app['index']}] {app['app']} {bundle} :{app['port']} {status}")
92
+ print(f" Web: {app['web']}")
93
+ print(f" MCP: {app['mcp']}")
94
+
95
+
96
+ def _run_devices(args: argparse.Namespace) -> int:
97
+ endpoints = discover(
98
+ args.timeout,
99
+ manual_endpoints=_manual_endpoints(args.endpoint),
100
+ enable_bonjour=not args.no_discovery,
101
+ )
102
+ data = inventory(endpoints)
103
+ if args.json:
104
+ print(json.dumps(data, ensure_ascii=False, indent=2))
105
+ else:
106
+ _print_inventory(data)
107
+ return 0
108
+
109
+
110
+ def _run_watch(args: argparse.Namespace) -> int:
111
+ registry = DiscoveryRegistry(manual_endpoints=_manual_endpoints(args.endpoint))
112
+ previous: dict[str, dict[str, object]] = {}
113
+ try:
114
+ registry.start(enable_bonjour=not args.no_discovery)
115
+ while True:
116
+ current = {item.key: item.as_dict() for item in registry.snapshot()}
117
+ for name in sorted(current.keys() - previous.keys()):
118
+ if args.json:
119
+ print(
120
+ json.dumps({"event": "add", "app": current[name]}, ensure_ascii=False),
121
+ flush=True,
122
+ )
123
+ else:
124
+ print(f"+ {current[name]['app']} {current[name]['web']}", flush=True)
125
+ for name in sorted(previous.keys() - current.keys()):
126
+ if args.json:
127
+ print(
128
+ json.dumps({"event": "remove", "app": previous[name]}, ensure_ascii=False),
129
+ flush=True,
130
+ )
131
+ else:
132
+ print(f"- {previous[name]['app']} {previous[name]['web']}", flush=True)
133
+ previous = current
134
+ time.sleep(max(0.1, args.interval))
135
+ except KeyboardInterrupt:
136
+ return 0
137
+ finally:
138
+ registry.close()
139
+
140
+
141
+ def _run_open(args: argparse.Namespace) -> int:
142
+ endpoints = discover(
143
+ args.timeout,
144
+ manual_endpoints=_manual_endpoints(args.endpoint),
145
+ enable_bonjour=not args.no_discovery,
146
+ )
147
+ try:
148
+ endpoint = resolve_target(endpoints, args.target)
149
+ except LookupError as exc:
150
+ print(f"idh: {exc}", file=sys.stderr)
151
+ return 2
152
+ print(endpoint.panel_url)
153
+ return 0 if webbrowser.open(endpoint.panel_url) else 1
154
+
155
+
156
+ def _target_ready(target: str) -> Callable[[list[AppEndpoint]], bool]:
157
+ def ready(endpoints: list[AppEndpoint]) -> bool:
158
+ try:
159
+ resolve_target(endpoints, target)
160
+ except LookupError:
161
+ return False
162
+ return True
163
+
164
+ return ready
165
+
166
+
167
+ def _run_mcp(args: argparse.Namespace) -> int:
168
+ manual = _manual_endpoints(args.endpoint)
169
+ registry = DiscoveryRegistry(manual_endpoints=manual)
170
+ try:
171
+ registry.start(enable_bonjour=not args.no_discovery)
172
+ if args.target:
173
+ registry.wait(args.startup_timeout, predicate=_target_ready(args.target))
174
+ handler = TransparentProxy(registry.snapshot, args.target).handle
175
+ else:
176
+ registry.wait(args.startup_timeout)
177
+ handler = GatewayServer(registry.snapshot).handle
178
+ serve_stdio(handler)
179
+ return 0
180
+ finally:
181
+ registry.close()
182
+
183
+
184
+ def _run_call(args: argparse.Namespace) -> int:
185
+ try:
186
+ tool_arguments = json.loads(args.arguments)
187
+ except json.JSONDecodeError as exc:
188
+ print(f"idh: --arguments 不是有效 JSON: {exc}", file=sys.stderr)
189
+ return 2
190
+ if not isinstance(tool_arguments, dict):
191
+ print("idh: --arguments 必须是 JSON 对象", file=sys.stderr)
192
+ return 2
193
+
194
+ registry = DiscoveryRegistry(manual_endpoints=_manual_endpoints(args.endpoint))
195
+ try:
196
+ registry.start(enable_bonjour=not args.no_discovery)
197
+ registry.wait(args.timeout, predicate=_target_ready(args.target))
198
+ gateway = GatewayServer(registry.snapshot)
199
+ response = gateway.handle(
200
+ {
201
+ "jsonrpc": "2.0",
202
+ "id": 1,
203
+ "method": "tools/call",
204
+ "params": {
205
+ "name": "idh_call_tool",
206
+ "arguments": {
207
+ "target": args.target,
208
+ "tool_name": args.tool_name,
209
+ "tool_arguments": tool_arguments,
210
+ "allow_mutation": args.allow_mutation,
211
+ },
212
+ },
213
+ }
214
+ )
215
+ finally:
216
+ registry.close()
217
+ result = response.get("result", {}) if isinstance(response, dict) else {}
218
+ if args.json:
219
+ print(json.dumps(result, ensure_ascii=False, indent=2))
220
+ else:
221
+ content = result.get("content", []) if isinstance(result, dict) else []
222
+ texts = [item.get("text") for item in content if isinstance(item, dict) and item.get("text")]
223
+ if texts:
224
+ print("\n".join(texts))
225
+ else:
226
+ print(json.dumps(result, ensure_ascii=False, indent=2))
227
+ return 1 if isinstance(result, dict) and result.get("isError") else 0
228
+
229
+
230
+ def main(argv: Sequence[str] | None = None) -> int:
231
+ parser = build_parser()
232
+ args = parser.parse_args(_normalize_argv(sys.argv[1:] if argv is None else argv))
233
+ if args.command == "devices":
234
+ return _run_devices(args)
235
+ if args.command == "watch":
236
+ return _run_watch(args)
237
+ if args.command == "open":
238
+ return _run_open(args)
239
+ if args.command == "mcp":
240
+ return _run_mcp(args)
241
+ if args.command == "call":
242
+ return _run_call(args)
243
+ parser.print_help()
244
+ return 0
idh/discovery.py ADDED
@@ -0,0 +1,231 @@
1
+ """UDP 广播信标发现 — 替代 Bonjour/zeroconf(注入场景无 plist/entitlement 条件)。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import socket
7
+ import threading
8
+ import time
9
+ from collections.abc import Callable, Iterable
10
+
11
+ from .models import AppEndpoint
12
+
13
+ BEACON_PORT = 8089
14
+ BEACON_MAGIC = "idh-beacon"
15
+ BEACON_STALE_AFTER = 6.0
16
+ BEACON_EXPIRE_AFTER = 10.0
17
+
18
+ EndpointPredicate = Callable[[list[AppEndpoint]], bool]
19
+
20
+
21
+ def _endpoint_from_beacon(data: dict, sender_ip: str) -> AppEndpoint | None:
22
+ """将 UDP 广播 JSON 包转换为 AppEndpoint。"""
23
+ if not isinstance(data, dict) or data.get("type") != BEACON_MAGIC:
24
+ return None
25
+ try:
26
+ port = int(data.get("port", 8088))
27
+ except (TypeError, ValueError):
28
+ return None
29
+ if not 1 <= port <= 65535:
30
+ return None
31
+
32
+ raw_id = data.get("id")
33
+ if raw_id is None:
34
+ raw_id = f"{sender_ip}:{port}:{data.get('bundle', '')}"
35
+ if not isinstance(raw_id, (str, int)):
36
+ return None
37
+ instance_id = str(raw_id).strip()
38
+ if not instance_id:
39
+ return None
40
+ service_name = f"idh-{instance_id[:8]}" if len(instance_id) >= 8 else f"idh-{instance_id}"
41
+ properties = {
42
+ "protocol": str(data.get("protocol", "2")),
43
+ "id": instance_id,
44
+ "app_name": str(data.get("app_name", "")),
45
+ "bundle": str(data.get("bundle", "")),
46
+ "app_version": str(data.get("app_version", "")),
47
+ "idh_version": str(data.get("idh_version", "")),
48
+ "mcp_path": str(data.get("mcp_path", "/api/mcp")),
49
+ "panel_path": str(data.get("panel_path", "/")),
50
+ "pid": str(data.get("pid", "")),
51
+ }
52
+ return AppEndpoint(
53
+ service_name=service_name,
54
+ server=sender_ip,
55
+ addresses=(sender_ip,),
56
+ port=port,
57
+ properties=properties,
58
+ source="udp",
59
+ )
60
+
61
+
62
+ class DiscoveryRegistry:
63
+ """监听 UDP 8089 端口,收集 dylib 广播信标。"""
64
+
65
+ def __init__(
66
+ self,
67
+ *,
68
+ manual_endpoints: Iterable[AppEndpoint] = (),
69
+ on_change: Callable[[], None] | None = None,
70
+ stale_after: float = BEACON_STALE_AFTER,
71
+ expire_after: float = BEACON_EXPIRE_AFTER,
72
+ clock: Callable[[], float] = time.monotonic,
73
+ ) -> None:
74
+ if stale_after <= 0 or expire_after <= stale_after:
75
+ raise ValueError("expire_after 必须大于 stale_after,且两者都必须为正数")
76
+ manual = list(manual_endpoints)
77
+ self._lock = threading.Condition()
78
+ self._endpoints: dict[str, AppEndpoint] = {
79
+ endpoint.key: endpoint for endpoint in manual
80
+ }
81
+ self._manual_keys = {endpoint.key for endpoint in manual}
82
+ self._last_seen: dict[str, float] = {}
83
+ self._on_change = on_change
84
+ self._stale_after = stale_after
85
+ self._expire_after = expire_after
86
+ self._clock = clock
87
+ self._sock: socket.socket | None = None
88
+ self._thread: threading.Thread | None = None
89
+ self._running = False
90
+
91
+ def start(self, *, enable_bonjour: bool = True) -> None:
92
+ """启动 UDP 监听(参数名保留兼容,实际走 UDP)。"""
93
+ if self._running:
94
+ return
95
+ if not enable_bonjour:
96
+ return
97
+ self._running = True
98
+ self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
99
+ self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
100
+ if hasattr(socket, "SO_REUSEPORT"):
101
+ try:
102
+ self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
103
+ except OSError:
104
+ pass
105
+ self._sock.bind(("", BEACON_PORT))
106
+ self._sock.settimeout(0.5)
107
+ self._thread = threading.Thread(target=self._listen_loop, daemon=True, name="idh-udp-discovery")
108
+ self._thread.start()
109
+
110
+ def close(self) -> None:
111
+ self._running = False
112
+ if self._thread is not None:
113
+ self._thread.join(timeout=2.0)
114
+ self._thread = None
115
+ if self._sock is not None:
116
+ self._sock.close()
117
+ self._sock = None
118
+
119
+ def wait(self, timeout: float, *, predicate: EndpointPredicate | None = None) -> bool:
120
+ deadline = self._clock() + max(0.0, timeout)
121
+ with self._lock:
122
+ while True:
123
+ items = self._snapshot_locked(self._clock())
124
+ if items and (predicate is None or predicate(items)):
125
+ return True
126
+ remaining = deadline - self._clock()
127
+ if remaining <= 0:
128
+ return False
129
+ self._lock.wait(timeout=remaining)
130
+
131
+ def snapshot(self) -> list[AppEndpoint]:
132
+ with self._lock:
133
+ return self._snapshot_locked(self._clock())
134
+
135
+ def _snapshot_locked(self, now: float) -> list[AppEndpoint]:
136
+ self._prune_locked(now)
137
+ items: list[AppEndpoint] = []
138
+ for key, endpoint in self._endpoints.items():
139
+ if key in self._manual_keys:
140
+ items.append(endpoint)
141
+ continue
142
+ age = max(0.0, now - self._last_seen.get(key, now))
143
+ status = "stale" if age >= self._stale_after else "fresh"
144
+ items.append(
145
+ endpoint.with_properties(
146
+ {"beacon_status": status, "last_seen_seconds": f"{age:.1f}"}
147
+ )
148
+ )
149
+ return sorted(
150
+ items,
151
+ key=lambda item: (item.device_key.casefold(), item.app_name.casefold(), item.port),
152
+ )
153
+
154
+ def _prune_locked(self, now: float) -> bool:
155
+ expired = [
156
+ key
157
+ for key, seen_at in self._last_seen.items()
158
+ if now - seen_at >= self._expire_after
159
+ ]
160
+ for key in expired:
161
+ self._last_seen.pop(key, None)
162
+ self._endpoints.pop(key, None)
163
+ return bool(expired)
164
+
165
+ def _remove_superseded_locked(self, endpoint: AppEndpoint) -> bool:
166
+ if not endpoint.bundle_id:
167
+ return False
168
+ superseded = [
169
+ key
170
+ for key, item in self._endpoints.items()
171
+ if key not in self._manual_keys
172
+ and key != endpoint.key
173
+ and item.server == endpoint.server
174
+ and item.bundle_id == endpoint.bundle_id
175
+ ]
176
+ for key in superseded:
177
+ self._last_seen.pop(key, None)
178
+ self._endpoints.pop(key, None)
179
+ return bool(superseded)
180
+
181
+ def _record_endpoint(self, endpoint: AppEndpoint) -> None:
182
+ with self._lock:
183
+ self._remove_superseded_locked(endpoint)
184
+ self._endpoints[endpoint.key] = endpoint
185
+ self._last_seen[endpoint.key] = self._clock()
186
+ self._lock.notify_all()
187
+ if self._on_change:
188
+ self._on_change()
189
+
190
+ def _listen_loop(self) -> None:
191
+ while self._running:
192
+ try:
193
+ raw, addr = self._sock.recvfrom(4096)
194
+ except TimeoutError:
195
+ with self._lock:
196
+ changed = self._prune_locked(self._clock())
197
+ if changed:
198
+ self._lock.notify_all()
199
+ if changed and self._on_change:
200
+ self._on_change()
201
+ continue
202
+ except OSError:
203
+ break
204
+ try:
205
+ data = json.loads(raw)
206
+ except (json.JSONDecodeError, UnicodeDecodeError):
207
+ continue
208
+ if not isinstance(data, dict):
209
+ continue
210
+ sender_ip = addr[0]
211
+ endpoint = _endpoint_from_beacon(data, sender_ip)
212
+ if endpoint is None:
213
+ continue
214
+ self._record_endpoint(endpoint)
215
+
216
+
217
+ def discover(
218
+ timeout: float = 1.5,
219
+ *,
220
+ manual_endpoints: Iterable[AppEndpoint] = (),
221
+ enable_bonjour: bool = True,
222
+ ) -> list[AppEndpoint]:
223
+ """发现局域网中的 IOSDecryptHub 实例。"""
224
+ registry = DiscoveryRegistry(manual_endpoints=manual_endpoints)
225
+ try:
226
+ registry.start(enable_bonjour=enable_bonjour)
227
+ if enable_bonjour:
228
+ threading.Event().wait(max(0.0, timeout))
229
+ return registry.snapshot()
230
+ finally:
231
+ registry.close()