pymobiledevice3 6.2.0__py3-none-any.whl → 7.0.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.
Files changed (61) hide show
  1. pymobiledevice3/__main__.py +136 -44
  2. pymobiledevice3/_version.py +2 -2
  3. pymobiledevice3/bonjour.py +19 -20
  4. pymobiledevice3/cli/activation.py +24 -22
  5. pymobiledevice3/cli/afc.py +49 -41
  6. pymobiledevice3/cli/amfi.py +13 -18
  7. pymobiledevice3/cli/apps.py +71 -65
  8. pymobiledevice3/cli/backup.py +134 -93
  9. pymobiledevice3/cli/bonjour.py +31 -29
  10. pymobiledevice3/cli/cli_common.py +179 -232
  11. pymobiledevice3/cli/companion_proxy.py +12 -12
  12. pymobiledevice3/cli/crash.py +95 -52
  13. pymobiledevice3/cli/developer/__init__.py +62 -0
  14. pymobiledevice3/cli/developer/accessibility/__init__.py +65 -0
  15. pymobiledevice3/cli/developer/accessibility/settings.py +43 -0
  16. pymobiledevice3/cli/developer/arbitration.py +50 -0
  17. pymobiledevice3/cli/developer/condition.py +33 -0
  18. pymobiledevice3/cli/developer/core_device.py +294 -0
  19. pymobiledevice3/cli/developer/debugserver.py +244 -0
  20. pymobiledevice3/cli/developer/dvt/__init__.py +387 -0
  21. pymobiledevice3/cli/developer/dvt/core_profile_session.py +295 -0
  22. pymobiledevice3/cli/developer/dvt/simulate_location.py +56 -0
  23. pymobiledevice3/cli/developer/dvt/sysmon/__init__.py +69 -0
  24. pymobiledevice3/cli/developer/dvt/sysmon/process.py +188 -0
  25. pymobiledevice3/cli/developer/fetch_symbols.py +108 -0
  26. pymobiledevice3/cli/developer/simulate_location.py +51 -0
  27. pymobiledevice3/cli/diagnostics/__init__.py +75 -0
  28. pymobiledevice3/cli/diagnostics/battery.py +47 -0
  29. pymobiledevice3/cli/idam.py +18 -22
  30. pymobiledevice3/cli/lockdown.py +70 -75
  31. pymobiledevice3/cli/mounter.py +99 -57
  32. pymobiledevice3/cli/notification.py +38 -26
  33. pymobiledevice3/cli/pcap.py +36 -20
  34. pymobiledevice3/cli/power_assertion.py +15 -16
  35. pymobiledevice3/cli/processes.py +11 -17
  36. pymobiledevice3/cli/profile.py +120 -75
  37. pymobiledevice3/cli/provision.py +27 -26
  38. pymobiledevice3/cli/remote.py +108 -99
  39. pymobiledevice3/cli/restore.py +134 -129
  40. pymobiledevice3/cli/springboard.py +50 -50
  41. pymobiledevice3/cli/syslog.py +138 -74
  42. pymobiledevice3/cli/usbmux.py +66 -27
  43. pymobiledevice3/cli/version.py +2 -5
  44. pymobiledevice3/cli/webinspector.py +149 -103
  45. pymobiledevice3/remote/remote_service_discovery.py +11 -10
  46. pymobiledevice3/restore/device.py +28 -4
  47. pymobiledevice3/service_connection.py +1 -1
  48. pymobiledevice3/services/mobilebackup2.py +4 -1
  49. pymobiledevice3/services/screenshot.py +2 -2
  50. pymobiledevice3/services/web_protocol/automation_session.py +4 -2
  51. pymobiledevice3/services/web_protocol/cdp_screencast.py +2 -1
  52. pymobiledevice3/services/web_protocol/element.py +3 -3
  53. {pymobiledevice3-6.2.0.dist-info → pymobiledevice3-7.0.0.dist-info}/METADATA +3 -2
  54. {pymobiledevice3-6.2.0.dist-info → pymobiledevice3-7.0.0.dist-info}/RECORD +58 -45
  55. pymobiledevice3/cli/completions.py +0 -50
  56. pymobiledevice3/cli/developer.py +0 -1645
  57. pymobiledevice3/cli/diagnostics.py +0 -110
  58. {pymobiledevice3-6.2.0.dist-info → pymobiledevice3-7.0.0.dist-info}/WHEEL +0 -0
  59. {pymobiledevice3-6.2.0.dist-info → pymobiledevice3-7.0.0.dist-info}/entry_points.txt +0 -0
  60. {pymobiledevice3-6.2.0.dist-info → pymobiledevice3-7.0.0.dist-info}/licenses/LICENSE +0 -0
  61. {pymobiledevice3-6.2.0.dist-info → pymobiledevice3-7.0.0.dist-info}/top_level.txt +0 -0
@@ -4,14 +4,15 @@ import logging
4
4
  import sys
5
5
  import tempfile
6
6
  from functools import partial
7
- from typing import Optional, TextIO
7
+ from pathlib import Path
8
+ from typing import Annotated, Optional, TextIO
8
9
 
9
- import click
10
+ import typer
11
+ from typer_injector import InjectingTyper
10
12
 
11
13
  from pymobiledevice3.bonjour import DEFAULT_BONJOUR_TIMEOUT, browse_remotepairing_manual_pairing
12
14
  from pymobiledevice3.cli.cli_common import (
13
- BaseCommand,
14
- RSDCommand,
15
+ RSDServiceProviderDep,
15
16
  print_json,
16
17
  prompt_device_list,
17
18
  sudo_required,
@@ -38,6 +39,7 @@ logger = logging.getLogger(__name__)
38
39
  async def browse_rsd(timeout: float = DEFAULT_BONJOUR_TIMEOUT) -> list[dict]:
39
40
  devices = []
40
41
  for rsd in await get_rsds(timeout):
42
+ assert rsd.peer_info is not None
41
43
  devices.append({
42
44
  "address": rsd.service.address[0],
43
45
  "port": RSD_PORT,
@@ -66,40 +68,36 @@ async def cli_browse(timeout: float = DEFAULT_BONJOUR_TIMEOUT) -> None:
66
68
  })
67
69
 
68
70
 
69
- @click.group()
70
- def cli() -> None:
71
- pass
72
-
73
-
74
- @cli.group("remote")
75
- def remote_cli() -> None:
76
- """Create RemoteXPC tunnels"""
77
- pass
71
+ cli = InjectingTyper(
72
+ name="remote",
73
+ help="Create and browse RemoteXPC tunnels (RSD/tunneld) for developer services.",
74
+ no_args_is_help=True,
75
+ )
78
76
 
79
77
 
80
- @remote_cli.command("tunneld", cls=BaseCommand)
81
- @click.option("--host", default=TUNNELD_DEFAULT_ADDRESS[0])
82
- @click.option("--port", type=click.INT, default=TUNNELD_DEFAULT_ADDRESS[1])
83
- @click.option("-d", "--daemonize", is_flag=True)
84
- @click.option(
85
- "-p",
86
- "--protocol",
87
- type=click.Choice([e.value for e in TunnelProtocol]),
88
- help="Transport protocol. If python version >= 3.13 will default to TCP. Otherwise will default to QUIC",
89
- default=TunnelProtocol.DEFAULT.value,
90
- )
91
- @click.option("--usb/--no-usb", default=True, help="Enable usb monitoring")
92
- @click.option("--wifi/--no-wifi", default=True, help="Enable wifi monitoring")
93
- @click.option("--usbmux/--no-usbmux", default=True, help="Enable usbmux monitoring")
94
- @click.option("--mobdev2/--no-mobdev2", default=True, help="Enable mobdev2 monitoring")
78
+ @cli.command("tunneld")
95
79
  @sudo_required
96
80
  def cli_tunneld(
97
- host: str, port: int, daemonize: bool, protocol: str, usb: bool, wifi: bool, usbmux: bool, mobdev2: bool
81
+ host: Annotated[str, typer.Option(help="Address to bind the tunneld server to.")] = TUNNELD_DEFAULT_ADDRESS[0],
82
+ port: Annotated[int, typer.Option(help="Port to bind the tunneld server to.")] = TUNNELD_DEFAULT_ADDRESS[1],
83
+ daemonize: Annotated[bool, typer.Option("--daemonize", "-d", help="Run tunneld in the background.")] = False,
84
+ protocol: Annotated[
85
+ TunnelProtocol,
86
+ typer.Option(
87
+ "--protocol",
88
+ "-p",
89
+ case_sensitive=False,
90
+ help="Transport protocol for tunneld (default: TCP on Python >=3.13, otherwise QUIC).",
91
+ ),
92
+ ] = TunnelProtocol.DEFAULT,
93
+ usb: Annotated[bool, typer.Option(help="Enable USB monitoring")] = True,
94
+ wifi: Annotated[bool, typer.Option(help="Enable WiFi monitoring")] = True,
95
+ usbmux: Annotated[bool, typer.Option(help="Enable usbmux monitoring")] = True,
96
+ mobdev2: Annotated[bool, typer.Option(help="Enable mobdev2 monitoring")] = True,
98
97
  ) -> None:
99
98
  """Start Tunneld service for remote tunneling"""
100
99
  if not verify_tunnel_imports():
101
100
  return
102
- protocol = TunnelProtocol(protocol)
103
101
  tunneld_runner = partial(
104
102
  TunneldRunner.create,
105
103
  host,
@@ -123,15 +121,16 @@ def cli_tunneld(
123
121
  tunneld_runner()
124
122
 
125
123
 
126
- @remote_cli.command("browse", cls=BaseCommand)
127
- @click.option("--timeout", type=click.FLOAT, default=DEFAULT_BONJOUR_TIMEOUT, help="Bonjour timeout (in seconds)")
128
- def browse(timeout: float) -> None:
124
+ @cli.command("browse")
125
+ def browse(
126
+ timeout: Annotated[float, typer.Option(help="Bonjour timeout (in seconds)")] = DEFAULT_BONJOUR_TIMEOUT,
127
+ ) -> None:
129
128
  """browse RemoteXPC devices using bonjour"""
130
129
  asyncio.run(cli_browse(timeout), debug=True)
131
130
 
132
131
 
133
- @remote_cli.command("rsd-info", cls=RSDCommand)
134
- def rsd_info(service_provider: RemoteServiceDiscoveryService):
132
+ @cli.command("rsd-info")
133
+ def rsd_info(service_provider: RSDServiceProviderDep) -> None:
135
134
  """show info extracted from RSD peer"""
136
135
  print_json(service_provider.peer_info)
137
136
 
@@ -153,32 +152,32 @@ async def tunnel_task(
153
152
  if user_requested_colored_output():
154
153
  if secrets is not None:
155
154
  print(
156
- click.style("Secrets: ", bold=True, fg="magenta")
157
- + click.style(secrets.name, bold=True, fg="white")
155
+ typer.style("Secrets: ", bold=True, fg="magenta")
156
+ + typer.style(secrets.name, bold=True, fg="white")
158
157
  )
159
158
  print(
160
- click.style("Identifier: ", bold=True, fg="yellow")
161
- + click.style(service.remote_identifier, bold=True, fg="white")
159
+ typer.style("Identifier: ", bold=True, fg="yellow")
160
+ + typer.style(service.remote_identifier, bold=True, fg="white")
162
161
  )
163
162
  print(
164
- click.style("Interface: ", bold=True, fg="yellow")
165
- + click.style(tunnel_result.interface, bold=True, fg="white")
163
+ typer.style("Interface: ", bold=True, fg="yellow")
164
+ + typer.style(tunnel_result.interface, bold=True, fg="white")
166
165
  )
167
166
  print(
168
- click.style("Protocol: ", bold=True, fg="yellow")
169
- + click.style(tunnel_result.protocol, bold=True, fg="white")
167
+ typer.style("Protocol: ", bold=True, fg="yellow")
168
+ + typer.style(tunnel_result.protocol, bold=True, fg="white")
170
169
  )
171
170
  print(
172
- click.style("RSD Address: ", bold=True, fg="yellow")
173
- + click.style(tunnel_result.address, bold=True, fg="white")
171
+ typer.style("RSD Address: ", bold=True, fg="yellow")
172
+ + typer.style(tunnel_result.address, bold=True, fg="white")
174
173
  )
175
174
  print(
176
- click.style("RSD Port: ", bold=True, fg="yellow")
177
- + click.style(tunnel_result.port, bold=True, fg="white")
175
+ typer.style("RSD Port: ", bold=True, fg="yellow")
176
+ + typer.style(tunnel_result.port, bold=True, fg="white")
178
177
  )
179
178
  print(
180
- click.style("Use the follow connection option:\n", bold=True, fg="yellow")
181
- + click.style(f"--rsd {tunnel_result.address} {tunnel_result.port}", bold=True, fg="cyan")
179
+ typer.style("Use the follow connection option:\n", bold=True, fg="yellow")
180
+ + typer.style(f"--rsd {tunnel_result.address} {tunnel_result.port}", bold=True, fg="cyan")
182
181
  )
183
182
  else:
184
183
  if secrets is not None:
@@ -224,52 +223,60 @@ async def start_tunnel_task(
224
223
  )
225
224
 
226
225
 
227
- @remote_cli.command("start-tunnel", cls=BaseCommand)
228
- @click.option(
229
- "-t",
230
- "--connection-type",
231
- type=click.Choice([e.value for e in ConnectionType], case_sensitive=False),
232
- default=ConnectionType.USB.value,
233
- )
234
- @click.option("--udid", help="UDID for a specific device to look for")
235
- @click.option("--secrets", type=click.File("wt"), help="TLS keyfile for decrypting with Wireshark")
236
- @click.option(
237
- "--script-mode",
238
- is_flag=True,
239
- help="Show only HOST and port number to allow easy parsing from external shell scripts",
240
- )
241
- @click.option(
242
- "--max-idle-timeout", type=click.FLOAT, default=MAX_IDLE_TIMEOUT, help="Maximum QUIC idle time (ping interval)"
243
- )
244
- @click.option(
245
- "-p",
246
- "--protocol",
247
- type=click.Choice([e.value for e in TunnelProtocol], case_sensitive=False),
248
- default=TunnelProtocol.DEFAULT.value,
249
- )
226
+ @cli.command("start-tunnel")
250
227
  @sudo_required
251
228
  def cli_start_tunnel(
252
- connection_type: ConnectionType,
253
- udid: Optional[str],
254
- secrets: TextIO,
255
- script_mode: bool,
256
- max_idle_timeout: float,
257
- protocol: str,
229
+ *,
230
+ connection_type: Annotated[
231
+ ConnectionType,
232
+ typer.Option(
233
+ "--connection-type",
234
+ "-t",
235
+ case_sensitive=False,
236
+ help="Connection interface to tunnel (USB, WiFi, etc.).",
237
+ ),
238
+ ] = ConnectionType.USB,
239
+ udid: Annotated[
240
+ Optional[str],
241
+ typer.Option(help="UDID for a specific device to look for"),
242
+ ] = None,
243
+ secrets: Annotated[
244
+ Path,
245
+ typer.Option(help="File to write TLS secrets for Wireshark decryption."),
246
+ ],
247
+ script_mode: Annotated[
248
+ bool,
249
+ typer.Option(help="Print only HOST and port for scripts instead of formatted output."),
250
+ ] = False,
251
+ max_idle_timeout: Annotated[
252
+ float,
253
+ typer.Option(help="Maximum idle time before QUIC keepalive pings are sent."),
254
+ ] = MAX_IDLE_TIMEOUT,
255
+ protocol: Annotated[
256
+ TunnelProtocol,
257
+ typer.Option(
258
+ "--protocol",
259
+ "-p",
260
+ case_sensitive=False,
261
+ help="Transport protocol for the tunnel (default: TCP on Python >=3.13, otherwise QUIC).",
262
+ ),
263
+ ] = TunnelProtocol.DEFAULT,
258
264
  ) -> None:
259
265
  """start tunnel"""
260
266
  if not verify_tunnel_imports():
261
267
  return
262
- asyncio.run(
263
- start_tunnel_task(
264
- ConnectionType(connection_type),
265
- secrets,
266
- udid,
267
- script_mode,
268
- max_idle_timeout=max_idle_timeout,
269
- protocol=TunnelProtocol(protocol),
270
- ),
271
- debug=True,
272
- )
268
+ with secrets.open("wt") as secrets_file:
269
+ asyncio.run(
270
+ start_tunnel_task(
271
+ connection_type,
272
+ secrets_file,
273
+ udid,
274
+ script_mode,
275
+ max_idle_timeout=max_idle_timeout,
276
+ protocol=protocol,
277
+ ),
278
+ debug=True,
279
+ )
273
280
 
274
281
 
275
282
  @dataclasses.dataclass
@@ -280,7 +287,7 @@ class RemotePairingManualPairingDevice:
280
287
  identifier: str
281
288
 
282
289
 
283
- async def start_remote_pair_task(device_name: str) -> None:
290
+ async def start_remote_pair_task(device_name: Optional[str]) -> None:
284
291
  if start_tunnel is None:
285
292
  raise NotImplementedError("failed to start the tunnel on your platform")
286
293
 
@@ -311,17 +318,20 @@ async def start_remote_pair_task(device_name: str) -> None:
311
318
  await service.connect(autopair=True)
312
319
 
313
320
 
314
- @remote_cli.command("pair", cls=BaseCommand)
315
- @click.option("--name", help="Device name for a specific device to look for")
316
- def cli_pair(name: Optional[str]) -> None:
321
+ @cli.command("pair")
322
+ def cli_pair(
323
+ name: Annotated[
324
+ Optional[str],
325
+ typer.Option(help="Device name for a specific device to look for"),
326
+ ] = None,
327
+ ) -> None:
317
328
  """start remote pairing for devices which allow"""
318
329
  asyncio.run(start_remote_pair_task(name), debug=True)
319
330
 
320
331
 
321
- @remote_cli.command("delete-pair", cls=BaseCommand)
322
- @click.argument("udid")
332
+ @cli.command("delete-pair")
323
333
  @sudo_required
324
- def cli_delete_pair(udid: str):
334
+ def cli_delete_pair(udid: str) -> None:
325
335
  """delete a pairing record"""
326
336
  pair_record_path = get_home_folder() / f"{get_remote_pairing_record_filename(udid)}.{PAIRING_RECORD_EXT}"
327
337
  pair_record_path.unlink()
@@ -332,8 +342,7 @@ async def cli_service_task(service_provider: RemoteServiceDiscoveryService, serv
332
342
  service.shell()
333
343
 
334
344
 
335
- @remote_cli.command("service", cls=RSDCommand)
336
- @click.argument("service_name")
337
- def cli_service(service_provider: RemoteServiceDiscoveryService, service_name: str) -> None:
345
+ @cli.command("service")
346
+ def cli_service(service_provider: RSDServiceProviderDep, service_name: str) -> None:
338
347
  """start an ipython shell for interacting with given service"""
339
348
  asyncio.run(cli_service_task(service_provider, service_name), debug=True)
@@ -4,18 +4,20 @@ import logging
4
4
  import plistlib
5
5
  import tempfile
6
6
  import traceback
7
- from collections.abc import Generator
7
+ from collections.abc import Iterator
8
8
  from pathlib import Path
9
- from typing import IO, Optional, Union
9
+ from typing import IO, Annotated, Optional
10
10
  from zipfile import ZipFile
11
11
 
12
12
  import click
13
13
  import IPython
14
14
  import requests
15
+ import typer
15
16
  from pygments import formatters, highlight, lexers
17
+ from typer_injector import Depends, InjectingTyper
16
18
 
17
19
  from pymobiledevice3 import usbmux
18
- from pymobiledevice3.cli.cli_common import is_invoked_for_completion, print_json, prompt_selection, set_verbosity
20
+ from pymobiledevice3.cli.cli_common import is_invoked_for_completion, print_json, prompt_selection
19
21
  from pymobiledevice3.exceptions import ConnectionFailedError, ConnectionFailedToUsbmuxdError, IncorrectModeError
20
22
  from pymobiledevice3.irecv import IRecv
21
23
  from pymobiledevice3.lockdown import LockdownClient, create_using_usbmux
@@ -25,55 +27,66 @@ from pymobiledevice3.restore.restore import Restore
25
27
  from pymobiledevice3.services.diagnostics import DiagnosticsService
26
28
  from pymobiledevice3.utils import file_download
27
29
 
30
+ logger = logging.getLogger(__name__)
31
+
32
+
28
33
  SHELL_USAGE = """
29
34
  # use `irecv` variable to access Restore mode API
30
35
  # for example:
31
36
  print(irecv.getenv('build-version'))
32
37
  """
33
-
34
- logger = logging.getLogger(__name__)
35
38
  IPSWME_API = "https://api.ipsw.me/v4/device/"
36
39
 
37
40
 
38
- class Command(click.Command):
39
- def __init__(self, *args, **kwargs):
40
- super().__init__(*args, **kwargs)
41
- self.params[:0] = [
42
- click.Option(("device", "--ecid"), type=click.INT, callback=self.device),
43
- click.Option(("verbosity", "-v", "--verbose"), count=True, callback=set_verbosity, expose_value=False),
44
- ]
45
-
46
- @staticmethod
47
- def device(ctx, param, value) -> Optional[Union[LockdownClient, IRecv]]:
48
- if is_invoked_for_completion():
49
- # prevent lockdown connection establishment when in autocomplete mode
50
- return
51
-
52
- ecid = value
53
- logger.debug("searching among connected devices via lockdownd")
54
- devices = [dev for dev in usbmux.list_devices() if dev.connection_type == "USB"]
55
- if len(devices) > 1:
56
- raise click.ClickException("Multiple device detected")
57
- try:
58
- for device in devices:
59
- try:
60
- lockdown = create_using_usbmux(serial=device.serial, connection_type="USB")
61
- except (ConnectionFailedError, IncorrectModeError):
62
- continue
63
- if (ecid is None) or (lockdown.ecid == value):
64
- logger.debug("found device")
65
- return lockdown
66
- else:
67
- continue
68
- except ConnectionFailedToUsbmuxdError:
69
- pass
70
-
71
- logger.debug("waiting for device to be available in Recovery mode")
72
- return IRecv(ecid=ecid)
41
+ cli = InjectingTyper(
42
+ name="restore",
43
+ help="Restore/erase IPSWs, fetch blobs, and manage devices in Recovery/DFU.",
44
+ no_args_is_help=True,
45
+ )
46
+
47
+
48
+ def device_dependency(
49
+ ecid: Annotated[
50
+ Optional[str],
51
+ typer.Option(
52
+ help="Target device ECID; defaults to the first connected USB device or waits for Recovery/DFU.",
53
+ ),
54
+ ] = None,
55
+ ) -> Optional[Device]:
56
+ if is_invoked_for_completion():
57
+ # prevent lockdown connection establishment when in autocomplete mode
58
+ return None
59
+
60
+ logger.debug("searching among connected devices via lockdownd")
61
+ devices = [dev for dev in usbmux.list_devices() if dev.connection_type == "USB"]
62
+ if len(devices) > 1:
63
+ raise click.ClickException("Multiple device detected")
64
+ try:
65
+ for device in devices:
66
+ try:
67
+ lockdown = create_using_usbmux(serial=device.serial, connection_type="USB")
68
+ except (ConnectionFailedError, IncorrectModeError):
69
+ continue
70
+ if (ecid is None) or (lockdown.ecid == ecid):
71
+ logger.debug("found device")
72
+ return Device(lockdown=lockdown)
73
+ else:
74
+ continue
75
+ except ConnectionFailedToUsbmuxdError:
76
+ pass
77
+
78
+ logger.debug("waiting for device to be available in Recovery mode")
79
+ return Device(irecv=IRecv(ecid=ecid))
80
+
81
+
82
+ DeviceDep = Annotated[
83
+ Device,
84
+ Depends(device_dependency),
85
+ ]
73
86
 
74
87
 
75
88
  @contextlib.contextmanager
76
- def tempzip_download_ctx(url: str) -> Generator[ZipFile, None, None]:
89
+ def tempzip_download_ctx(url: str) -> Iterator[ZipFile]:
77
90
  with tempfile.TemporaryDirectory() as tmpdir:
78
91
  tmpzip = Path(tmpdir) / url.split("/")[-1]
79
92
  file_download(url, tmpzip)
@@ -81,33 +94,52 @@ def tempzip_download_ctx(url: str) -> Generator[ZipFile, None, None]:
81
94
 
82
95
 
83
96
  @contextlib.contextmanager
84
- def zipfile_ctx(path: str) -> Generator[ZipFile, None, None]:
97
+ def zipfile_ctx(path: str) -> Iterator[ZipFile]:
85
98
  yield ZipFile(path)
86
99
 
87
100
 
88
- class IPSWCommand(Command):
89
- def __init__(self, *args, **kwargs):
90
- super().__init__(*args, **kwargs)
91
- self.params.extend([
92
- click.Option(("ipsw_ctx", "-i", "--ipsw"), required=False, callback=self.ipsw_ctx, help="local IPSW file"),
93
- click.Option(("tss", "--tss"), type=click.File("rb"), callback=self.tss),
94
- ])
101
+ def ipsw_ctx_dependency(
102
+ device: DeviceDep,
103
+ ipsw: Annotated[
104
+ Optional[str],
105
+ typer.Option(
106
+ "--ipsw",
107
+ "-i",
108
+ help="Path or URL to an IPSW. If omitted, choose a signed build interactively.",
109
+ ),
110
+ ] = None,
111
+ ) -> contextlib.AbstractContextManager[ZipFile]:
112
+ if ipsw and not ipsw.startswith(("http://", "https://")):
113
+ return zipfile_ctx(ipsw)
114
+
115
+ url = ipsw
116
+ if url is None:
117
+ url = query_ipswme(device.product_type)
118
+ return tempzip_download_ctx(url)
119
+
120
+
121
+ IPSWCtxDep = Annotated[
122
+ contextlib.AbstractContextManager[ZipFile],
123
+ Depends(ipsw_ctx_dependency),
124
+ ]
95
125
 
96
- @staticmethod
97
- def ipsw_ctx(ctx, param, value) -> Generator[ZipFile, None, None]:
98
- if value and not value.startswith(("http://", "https://")):
99
- return zipfile_ctx(value)
100
126
 
101
- url = value
102
- if url is None:
103
- url = query_ipswme(ctx.params["device"].product_type)
104
- return tempzip_download_ctx(url)
127
+ def tss_dependency(
128
+ tss: Annotated[
129
+ Optional[Path],
130
+ typer.Option(help="Path to SHSH blob plist to use for signing requests."),
131
+ ] = None,
132
+ ) -> None:
133
+ if tss is None:
134
+ return
135
+ with tss.open("rb") as tss_file:
136
+ return plistlib.load(tss_file)
105
137
 
106
- @staticmethod
107
- def tss(ctx, param, value) -> Optional[IO]:
108
- if value is None:
109
- return
110
- return plistlib.load(value)
138
+
139
+ TSSDep = Annotated[
140
+ Optional[dict],
141
+ Depends(tss_dependency),
142
+ ]
111
143
 
112
144
 
113
145
  def query_ipswme(identifier: str) -> str:
@@ -118,15 +150,9 @@ def query_ipswme(identifier: str) -> str:
118
150
  return firmwares[idx]["url"]
119
151
 
120
152
 
121
- async def restore_update_task(device: Device, ipsw: ZipFile, tss: Optional[IO], erase: bool, ignore_fdr: bool) -> None:
122
- lockdown = None
123
- irecv = None
124
- if isinstance(device, LockdownClient):
125
- lockdown = device
126
- elif isinstance(device, IRecv):
127
- irecv = device
128
- device = Device(lockdown=lockdown, irecv=irecv)
129
-
153
+ async def restore_update_task(
154
+ device: Device, ipsw: ZipFile, tss: Optional[dict], erase: bool, ignore_fdr: bool
155
+ ) -> None:
130
156
  behavior = Behavior.Update
131
157
  if erase:
132
158
  behavior = Behavior.Erase
@@ -139,19 +165,8 @@ async def restore_update_task(device: Device, ipsw: ZipFile, tss: Optional[IO],
139
165
  raise
140
166
 
141
167
 
142
- @click.group()
143
- def cli() -> None:
144
- pass
145
-
146
-
147
- @cli.group()
148
- def restore() -> None:
149
- """Restore an IPSW or access device in recovery mode"""
150
- pass
151
-
152
-
153
- @restore.command("shell", cls=Command)
154
- def restore_shell(device):
168
+ @cli.command("shell")
169
+ def restore_shell(device: DeviceDep) -> None:
155
170
  """create an IPython shell for interacting with iBoot"""
156
171
  IPython.embed(
157
172
  header=highlight(SHELL_USAGE, lexers.PythonLexer(), formatters.Terminal256Formatter(style="native")),
@@ -161,40 +176,34 @@ def restore_shell(device):
161
176
  )
162
177
 
163
178
 
164
- @restore.command("enter", cls=Command)
165
- def restore_enter(device):
179
+ @cli.command("enter")
180
+ def restore_enter(device: DeviceDep) -> None:
166
181
  """enter Recovery mode"""
167
182
  if isinstance(device, LockdownClient):
168
183
  device.enter_recovery()
169
184
 
170
185
 
171
- @restore.command("exit")
172
- def restore_exit():
186
+ @cli.command("exit")
187
+ def restore_exit() -> None:
173
188
  """exit Recovery mode"""
174
189
  irecv = IRecv()
175
190
  irecv.set_autoboot(True)
176
191
  irecv.reboot()
177
192
 
178
193
 
179
- @restore.command("restart", cls=Command)
180
- def restore_restart(device):
194
+ @cli.command("restart")
195
+ def restore_restart(device: DeviceDep) -> None:
181
196
  """restarts device"""
182
- if isinstance(device, LockdownClient):
183
- with DiagnosticsService(device) as diagnostics:
197
+ if device.is_lockdown:
198
+ with DiagnosticsService(device.lockdown) as diagnostics:
184
199
  diagnostics.restart()
185
200
  else:
186
- device.reboot()
201
+ device.irecv.reboot()
187
202
 
188
203
 
189
- async def restore_tss_task(device: Device, ipsw_ctx: Generator, tss: IO, out: Optional[IO]) -> None:
190
- lockdown = None
191
- irecv = None
192
- if isinstance(device, LockdownClient):
193
- lockdown = device
194
- elif isinstance(device, IRecv):
195
- irecv = device
196
-
197
- device = Device(lockdown=lockdown, irecv=irecv)
204
+ async def restore_tss_task(
205
+ device: Device, ipsw_ctx: contextlib.AbstractContextManager[ZipFile], out: Optional[IO]
206
+ ) -> None:
198
207
  with ipsw_ctx as ipsw:
199
208
  tss = await Recovery(ipsw, device).fetch_tss_record()
200
209
  if out:
@@ -202,46 +211,42 @@ async def restore_tss_task(device: Device, ipsw_ctx: Generator, tss: IO, out: Op
202
211
  print_json(tss)
203
212
 
204
213
 
205
- @restore.command("tss", cls=IPSWCommand)
206
- @click.argument("out", type=click.File("wb"), required=False)
207
- def restore_tss(device: Device, ipsw_ctx: Generator, tss: IO, out: Optional[IO]) -> None:
214
+ @cli.command("tss")
215
+ def restore_tss(device: DeviceDep, ipsw_ctx: IPSWCtxDep, out: Optional[Path] = None) -> None:
208
216
  """query SHSH blobs"""
209
- asyncio.run(restore_tss_task(device, ipsw_ctx, tss, out), debug=True)
217
+ with out.open("wb") if out else contextlib.nullcontext() as out_file:
218
+ asyncio.run(restore_tss_task(device, ipsw_ctx, out_file), debug=True)
210
219
 
211
220
 
212
- async def restore_ramdisk_task(device: Device, ipsw_ctx: Generator) -> None:
213
- lockdown = None
214
- irecv = None
215
- if isinstance(device, LockdownClient):
216
- lockdown = device
217
- elif isinstance(device, IRecv):
218
- irecv = device
219
- device = Device(lockdown=lockdown, irecv=irecv)
220
-
221
+ async def restore_ramdisk_task(device: Device, ipsw_ctx: contextlib.AbstractContextManager[ZipFile]) -> None:
221
222
  with ipsw_ctx as ipsw:
222
223
  await Recovery(ipsw, device).boot_ramdisk()
223
224
 
224
225
 
225
- @restore.command("ramdisk", cls=IPSWCommand)
226
- def restore_ramdisk(device: Device, ipsw_ctx: Generator, tss: IO) -> None:
226
+ @cli.command("ramdisk")
227
+ def restore_ramdisk(device: DeviceDep, ipsw_ctx: IPSWCtxDep) -> None:
227
228
  """
228
- don't perform an actual restore. just enter the update ramdisk
229
-
230
- ipsw can be either a filename or an url
229
+ Boot only the update ramdisk without performing a restore (IPSW path or URL accepted).
231
230
  """
232
231
  asyncio.run(restore_ramdisk_task(device, ipsw_ctx), debug=True)
233
232
 
234
233
 
235
- @restore.command("update", cls=IPSWCommand)
236
- @click.option("--erase", is_flag=True, help="use the Erase BuildIdentity (full factory-reset)")
237
- @click.option(
238
- "--ignore-fdr", is_flag=True, help="only establish an FDR service connection, but don't proxy any traffic"
239
- )
240
- def restore_update(device: Device, ipsw_ctx: Generator, tss: IO, erase: bool, ignore_fdr: bool) -> None:
234
+ @cli.command("update")
235
+ def restore_update(
236
+ device: DeviceDep,
237
+ ipsw_ctx: IPSWCtxDep,
238
+ tss: TSSDep,
239
+ erase: Annotated[
240
+ bool,
241
+ typer.Option(help="Erase and restore (factory reset) instead of updating in place."),
242
+ ] = False,
243
+ ignore_fdr: Annotated[
244
+ bool,
245
+ typer.Option(help="Connect to the FDR service only (debug mode; no traffic proxying)."),
246
+ ] = False,
247
+ ) -> None:
241
248
  """
242
- perform an update
243
-
244
- ipsw can be either a filename or an url
249
+ Update or restore the device using an IPSW (local path or URL).
245
250
  """
246
251
  with ipsw_ctx as ipsw:
247
252
  asyncio.run(restore_update_task(device, ipsw, tss, erase, ignore_fdr), debug=True)