pymobiledevice3 5.0.4__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 (79) hide show
  1. misc/understanding_idevice_protocol_layers.md +10 -5
  2. pymobiledevice3/__main__.py +171 -46
  3. pymobiledevice3/_version.py +2 -2
  4. pymobiledevice3/bonjour.py +22 -21
  5. pymobiledevice3/cli/activation.py +24 -22
  6. pymobiledevice3/cli/afc.py +49 -41
  7. pymobiledevice3/cli/amfi.py +13 -18
  8. pymobiledevice3/cli/apps.py +71 -65
  9. pymobiledevice3/cli/backup.py +134 -93
  10. pymobiledevice3/cli/bonjour.py +31 -29
  11. pymobiledevice3/cli/cli_common.py +175 -232
  12. pymobiledevice3/cli/companion_proxy.py +12 -12
  13. pymobiledevice3/cli/crash.py +95 -52
  14. pymobiledevice3/cli/developer/__init__.py +62 -0
  15. pymobiledevice3/cli/developer/accessibility/__init__.py +65 -0
  16. pymobiledevice3/cli/developer/accessibility/settings.py +43 -0
  17. pymobiledevice3/cli/developer/arbitration.py +50 -0
  18. pymobiledevice3/cli/developer/condition.py +33 -0
  19. pymobiledevice3/cli/developer/core_device.py +294 -0
  20. pymobiledevice3/cli/developer/debugserver.py +244 -0
  21. pymobiledevice3/cli/developer/dvt/__init__.py +438 -0
  22. pymobiledevice3/cli/developer/dvt/core_profile_session.py +295 -0
  23. pymobiledevice3/cli/developer/dvt/simulate_location.py +56 -0
  24. pymobiledevice3/cli/developer/dvt/sysmon/__init__.py +69 -0
  25. pymobiledevice3/cli/developer/dvt/sysmon/process.py +188 -0
  26. pymobiledevice3/cli/developer/fetch_symbols.py +108 -0
  27. pymobiledevice3/cli/developer/simulate_location.py +51 -0
  28. pymobiledevice3/cli/diagnostics/__init__.py +75 -0
  29. pymobiledevice3/cli/diagnostics/battery.py +47 -0
  30. pymobiledevice3/cli/idam.py +42 -0
  31. pymobiledevice3/cli/lockdown.py +70 -75
  32. pymobiledevice3/cli/mounter.py +99 -57
  33. pymobiledevice3/cli/notification.py +38 -26
  34. pymobiledevice3/cli/pcap.py +36 -20
  35. pymobiledevice3/cli/power_assertion.py +15 -16
  36. pymobiledevice3/cli/processes.py +11 -17
  37. pymobiledevice3/cli/profile.py +120 -75
  38. pymobiledevice3/cli/provision.py +27 -26
  39. pymobiledevice3/cli/remote.py +109 -100
  40. pymobiledevice3/cli/restore.py +134 -129
  41. pymobiledevice3/cli/springboard.py +50 -50
  42. pymobiledevice3/cli/syslog.py +145 -65
  43. pymobiledevice3/cli/usbmux.py +66 -27
  44. pymobiledevice3/cli/version.py +2 -5
  45. pymobiledevice3/cli/webinspector.py +232 -156
  46. pymobiledevice3/exceptions.py +6 -2
  47. pymobiledevice3/lockdown.py +5 -1
  48. pymobiledevice3/lockdown_service_provider.py +5 -0
  49. pymobiledevice3/remote/remote_service_discovery.py +18 -10
  50. pymobiledevice3/restore/device.py +28 -4
  51. pymobiledevice3/restore/restore.py +2 -2
  52. pymobiledevice3/service_connection.py +15 -12
  53. pymobiledevice3/services/afc.py +731 -220
  54. pymobiledevice3/services/device_link.py +45 -31
  55. pymobiledevice3/services/idam.py +20 -0
  56. pymobiledevice3/services/lockdown_service.py +12 -9
  57. pymobiledevice3/services/mobile_config.py +1 -0
  58. pymobiledevice3/services/mobilebackup2.py +6 -3
  59. pymobiledevice3/services/os_trace.py +97 -55
  60. pymobiledevice3/services/remote_fetch_symbols.py +13 -8
  61. pymobiledevice3/services/screenshot.py +2 -2
  62. pymobiledevice3/services/web_protocol/alert.py +8 -8
  63. pymobiledevice3/services/web_protocol/automation_session.py +87 -79
  64. pymobiledevice3/services/web_protocol/cdp_screencast.py +2 -1
  65. pymobiledevice3/services/web_protocol/driver.py +71 -70
  66. pymobiledevice3/services/web_protocol/element.py +58 -56
  67. pymobiledevice3/services/web_protocol/selenium_api.py +47 -47
  68. pymobiledevice3/services/web_protocol/session_protocol.py +3 -2
  69. pymobiledevice3/services/web_protocol/switch_to.py +23 -19
  70. pymobiledevice3/services/webinspector.py +42 -67
  71. {pymobiledevice3-5.0.4.dist-info → pymobiledevice3-7.0.6.dist-info}/METADATA +5 -3
  72. {pymobiledevice3-5.0.4.dist-info → pymobiledevice3-7.0.6.dist-info}/RECORD +76 -61
  73. pymobiledevice3/cli/completions.py +0 -50
  74. pymobiledevice3/cli/developer.py +0 -1539
  75. pymobiledevice3/cli/diagnostics.py +0 -110
  76. {pymobiledevice3-5.0.4.dist-info → pymobiledevice3-7.0.6.dist-info}/WHEEL +0 -0
  77. {pymobiledevice3-5.0.4.dist-info → pymobiledevice3-7.0.6.dist-info}/entry_points.txt +0 -0
  78. {pymobiledevice3-5.0.4.dist-info → pymobiledevice3-7.0.6.dist-info}/licenses/LICENSE +0 -0
  79. {pymobiledevice3-5.0.4.dist-info → pymobiledevice3-7.0.6.dist-info}/top_level.txt +0 -0
@@ -287,14 +287,14 @@ pymobiledevice3 developer dvt launch com.apple.mobilesafari
287
287
 
288
288
  ## DVT
289
289
 
290
- One of the more interesting developer services, is the one exposed by `DTServiceHub`. It is using DTX protocol messages,
290
+ One of the more interesting developer services is the one exposed by `DTServiceHub`. It is using DTX protocol messages,
291
291
  but since it mainly wraps and allows access to stuff in `DVTFoundation.framework` we called it DVT in our
292
292
  implementation (probably standing for DeveloperTools).
293
293
 
294
294
  We don't delve too much into this protocol, but we'll say in general it allows us to invoke a whitelist of ObjC methods
295
295
  in different ObjC objects. The terminology used by DVT to each such ObjC object is called "channels".
296
296
 
297
- In order to access this different object use the following APIs:
297
+ To access this different object use the following APIs:
298
298
 
299
299
  ```python
300
300
  from pymobiledevice3.lockdown import create_using_usbmux
@@ -313,17 +313,22 @@ dvt_channel = Screenshot(dvt)
313
313
  open('/tmp/screen.png', 'wb').write(dvt_channel.get_screenshot())
314
314
  ```
315
315
 
316
- Looking for an unimplemented feature/channel? Feel free to play with it (and submit a PR afterwards 🙏) using the
316
+ Looking for an unimplemented feature/channel? Feel free to play with it (and submit a PR afterward 🙏) using the
317
317
  following shell:
318
318
 
319
319
  ```shell
320
320
  pymobiledevice3 developer dvt shell
321
321
  ```
322
322
 
323
+ > **NOTE:** The full list of the methods that can be invoked on a DVT channel can be found by looking at all ObjC
324
+ > classes in `DVTInstrumentsFoundation.framework` implementing the `DTXAllowedRPC` protocol.
325
+ > There is an existing [Anubis rule](https://github.com/netanelc305/anubis/blob/9da337178ebd7e9f168e9df2d82b192eba4f1b30/example_rules.yaml#L14-L17)
326
+ > I use to diff new methods against the existing ones.
327
+
323
328
  ## RemoteXPC
324
329
 
325
330
  Starting at iOS 17.0, Apple made a large refactor in the manner we all interact with the developer services. There can
326
- be multiple reasons for that decision, but in general this refactor main key points are:
331
+ be multiple reasons for that decision, but in general these refactor main key points are:
327
332
 
328
333
  - Create a single standard for interacting with the new lockdown services (XPC Messages, Apple's proprietary IPC)
329
334
  - Optimize the protocol for large file transfers (such as the dyld_shared_cache)
@@ -347,7 +352,7 @@ Since all this communication is IP-based, but without any additional exported TC
347
352
  help us here. Instead, starting at iOS 16.0, when connecting an iDevice, it exports another non-standard USB-Ethernet
348
353
  adapter (with IPv6 link-local address), placing us in a subnet with the device's `remoted`.
349
354
 
350
- As we've said this communication is non-standard, and requires either:
355
+ As we've said, this communication is non-standard and requires either:
351
356
 
352
357
  - macOS Monterey or higher
353
358
  - Special driver on your linux/Windows machine
@@ -1,5 +1,6 @@
1
1
  import asyncio
2
2
  import difflib
3
+ import importlib
3
4
  import logging
4
5
  import os
5
6
  import re
@@ -7,12 +8,18 @@ import sys
7
8
  import textwrap
8
9
  import traceback
9
10
  import warnings
10
- from typing import Union
11
+ from collections.abc import Sequence
12
+ from typing import Annotated, Optional, Union
11
13
 
12
14
  import click
13
15
  import coloredlogs
16
+ import typer
17
+ import typer.core
18
+ from packaging.version import Version
19
+ from typer.core import TyperGroup
20
+ from typer_injector import InjectingTyper
14
21
 
15
- from pymobiledevice3.cli.cli_common import TUNNEL_ENV_VAR, isatty
22
+ from pymobiledevice3.cli.cli_common import TUNNEL_ENV_VAR, isatty, set_color_flag, set_verbosity
16
23
  from pymobiledevice3.exceptions import (
17
24
  AccessDeniedError,
18
25
  CloudConfigurationAlreadyPresentError,
@@ -37,10 +44,11 @@ from pymobiledevice3.exceptions import (
37
44
  QuicProtocolNotSupportedError,
38
45
  RSDRequiredError,
39
46
  SetProhibitedError,
47
+ StartServiceError,
40
48
  TunneldConnectionError,
41
49
  UserDeniedPairingError,
42
50
  )
43
- from pymobiledevice3.lockdown import retry_create_using_usbmux
51
+ from pymobiledevice3.lockdown import create_using_usbmux, retry_create_using_usbmux
44
52
  from pymobiledevice3.osu.os_utils import get_os_utils
45
53
 
46
54
  coloredlogs.install(level=logging.INFO)
@@ -109,26 +117,28 @@ CLI_GROUPS = {
109
117
  "syslog": "syslog",
110
118
  "usbmux": "usbmux",
111
119
  "webinspector": "webinspector",
120
+ "idam": "idam",
112
121
  "version": "version",
113
- "install-completions": "completions",
114
122
  }
115
123
 
116
124
  # Set if used the `--reconnect` option
117
125
  RECONNECT = False
118
126
 
119
127
 
120
- class Pmd3Cli(click.Group):
121
- def list_commands(self, ctx):
122
- return CLI_GROUPS.keys()
128
+ class Pmd3TyperGroup(TyperGroup):
129
+ def list_commands(self, ctx: click.Context) -> list[str]:
130
+ # Order is preserved by dict insertion; adjust if you want alphabetical
131
+ return list(CLI_GROUPS.keys())
123
132
 
124
- def get_command(self, ctx: click.Context, name: str) -> click.Command:
125
- if name not in CLI_GROUPS:
126
- self.handle_invalid_command(ctx, name)
127
- return self.import_and_get_command(ctx, name)
133
+ def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command:
134
+ if cmd_name not in CLI_GROUPS:
135
+ self.handle_invalid_command(ctx, cmd_name)
136
+ return self.import_and_get_command(ctx, cmd_name)
128
137
 
129
- def handle_invalid_command(self, ctx: click.Context, name: str) -> None:
138
+ def handle_invalid_command(self, ctx, name: str) -> None:
130
139
  suggested_commands = self.search_commands(name)
131
140
  suggestion = self.format_suggestions(suggested_commands)
141
+ # ctx.fail raises a ClickException underneath, which Typer displays nicely
132
142
  ctx.fail(f"No such command {name!r}{suggestion}")
133
143
 
134
144
  @staticmethod
@@ -136,70 +146,162 @@ class Pmd3Cli(click.Group):
136
146
  if not suggestions:
137
147
  return ""
138
148
  cmds = textwrap.indent("\n".join(suggestions), " " * 4)
139
- return f"\nDid you mean this?\n{cmds}"
149
+ return f"\nDid you mean:\n{cmds}"
140
150
 
141
151
  @staticmethod
142
152
  def import_and_get_command(ctx: click.Context, name: str) -> click.Command:
143
153
  module_name = f"pymobiledevice3.cli.{CLI_GROUPS[name]}"
144
- mod = __import__(module_name, None, None, ["cli"])
145
- command = mod.cli.get_command(ctx, name)
146
- if not command:
147
- command_name = mod.cli.list_commands(ctx)[0]
148
- command = mod.cli.get_command(ctx, command_name)
149
- return command
154
+ mod = importlib.import_module(module_name)
155
+ # submodules expose a Typer Group named "cli"
156
+ cli: typer.Typer = mod.cli
157
+ return typer.main.get_command(cli)
150
158
 
151
159
  @staticmethod
152
160
  def highlight_keyword(text: str, keyword: str) -> str:
153
- return re.sub(f"({keyword})", click.style("\\1", bold=True), text, flags=re.IGNORECASE)
161
+ return re.sub(f"({keyword})", typer.style("\\1", bold=True), text, flags=re.IGNORECASE)
154
162
 
155
163
  @staticmethod
156
- def collect_commands(command: click.Command) -> Union[str, list[str]]:
157
- commands = []
158
- if isinstance(command, click.Group):
159
- for _k, v in command.commands.items():
160
- cmd = Pmd3Cli.collect_commands(v)
161
- if isinstance(cmd, list):
162
- commands.extend([f"{command.name} {c}" for c in cmd])
164
+ def collect_commands(command: Union[TyperGroup, click.Command]) -> Union[str, list[str]]:
165
+ if isinstance(command, TyperGroup): # group
166
+ cmds = []
167
+ for v in command.commands.values():
168
+ child = Pmd3TyperGroup.collect_commands(v)
169
+ if isinstance(child, list):
170
+ cmds.extend([f"{command.name} {c}" for c in child])
163
171
  else:
164
- commands.append(f"{command.name} {cmd}")
165
- return commands
166
- return f"{command.name}"
172
+ cmds.append(f"{command.name} {child}")
173
+ return cmds
174
+ return command.name or ""
167
175
 
168
176
  @staticmethod
169
177
  def search_commands(pattern: str) -> list[str]:
170
- all_commands = Pmd3Cli.load_all_commands()
178
+ all_commands = Pmd3TyperGroup.load_all_commands()
171
179
  matched = sorted(filter(lambda cmd: re.search(pattern, cmd), all_commands))
172
180
  if not matched:
173
181
  matched = difflib.get_close_matches(pattern, all_commands, n=20, cutoff=0.4)
174
182
  if isatty():
175
- matched = [Pmd3Cli.highlight_keyword(cmd, pattern) for cmd in matched]
183
+ matched = [Pmd3TyperGroup.highlight_keyword(cmd, pattern) for cmd in matched]
176
184
  return matched
177
185
 
178
186
  @staticmethod
179
187
  def load_all_commands() -> list[str]:
180
- all_commands = []
188
+ all_commands: list[str] = []
181
189
  for key in CLI_GROUPS:
182
190
  module_name = f"pymobiledevice3.cli.{CLI_GROUPS[key]}"
183
- mod = __import__(module_name, None, None, ["cli"])
184
- cmd = Pmd3Cli.collect_commands(mod.cli.commands[key])
191
+ mod = importlib.import_module(module_name)
192
+ if isinstance(mod.cli, typer.Typer):
193
+ cmd = Pmd3TyperGroup.collect_commands(typer.main.get_group(mod.cli))
194
+ else:
195
+ cmd = Pmd3TyperGroup.collect_commands(mod.cli.commands[key])
185
196
  if isinstance(cmd, list):
186
197
  all_commands.extend(cmd)
187
198
  else:
188
199
  all_commands.append(cmd)
189
200
  return all_commands
190
201
 
202
+ def resolve_command(
203
+ self, ctx: click.Context, args: list[str]
204
+ ) -> tuple[Optional[str], Optional[click.Command], list[str]]:
205
+ return super().resolve_command(ctx, args)
206
+
207
+
208
+ app = InjectingTyper(
209
+ cls=Pmd3TyperGroup,
210
+ context_settings=CONTEXT_SETTINGS,
211
+ no_args_is_help=True,
212
+ # add_completion=False,
213
+ rich_markup_mode="markdown",
214
+ help=(
215
+ "Swiss-army CLI for pairing, inspecting, backing up, and automating iOS devices.\n\n"
216
+ "Docs and examples: https://github.com/doronz88/pymobiledevice3"
217
+ ),
218
+ )
219
+
191
220
 
192
- @click.command(cls=Pmd3Cli, context_settings=CONTEXT_SETTINGS)
193
- @click.option("--reconnect", is_flag=True, default=False, help="Reconnect to device when disconnected.")
194
- def cli(reconnect: bool) -> None:
221
+ @app.callback()
222
+ def _root(
223
+ reconnect: Annotated[
224
+ bool,
225
+ typer.Option(
226
+ "--reconnect",
227
+ help="Automatically reconnect if the device disconnects mid-command.",
228
+ show_default=False,
229
+ ),
230
+ ] = False,
231
+ verbosity: Annotated[
232
+ int,
233
+ typer.Option(
234
+ "--verbose",
235
+ "-v",
236
+ count=True,
237
+ help="Increase logging verbosity (repeat for more detail).",
238
+ ),
239
+ ] = 0,
240
+ color: Annotated[
241
+ bool,
242
+ typer.Option(help="Colorize output; disable with --no-color for plain logs."),
243
+ ] = True,
244
+ ) -> None:
195
245
  """
196
- \b
197
- Interact with a connected iDevice (iPhone, iPad, ...)
198
- For more information please look at:
199
- https://github.com/doronz88/pymobiledevice3
246
+ Top-level options for pymobiledevice3.
200
247
  """
201
248
  global RECONNECT
202
249
  RECONNECT = reconnect
250
+ set_verbosity(verbosity)
251
+ set_color_flag(color)
252
+
253
+
254
+ def device_might_need_tunneld(identifier: str) -> bool:
255
+ """
256
+ Determines if the device might require tunneling based on its product version.
257
+
258
+ This function uses the `create_using_usbmux` context manager to establish a lockdown
259
+ session with the specified identifier. It retrieves the device's product version,
260
+ and checks if it is greater than or equal to version "17.0". If so, the function
261
+ returns True, indicating that the device might require tunneling. Otherwise, it
262
+ returns False.
263
+
264
+ :param identifier: A string representing the device identifier.
265
+ :return: A boolean indicating whether the device might require tunneling.
266
+ """
267
+ with create_using_usbmux(identifier) as lockdown:
268
+ return Version(lockdown.product_version) >= Version("17.0")
269
+
270
+
271
+ class PossiblyMisplacedOption(click.NoSuchOption):
272
+ def __init__(
273
+ self,
274
+ option_name: str,
275
+ message: Optional[str] = None,
276
+ possibilities: Optional[Sequence[str]] = None,
277
+ ctx: Optional[click.Context] = None,
278
+ suggested_ctx: Optional[click.Context] = None,
279
+ ) -> None:
280
+ super().__init__(option_name, message, possibilities, ctx)
281
+ if suggested_ctx is not None:
282
+ if ctx is not None:
283
+ self.message += f" for subcommand: {ctx.command_path}"
284
+
285
+ suggestion = f"{suggested_ctx.command_path} {option_name}"
286
+ suggestion += ctx.command_path.removeprefix(suggested_ctx.command_path) if ctx is not None else " ..."
287
+
288
+ self.message += f"\nDid you mean: {suggestion}?"
289
+
290
+ @staticmethod
291
+ def from_no_such_option(e: click.NoSuchOption) -> "PossiblyMisplacedOption":
292
+ ctx = e.ctx
293
+ while ctx:
294
+ for param in ctx.command.params:
295
+ if isinstance(param, typer.core.TyperOption) and (
296
+ e.option_name in param.opts or e.option_name in param.secondary_opts
297
+ ):
298
+ break
299
+ else:
300
+ ctx = ctx.parent
301
+ continue
302
+ break
303
+
304
+ return PossiblyMisplacedOption(e.option_name, e.message, e.possibilities, e.ctx, ctx)
203
305
 
204
306
 
205
307
  def invoke_cli_with_error_handling() -> bool:
@@ -208,7 +310,11 @@ def invoke_cli_with_error_handling() -> bool:
208
310
  disconnected.
209
311
  """
210
312
  try:
211
- cli()
313
+ # Typer apps are callable; this executes the CLI with current sys.argv
314
+ try:
315
+ app(standalone_mode=False)
316
+ except click.NoSuchOption as e:
317
+ raise PossiblyMisplacedOption.from_no_such_option(e) from e
212
318
  except NoDeviceConnectedError:
213
319
  logger.error("Device is not connected")
214
320
  return True
@@ -249,13 +355,19 @@ def invoke_cli_with_error_handling() -> bool:
249
355
  if isinstance(e, RSDRequiredError):
250
356
  logger.warning("Trying again over tunneld since RSD is required for this command")
251
357
  should_retry_over_tunneld = True
252
- elif (e.identifier is not None) and ("developer" in sys.argv) and ("--tunnel" not in sys.argv):
358
+ elif (
359
+ (e.identifier is not None)
360
+ and ("developer" in sys.argv)
361
+ and ("--tunnel" not in sys.argv)
362
+ and device_might_need_tunneld(e.identifier)
363
+ ):
253
364
  logger.warning("Got an InvalidServiceError. Trying again over tunneld since it is a developer command")
254
365
  should_retry_over_tunneld = True
255
366
  if should_retry_over_tunneld:
256
- # use a single space because click will ignore envvars of empty strings
367
+ # use a single space because Typer/Click will ignore envvars of empty strings
257
368
  os.environ[TUNNEL_ENV_VAR] = e.identifier or " "
258
- return main()
369
+ main()
370
+ return False
259
371
  logger.error(INVALID_SERVICE_MESSAGE)
260
372
  except PasswordRequiredError:
261
373
  logger.error("Device is password protected. Please unlock and retry")
@@ -291,6 +403,19 @@ def invoke_cli_with_error_handling() -> bool:
291
403
  )
292
404
  except QuicProtocolNotSupportedError:
293
405
  logger.error("Encountered a QUIC protocol error.")
406
+ except StartServiceError as e:
407
+ if e.message == "ServiceProhibited" and e.service_name == "com.apple.pcapd.shim.remote":
408
+ logger.error(
409
+ f"The {e.service_name} service is USB only (at least for some iOS versions).\n"
410
+ "Full discussion is available in: https://github.com/doronz88/pymobiledevice3/issues/1515"
411
+ )
412
+ else:
413
+ logger.error(f"Failed to start: {e.service_name} with. Received error: {e.message}.")
414
+ except click.ClickException as e:
415
+ from typer import rich_utils
416
+
417
+ rich_utils.rich_format_error(e)
418
+ sys.exit(e.exit_code)
294
419
 
295
420
  return False
296
421
 
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '5.0.4'
32
- __version_tuple__ = version_tuple = (5, 0, 4)
31
+ __version__ = version = '7.0.6'
32
+ __version_tuple__ = version_tuple = (7, 0, 6)
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -50,7 +50,7 @@ def dataclass_compat(*d_args, **d_kwargs):
50
50
  @dataclass_compat(slots=True)
51
51
  class Address:
52
52
  ip: str
53
- iface: Optional[str] # local interface name (e.g., "en0"), or None if unknown
53
+ iface: str # local interface name (e.g., "en0"), or None if unknown
54
54
 
55
55
  @property
56
56
  def full_ip(self) -> str:
@@ -300,10 +300,9 @@ async def browse_service(service_type: str, timeout: float = 4.0) -> list[Servic
300
300
  adapters = _Adapters()
301
301
 
302
302
  ptr_targets: set[str] = set()
303
- srv_map: dict[str, dict] = {}
303
+ srv_map: dict[str, list[dict]] = defaultdict(list) # instance_name -> list of {"target", "port"}
304
304
  txt_map: dict[str, dict] = {}
305
- # host -> list[(ip, iface)]
306
- host_addrs: dict[str, list[Address]] = defaultdict(list)
305
+ host_addrs: dict[str, list[Address]] = defaultdict(list) # host -> list[(ip, iface)]
307
306
 
308
307
  def _record_addr(rr_name: str, ip_str: str, pkt_addr):
309
308
  # Determine family and possible scopeid from the packet that delivered this RR
@@ -312,7 +311,9 @@ async def browse_service(service_type: str, timeout: float = 4.0) -> list[Servic
312
311
  if isinstance(pkt_addr, tuple) and len(pkt_addr) == 4: # IPv6 remote tuple
313
312
  scopeid = pkt_addr[3]
314
313
  iface = adapters.pick_iface_for_ip(ip_str, family, scopeid)
315
- # avoid duplicates for the same host/ip
314
+ if iface is None:
315
+ return
316
+ # Avoid duplicates for the same host/ip
316
317
  existing = host_addrs[rr_name]
317
318
  if not any(a.ip == ip_str for a in existing):
318
319
  existing.append(Address(ip=ip_str, iface=iface))
@@ -331,11 +332,10 @@ async def browse_service(service_type: str, timeout: float = 4.0) -> list[Servic
331
332
  if t == QTYPE_PTR and rr.get("name") == service_type:
332
333
  ptr_targets.add(rr.get("ptrdname"))
333
334
  elif t == QTYPE_SRV:
334
- srv_map[rr["name"]] = {
335
- "target": rr.get("target"),
336
- "port": rr.get("port"),
337
- }
335
+ srv_map[rr["name"]].append({"target": rr.get("target"), "port": rr.get("port")})
338
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
339
  txt_map[rr["name"]] = rr.get("txt", {})
340
340
  elif (t == QTYPE_A and rr.get("address")) or (t == QTYPE_AAAA and rr.get("address")):
341
341
  _record_addr(rr["name"], rr["address"], pkt_addr)
@@ -346,20 +346,21 @@ async def browse_service(service_type: str, timeout: float = 4.0) -> list[Servic
346
346
  # Assemble dataclasses
347
347
  results: list[ServiceInstance] = []
348
348
  for inst in sorted(ptr_targets):
349
- srv = srv_map.get(inst, {})
350
- target = srv.get("target")
351
- host = (target[:-1] if target and target.endswith(".") else target) or None
352
- addrs = host_addrs.get(target, []) if target else []
349
+ srv_entries = srv_map.get(inst, [])
353
350
  props = txt_map.get(inst, {})
354
- results.append(
355
- ServiceInstance(
356
- instance=inst,
357
- host=host,
358
- port=srv.get("port"),
359
- addresses=addrs,
360
- properties=props,
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
+ )
361
363
  )
362
- )
363
364
  return results
364
365
 
365
366
 
@@ -1,30 +1,32 @@
1
- import click
2
-
3
- from pymobiledevice3.cli.cli_common import Command
4
- from pymobiledevice3.lockdown import LockdownClient
5
- from pymobiledevice3.services.mobile_activation import MobileActivationService
1
+ from typing import Annotated
6
2
 
3
+ import typer
4
+ from typer_injector import InjectingTyper
7
5
 
8
- @click.group()
9
- def cli() -> None:
10
- pass
11
-
6
+ from pymobiledevice3.cli.cli_common import ServiceProviderDep
7
+ from pymobiledevice3.services.mobile_activation import MobileActivationService
12
8
 
13
- @cli.group()
14
- def activation() -> None:
15
- """Perform iCloud activation/deactivation or query the current state"""
16
- pass
9
+ cli = InjectingTyper(
10
+ name="activation",
11
+ help="Perform iCloud activation/deactivation or query the current state",
12
+ no_args_is_help=True,
13
+ )
17
14
 
18
15
 
19
- @activation.command(cls=Command)
20
- def state(service_provider: LockdownClient):
16
+ @cli.command()
17
+ def state(service_provider: ServiceProviderDep) -> None:
21
18
  """Get current activation state"""
22
19
  print(MobileActivationService(service_provider).state)
23
20
 
24
21
 
25
- @activation.command(cls=Command)
26
- @click.option("--now", is_flag=True, help="do not wait for next nonce cycle")
27
- def activate(service_provider: LockdownClient, now):
22
+ @cli.command()
23
+ def activate(
24
+ service_provider: ServiceProviderDep,
25
+ now: Annotated[
26
+ bool,
27
+ typer.Option(help="do not wait for next nonce cycle"),
28
+ ] = False,
29
+ ) -> None:
28
30
  """Activate device"""
29
31
  activation_service = MobileActivationService(service_provider)
30
32
  if not now:
@@ -32,13 +34,13 @@ def activate(service_provider: LockdownClient, now):
32
34
  activation_service.activate()
33
35
 
34
36
 
35
- @activation.command(cls=Command)
36
- def deactivate(service_provider: LockdownClient):
37
+ @cli.command()
38
+ def deactivate(service_provider: ServiceProviderDep) -> None:
37
39
  """Deactivate device"""
38
40
  MobileActivationService(service_provider).deactivate()
39
41
 
40
42
 
41
- @activation.command(cls=Command)
42
- def itunes(service_provider: LockdownClient):
43
+ @cli.command()
44
+ def itunes(service_provider: ServiceProviderDep) -> None:
43
45
  """Tell the device that it has been connected to iTunes (useful for < iOS 4)"""
44
46
  service_provider.set_value(True, key="iTunesHasConnected")
@@ -1,56 +1,64 @@
1
- import click
2
-
3
- from pymobiledevice3.cli.cli_common import Command
4
- from pymobiledevice3.lockdown import LockdownClient
5
- from pymobiledevice3.lockdown_service_provider import LockdownServiceProvider
6
- from pymobiledevice3.services.afc import AfcService, AfcShell
1
+ from pathlib import Path
2
+ from typing import Annotated
7
3
 
4
+ import typer
5
+ from typer_injector import InjectingTyper
8
6
 
9
- @click.group()
10
- def cli() -> None:
11
- pass
12
-
7
+ from pymobiledevice3.cli.cli_common import ServiceProviderDep
8
+ from pymobiledevice3.services.afc import AfcService, AfcShell
13
9
 
14
- @cli.group()
15
- def afc() -> None:
16
- """Manage device multimedia files"""
17
- pass
10
+ cli = InjectingTyper(
11
+ name="afc",
12
+ help="Browse, push, and pull files via the AFC service (/var/mobile/Media).",
13
+ no_args_is_help=True,
14
+ )
18
15
 
19
16
 
20
- @afc.command("shell", cls=Command)
21
- def afc_shell(service_provider: LockdownClient):
22
- """open an AFC shell rooted at /var/mobile/Media"""
17
+ @cli.command("shell")
18
+ def afc_shell(service_provider: ServiceProviderDep) -> None:
19
+ """Open an interactive AFC shell rooted at /var/mobile/Media."""
23
20
  AfcShell.create(service_provider)
24
21
 
25
22
 
26
- @afc.command("pull", cls=Command)
27
- @click.option("-i", "--ignore-errors", is_flag=True, help="Ignore AFC pull errors")
28
- @click.argument("remote_file", type=click.Path(exists=False))
29
- @click.argument("local_file", type=click.Path(exists=False))
30
- def afc_pull(service_provider: LockdownServiceProvider, remote_file: str, local_file: str, ignore_errors: bool) -> None:
31
- """pull remote file from /var/mobile/Media"""
32
- AfcService(lockdown=service_provider).pull(remote_file, local_file, ignore_errors=ignore_errors)
23
+ @cli.command("pull")
24
+ def afc_pull(
25
+ service_provider: ServiceProviderDep,
26
+ remote_file: Path,
27
+ local_file: Path,
28
+ ignore_errors: Annotated[
29
+ bool,
30
+ typer.Option(
31
+ "--ignore-errors",
32
+ "-i",
33
+ help="Continue downloading even if some files error (best-effort pull).",
34
+ ),
35
+ ],
36
+ ) -> None:
37
+ """Download a remote path under /var/mobile/Media to the local filesystem."""
38
+ AfcService(lockdown=service_provider).pull(str(remote_file), str(local_file), ignore_errors=ignore_errors)
33
39
 
34
40
 
35
- @afc.command("push", cls=Command)
36
- @click.argument("local_file", type=click.Path(exists=False))
37
- @click.argument("remote_file", type=click.Path(exists=False))
38
- def afc_push(service_provider: LockdownServiceProvider, local_file: str, remote_file: str) -> None:
39
- """push local file into /var/mobile/Media"""
40
- AfcService(lockdown=service_provider).push(local_file, remote_file)
41
+ @cli.command("push")
42
+ def afc_push(service_provider: ServiceProviderDep, local_file: Path, remote_file: Path) -> None:
43
+ """Upload a local file into /var/mobile/Media."""
44
+ AfcService(lockdown=service_provider).push(str(local_file), str(remote_file))
41
45
 
42
46
 
43
- @afc.command("ls", cls=Command)
44
- @click.argument("remote_file", type=click.Path(exists=False))
45
- @click.option("-r", "--recursive", is_flag=True)
46
- def afc_ls(service_provider: LockdownClient, remote_file, recursive):
47
- """perform a dirlist rooted at /var/mobile/Media"""
48
- for path in AfcService(lockdown=service_provider).dirlist(remote_file, -1 if recursive else 1):
47
+ @cli.command("ls")
48
+ def afc_ls(
49
+ service_provider: ServiceProviderDep,
50
+ remote_file: Path,
51
+ recursive: Annotated[
52
+ bool,
53
+ typer.Option("--recursive", "-r", help="Recurse into subdirectories when listing."),
54
+ ] = False,
55
+ ) -> None:
56
+ """List files under /var/mobile/Media (optionally recursively)."""
57
+ for path in AfcService(lockdown=service_provider).dirlist(str(remote_file), -1 if recursive else 1):
49
58
  print(path)
50
59
 
51
60
 
52
- @afc.command("rm", cls=Command)
53
- @click.argument("remote_file", type=click.Path(exists=False))
54
- def afc_rm(service_provider: LockdownClient, remote_file):
55
- """remove a file rooted at /var/mobile/Media"""
56
- AfcService(lockdown=service_provider).rm(remote_file)
61
+ @cli.command("rm")
62
+ def afc_rm(service_provider: ServiceProviderDep, remote_file: Path) -> None:
63
+ """Delete a file under /var/mobile/Media."""
64
+ AfcService(lockdown=service_provider).rm(str(remote_file))