pymobiledevice3 4.14.6__py3-none-any.whl → 7.0.6__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 (164) hide show
  1. misc/plist_sniffer.py +15 -15
  2. misc/remotexpc_sniffer.py +29 -28
  3. misc/understanding_idevice_protocol_layers.md +15 -10
  4. pymobiledevice3/__main__.py +317 -127
  5. pymobiledevice3/_version.py +22 -4
  6. pymobiledevice3/bonjour.py +358 -113
  7. pymobiledevice3/ca.py +253 -16
  8. pymobiledevice3/cli/activation.py +31 -23
  9. pymobiledevice3/cli/afc.py +49 -40
  10. pymobiledevice3/cli/amfi.py +16 -21
  11. pymobiledevice3/cli/apps.py +87 -42
  12. pymobiledevice3/cli/backup.py +160 -90
  13. pymobiledevice3/cli/bonjour.py +44 -40
  14. pymobiledevice3/cli/cli_common.py +204 -198
  15. pymobiledevice3/cli/companion_proxy.py +14 -14
  16. pymobiledevice3/cli/crash.py +105 -56
  17. pymobiledevice3/cli/developer/__init__.py +62 -0
  18. pymobiledevice3/cli/developer/accessibility/__init__.py +65 -0
  19. pymobiledevice3/cli/developer/accessibility/settings.py +43 -0
  20. pymobiledevice3/cli/developer/arbitration.py +50 -0
  21. pymobiledevice3/cli/developer/condition.py +33 -0
  22. pymobiledevice3/cli/developer/core_device.py +294 -0
  23. pymobiledevice3/cli/developer/debugserver.py +244 -0
  24. pymobiledevice3/cli/developer/dvt/__init__.py +438 -0
  25. pymobiledevice3/cli/developer/dvt/core_profile_session.py +295 -0
  26. pymobiledevice3/cli/developer/dvt/simulate_location.py +56 -0
  27. pymobiledevice3/cli/developer/dvt/sysmon/__init__.py +69 -0
  28. pymobiledevice3/cli/developer/dvt/sysmon/process.py +188 -0
  29. pymobiledevice3/cli/developer/fetch_symbols.py +108 -0
  30. pymobiledevice3/cli/developer/simulate_location.py +51 -0
  31. pymobiledevice3/cli/diagnostics/__init__.py +75 -0
  32. pymobiledevice3/cli/diagnostics/battery.py +47 -0
  33. pymobiledevice3/cli/idam.py +42 -0
  34. pymobiledevice3/cli/lockdown.py +108 -103
  35. pymobiledevice3/cli/mounter.py +158 -99
  36. pymobiledevice3/cli/notification.py +38 -26
  37. pymobiledevice3/cli/pcap.py +45 -24
  38. pymobiledevice3/cli/power_assertion.py +18 -17
  39. pymobiledevice3/cli/processes.py +17 -23
  40. pymobiledevice3/cli/profile.py +165 -109
  41. pymobiledevice3/cli/provision.py +35 -34
  42. pymobiledevice3/cli/remote.py +217 -129
  43. pymobiledevice3/cli/restore.py +159 -143
  44. pymobiledevice3/cli/springboard.py +63 -53
  45. pymobiledevice3/cli/syslog.py +193 -86
  46. pymobiledevice3/cli/usbmux.py +73 -33
  47. pymobiledevice3/cli/version.py +5 -7
  48. pymobiledevice3/cli/webinspector.py +376 -214
  49. pymobiledevice3/common.py +3 -1
  50. pymobiledevice3/exceptions.py +182 -58
  51. pymobiledevice3/irecv.py +52 -53
  52. pymobiledevice3/irecv_devices.py +1489 -464
  53. pymobiledevice3/lockdown.py +473 -275
  54. pymobiledevice3/lockdown_service_provider.py +15 -8
  55. pymobiledevice3/osu/os_utils.py +27 -9
  56. pymobiledevice3/osu/posix_util.py +34 -15
  57. pymobiledevice3/osu/win_util.py +14 -8
  58. pymobiledevice3/pair_records.py +102 -21
  59. pymobiledevice3/remote/common.py +8 -4
  60. pymobiledevice3/remote/core_device/app_service.py +94 -67
  61. pymobiledevice3/remote/core_device/core_device_service.py +17 -14
  62. pymobiledevice3/remote/core_device/device_info.py +5 -5
  63. pymobiledevice3/remote/core_device/diagnostics_service.py +19 -4
  64. pymobiledevice3/remote/core_device/file_service.py +53 -23
  65. pymobiledevice3/remote/remote_service_discovery.py +79 -45
  66. pymobiledevice3/remote/remotexpc.py +73 -44
  67. pymobiledevice3/remote/tunnel_service.py +442 -317
  68. pymobiledevice3/remote/utils.py +14 -13
  69. pymobiledevice3/remote/xpc_message.py +145 -125
  70. pymobiledevice3/resources/dsc_uuid_map.py +19 -19
  71. pymobiledevice3/resources/firmware_notifications.py +20 -16
  72. pymobiledevice3/resources/notifications.txt +144 -0
  73. pymobiledevice3/restore/asr.py +27 -27
  74. pymobiledevice3/restore/base_restore.py +110 -21
  75. pymobiledevice3/restore/consts.py +87 -66
  76. pymobiledevice3/restore/device.py +59 -12
  77. pymobiledevice3/restore/fdr.py +46 -48
  78. pymobiledevice3/restore/ftab.py +19 -19
  79. pymobiledevice3/restore/img4.py +163 -0
  80. pymobiledevice3/restore/mbn.py +587 -0
  81. pymobiledevice3/restore/recovery.py +151 -151
  82. pymobiledevice3/restore/restore.py +562 -544
  83. pymobiledevice3/restore/restore_options.py +131 -110
  84. pymobiledevice3/restore/restored_client.py +51 -31
  85. pymobiledevice3/restore/tss.py +385 -267
  86. pymobiledevice3/service_connection.py +252 -59
  87. pymobiledevice3/services/accessibilityaudit.py +202 -120
  88. pymobiledevice3/services/afc.py +962 -365
  89. pymobiledevice3/services/amfi.py +24 -30
  90. pymobiledevice3/services/companion.py +23 -19
  91. pymobiledevice3/services/crash_reports.py +71 -47
  92. pymobiledevice3/services/debugserver_applist.py +3 -3
  93. pymobiledevice3/services/device_arbitration.py +8 -8
  94. pymobiledevice3/services/device_link.py +101 -79
  95. pymobiledevice3/services/diagnostics.py +973 -967
  96. pymobiledevice3/services/dtfetchsymbols.py +8 -8
  97. pymobiledevice3/services/dvt/dvt_secure_socket_proxy.py +4 -4
  98. pymobiledevice3/services/dvt/dvt_testmanaged_proxy.py +4 -4
  99. pymobiledevice3/services/dvt/instruments/activity_trace_tap.py +85 -74
  100. pymobiledevice3/services/dvt/instruments/application_listing.py +2 -3
  101. pymobiledevice3/services/dvt/instruments/condition_inducer.py +7 -6
  102. pymobiledevice3/services/dvt/instruments/core_profile_session_tap.py +466 -384
  103. pymobiledevice3/services/dvt/instruments/device_info.py +20 -11
  104. pymobiledevice3/services/dvt/instruments/energy_monitor.py +1 -1
  105. pymobiledevice3/services/dvt/instruments/graphics.py +1 -1
  106. pymobiledevice3/services/dvt/instruments/location_simulation.py +1 -1
  107. pymobiledevice3/services/dvt/instruments/location_simulation_base.py +10 -10
  108. pymobiledevice3/services/dvt/instruments/network_monitor.py +17 -17
  109. pymobiledevice3/services/dvt/instruments/notifications.py +1 -1
  110. pymobiledevice3/services/dvt/instruments/process_control.py +35 -10
  111. pymobiledevice3/services/dvt/instruments/screenshot.py +2 -2
  112. pymobiledevice3/services/dvt/instruments/sysmontap.py +15 -15
  113. pymobiledevice3/services/dvt/testmanaged/xcuitest.py +42 -52
  114. pymobiledevice3/services/file_relay.py +10 -10
  115. pymobiledevice3/services/heartbeat.py +9 -8
  116. pymobiledevice3/services/house_arrest.py +16 -15
  117. pymobiledevice3/services/idam.py +20 -0
  118. pymobiledevice3/services/installation_proxy.py +173 -81
  119. pymobiledevice3/services/lockdown_service.py +20 -10
  120. pymobiledevice3/services/misagent.py +22 -19
  121. pymobiledevice3/services/mobile_activation.py +147 -64
  122. pymobiledevice3/services/mobile_config.py +331 -294
  123. pymobiledevice3/services/mobile_image_mounter.py +141 -113
  124. pymobiledevice3/services/mobilebackup2.py +203 -145
  125. pymobiledevice3/services/notification_proxy.py +11 -11
  126. pymobiledevice3/services/os_trace.py +134 -74
  127. pymobiledevice3/services/pcapd.py +314 -302
  128. pymobiledevice3/services/power_assertion.py +10 -9
  129. pymobiledevice3/services/preboard.py +4 -4
  130. pymobiledevice3/services/remote_fetch_symbols.py +21 -14
  131. pymobiledevice3/services/remote_server.py +176 -146
  132. pymobiledevice3/services/restore_service.py +16 -16
  133. pymobiledevice3/services/screenshot.py +15 -12
  134. pymobiledevice3/services/simulate_location.py +7 -7
  135. pymobiledevice3/services/springboard.py +15 -15
  136. pymobiledevice3/services/syslog.py +5 -5
  137. pymobiledevice3/services/web_protocol/alert.py +11 -11
  138. pymobiledevice3/services/web_protocol/automation_session.py +251 -239
  139. pymobiledevice3/services/web_protocol/cdp_screencast.py +46 -37
  140. pymobiledevice3/services/web_protocol/cdp_server.py +19 -19
  141. pymobiledevice3/services/web_protocol/cdp_target.py +411 -373
  142. pymobiledevice3/services/web_protocol/driver.py +114 -111
  143. pymobiledevice3/services/web_protocol/element.py +124 -111
  144. pymobiledevice3/services/web_protocol/inspector_session.py +106 -102
  145. pymobiledevice3/services/web_protocol/selenium_api.py +49 -49
  146. pymobiledevice3/services/web_protocol/session_protocol.py +18 -12
  147. pymobiledevice3/services/web_protocol/switch_to.py +30 -27
  148. pymobiledevice3/services/webinspector.py +189 -155
  149. pymobiledevice3/tcp_forwarder.py +87 -69
  150. pymobiledevice3/tunneld/__init__.py +0 -0
  151. pymobiledevice3/tunneld/api.py +63 -0
  152. pymobiledevice3/tunneld/server.py +603 -0
  153. pymobiledevice3/usbmux.py +198 -147
  154. pymobiledevice3/utils.py +14 -11
  155. {pymobiledevice3-4.14.6.dist-info → pymobiledevice3-7.0.6.dist-info}/METADATA +55 -28
  156. pymobiledevice3-7.0.6.dist-info/RECORD +188 -0
  157. {pymobiledevice3-4.14.6.dist-info → pymobiledevice3-7.0.6.dist-info}/WHEEL +1 -1
  158. pymobiledevice3/cli/developer.py +0 -1215
  159. pymobiledevice3/cli/diagnostics.py +0 -99
  160. pymobiledevice3/tunneld.py +0 -524
  161. pymobiledevice3-4.14.6.dist-info/RECORD +0 -168
  162. {pymobiledevice3-4.14.6.dist-info → pymobiledevice3-7.0.6.dist-info}/entry_points.txt +0 -0
  163. {pymobiledevice3-4.14.6.dist-info → pymobiledevice3-7.0.6.dist-info/licenses}/LICENSE +0 -0
  164. {pymobiledevice3-4.14.6.dist-info → pymobiledevice3-7.0.6.dist-info}/top_level.txt +0 -0
@@ -1,135 +1,380 @@
1
+ # Async, dependency-light mDNS browser returning dataclasses with per-address interface names.
2
+ # Works for any DNS-SD type, e.g. "_remoted._tcp.local."
3
+ # - Uses ifaddr (optional) to map IPs -> local interfaces; otherwise iface will be None.
4
+
1
5
  import asyncio
2
- import dataclasses
3
- from socket import AF_INET, AF_INET6, inet_ntop
6
+ import contextlib
7
+ import ipaddress
8
+ import socket
9
+ import struct
10
+ import sys
11
+ from collections import defaultdict
12
+ from dataclasses import dataclass, field
4
13
  from typing import Optional
5
14
 
6
- from ifaddr import get_adapters
7
- from zeroconf import IPVersion, ServiceListener, ServiceStateChange, Zeroconf
8
- from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf
15
+ import ifaddr # pip install ifaddr
9
16
 
10
17
  from pymobiledevice3.osu.os_utils import get_os_utils
11
18
 
12
- REMOTEPAIRING_SERVICE_NAMES = ['_remotepairing._tcp.local.']
13
- REMOTEPAIRING_MANUAL_PAIRING_SERVICE_NAMES = ['_remotepairing-manual-pairing._tcp.local.']
14
- MOBDEV2_SERVICE_NAMES = ['_apple-mobdev2._tcp.local.']
15
- REMOTED_SERVICE_NAMES = ['_remoted._tcp.local.']
19
+ REMOTEPAIRING_SERVICE_NAME = "_remotepairing._tcp.local."
20
+ REMOTEPAIRING_MANUAL_PAIRING_SERVICE_NAME = "_remotepairing-manual-pairing._tcp.local."
21
+ MOBDEV2_SERVICE_NAME = "_apple-mobdev2._tcp.local."
22
+ REMOTED_SERVICE_NAME = "_remoted._tcp.local."
16
23
  OSUTILS = get_os_utils()
17
24
  DEFAULT_BONJOUR_TIMEOUT = OSUTILS.bonjour_timeout
18
25
 
26
+ MDNS_PORT = 5353
27
+ MDNS_MCAST_V4 = "224.0.0.251"
28
+ MDNS_MCAST_V6 = "ff02::fb"
19
29
 
20
- @dataclasses.dataclass
21
- class BonjourAnswer:
22
- name: str
23
- properties: dict[bytes, bytes]
24
- ips: list[str]
25
- port: int
26
-
27
-
28
- class BonjourListener(ServiceListener):
29
- def __init__(self, ip: str):
30
- super().__init__()
31
- self.name: Optional[str] = None
32
- self.properties: dict[bytes, bytes] = {}
33
- self.ip = ip
34
- self.port: Optional[int] = None
35
- self.addresses: list[str] = []
36
- self.queue: asyncio.Queue = asyncio.Queue()
37
- self.querying_task: Optional[asyncio.Task] = asyncio.create_task(self.query_addresses())
38
-
39
- def async_on_service_state_change(
40
- self, zeroconf: Zeroconf, service_type: str, name: str, state_change: ServiceStateChange) -> None:
41
- self.queue.put_nowait((zeroconf, service_type, name, state_change))
42
-
43
- async def query_addresses(self) -> None:
44
- zeroconf, service_type, name, state_change = await self.queue.get()
45
- self.name = name
46
- service_info = AsyncServiceInfo(service_type, name)
47
- await service_info.async_request(zeroconf, 3000)
48
- ipv4 = [inet_ntop(AF_INET, address.packed) for address in
49
- service_info.ip_addresses_by_version(IPVersion.V4Only)]
50
- ipv6 = []
51
- if '%' in self.ip:
52
- ipv6 = [inet_ntop(AF_INET6, address.packed) + '%' + self.ip.split('%')[1] for address in
53
- service_info.ip_addresses_by_version(IPVersion.V6Only)]
54
- self.addresses = ipv4 + ipv6
55
- self.properties = service_info.properties
56
- self.port = service_info.port
57
-
58
- async def close(self) -> None:
59
- self.querying_task.cancel()
60
- try:
61
- await self.querying_task
62
- except asyncio.CancelledError:
63
- pass
64
-
65
-
66
- @dataclasses.dataclass
67
- class BonjourQuery:
68
- zc: AsyncZeroconf
69
- service_browser: AsyncServiceBrowser
70
- listener: BonjourListener
71
-
72
-
73
- def query_bonjour(service_names: list[str], ip: str) -> BonjourQuery:
74
- aiozc = AsyncZeroconf(interfaces=[ip])
75
- listener = BonjourListener(ip)
76
- service_browser = AsyncServiceBrowser(aiozc.zeroconf, service_names,
77
- handlers=[listener.async_on_service_state_change])
78
- return BonjourQuery(aiozc, service_browser, listener)
79
-
80
-
81
- async def browse(service_names: list[str], ips: list[str], timeout: float = DEFAULT_BONJOUR_TIMEOUT) \
82
- -> list[BonjourAnswer]:
83
- bonjour_queries = [query_bonjour(service_names, adapter) for adapter in ips]
84
- answers = []
85
- await asyncio.sleep(timeout)
86
- for bonjour_query in bonjour_queries:
87
- if bonjour_query.listener.addresses:
88
- answer = BonjourAnswer(
89
- bonjour_query.listener.name, bonjour_query.listener.properties, bonjour_query.listener.addresses,
90
- bonjour_query.listener.port)
91
- if answer not in answers:
92
- answers.append(answer)
93
- await bonjour_query.listener.close()
94
- await bonjour_query.service_browser.async_cancel()
95
- await bonjour_query.zc.async_close()
96
- return answers
97
-
98
-
99
- async def browse_ipv6(service_names: list[str], timeout: float = DEFAULT_BONJOUR_TIMEOUT) -> list[BonjourAnswer]:
100
- return await browse(service_names, OSUTILS.get_ipv6_ips(), timeout=timeout)
101
-
102
-
103
- def get_ipv4_addresses() -> list[str]:
104
- ips = []
105
- for adapter in get_adapters():
106
- if adapter.nice_name.startswith('tun'):
107
- # skip browsing on already established tunnels
30
+ QTYPE_A = 1
31
+ QTYPE_PTR = 12
32
+ QTYPE_TXT = 16
33
+ QTYPE_AAAA = 28
34
+ QTYPE_SRV = 33
35
+
36
+ CLASS_IN = 0x0001
37
+ CLASS_QU = 0x8000 # unicast-response bit (we use multicast queries)
38
+
39
+
40
+ # ---------------- Dataclasses ----------------
41
+
42
+
43
+ # --- Dataclass decorator shim (adds slots only on 3.10+)
44
+ def dataclass_compat(*d_args, **d_kwargs):
45
+ if sys.version_info < (3, 10):
46
+ d_kwargs.pop("slots", None) # ignore on 3.9
47
+ return dataclass(*d_args, **d_kwargs)
48
+
49
+
50
+ @dataclass_compat(slots=True)
51
+ class Address:
52
+ ip: str
53
+ iface: str # local interface name (e.g., "en0"), or None if unknown
54
+
55
+ @property
56
+ def full_ip(self) -> str:
57
+ if self.iface and self.ip.lower().startswith("fe80:"):
58
+ return f"{self.ip}%{self.iface}"
59
+ return self.ip
60
+
61
+
62
+ @dataclass_compat(slots=True)
63
+ class ServiceInstance:
64
+ instance: str # "<Instance Name>._type._proto.local."
65
+ host: Optional[str] # "host.local" (without trailing dot), or None if unresolved
66
+ port: Optional[int] # SRV port
67
+ addresses: list[Address] = field(default_factory=list) # IPs with interface names
68
+ properties: dict[str, str] = field(default_factory=dict) # TXT key/values
69
+
70
+
71
+ # ---------------- DNS helpers ----------------
72
+
73
+
74
+ def encode_name(name: str) -> bytes:
75
+ name = name.rstrip(".")
76
+ out = bytearray()
77
+ for label in name.split(".") if name else []:
78
+ b = label.encode("utf-8")
79
+ if len(b) > 63:
80
+ raise ValueError("label too long")
81
+ out.append(len(b))
82
+ out += b
83
+ out.append(0)
84
+ return bytes(out)
85
+
86
+
87
+ def decode_name(data: bytes, off: int) -> tuple[str, int]:
88
+ labels = []
89
+ jumped = False
90
+ orig_end = off
91
+ for _ in range(128): # loop guard
92
+ if off >= len(data):
93
+ break
94
+ length = data[off]
95
+ if length == 0:
96
+ off += 1
97
+ break
98
+ if (length & 0xC0) == 0xC0:
99
+ if off + 1 >= len(data):
100
+ raise ValueError("truncated name pointer")
101
+ ptr = ((length & 0x3F) << 8) | data[off + 1]
102
+ if ptr >= len(data):
103
+ raise ValueError("bad name pointer")
104
+ if not jumped:
105
+ orig_end = off + 2
106
+ off = ptr
107
+ jumped = True
108
108
  continue
109
- for ip in adapter.ips:
110
- if ip.ip == '127.0.0.1':
111
- continue
112
- if not ip.is_IPv4:
109
+ off += 1
110
+ end = off + length
111
+ if end > len(data):
112
+ raise ValueError("truncated label")
113
+ labels.append(data[off:end].decode("utf-8", errors="replace"))
114
+ off = end
115
+ return ".".join(labels) + ".", (orig_end if jumped else off)
116
+
117
+
118
+ def build_query(name: str, qtype: int, unicast: bool = False) -> bytes:
119
+ hdr = struct.pack("!HHHHHH", 0, 0, 1, 0, 0, 0) # TXID=0, flags=0, 1 question
120
+ qclass = CLASS_IN | (CLASS_QU if unicast else 0)
121
+ return hdr + encode_name(name) + struct.pack("!HH", qtype, qclass)
122
+
123
+
124
+ def parse_rr(data: bytes, off: int):
125
+ name, off = decode_name(data, off)
126
+ if off + 10 > len(data):
127
+ raise ValueError("truncated RR header")
128
+ rtype, rclass, ttl, rdlen = struct.unpack("!HHIH", data[off : off + 10])
129
+ off += 10
130
+ rdata = data[off : off + rdlen]
131
+ off += rdlen
132
+
133
+ rr = {"name": name, "type": rtype, "class": rclass & 0x7FFF, "ttl": ttl}
134
+ if rtype == QTYPE_PTR:
135
+ target, _ = decode_name(data, off - rdlen)
136
+ rr["ptrdname"] = target
137
+ elif rtype == QTYPE_SRV and rdlen >= 6:
138
+ priority, weight, port = struct.unpack("!HHH", rdata[:6])
139
+ target, _ = decode_name(data, off - rdlen + 6)
140
+ rr.update({"priority": priority, "weight": weight, "port": port, "target": target})
141
+ elif rtype == QTYPE_TXT:
142
+ kv = {}
143
+ i = 0
144
+ while i < rdlen:
145
+ b = rdata[i]
146
+ i += 1
147
+ seg = rdata[i : i + b]
148
+ i += b
149
+ if not seg:
113
150
  continue
114
- ips.append(ip.ip)
115
- return ips
151
+ if b"=" in seg:
152
+ k, v = seg.split(b"=", 1)
153
+ kv[k.decode()] = v.decode(errors="replace")
154
+ else:
155
+ kv[seg.decode()] = ""
156
+ rr["txt"] = kv
157
+ elif rtype == QTYPE_A and rdlen == 4:
158
+ rr["address"] = socket.inet_ntop(socket.AF_INET, rdata)
159
+ elif rtype == QTYPE_AAAA and rdlen == 16:
160
+ rr["address"] = socket.inet_ntop(socket.AF_INET6, rdata)
161
+ else:
162
+ rr["raw"] = rdata
163
+ return rr, off
164
+
165
+
166
+ def parse_mdns_message(data: bytes):
167
+ if len(data) < 12:
168
+ return []
169
+ _, _, qd, an, ns, ar = struct.unpack("!HHHHHH", data[:12])
170
+ off = 12
171
+ for _ in range(qd):
172
+ _, off = decode_name(data, off)
173
+ off += 4
174
+ rrs = []
175
+ for _ in range(an + ns + ar):
176
+ rr, off = parse_rr(data, off)
177
+ rrs.append(rr)
178
+ return rrs
179
+
180
+
181
+ # ---------------- Interface mapping helpers ----------------
182
+
183
+
184
+ class _Adapters:
185
+ def __init__(self):
186
+ self.adapters = ifaddr.get_adapters() if ifaddr is not None else []
187
+
188
+ def pick_iface_for_ip(self, ip_str: str, family: int, v6_scopeid: Optional[int]) -> Optional[str]:
189
+ # Prefer scope id for IPv6 link-local
190
+ if family == socket.AF_INET6 and ip_str.lower().startswith("fe80:") and v6_scopeid:
191
+ try:
192
+ return socket.if_indextoname(v6_scopeid)
193
+ except OSError:
194
+ pass
195
+
196
+ # Otherwise, try to match destination ip to local subnet via ifaddr
197
+ if not self.adapters:
198
+ return None
199
+ ip = ipaddress.ip_address(ip_str)
200
+ best = (None, -1) # (name, prefix_len)
201
+ for ad in self.adapters:
202
+ for ipn in ad.ips:
203
+ if isinstance(ipn.ip, str):
204
+ # IPv4
205
+ fam = socket.AF_INET
206
+ ipn_ip = ipn.ip
207
+ else:
208
+ # IPv6
209
+ fam = socket.AF_INET6
210
+ ipn_ip = ipn.ip[0]
211
+ if fam != family:
212
+ continue
213
+ net = ipaddress.ip_network(f"{ipn_ip}/{ipn.network_prefix}", strict=False)
214
+ if ip in net and ipn.network_prefix > best[1]:
215
+ best = (ad.nice_name or ad.name, ipn.network_prefix)
216
+ return best[0]
217
+
218
+
219
+ # ---------------- async sockets ----------------
220
+
221
+
222
+ class _DatagramProtocol(asyncio.DatagramProtocol):
223
+ def __init__(self, queue: asyncio.Queue):
224
+ self.queue = queue
225
+
226
+ def datagram_received(self, data, addr):
227
+ # addr: IPv4 -> (host, port); IPv6 -> (host, port, flowinfo, scopeid)
228
+ self.queue.put_nowait((data, addr))
229
+
230
+
231
+ async def _bind_ipv4(queue: asyncio.Queue):
232
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
233
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
234
+ if hasattr(socket, "SO_REUSEPORT"):
235
+ with contextlib.suppress(OSError):
236
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
237
+ s.bind(("0.0.0.0", MDNS_PORT))
238
+ try:
239
+ mreq = struct.pack("=4s4s", socket.inet_aton(MDNS_MCAST_V4), socket.inet_aton("0.0.0.0"))
240
+ s.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
241
+ except OSError:
242
+ pass
243
+ transport, _ = await asyncio.get_running_loop().create_datagram_endpoint(lambda: _DatagramProtocol(queue), sock=s)
244
+ return transport, s
245
+
246
+
247
+ async def _bind_ipv6_all_ifaces(queue: asyncio.Queue):
248
+ s = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
249
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
250
+ if hasattr(socket, "SO_REUSEPORT"):
251
+ with contextlib.suppress(OSError):
252
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
253
+ s.bind(("::", MDNS_PORT))
254
+ grp = socket.inet_pton(socket.AF_INET6, MDNS_MCAST_V6)
255
+ for ifindex, _ in socket.if_nameindex():
256
+ mreq6 = grp + struct.pack("@I", ifindex)
257
+ try:
258
+ s.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_JOIN_GROUP, mreq6)
259
+ except OSError:
260
+ continue
261
+ transport, _ = await asyncio.get_running_loop().create_datagram_endpoint(lambda: _DatagramProtocol(queue), sock=s)
262
+ return transport, s
263
+
264
+
265
+ async def _open_mdns_sockets():
266
+ queue = asyncio.Queue()
267
+ transports: list[tuple[asyncio.BaseTransport, socket.socket]] = []
268
+ t4, s4 = await _bind_ipv4(queue)
269
+ transports.append((t4, s4))
270
+ t6, s6 = await _bind_ipv6_all_ifaces(queue)
271
+ transports.append((t6, s6))
272
+ if not transports:
273
+ raise RuntimeError("Failed to open mDNS sockets (UDP/5353)")
274
+ return transports, queue
275
+
276
+
277
+ async def _send_query_all(transports, pkt: bytes):
278
+ for transport, sock in transports:
279
+ if sock.family == socket.AF_INET:
280
+ transport.sendto(pkt, (MDNS_MCAST_V4, MDNS_PORT))
281
+ else:
282
+ # Send once per iface index for better reachability
283
+ for ifindex, _ in socket.if_nameindex():
284
+ transport.sendto(pkt, (MDNS_MCAST_V6, MDNS_PORT, 0, ifindex))
285
+
286
+
287
+ # ---------------- Public API ----------------
288
+
289
+
290
+ async def browse_service(service_type: str, timeout: float = 4.0) -> list[ServiceInstance]:
291
+ """
292
+ Discover a DNS-SD/mDNS service type (e.g. "_remoted._tcp.local.") on the local network.
293
+
294
+ Returns: List[ServiceInstance] with Address(ip, iface) entries.
295
+ """
296
+ if not service_type.endswith("."):
297
+ service_type += "."
298
+
299
+ transports, queue = await _open_mdns_sockets()
300
+ adapters = _Adapters()
301
+
302
+ ptr_targets: set[str] = set()
303
+ srv_map: dict[str, list[dict]] = defaultdict(list) # instance_name -> list of {"target", "port"}
304
+ txt_map: dict[str, dict] = {}
305
+ host_addrs: dict[str, list[Address]] = defaultdict(list) # host -> list[(ip, iface)]
306
+
307
+ def _record_addr(rr_name: str, ip_str: str, pkt_addr):
308
+ # Determine family and possible scopeid from the packet that delivered this RR
309
+ family = socket.AF_INET6 if ":" in ip_str else socket.AF_INET
310
+ scopeid = None
311
+ if isinstance(pkt_addr, tuple) and len(pkt_addr) == 4: # IPv6 remote tuple
312
+ scopeid = pkt_addr[3]
313
+ iface = adapters.pick_iface_for_ip(ip_str, family, scopeid)
314
+ if iface is None:
315
+ return
316
+ # Avoid duplicates for the same host/ip
317
+ existing = host_addrs[rr_name]
318
+ if not any(a.ip == ip_str for a in existing):
319
+ existing.append(Address(ip=ip_str, iface=iface))
116
320
 
321
+ try:
322
+ await _send_query_all(transports, build_query(service_type, QTYPE_PTR, unicast=False))
323
+ loop = asyncio.get_running_loop()
324
+ end = loop.time() + timeout
325
+ while loop.time() < end:
326
+ try:
327
+ data, pkt_addr = await asyncio.wait_for(queue.get(), timeout=end - loop.time())
328
+ except asyncio.TimeoutError:
329
+ break
330
+ for rr in parse_mdns_message(data):
331
+ t = rr.get("type")
332
+ if t == QTYPE_PTR and rr.get("name") == service_type:
333
+ ptr_targets.add(rr.get("ptrdname"))
334
+ elif t == QTYPE_SRV:
335
+ srv_map[rr["name"]].append({"target": rr.get("target"), "port": rr.get("port")})
336
+ elif t == QTYPE_TXT:
337
+ # TODO: This could possibly mix the properties of multiple TXT records for the same instance.
338
+ # However, it's currently unused.
339
+ txt_map[rr["name"]] = rr.get("txt", {})
340
+ elif (t == QTYPE_A and rr.get("address")) or (t == QTYPE_AAAA and rr.get("address")):
341
+ _record_addr(rr["name"], rr["address"], pkt_addr)
342
+ finally:
343
+ for transport, _ in transports:
344
+ transport.close()
117
345
 
118
- async def browse_ipv4(service_names: list[str], timeout: float = DEFAULT_BONJOUR_TIMEOUT) -> list[BonjourAnswer]:
119
- return await browse(service_names, get_ipv4_addresses(), timeout=timeout)
346
+ # Assemble dataclasses
347
+ results: list[ServiceInstance] = []
348
+ for inst in sorted(ptr_targets):
349
+ srv_entries = srv_map.get(inst, [])
350
+ props = txt_map.get(inst, {})
351
+ for srv in srv_entries:
352
+ target = srv.get("target")
353
+ host = (target[:-1] if target and target.endswith(".") else target) or None
354
+ addrs = host_addrs.get(target, []) if target else []
355
+ results.append(
356
+ ServiceInstance(
357
+ instance=inst,
358
+ host=host,
359
+ port=srv.get("port"),
360
+ addresses=addrs,
361
+ properties=props,
362
+ )
363
+ )
364
+ return results
120
365
 
121
366
 
122
- async def browse_remoted(timeout: float = DEFAULT_BONJOUR_TIMEOUT) -> list[BonjourAnswer]:
123
- return await browse_ipv6(REMOTED_SERVICE_NAMES, timeout=timeout)
367
+ async def browse_remoted(timeout: float = DEFAULT_BONJOUR_TIMEOUT) -> list[ServiceInstance]:
368
+ return await browse_service(REMOTED_SERVICE_NAME, timeout=timeout)
124
369
 
125
370
 
126
- async def browse_mobdev2(timeout: float = DEFAULT_BONJOUR_TIMEOUT) -> list[BonjourAnswer]:
127
- return await browse(MOBDEV2_SERVICE_NAMES, get_ipv4_addresses() + OSUTILS.get_ipv6_ips(), timeout=timeout)
371
+ async def browse_mobdev2(timeout: float = DEFAULT_BONJOUR_TIMEOUT) -> list[ServiceInstance]:
372
+ return await browse_service(MOBDEV2_SERVICE_NAME, timeout=timeout)
128
373
 
129
374
 
130
- async def browse_remotepairing(timeout: float = DEFAULT_BONJOUR_TIMEOUT) -> list[BonjourAnswer]:
131
- return await browse_ipv4(REMOTEPAIRING_SERVICE_NAMES, timeout=timeout)
375
+ async def browse_remotepairing(timeout: float = DEFAULT_BONJOUR_TIMEOUT) -> list[ServiceInstance]:
376
+ return await browse_service(REMOTEPAIRING_SERVICE_NAME, timeout=timeout)
132
377
 
133
378
 
134
- async def browse_remotepairing_manual_pairing(timeout: float = DEFAULT_BONJOUR_TIMEOUT) -> list[BonjourAnswer]:
135
- return await browse_ipv4(REMOTEPAIRING_MANUAL_PAIRING_SERVICE_NAMES, timeout=timeout)
379
+ async def browse_remotepairing_manual_pairing(timeout: float = DEFAULT_BONJOUR_TIMEOUT) -> list[ServiceInstance]:
380
+ return await browse_service(REMOTEPAIRING_MANUAL_PAIRING_SERVICE_NAME, timeout=timeout)