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 +3 -0
- idh/__main__.py +6 -0
- idh/cli.py +244 -0
- idh/discovery.py +231 -0
- idh/gateway.py +516 -0
- idh/http_mcp.py +109 -0
- idh/models.py +133 -0
- ios_decrypt_hub-0.2.0.dist-info/METADATA +190 -0
- ios_decrypt_hub-0.2.0.dist-info/RECORD +12 -0
- ios_decrypt_hub-0.2.0.dist-info/WHEEL +5 -0
- ios_decrypt_hub-0.2.0.dist-info/entry_points.txt +2 -0
- ios_decrypt_hub-0.2.0.dist-info/top_level.txt +1 -0
idh/__init__.py
ADDED
idh/__main__.py
ADDED
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()
|