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,4 +1,11 @@
1
1
  #!/usr/bin/env python3
2
+ """
3
+ AFC (Apple File Connection) Service Module
4
+
5
+ This module provides an interface to interact with iOS devices' file systems through the AFC protocol.
6
+ It supports file operations like reading, writing, deleting, and directory traversal, as well as an
7
+ interactive shell for navigating the device's file system rooted at /var/mobile/Media.
8
+ """
2
9
 
3
10
  import logging
4
11
  import os
@@ -9,19 +16,22 @@ import shutil
9
16
  import stat as stat_module
10
17
  import struct
11
18
  import sys
19
+ import warnings
12
20
  from collections import namedtuple
21
+ from dataclasses import dataclass, field
13
22
  from datetime import datetime
14
23
  from re import Pattern
15
24
  from typing import Callable, Optional, Union
16
25
 
17
26
  import hexdump
18
27
  from click.exceptions import Exit
19
- from construct import Const, Container, CString, Enum, GreedyRange, Int64ul, Struct, Tell
28
+ from construct import Const, CString, GreedyRange, Int64ul, Tell
29
+ from construct_typed import DataclassMixin, EnumBase, TEnum, TStruct, csfield
20
30
  from parameter_decorators import path_to_str
21
31
  from pygments import formatters, highlight, lexers
22
32
  from pygnuutils.cli.ls import ls as ls_cli
23
33
  from pygnuutils.ls import Ls, LsStub
24
- from tqdm import trange
34
+ from tqdm.auto import trange
25
35
  from xonsh.built_ins import XSH
26
36
  from xonsh.cli_utils import Annotated, Arg, ArgParserAlias
27
37
  from xonsh.main import main as xonsh_main
@@ -33,216 +43,310 @@ from pymobiledevice3.lockdown_service_provider import LockdownServiceProvider
33
43
  from pymobiledevice3.services.lockdown_service import LockdownService
34
44
  from pymobiledevice3.utils import try_decode
35
45
 
36
- MAXIMUM_READ_SIZE = 4 * 1024 ** 2 # 4 MB
46
+ MAXIMUM_READ_SIZE = 4 * 1024**2 # 4 MB
37
47
  MODE_MASK = 0o0000777
38
48
 
39
- StatResult = namedtuple('StatResult',
40
- ['st_mode', 'st_ino', 'st_dev', 'st_nlink', 'st_uid', 'st_gid', 'st_size', 'st_atime',
41
- 'st_mtime', 'st_ctime', 'st_blocks', 'st_blksize', 'st_birthtime'])
42
-
43
- afc_opcode_t = Enum(Int64ul,
44
- STATUS=0x00000001,
45
- DATA=0x00000002, # Data */
46
- READ_DIR=0x00000003, # ReadDir */
47
- READ_FILE=0x00000004, # ReadFile */
48
- WRITE_FILE=0x00000005, # WriteFile */
49
- WRITE_PART=0x00000006, # WritePart */
50
- TRUNCATE=0x00000007, # TruncateFile */
51
- REMOVE_PATH=0x00000008, # RemovePath */
52
- MAKE_DIR=0x00000009, # MakeDir */
53
- GET_FILE_INFO=0x0000000a, # GetFileInfo */
54
- GET_DEVINFO=0x0000000b, # GetDeviceInfo */
55
- WRITE_FILE_ATOM=0x0000000c, # WriteFileAtomic (tmp file+rename) */
56
- FILE_OPEN=0x0000000d, # FileRefOpen */
57
- FILE_OPEN_RES=0x0000000e, # FileRefOpenResult */
58
- READ=0x0000000f, # FileRefRead */
59
- WRITE=0x00000010, # FileRefWrite */
60
- FILE_SEEK=0x00000011, # FileRefSeek */
61
- FILE_TELL=0x00000012, # FileRefTell */
62
- FILE_TELL_RES=0x00000013, # FileRefTellResult */
63
- FILE_CLOSE=0x00000014, # FileRefClose */
64
- FILE_SET_SIZE=0x00000015, # FileRefSetFileSize (ftruncate) */
65
- GET_CON_INFO=0x00000016, # GetConnectionInfo */
66
- SET_CON_OPTIONS=0x00000017, # SetConnectionOptions */
67
- RENAME_PATH=0x00000018, # RenamePath */
68
- SET_FS_BS=0x00000019, # SetFSBlockSize (0x800000) */
69
- SET_SOCKET_BS=0x0000001A, # SetSocketBlockSize (0x800000) */
70
- FILE_LOCK=0x0000001B, # FileRefLock */
71
- MAKE_LINK=0x0000001C, # MakeLink */
72
- SET_FILE_TIME=0x0000001E, # set st_mtime */
73
- )
49
+ StatResult = namedtuple(
50
+ "StatResult",
51
+ [
52
+ "st_mode",
53
+ "st_ino",
54
+ "st_dev",
55
+ "st_nlink",
56
+ "st_uid",
57
+ "st_gid",
58
+ "st_size",
59
+ "st_atime",
60
+ "st_mtime",
61
+ "st_ctime",
62
+ "st_blocks",
63
+ "st_blksize",
64
+ "st_birthtime",
65
+ ],
66
+ )
74
67
 
75
- afc_error_t = Enum(Int64ul,
76
- SUCCESS=0,
77
- UNKNOWN_ERROR=1,
78
- OP_HEADER_INVALID=2,
79
- NO_RESOURCES=3,
80
- READ_ERROR=4,
81
- WRITE_ERROR=5,
82
- UNKNOWN_PACKET_TYPE=6,
83
- INVALID_ARG=7,
84
- OBJECT_NOT_FOUND=8,
85
- OBJECT_IS_DIR=9,
86
- PERM_DENIED=10,
87
- SERVICE_NOT_CONNECTED=11,
88
- OP_TIMEOUT=12,
89
- TOO_MUCH_DATA=13,
90
- END_OF_DATA=14,
91
- OP_NOT_SUPPORTED=15,
92
- OBJECT_EXISTS=16,
93
- OBJECT_BUSY=17,
94
- NO_SPACE_LEFT=18,
95
- OP_WOULD_BLOCK=19,
96
- IO_ERROR=20,
97
- OP_INTERRUPTED=21,
98
- OP_IN_PROGRESS=22,
99
- INTERNAL_ERROR=23,
100
- MUX_ERROR=30,
101
- NO_MEM=31,
102
- NOT_ENOUGH_DATA=32,
103
- DIR_NOT_EMPTY=33,
104
- )
105
-
106
- afc_link_type_t = Enum(Int64ul,
107
- HARDLINK=1,
108
- SYMLINK=2,
109
- )
110
-
111
- afc_fopen_mode_t = Enum(Int64ul,
112
- RDONLY=0x00000001, # /**< r O_RDONLY */
113
- RW=0x00000002, # /**< r+ O_RDWR | O_CREAT */
114
- WRONLY=0x00000003, # /**< w O_WRONLY | O_CREAT | O_TRUNC */
115
- WR=0x00000004, # /**< w+ O_RDWR | O_CREAT | O_TRUNC */
116
- APPEND=0x00000005, # /**< a O_WRONLY | O_APPEND | O_CREAT */
117
- RDAPPEND=0x00000006, # /**< a+ O_RDWR | O_APPEND | O_CREAT */
118
- )
68
+
69
+ class AfcOpcode(EnumBase):
70
+ STATUS = 0x00000001
71
+ DATA = 0x00000002 # Data
72
+ READ_DIR = 0x00000003 # ReadDir
73
+ READ_FILE = 0x00000004 # ReadFile
74
+ WRITE_FILE = 0x00000005 # WriteFile
75
+ WRITE_PART = 0x00000006 # WritePart
76
+ TRUNCATE = 0x00000007 # TruncateFile
77
+ REMOVE_PATH = 0x00000008 # RemovePath
78
+ MAKE_DIR = 0x00000009 # MakeDir
79
+ GET_FILE_INFO = 0x0000000A # GetFileInfo
80
+ GET_DEVINFO = 0x0000000B # GetDeviceInfo
81
+ WRITE_FILE_ATOM = 0x0000000C # WriteFileAtomic (tmp file+rename)
82
+ FILE_OPEN = 0x0000000D # FileRefOpen
83
+ FILE_OPEN_RES = 0x0000000E # FileRefOpenResult
84
+ READ = 0x0000000F # FileRefRead
85
+ WRITE = 0x00000010 # FileRefWrite
86
+ FILE_SEEK = 0x00000011 # FileRefSeek
87
+ FILE_TELL = 0x00000012 # FileRefTell
88
+ FILE_TELL_RES = 0x00000013 # FileRefTellResult
89
+ FILE_CLOSE = 0x00000014 # FileRefClose
90
+ FILE_SET_SIZE = 0x00000015 # FileRefSetFileSize (ftruncate)
91
+ GET_CON_INFO = 0x00000016 # GetConnectionInfo
92
+ SET_CON_OPTIONS = 0x00000017 # SetConnectionOptions
93
+ RENAME_PATH = 0x00000018 # RenamePath
94
+ SET_FS_BS = 0x00000019 # SetFSBlockSize (0x800000)
95
+ SET_SOCKET_BS = 0x0000001A # SetSocketBlockSize (0x800000)
96
+ FILE_LOCK = 0x0000001B # FileRefLock
97
+ MAKE_LINK = 0x0000001C # MakeLink
98
+ SET_FILE_TIME = 0x0000001E # set st_mtime
99
+
100
+
101
+ class AfcError(EnumBase):
102
+ SUCCESS = 0
103
+ UNKNOWN_ERROR = 1
104
+ OP_HEADER_INVALID = 2
105
+ NO_RESOURCES = 3
106
+ READ_ERROR = 4
107
+ WRITE_ERROR = 5
108
+ UNKNOWN_PACKET_TYPE = 6
109
+ INVALID_ARG = 7
110
+ OBJECT_NOT_FOUND = 8
111
+ OBJECT_IS_DIR = 9
112
+ PERM_DENIED = 10
113
+ SERVICE_NOT_CONNECTED = 11
114
+ OP_TIMEOUT = 12
115
+ TOO_MUCH_DATA = 13
116
+ END_OF_DATA = 14
117
+ OP_NOT_SUPPORTED = 15
118
+ OBJECT_EXISTS = 16
119
+ OBJECT_BUSY = 17
120
+ NO_SPACE_LEFT = 18
121
+ OP_WOULD_BLOCK = 19
122
+ IO_ERROR = 20
123
+ OP_INTERRUPTED = 21
124
+ OP_IN_PROGRESS = 22
125
+ INTERNAL_ERROR = 23
126
+ MUX_ERROR = 30
127
+ NO_MEM = 31
128
+ NOT_ENOUGH_DATA = 32
129
+ DIR_NOT_EMPTY = 33
130
+
131
+
132
+ class AfcLinkType(EnumBase):
133
+ HARDLINK = 1
134
+ SYMLINK = 2
135
+
136
+
137
+ class AfcFopenMode(EnumBase):
138
+ RDONLY = 0x00000001 # r O_RDONLY
139
+ RW = 0x00000002 # r+ O_RDWR | O_CREAT
140
+ WRONLY = 0x00000003 # w O_WRONLY | O_CREAT | O_TRUNC
141
+ WR = 0x00000004 # w+ O_RDWR | O_CREAT | O_TRUNC
142
+ APPEND = 0x00000005 # a O_WRONLY | O_APPEND | O_CREAT
143
+ RDAPPEND = 0x00000006 # a+ O_RDWR | O_APPEND | O_CREAT
144
+
145
+
146
+ # typed construct adapters for the enums
147
+ afc_opcode_t = TEnum(Int64ul, AfcOpcode)
148
+ afc_error_construct = TEnum(Int64ul, AfcError)
149
+ afc_link_type_construct = TEnum(Int64ul, AfcLinkType)
150
+ afc_fopen_mode_construct = TEnum(Int64ul, AfcFopenMode)
119
151
 
120
152
  AFC_FOPEN_TEXTUAL_MODES = {
121
- 'r': afc_fopen_mode_t.RDONLY,
122
- 'r+': afc_fopen_mode_t.RW,
123
- 'w': afc_fopen_mode_t.WRONLY,
124
- 'w+': afc_fopen_mode_t.WR,
125
- 'a': afc_fopen_mode_t.APPEND,
126
- 'a+': afc_fopen_mode_t.RDAPPEND,
153
+ "r": AfcFopenMode.RDONLY,
154
+ "r+": AfcFopenMode.RW,
155
+ "w": AfcFopenMode.WRONLY,
156
+ "w+": AfcFopenMode.WR,
157
+ "a": AfcFopenMode.APPEND,
158
+ "a+": AfcFopenMode.RDAPPEND,
127
159
  }
128
160
 
129
- AFC_LOCK_SH = 1 | 4 # /**< shared lock */
130
- AFC_LOCK_EX = 2 | 4 # /**< exclusive lock */
131
- AFC_LOCK_UN = 8 | 4 # /**< unlock */
161
+ AFC_LOCK_SH = 1 | 4 # shared lock
162
+ AFC_LOCK_EX = 2 | 4 # exclusive lock
163
+ AFC_LOCK_UN = 8 | 4 # unlock
132
164
 
133
165
  MAXIMUM_WRITE_SIZE = 1 << 30
134
166
 
135
- AFCMAGIC = b'CFA6LPAA'
167
+ AFCMAGIC = b"CFA6LPAA"
136
168
 
137
- afc_header_t = Struct(
138
- 'magic' / Const(AFCMAGIC),
139
- 'entire_length' / Int64ul,
140
- 'this_length' / Int64ul,
141
- 'packet_num' / Int64ul,
142
- 'operation' / afc_opcode_t,
143
- '_data_offset' / Tell,
144
- )
145
169
 
146
- afc_read_dir_req_t = Struct(
147
- 'filename' / CString('utf8'),
148
- )
170
+ @dataclass
171
+ class AfcHeader(DataclassMixin):
172
+ magic: bytes = csfield(Const(AFCMAGIC))
173
+ entire_length: int = csfield(Int64ul)
174
+ this_length: int = csfield(Int64ul)
175
+ packet_num: int = csfield(Int64ul)
176
+ operation: AfcOpcode = csfield(afc_opcode_t)
177
+ _data_offset: int = field(default=0, init=False, metadata={"subcon": Tell})
149
178
 
150
- afc_read_dir_resp_t = Struct(
151
- 'filenames' / GreedyRange(CString('utf8')),
152
- )
153
179
 
154
- afc_mkdir_req_t = Struct(
155
- 'filename' / CString('utf8'),
156
- )
180
+ @dataclass
181
+ class AfcReadDirRequest(DataclassMixin):
182
+ filename: str = csfield(CString("utf8"))
157
183
 
158
- afc_stat_t = Struct(
159
- 'filename' / CString('utf8'),
160
- )
161
184
 
162
- afc_make_link_req_t = Struct(
163
- 'type' / afc_link_type_t,
164
- 'target' / CString('utf8'),
165
- 'source' / CString('utf8'),
166
- )
185
+ @dataclass
186
+ class AfcReadDirResponse(DataclassMixin):
187
+ filenames: list[str] = csfield(GreedyRange(CString("utf8")))
167
188
 
168
- afc_fopen_req_t = Struct(
169
- 'mode' / afc_fopen_mode_t,
170
- 'filename' / CString('utf8'),
171
- )
172
189
 
173
- afc_fopen_resp_t = Struct(
174
- 'handle' / Int64ul,
175
- )
190
+ @dataclass
191
+ class AfcMkdirRequest(DataclassMixin):
192
+ filename: str = csfield(CString("utf8"))
176
193
 
177
- afc_fclose_req_t = Struct(
178
- 'handle' / Int64ul,
179
- )
180
194
 
181
- afc_rm_req_t = Struct(
182
- 'filename' / CString('utf8'),
183
- )
195
+ @dataclass
196
+ class AfcStatRequest(DataclassMixin):
197
+ filename: str = csfield(CString("utf8"))
184
198
 
185
- afc_rename_req_t = Struct(
186
- 'source' / CString('utf8'),
187
- 'target' / CString('utf8'),
188
- )
189
199
 
190
- afc_fread_req_t = Struct(
191
- 'handle' / Int64ul,
192
- 'size' / Int64ul,
193
- )
200
+ @dataclass
201
+ class AfcMakeLinkRequest(DataclassMixin):
202
+ type: AfcLinkType = csfield(afc_link_type_construct)
203
+ target: str = csfield(CString("utf8"))
204
+ source: str = csfield(CString("utf8"))
194
205
 
195
- afc_lock_t = Struct(
196
- 'handle' / Int64ul,
197
- 'op' / Int64ul,
198
- )
206
+
207
+ @dataclass
208
+ class AfcFopenRequest(DataclassMixin):
209
+ mode: AfcFopenMode = csfield(afc_fopen_mode_construct)
210
+ filename: str = csfield(CString("utf8"))
211
+
212
+
213
+ @dataclass
214
+ class AfcFopenResponse(DataclassMixin):
215
+ handle: int = csfield(Int64ul)
216
+
217
+
218
+ @dataclass
219
+ class AfcFcloseRequest(DataclassMixin):
220
+ handle: int = csfield(Int64ul)
221
+
222
+
223
+ @dataclass
224
+ class AfcRmRequest(DataclassMixin):
225
+ filename: str = csfield(CString("utf8"))
226
+
227
+
228
+ @dataclass
229
+ class AfcRenameRequest(DataclassMixin):
230
+ source: str = csfield(CString("utf8"))
231
+ target: str = csfield(CString("utf8"))
232
+
233
+
234
+ @dataclass
235
+ class AfcFreadRequest(DataclassMixin):
236
+ handle: int = csfield(Int64ul)
237
+ size: int = csfield(Int64ul)
238
+
239
+
240
+ @dataclass
241
+ class AfcLockRequest(DataclassMixin):
242
+ handle: int = csfield(Int64ul)
243
+ op: int = csfield(Int64ul)
199
244
 
200
245
 
201
- def list_to_dict(d):
202
- d = d.decode('utf-8')
203
- t = d.split('\x00')
204
- t = t[:-1]
246
+ afc_header_t = TStruct(AfcHeader)
247
+ afc_read_dir_req_t = TStruct(AfcReadDirRequest)
248
+ afc_read_dir_resp_t = TStruct(AfcReadDirResponse)
249
+ afc_mkdir_req_t = TStruct(AfcMkdirRequest)
250
+ afc_stat_t = TStruct(AfcStatRequest)
251
+ afc_make_link_req_t = TStruct(AfcMakeLinkRequest)
252
+ afc_fopen_req_t = TStruct(AfcFopenRequest)
253
+ afc_fopen_resp_t = TStruct(AfcFopenResponse)
254
+ afc_fclose_req_t = TStruct(AfcFcloseRequest)
255
+ afc_rm_req_t = TStruct(AfcRmRequest)
256
+ afc_rename_req_t = TStruct(AfcRenameRequest)
257
+ afc_fread_req_t = TStruct(AfcFreadRequest)
258
+ afc_lock_t = TStruct(AfcLockRequest)
205
259
 
206
- assert len(t) % 2 == 0
207
- res = {}
208
- for i in range(int(len(t) / 2)):
209
- res[t[i * 2]] = t[i * 2 + 1]
210
- return res
260
+
261
+ def list_to_dict(raw: bytes) -> dict[str, str]:
262
+ """
263
+ Convert a null-terminated key-value list to a dictionary.
264
+
265
+ The input is expected to be a byte string with alternating keys and values,
266
+ each separated by null bytes (``\\x00``).
267
+ """
268
+ parts = raw.decode("utf-8").split("\x00")[:-1]
269
+ if len(parts) % 2:
270
+ raise ValueError("AFC list is not key/value aligned")
271
+ return dict(zip(parts[::2], parts[1::2]))
211
272
 
212
273
 
213
274
  class AfcService(LockdownService):
214
- SERVICE_NAME = 'com.apple.afc'
215
- RSD_SERVICE_NAME = 'com.apple.afc.shim.remote'
275
+ """
276
+ Apple File Connection (AFC) Service for iOS device file system access.
277
+
278
+ This service provides full file system access to the /var/mobile/Media directory
279
+ on iOS devices. It supports standard file operations including read, write, delete,
280
+ rename, and directory operations.
281
+
282
+ The service communicates using a custom binary protocol with operation codes for
283
+ different file system operations.
284
+
285
+ Attributes:
286
+ SERVICE_NAME: Service identifier for lockdown-based connections
287
+ RSD_SERVICE_NAME: Service identifier for RSD-based connections
288
+ packet_num: Counter for tracking packet sequence numbers
289
+ """
290
+
291
+ SERVICE_NAME = "com.apple.afc"
292
+ RSD_SERVICE_NAME = "com.apple.afc.shim.remote"
293
+
294
+ def __init__(self, lockdown: LockdownServiceProvider, service_name: Optional[str] = None):
295
+ """
296
+ Initialize the AFC service.
216
297
 
217
- def __init__(self, lockdown: LockdownServiceProvider, service_name: str = None):
298
+ :param lockdown: Lockdown service provider for establishing connection
299
+ :param service_name: Optional service name override. Auto-detected if None
300
+ """
218
301
  if service_name is None:
219
- if isinstance(lockdown, LockdownClient):
220
- service_name = self.SERVICE_NAME
221
- else:
222
- service_name = self.RSD_SERVICE_NAME
302
+ service_name = self.SERVICE_NAME if isinstance(lockdown, LockdownClient) else self.RSD_SERVICE_NAME
223
303
  super().__init__(lockdown, service_name)
224
304
  self.packet_num = 0
225
305
 
226
- def pull(self, relative_src: str, dst: str, match: Optional[Pattern] = None, callback: Optional[Callable] = None,
227
- src_dir: str = '') -> None:
306
+ def pull(
307
+ self,
308
+ relative_src: str,
309
+ dst: str,
310
+ match: Optional[Pattern] = None,
311
+ callback: Optional[Callable] = None,
312
+ src_dir: str = "",
313
+ ignore_errors: bool = False,
314
+ progress_bar: bool = True,
315
+ ) -> None:
316
+ """
317
+ Pull (download) a file or directory from the device to the local machine.
318
+
319
+ Recursively copies files and directories from the device to the local file system.
320
+ Preserves modification times and handles large files by reading in chunks.
321
+
322
+ :param relative_src: Source path relative to src_dir on the device
323
+ :param dst: Destination path on the local machine
324
+ :param match: Optional regex pattern to filter files (by basename)
325
+ :param callback: Optional callback function called for each file copied (src, dst)
326
+ :param src_dir: Base directory for resolving relative_src
327
+ :param ignore_errors: If True, continue on errors instead of raising exceptions
328
+ :param progress_bar: If True, show progress bar for large files
329
+ """
228
330
  src = self.resolve_path(posixpath.join(src_dir, relative_src))
229
331
 
230
332
  if not self.isdir(src):
231
333
  # normal file
232
334
  if os.path.isdir(dst):
233
335
  dst = os.path.join(dst, os.path.basename(relative_src))
234
- with open(dst, 'wb') as f:
235
- src_size = self.stat(src)['st_size']
336
+ with open(dst, "wb") as f:
337
+ src_size = self.stat(src)["st_size"]
236
338
  if src_size <= MAXIMUM_READ_SIZE:
237
339
  f.write(self.get_file_contents(src))
238
340
  else:
239
- left_size = src_size
240
341
  handle = self.fopen(src)
241
- for _ in trange(src_size // MAXIMUM_READ_SIZE + 1):
242
- f.write(self.fread(handle, min(MAXIMUM_READ_SIZE, left_size)))
243
- left_size -= MAXIMUM_READ_SIZE
342
+ iterator = range(0, src_size, MAXIMUM_READ_SIZE)
343
+ if progress_bar:
344
+ iterator = trange(0, src_size, MAXIMUM_READ_SIZE)
345
+ for offset in iterator:
346
+ to_read = min(MAXIMUM_READ_SIZE, src_size - offset)
347
+ f.write(self.fread(handle, to_read))
244
348
  self.fclose(handle)
245
- os.utime(dst, (os.stat(dst).st_atime, self.stat(src)['st_mtime'].timestamp()))
349
+ os.utime(dst, (os.stat(dst).st_atime, self.stat(src)["st_mtime"].timestamp()))
246
350
  if callback is not None:
247
351
  callback(src, dst)
248
352
  else:
@@ -259,28 +363,67 @@ class AfcService(LockdownService):
259
363
  if match is not None and not match.match(posixpath.basename(src_filename)):
260
364
  continue
261
365
 
262
- if self.isdir(src_filename):
263
- dst_filename.mkdir(exist_ok=True)
264
- self.pull(src_filename, str(dst_path), callback=callback)
265
- continue
366
+ try:
367
+ if self.isdir(src_filename):
368
+ dst_filename.mkdir(exist_ok=True)
369
+ self.pull(
370
+ src_filename,
371
+ str(dst_path),
372
+ callback=callback,
373
+ ignore_errors=ignore_errors,
374
+ progress_bar=progress_bar,
375
+ )
376
+ continue
377
+
378
+ self.pull(
379
+ src_filename,
380
+ str(dst_path),
381
+ callback=callback,
382
+ ignore_errors=ignore_errors,
383
+ progress_bar=progress_bar,
384
+ )
266
385
 
267
- self.pull(src_filename, str(dst_path), callback=callback)
386
+ except Exception as afc_exception:
387
+ if not ignore_errors:
388
+ raise
389
+ self.logger.warning(f"(Ignoring) Error: {afc_exception} occurred during the copy of {src_filename}")
268
390
 
269
391
  @path_to_str()
270
392
  def exists(self, filename):
393
+ """
394
+ Check if a file or directory exists on the device.
395
+
396
+ :param filename: Path to check
397
+ :return: True if the path exists, False otherwise
398
+ """
271
399
  try:
272
400
  self.stat(filename)
273
- return True
274
401
  except AfcFileNotFoundError:
275
402
  return False
403
+ return True
276
404
 
277
405
  @path_to_str()
278
406
  def wait_exists(self, filename):
407
+ """
408
+ Block until a file or directory exists on the device.
409
+
410
+ Continuously polls the device until the specified path exists.
411
+ Warning: This is a busy-wait and may consume significant resources.
412
+
413
+ :param filename: Path to wait for
414
+ """
279
415
  while not self.exists(filename):
280
416
  pass
281
417
 
282
418
  @path_to_str()
283
419
  def _push_internal(self, local_path, remote_path, callback=None):
420
+ """
421
+ Internal method for pushing files to the device.
422
+
423
+ :param local_path: Local file or directory path
424
+ :param remote_path: Remote destination path on the device
425
+ :param callback: Optional callback function called for each file copied (src, dst)
426
+ """
284
427
  if callback is not None:
285
428
  callback(local_path, remote_path)
286
429
 
@@ -296,7 +439,7 @@ class AfcService(LockdownService):
296
439
  if not self.exists(remote_parent):
297
440
  raise
298
441
  remote_path = posixpath.join(remote_parent, os.path.basename(remote_path))
299
- with open(local_path, 'rb') as f:
442
+ with open(local_path, "rb") as f:
300
443
  self.set_file_contents(remote_path, f.read())
301
444
  else:
302
445
  # directory
@@ -305,7 +448,7 @@ class AfcService(LockdownService):
305
448
 
306
449
  for filename in os.listdir(local_path):
307
450
  local_filename = os.path.join(local_path, filename)
308
- remote_filename = posixpath.join(remote_path, filename).removeprefix('/')
451
+ remote_filename = posixpath.join(remote_path, filename).removeprefix("/")
309
452
 
310
453
  if os.path.isdir(local_filename):
311
454
  if not self.exists(remote_filename):
@@ -317,13 +460,23 @@ class AfcService(LockdownService):
317
460
 
318
461
  @path_to_str()
319
462
  def push(self, local_path, remote_path, callback=None):
463
+ """
464
+ Push (upload) a file or directory from the local machine to the device.
465
+
466
+ Recursively copies files and directories from the local file system to the device.
467
+ Creates necessary parent directories if they don't exist.
468
+
469
+ :param local_path: Source path on the local machine
470
+ :param remote_path: Destination path on the device
471
+ :param callback: Optional callback function called for each file copied (src, dst)
472
+ """
320
473
  if os.path.isdir(local_path):
321
474
  remote_path = posixpath.join(remote_path, os.path.basename(local_path))
322
475
  self._push_internal(local_path, remote_path, callback)
323
476
 
324
477
  @path_to_str()
325
478
  def rm_single(self, filename: str, force: bool = False) -> bool:
326
- """ remove single file or directory
479
+ """remove single file or directory
327
480
 
328
481
  return if succeed or raise exception depending on force parameter.
329
482
 
@@ -333,16 +486,16 @@ class AfcService(LockdownService):
333
486
  :rtype: bool
334
487
  """
335
488
  try:
336
- self._do_operation(afc_opcode_t.REMOVE_PATH, afc_rm_req_t.build({'filename': filename}))
337
- return True
489
+ self._do_operation(AfcOpcode.REMOVE_PATH, afc_rm_req_t.build(AfcRmRequest(filename=filename)), filename)
338
490
  except AfcException:
339
491
  if force:
340
492
  return False
341
493
  raise
494
+ return True
342
495
 
343
496
  @path_to_str()
344
497
  def rm(self, filename: str, match: Optional[Pattern] = None, force: bool = False) -> list[str]:
345
- """ recursive removal of a directory or a file
498
+ """recursive removal of a directory or a file
346
499
 
347
500
  if did not succeed, return list of undeleted filenames or raise exception depending on force parameter.
348
501
 
@@ -352,9 +505,8 @@ class AfcService(LockdownService):
352
505
  :return: list of undeleted paths
353
506
  :rtype: list[str]
354
507
  """
355
- if not self.exists(filename):
356
- if not self.rm_single(filename, force=force):
357
- return [filename]
508
+ if not self.exists(filename) and not self.rm_single(filename, force=force):
509
+ return [filename]
358
510
 
359
511
  # single file
360
512
  if not self.isdir(filename):
@@ -389,170 +541,308 @@ class AfcService(LockdownService):
389
541
  raise
390
542
 
391
543
  if undeleted_items:
392
- raise AfcException(f'Failed to delete paths: {undeleted_items}', None)
544
+ raise AfcException(f"Failed to delete paths: {undeleted_items}", None)
393
545
 
394
546
  return []
395
547
 
396
548
  def get_device_info(self):
397
- return list_to_dict(self._do_operation(afc_opcode_t.GET_DEVINFO))
549
+ """
550
+ Get device file system information.
551
+
552
+ Returns information about the device's file system such as total capacity,
553
+ free space, and block size.
554
+
555
+ :return: Dictionary containing device file system information
556
+ """
557
+ return list_to_dict(self._do_operation(AfcOpcode.GET_DEVINFO))
398
558
 
399
559
  @path_to_str()
400
560
  def listdir(self, filename: str):
401
- data = self._do_operation(afc_opcode_t.READ_DIR, afc_read_dir_req_t.build({'filename': filename}))
561
+ """
562
+ List contents of a directory on the device.
563
+
564
+ :param filename: Path to the directory
565
+ :return: List of filenames in the directory (excluding '.' and '..')
566
+ :raises: AfcException if the path is not a directory or doesn't exist
567
+ """
568
+ data = self._do_operation(AfcOpcode.READ_DIR, afc_read_dir_req_t.build(AfcReadDirRequest(filename=filename)))
402
569
  return afc_read_dir_resp_t.parse(data).filenames[2:] # skip the . and ..
403
570
 
404
571
  @path_to_str()
405
572
  def makedirs(self, filename: str):
406
- return self._do_operation(afc_opcode_t.MAKE_DIR, afc_mkdir_req_t.build({'filename': filename}))
573
+ """
574
+ Create a directory on the device.
575
+
576
+ Note: This behaves like os.makedirs and will create parent directories as needed.
577
+ It is idempotent and will not raise an error if the directory already exists.
578
+
579
+ :param filename: Path of the directory to create
580
+ :return: Response data from the operation
581
+ """
582
+ return self._do_operation(AfcOpcode.MAKE_DIR, afc_mkdir_req_t.build(AfcMkdirRequest(filename=filename)))
407
583
 
408
584
  @path_to_str()
409
585
  def isdir(self, filename: str) -> bool:
586
+ """
587
+ Check if a path is a directory.
588
+
589
+ :param filename: Path to check
590
+ :return: True if the path is a directory, False otherwise
591
+ """
410
592
  stat = self.stat(filename)
411
- return stat.get('st_ifmt') == 'S_IFDIR'
593
+ return stat.get("st_ifmt") == "S_IFDIR"
412
594
 
413
595
  @path_to_str()
414
596
  def stat(self, filename: str):
597
+ """
598
+ Get file or directory statistics.
599
+
600
+ :param filename: Path to the file or directory
601
+ :return: Dictionary containing file statistics (size, mode, mtime, etc.)
602
+ :raises: AfcFileNotFoundError if the path doesn't exist
603
+ """
415
604
  try:
416
605
  stat = list_to_dict(
417
- self._do_operation(afc_opcode_t.GET_FILE_INFO, afc_stat_t.build({'filename': filename})))
606
+ self._do_operation(
607
+ AfcOpcode.GET_FILE_INFO, afc_stat_t.build(AfcStatRequest(filename=filename)), filename
608
+ )
609
+ )
418
610
  except AfcException as e:
419
- if e.status != afc_error_t.READ_ERROR:
611
+ if e.status != AfcError.READ_ERROR:
420
612
  raise
421
613
  raise AfcFileNotFoundError(e.args[0], e.status) from e
422
614
 
423
- stat['st_size'] = int(stat['st_size'])
424
- stat['st_blocks'] = int(stat['st_blocks'])
425
- stat['st_mtime'] = int(stat['st_mtime'])
426
- stat['st_birthtime'] = int(stat['st_birthtime'])
427
- stat['st_nlink'] = int(stat['st_nlink'])
428
- stat['st_mtime'] = datetime.fromtimestamp(stat['st_mtime'] / (10 ** 9))
429
- stat['st_birthtime'] = datetime.fromtimestamp(stat['st_birthtime'] / (10 ** 9))
615
+ stat["st_size"] = int(stat["st_size"])
616
+ stat["st_blocks"] = int(stat["st_blocks"])
617
+ stat["st_mtime"] = int(stat["st_mtime"])
618
+ stat["st_birthtime"] = int(stat["st_birthtime"])
619
+ stat["st_nlink"] = int(stat["st_nlink"])
620
+ stat["st_mtime"] = datetime.fromtimestamp(stat["st_mtime"] / (10**9))
621
+ stat["st_birthtime"] = datetime.fromtimestamp(stat["st_birthtime"] / (10**9))
430
622
  return stat
431
623
 
432
624
  @path_to_str()
433
625
  def os_stat(self, path: str):
626
+ """
627
+ Get file statistics in os.stat format.
628
+
629
+ Returns a StatResult namedtuple compatible with os.stat results,
630
+ suitable for use with standard Python file handling code.
631
+
632
+ :param path: Path to the file or directory
633
+ :return: StatResult namedtuple with file statistics
634
+ """
434
635
  stat = self.stat(path)
435
636
  mode = 0
436
- for s_mode in ['S_IFDIR', 'S_IFCHR', 'S_IFBLK', 'S_IFREG', 'S_IFIFO', 'S_IFLNK', 'S_IFSOCK']:
437
- if stat['st_ifmt'] == s_mode:
637
+ for s_mode in ["S_IFDIR", "S_IFCHR", "S_IFBLK", "S_IFREG", "S_IFIFO", "S_IFLNK", "S_IFSOCK"]:
638
+ if stat["st_ifmt"] == s_mode:
438
639
  mode = getattr(stat_module, s_mode)
439
640
  return StatResult(
440
- mode, hash(posixpath.normpath(path)), 0, stat['st_nlink'], 0, 0, stat['st_size'],
441
- stat['st_mtime'].timestamp(), stat['st_mtime'].timestamp(), stat['st_birthtime'].timestamp(),
442
- stat['st_blocks'], 4096, stat['st_birthtime'].timestamp(),
641
+ mode,
642
+ hash(posixpath.normpath(path)),
643
+ 0,
644
+ stat["st_nlink"],
645
+ 0,
646
+ 0,
647
+ stat["st_size"],
648
+ stat["st_mtime"].timestamp(),
649
+ stat["st_mtime"].timestamp(),
650
+ stat["st_birthtime"].timestamp(),
651
+ stat["st_blocks"],
652
+ 4096,
653
+ stat["st_birthtime"].timestamp(),
443
654
  )
444
655
 
445
656
  @path_to_str()
446
- def link(self, target: str, source: str, type_=afc_link_type_t.SYMLINK):
447
- return self._do_operation(afc_opcode_t.MAKE_LINK,
448
- afc_make_link_req_t.build({'type': type_, 'target': target, 'source': source}))
657
+ def link(self, target: str, source: str, type_=AfcLinkType.SYMLINK):
658
+ """
659
+ Create a symbolic or hard link on the device.
660
+
661
+ :param target: The target path that the link will point to
662
+ :param source: The path where the link will be created
663
+ :param type_: Link type (SYMLINK or HARDLINK)
664
+ :return: Response data from the operation
665
+ """
666
+ return self._do_operation(
667
+ AfcOpcode.MAKE_LINK,
668
+ afc_make_link_req_t.build(AfcMakeLinkRequest(type=type_, target=target, source=source)),
669
+ )
449
670
 
450
671
  @path_to_str()
451
- def fopen(self, filename: str, mode: str = 'r') -> int:
672
+ def fopen(self, filename: str, mode: str = "r") -> int:
673
+ """
674
+ Open a file on the device and return a file handle.
675
+
676
+ :param filename: Path to the file
677
+ :param mode: Open mode ('r', 'r+', 'w', 'w+', 'a', 'a+')
678
+ :return: Integer file handle for subsequent operations
679
+ :raises: ArgumentError if mode is invalid
680
+ """
452
681
  if mode not in AFC_FOPEN_TEXTUAL_MODES:
453
- raise ArgumentError(f'mode can be only one of: {AFC_FOPEN_TEXTUAL_MODES.keys()}')
682
+ raise ArgumentError(f"mode can be only one of: {AFC_FOPEN_TEXTUAL_MODES.keys()}")
454
683
 
455
- data = self._do_operation(afc_opcode_t.FILE_OPEN,
456
- afc_fopen_req_t.build({'mode': AFC_FOPEN_TEXTUAL_MODES[mode], 'filename': filename}))
684
+ data = self._do_operation(
685
+ AfcOpcode.FILE_OPEN,
686
+ afc_fopen_req_t.build(
687
+ AfcFopenRequest(mode=AFC_FOPEN_TEXTUAL_MODES[mode], filename=filename),
688
+ ),
689
+ )
457
690
  return afc_fopen_resp_t.parse(data).handle
458
691
 
459
692
  def fclose(self, handle: int):
460
- return self._do_operation(afc_opcode_t.FILE_CLOSE, afc_fclose_req_t.build({'handle': handle}))
693
+ """
694
+ Close an open file handle.
695
+
696
+ :param handle: File handle returned from fopen
697
+ :return: Response data from the operation
698
+ """
699
+ return self._do_operation(AfcOpcode.FILE_CLOSE, afc_fclose_req_t.build(AfcFcloseRequest(handle=handle)))
461
700
 
462
701
  @path_to_str()
463
- def rename(self, source: str, target: str):
702
+ def rename(self, source: str, target: str) -> None:
703
+ """
704
+ Rename or move a file or directory on the device.
705
+
706
+ :param source: Current path of the file or directory
707
+ :param target: New path for the file or directory
708
+ :raises: AfcFileNotFoundError if source doesn't exist
709
+ """
464
710
  try:
465
- return self._do_operation(afc_opcode_t.RENAME_PATH,
466
- afc_rename_req_t.build({'source': source, 'target': target}))
711
+ self._do_operation(
712
+ AfcOpcode.RENAME_PATH,
713
+ afc_rename_req_t.build(AfcRenameRequest(source=source, target=target)),
714
+ )
467
715
  except AfcException as e:
468
716
  if self.exists(source):
469
717
  raise
470
- raise AfcFileNotFoundError(e.args[0], e.status) from e
718
+ raise AfcFileNotFoundError(
719
+ f"Failed to rename {source} into {target}. Got status: {e.status}", e.args[0], str(e.status)
720
+ ) from e
721
+
722
+ def fread(self, handle: int, sz: int) -> bytes:
723
+ """
724
+ Read data from an open file handle.
725
+
726
+ Automatically handles large reads by splitting into multiple operations.
471
727
 
472
- def fread(self, handle: int, sz: bytes) -> bytes:
473
- data = b''
728
+ :param handle: File handle returned from fopen
729
+ :param sz: Number of bytes to read
730
+ :return: Bytes read from the file
731
+ :raises: AfcException if read operation fails
732
+ """
733
+ data = b""
474
734
  while sz > 0:
475
- if sz > MAXIMUM_READ_SIZE:
476
- to_read = MAXIMUM_READ_SIZE
477
- else:
478
- to_read = sz
479
- self._dispatch_packet(afc_opcode_t.READ, afc_fread_req_t.build({'handle': handle, 'size': to_read}))
735
+ to_read = MAXIMUM_READ_SIZE if sz > MAXIMUM_READ_SIZE else sz
736
+ self._dispatch_packet(AfcOpcode.READ, afc_fread_req_t.build(AfcFreadRequest(handle=handle, size=to_read)))
480
737
  status, chunk = self._receive_data()
481
- if status != afc_error_t.SUCCESS:
482
- raise AfcException('fread error', status)
738
+ if status != AfcError.SUCCESS:
739
+ raise AfcException("fread error", status)
483
740
  sz -= to_read
484
741
  data += chunk
485
742
  return data
486
743
 
487
- def fwrite(self, handle, data, chunk_size=MAXIMUM_WRITE_SIZE):
488
- file_handle = struct.pack('<Q', handle)
744
+ def fwrite(self, handle: int, data: bytes, chunk_size: int = MAXIMUM_WRITE_SIZE) -> None:
745
+ """
746
+ Write data to an open file handle.
747
+
748
+ Automatically handles large writes by splitting into multiple operations.
749
+
750
+ :param handle: File handle returned from fopen
751
+ :param data: Bytes to write
752
+ :param chunk_size: Size of each write chunk (default: MAXIMUM_WRITE_SIZE)
753
+ :raises: AfcException if write operation fails
754
+ """
755
+ file_handle = struct.pack("<Q", handle)
489
756
  chunks_count = len(data) // chunk_size
490
- b = b''
491
757
  for i in range(chunks_count):
492
- chunk = data[i * chunk_size:(i + 1) * chunk_size]
493
- self._dispatch_packet(afc_opcode_t.WRITE,
494
- file_handle + chunk,
495
- this_length=48)
496
- b += chunk
758
+ chunk = data[i * chunk_size : (i + 1) * chunk_size]
759
+ self._dispatch_packet(AfcOpcode.WRITE, file_handle + chunk, this_length=48)
497
760
 
498
- status, response = self._receive_data()
499
- if status != afc_error_t.SUCCESS:
500
- raise AfcException(f'failed to write chunk: {status}', status)
761
+ status, _response = self._receive_data()
762
+ if status != AfcError.SUCCESS:
763
+ raise AfcException(f"failed to write chunk: {status}", status)
501
764
 
502
765
  if len(data) % chunk_size:
503
- chunk = data[chunks_count * chunk_size:]
504
- self._dispatch_packet(afc_opcode_t.WRITE,
505
- file_handle + chunk,
506
- this_length=48)
766
+ chunk = data[chunks_count * chunk_size :]
767
+ self._dispatch_packet(AfcOpcode.WRITE, file_handle + chunk, this_length=48)
507
768
 
508
- b += chunk
509
-
510
- status, response = self._receive_data()
511
- if status != afc_error_t.SUCCESS:
512
- raise AfcException(f'failed to write last chunk: {status}', status)
769
+ status, _response = self._receive_data()
770
+ if status != AfcError.SUCCESS:
771
+ raise AfcException(f"failed to write last chunk: {status}", status)
513
772
 
514
773
  @path_to_str()
515
774
  def resolve_path(self, filename: str):
775
+ """
776
+ Resolve symbolic links to their target paths.
777
+
778
+ If the path is a symbolic link, returns the target path. Otherwise,
779
+ returns the path unchanged.
780
+
781
+ :param filename: Path to resolve
782
+ :return: Resolved path (or original path if not a symlink)
783
+ """
516
784
  info = self.stat(filename)
517
- if info['st_ifmt'] == 'S_IFLNK':
518
- target = info['LinkTarget']
519
- if not target.startswith('/'):
520
- # relative path
521
- filename = posixpath.join(posixpath.dirname(filename), target)
522
- else:
523
- filename = target
785
+ if info["st_ifmt"] == "S_IFLNK":
786
+ target = info["LinkTarget"]
787
+ filename = posixpath.join(posixpath.dirname(filename), target) if not target.startswith("/") else target
524
788
  return filename
525
789
 
526
790
  @path_to_str()
527
791
  def get_file_contents(self, filename):
792
+ """
793
+ Read and return the entire contents of a file.
794
+
795
+ Convenience method that opens a file, reads all its contents, and closes it.
796
+
797
+ :param filename: Path to the file
798
+ :return: Bytes containing the file contents
799
+ :raises: AfcException if the path is not a regular file
800
+ """
528
801
  filename = self.resolve_path(filename)
529
802
  info = self.stat(filename)
530
803
 
531
- if info['st_ifmt'] != 'S_IFREG':
532
- raise AfcException(f'{filename} isn\'t a file', afc_error_t.INVALID_ARG)
804
+ if info["st_ifmt"] != "S_IFREG":
805
+ raise AfcException(f"{filename} isn't a file", AfcError.INVALID_ARG)
533
806
 
534
807
  h = self.fopen(filename)
535
808
  if not h:
536
809
  return
537
- d = self.fread(h, int(info['st_size']))
810
+ d = self.fread(h, int(info["st_size"]))
538
811
  self.fclose(h)
539
812
  return d
540
813
 
541
814
  @path_to_str()
542
815
  def set_file_contents(self, filename: str, data: bytes) -> None:
543
- h = self.fopen(filename, 'w')
816
+ """
817
+ Write data to a file, creating or overwriting it.
818
+
819
+ Convenience method that opens a file in write mode, writes data, and closes it.
820
+
821
+ :param filename: Path to the file
822
+ :param data: Bytes to write to the file
823
+ """
824
+ h = self.fopen(filename, "w")
544
825
  self.fwrite(h, data)
545
826
  self.fclose(h)
546
827
 
547
828
  @path_to_str()
548
829
  def walk(self, dirname: str):
830
+ """
831
+ Walk a directory tree, similar to os.walk.
832
+
833
+ Generates tuples of (dirpath, dirnames, filenames) for each directory
834
+ in the tree, starting from dirname.
835
+
836
+ :param dirname: Root directory to walk
837
+ :yields: Tuples of (dirpath, dirnames, filenames)
838
+ """
549
839
  dirs = []
550
840
  files = []
551
841
  for fd in self.listdir(dirname):
552
- if fd in ('.', '..', ''):
842
+ if fd in (".", "..", ""):
553
843
  continue
554
844
  infos = self.stat(posixpath.join(dirname, fd))
555
- if infos and infos.get('st_ifmt') == 'S_IFDIR':
845
+ if infos and infos.get("st_ifmt") == "S_IFDIR":
556
846
  dirs.append(fd)
557
847
  else:
558
848
  files.append(fd)
@@ -565,6 +855,13 @@ class AfcService(LockdownService):
565
855
 
566
856
  @path_to_str()
567
857
  def dirlist(self, root, depth=-1):
858
+ """
859
+ List all files and directories recursively up to a specified depth.
860
+
861
+ :param root: Root directory to list
862
+ :param depth: Maximum depth to traverse (-1 for unlimited)
863
+ :yields: Full paths of files and directories
864
+ """
568
865
  for folder, dirs, files in self.walk(root):
569
866
  if folder == root:
570
867
  yield folder
@@ -576,53 +873,109 @@ class AfcService(LockdownService):
576
873
  yield posixpath.join(folder, entry)
577
874
 
578
875
  def lock(self, handle, operation):
579
- return self._do_operation(afc_opcode_t.FILE_LOCK, afc_lock_t.build({'handle': handle, 'op': operation}))
580
-
581
- def _dispatch_packet(self, operation, data, this_length=0):
582
- afcpack = Container(magic=AFCMAGIC,
583
- entire_length=afc_header_t.sizeof() + len(data),
584
- this_length=afc_header_t.sizeof() + len(data),
585
- packet_num=self.packet_num,
586
- operation=operation)
587
- if this_length:
588
- afcpack.this_length = this_length
589
- header = afc_header_t.build(afcpack)
876
+ """
877
+ Apply or remove an advisory lock on an open file.
878
+
879
+ :param handle: File handle returned from fopen
880
+ :param operation: Lock operation (AFC_LOCK_SH, AFC_LOCK_EX, or AFC_LOCK_UN)
881
+ :return: Response data from the operation
882
+ """
883
+ return self._do_operation(AfcOpcode.FILE_LOCK, afc_lock_t.build(AfcLockRequest(handle=handle, op=operation)))
884
+
885
+ def _dispatch_packet(self, operation: AfcOpcode, data: bytes, this_length: int = 0) -> None:
886
+ """
887
+ Send an AFC protocol packet to the device.
888
+
889
+ :param operation: AFC operation code
890
+ :param data: Packet payload data
891
+ :param this_length: Override for the packet length field (0 for auto-calculation)
892
+ """
893
+ entire_length = afc_header_t.sizeof() + len(data)
894
+ header = afc_header_t.build(
895
+ AfcHeader(
896
+ entire_length=entire_length,
897
+ this_length=this_length or entire_length,
898
+ packet_num=self.packet_num,
899
+ operation=operation,
900
+ )
901
+ )
590
902
  self.packet_num += 1
591
903
  self.service.sendall(header + data)
592
904
 
593
905
  def _receive_data(self):
594
- res = self.service.recvall(afc_header_t.sizeof())
595
- status = afc_error_t.SUCCESS
596
- data = ''
597
- if res:
598
- res = afc_header_t.parse(res)
599
- assert res['entire_length'] >= afc_header_t.sizeof()
600
- length = res['entire_length'] - afc_header_t.sizeof()
906
+ """
907
+ Receive an AFC protocol response packet from the device.
908
+
909
+ :return: Tuple of (status_code, response_data)
910
+ """
911
+ header_bytes = self.service.recvall(afc_header_t.sizeof())
912
+ status = AfcError.SUCCESS
913
+ data = b""
914
+ if header_bytes:
915
+ header = afc_header_t.parse(header_bytes)
916
+ assert header.entire_length >= afc_header_t.sizeof()
917
+ length = header.entire_length - afc_header_t.sizeof()
601
918
  data = self.service.recvall(length)
602
- if res.operation == afc_opcode_t.STATUS:
919
+ if header.operation == AfcOpcode.STATUS:
603
920
  if length != 8:
604
- self.logger.error('Status length != 8')
605
- status = afc_error_t.parse(data)
606
- elif res.operation != afc_opcode_t.DATA:
607
- pass
921
+ self.logger.error("Status length != 8")
922
+ status = afc_error_construct.parse(data)
923
+ elif header.operation != AfcOpcode.DATA:
924
+ self.logger.debug("Unexpected AFC opcode %s", header.operation)
608
925
  return status, data
609
926
 
610
- def _do_operation(self, opcode: afc_opcode_t, data: bytes = b''):
927
+ def _do_operation(self, opcode: AfcOpcode, data: bytes = b"", filename: Optional[str] = None) -> bytes:
928
+ """
929
+ Performs a low-level operation using the specified opcode and additional data.
930
+
931
+ This method dispatches a packet with the given opcode and data, waits for a
932
+ response, and processes the result to determine success or failure. If the
933
+ operation is unsuccessful, an appropriate exception is raised.
934
+
935
+ :param opcode: The operation code specifying the type of operation to perform.
936
+ :param data: The additional data to send along with the operation. Defaults to an empty byte string.
937
+ :param filename: The filename associated with the operation, if applicable. Defaults to None.
938
+
939
+ :returns: bytes: The data received as a response to the operation.
940
+
941
+ :raises:
942
+ AfcException: General exception raised if the operation fails with an
943
+ unspecified error status.
944
+ AfcFileNotFoundError: Exception raised when the operation fails due to
945
+ an object not being found (e.g., file or directory).
946
+ """
611
947
  self._dispatch_packet(opcode, data)
612
948
  status, data = self._receive_data()
613
949
 
614
950
  exception = AfcException
615
- if status != afc_error_t.SUCCESS:
616
- if status == afc_error_t.OBJECT_NOT_FOUND:
951
+ if status != AfcError.SUCCESS:
952
+ if status == AfcError.OBJECT_NOT_FOUND:
617
953
  exception = AfcFileNotFoundError
618
954
 
619
- raise exception(f'opcode: {opcode} failed with status: {status}', status)
955
+ opcode_name = opcode.name if isinstance(opcode, AfcOpcode) else opcode
956
+ message = f"Opcode: {opcode_name} failed with status: {status}"
957
+ if filename is not None:
958
+ message += f" for file: {filename}"
959
+ raise exception(message, status, filename)
620
960
 
621
961
  return data
622
962
 
623
963
 
624
964
  class AfcLsStub(LsStub):
965
+ """
966
+ Adapter class to make AfcShell compatible with pygnuutils ls implementation.
967
+
968
+ This stub provides an interface between the pygnuutils Ls class and the AFC
969
+ file system, translating calls to work with remote device paths.
970
+ """
971
+
625
972
  def __init__(self, afc_shell, stdout):
973
+ """
974
+ Initialize the ls stub.
975
+
976
+ :param afc_shell: AfcShell instance providing device access
977
+ :param stdout: Output stream for ls results
978
+ """
626
979
  self.afc_shell = afc_shell
627
980
  self.stdout = stdout
628
981
 
@@ -654,34 +1007,47 @@ class AfcLsStub(LsStub):
654
1007
  return posixpath.basename(path)
655
1008
 
656
1009
  def getgroup(self, st_gid):
657
- return '-'
1010
+ return "-"
658
1011
 
659
1012
  def getuser(self, st_uid):
660
- return '-'
1013
+ return "-"
661
1014
 
662
1015
  def now(self):
663
1016
  return self.afc_shell.lockdown.date
664
1017
 
665
- def listdir(self, path='.'):
1018
+ def listdir(self, path="."):
666
1019
  return self.afc_shell.afc.listdir(path)
667
1020
 
668
1021
  def system(self):
669
- return 'Darwin'
1022
+ return "Darwin"
670
1023
 
671
1024
  def getenv(self, key, default=None):
672
- return ''
1025
+ return ""
673
1026
 
674
- def print(self, *objects, sep=' ', end='\n', file=sys.stdout, flush=False):
1027
+ def print(self, *objects, sep=" ", end="\n", file=sys.stdout, flush=False):
675
1028
  print(objects[0], end=end)
676
1029
 
677
1030
  def get_tty_width(self):
678
1031
  return os.get_terminal_size().columns
679
1032
 
680
1033
 
681
- def path_completer(xsh, action, completer, alias, command):
682
- shell: AfcShell = XSH.ctx['_shell']
1034
+ def path_completer(xsh, action, completer, alias, command) -> list[str]:
1035
+ """
1036
+ Provide path completion for xonsh shell commands.
1037
+
1038
+ Generates completion suggestions based on the current working directory
1039
+ and available files/directories on the device.
1040
+
1041
+ :param xsh: Xonsh shell instance
1042
+ :param action: Completion action
1043
+ :param completer: Completer instance
1044
+ :param alias: Command alias
1045
+ :param command: Command being completed
1046
+ :return: List of completion suggestions
1047
+ """
1048
+ shell: AfcShell = XSH.ctx["_shell"]
683
1049
  pwd = shell.cwd
684
- is_absolute = command.prefix.startswith('/')
1050
+ is_absolute = command.prefix.startswith("/")
685
1051
  dirpath = posixpath.join(pwd, command.prefix)
686
1052
  if not shell.afc.exists(dirpath):
687
1053
  dirpath = posixpath.dirname(dirpath)
@@ -693,7 +1059,7 @@ def path_completer(xsh, action, completer, alias, command):
693
1059
  completion_option = posixpath.relpath(posixpath.join(dirpath, f), pwd)
694
1060
  try:
695
1061
  if shell.afc.isdir(posixpath.join(dirpath, f)):
696
- result.append(f'{completion_option}/')
1062
+ result.append(f"{completion_option}/")
697
1063
  else:
698
1064
  result.append(completion_option)
699
1065
  except AfcException:
@@ -702,9 +1068,21 @@ def path_completer(xsh, action, completer, alias, command):
702
1068
 
703
1069
 
704
1070
  def dir_completer(xsh, action, completer, alias, command):
705
- shell: AfcShell = XSH.ctx['_shell']
1071
+ """
1072
+ Provide directory-only completion for xonsh shell commands.
1073
+
1074
+ Similar to path_completer but only suggests directories, not files.
1075
+
1076
+ :param xsh: Xonsh shell instance
1077
+ :param action: Completion action
1078
+ :param completer: Completer instance
1079
+ :param alias: Command alias
1080
+ :param command: Command being completed
1081
+ :return: List of directory completion suggestions
1082
+ """
1083
+ shell: AfcShell = XSH.ctx["_shell"]
706
1084
  pwd = shell.cwd
707
- is_absolute = command.prefix.startswith('/')
1085
+ is_absolute = command.prefix.startswith("/")
708
1086
  dirpath = posixpath.join(pwd, command.prefix)
709
1087
  if not shell.afc.exists(dirpath):
710
1088
  dirpath = posixpath.dirname(dirpath)
@@ -716,50 +1094,95 @@ def dir_completer(xsh, action, completer, alias, command):
716
1094
  completion_option = posixpath.relpath(posixpath.join(dirpath, f), pwd)
717
1095
  try:
718
1096
  if shell.afc.isdir(posixpath.join(dirpath, f)):
719
- result.append(f'{completion_option}/')
1097
+ result.append(f"{completion_option}/")
720
1098
  except AfcException:
721
1099
  result.append(completion_option)
722
1100
  return result
723
1101
 
724
1102
 
725
1103
  class AfcShell:
1104
+ """
1105
+ Interactive xonsh-based shell for navigating an iOS device's file system via AFC.
1106
+
1107
+ Provides a familiar shell environment with common commands (ls, cd, cat, etc.)
1108
+ that operate on the remote device's file system. The shell is powered by xonsh
1109
+ and includes features like tab completion, history, and command aliases.
1110
+
1111
+ Attributes:
1112
+ lockdown: Lockdown service provider for device communication
1113
+ afc: AfcService instance for file operations
1114
+ cwd: Current working directory on the device
1115
+ """
1116
+
726
1117
  @classmethod
727
- def create(cls, service_provider: LockdownServiceProvider, service_name: Optional[str] = None,
728
- service: Optional[LockdownService] = None, auto_cd: Optional[str] = '/'):
729
- args = ['--rc', str(pathlib.Path(__file__).absolute())]
730
- os.environ['XONSH_COLOR_STYLE'] = 'default'
731
- XSH.ctx['_class'] = cls
732
- XSH.ctx['_lockdown'] = service_provider
733
- XSH.ctx['_auto_cd'] = auto_cd
1118
+ def create(
1119
+ cls,
1120
+ service_provider: LockdownServiceProvider,
1121
+ service_name: Optional[str] = None,
1122
+ service: Optional[LockdownService] = None,
1123
+ auto_cd: Optional[str] = "/",
1124
+ ):
1125
+ """
1126
+ Create and launch an AFC shell session.
1127
+
1128
+ This class method sets up the xonsh environment and starts an interactive
1129
+ shell session for navigating the device's file system.
1130
+
1131
+ :param service_provider: Lockdown service provider for device connection
1132
+ :param service_name: Optional AFC service name override
1133
+ :param service: Optional pre-initialized AFC service instance
1134
+ :param auto_cd: Initial working directory (default: "/")
1135
+ """
1136
+ warnings.filterwarnings("ignore", category=DeprecationWarning)
1137
+ args = ["--rc", str(pathlib.Path(__file__).absolute())]
1138
+ os.environ["XONSH_COLOR_STYLE"] = "default"
1139
+ XSH.ctx["_class"] = cls
1140
+ XSH.ctx["_lockdown"] = service_provider
1141
+ XSH.ctx["_auto_cd"] = auto_cd
734
1142
  if service is not None:
735
- XSH.ctx['_service'] = service
1143
+ XSH.ctx["_service"] = service
736
1144
  else:
737
- XSH.ctx['_service'] = AfcService(service_provider, service_name=service_name)
1145
+ XSH.ctx["_service"] = AfcService(service_provider, service_name=service_name)
738
1146
 
739
1147
  try:
740
- logging.getLogger('parso.python.diff').disabled = True
741
- logging.getLogger('parso.cache').disabled = True
1148
+ logging.getLogger("parso.python.diff").disabled = True
1149
+ logging.getLogger("parso.cache").disabled = True
742
1150
  xonsh_main(args)
743
1151
  except SystemExit:
744
1152
  pass
745
1153
 
746
1154
  def __init__(self, lockdown: LockdownServiceProvider, service: AfcService):
1155
+ """
1156
+ Initialize the AFC shell.
1157
+
1158
+ Sets up the shell environment, registers commands, and configures the prompt.
1159
+ This is called internally by the create() class method.
1160
+
1161
+ :param lockdown: Lockdown service provider for device communication
1162
+ :param service: AFC service instance for file operations
1163
+ """
747
1164
  self.lockdown = lockdown
748
1165
  self.afc = service
749
- XSH.ctx['_shell'] = self
750
- self.cwd = XSH.ctx.get('_auto_cd', '/')
1166
+ XSH.ctx["_shell"] = self
1167
+ self.cwd = XSH.ctx.get("_auto_cd", "/")
751
1168
  self._commands = {}
752
1169
  self._orig_aliases = {}
753
- self._orig_prompt = XSH.env['PROMPT']
1170
+ self._orig_prompt = XSH.env["PROMPT"]
754
1171
  self._setup_shell_commands()
755
1172
 
756
- print_color('''
1173
+ print_color("""
757
1174
  {BOLD_WHITE}Welcome to xonsh-afc shell! 👋{RESET}
758
1175
  Use {CYAN}show-help{RESET} to view a list of all available special commands.
759
1176
  These special commands will replace all already existing commands.
760
- ''')
1177
+ """)
761
1178
 
762
1179
  def _register_arg_parse_alias(self, name: str, handler: Union[Callable, str]):
1180
+ """
1181
+ Register a command with argument parsing support.
1182
+
1183
+ :param name: Command name
1184
+ :param handler: Command handler function or string
1185
+ """
763
1186
  handler = ArgParserAlias(func=handler, has_args=True, prog=name)
764
1187
  self._commands[name] = handler
765
1188
  if XSH.aliases.get(name):
@@ -767,87 +1190,134 @@ class AfcShell:
767
1190
  XSH.aliases[name] = handler
768
1191
 
769
1192
  def _register_rpc_command(self, name, handler):
1193
+ """
1194
+ Register a simple command without argument parsing.
1195
+
1196
+ :param name: Command name
1197
+ :param handler: Command handler function or executable path
1198
+ """
770
1199
  self._commands[name] = handler
771
1200
  if XSH.aliases.get(name):
772
1201
  self._orig_aliases[name] = XSH.aliases[name]
773
1202
  XSH.aliases[name] = handler
774
1203
 
775
1204
  def _setup_shell_commands(self):
1205
+ """
1206
+ Initialize all shell commands and configure the shell environment.
1207
+
1208
+ Clears the PATH to prevent host command execution (except for specific
1209
+ utilities), registers AFC-specific commands, and sets up the custom prompt.
1210
+ """
776
1211
  # clear all host commands except for some useful ones
777
- XSH.env['PATH'].clear()
1212
+ XSH.env["PATH"].clear()
778
1213
  # adding "file" just to fix xonsh errors
779
- for cmd in ['wc', 'grep', 'egrep', 'sed', 'awk', 'print', 'yes', 'cat', 'file']:
1214
+ for cmd in ["wc", "grep", "egrep", "sed", "awk", "print", "yes", "cat"]:
780
1215
  executable = shutil.which(cmd)
781
1216
  if executable is not None:
782
1217
  self._register_rpc_command(cmd, executable)
783
1218
 
784
- self._register_rpc_command('ls', self.do_ls)
785
- self._register_arg_parse_alias('pwd', self._do_pwd)
786
- self._register_arg_parse_alias('link', self._do_link)
787
- self._register_arg_parse_alias('cd', self._do_cd)
788
- self._register_arg_parse_alias('cat', self._do_cat)
789
- self._register_arg_parse_alias('rm', self._do_rm)
790
- self._register_arg_parse_alias('pull', self._do_pull)
791
- self._register_arg_parse_alias('push', self._do_push)
792
- self._register_arg_parse_alias('walk', self._do_walk)
793
- self._register_arg_parse_alias('head', self._do_head)
794
- self._register_arg_parse_alias('hexdump', self._do_hexdump)
795
- self._register_arg_parse_alias('mkdir', self._do_mkdir)
796
- self._register_arg_parse_alias('info', self._do_info)
797
- self._register_arg_parse_alias('mv', self._do_mv)
798
- self._register_arg_parse_alias('stat', self._do_stat)
799
- self._register_arg_parse_alias('show-help', self._do_show_help)
800
-
801
- XSH.env['PROMPT'] = f'[{{BOLD_CYAN}}{self.afc.service_name}:{{afc_cwd}}{{RESET}}]{{prompt_end}} '
802
- XSH.env['PROMPT_FIELDS']['afc_cwd'] = self._afc_cwd
803
- XSH.env['PROMPT_FIELDS']['prompt_end'] = self._prompt
1219
+ self._register_rpc_command("ls", self.do_ls)
1220
+ self._register_arg_parse_alias("pwd", self._do_pwd)
1221
+ self._register_arg_parse_alias("link", self._do_link)
1222
+ self._register_arg_parse_alias("cd", self._do_cd)
1223
+ self._register_arg_parse_alias("cat", self._do_cat)
1224
+ self._register_arg_parse_alias("rm", self._do_rm)
1225
+ self._register_arg_parse_alias("pull", self._do_pull)
1226
+ self._register_arg_parse_alias("push", self._do_push)
1227
+ self._register_arg_parse_alias("walk", self._do_walk)
1228
+ self._register_arg_parse_alias("head", self._do_head)
1229
+ self._register_arg_parse_alias("hexdump", self._do_hexdump)
1230
+ self._register_arg_parse_alias("mkdir", self._do_mkdir)
1231
+ self._register_arg_parse_alias("info", self._do_info)
1232
+ self._register_arg_parse_alias("mv", self._do_mv)
1233
+ self._register_arg_parse_alias("stat", self._do_stat)
1234
+ self._register_arg_parse_alias("show-help", self._do_show_help)
1235
+
1236
+ XSH.env["PROMPT"] = f"[{{BOLD_CYAN}}{self.afc.service_name}:{{afc_cwd}}{{RESET}}]{{prompt_end}} "
1237
+ XSH.env["PROMPT_FIELDS"]["afc_cwd"] = self._afc_cwd
1238
+ XSH.env["PROMPT_FIELDS"]["prompt_end"] = self._prompt
804
1239
 
805
1240
  def _prompt(self) -> str:
1241
+ """
1242
+ Generate the prompt suffix based on the last command's exit status.
1243
+
1244
+ :return: Green '$' for success, red '$' for failure
1245
+ """
806
1246
  if len(XSH.history) == 0 or XSH.history[-1].rtn == 0:
807
- return '{BOLD_GREEN}${RESET}'
808
- return '{BOLD_RED}${RESET}'
1247
+ return "{BOLD_GREEN}${RESET}"
1248
+ return "{BOLD_RED}${RESET}"
809
1249
 
810
1250
  def _afc_cwd(self) -> str:
1251
+ """
1252
+ Get the current working directory for prompt display.
1253
+
1254
+ :return: Current working directory path
1255
+ """
811
1256
  return self.cwd
812
1257
 
813
1258
  def _relative_path(self, filename: str) -> str:
1259
+ """
1260
+ Convert a relative path to an absolute path based on cwd.
1261
+
1262
+ :param filename: Relative or absolute path
1263
+ :return: Absolute path
1264
+ """
814
1265
  return posixpath.join(self.cwd, filename)
815
1266
 
816
1267
  def _do_show_help(self):
817
- """
818
- list all rpc commands
819
- """
820
- buf = ''
821
- for k, v in self._commands.items():
822
- buf += f'👾 {k}\n'
1268
+ """Display a list of all available shell commands."""
1269
+ buf = ""
1270
+ for k, _v in self._commands.items():
1271
+ buf += f"👾 {k}\n"
823
1272
  print(buf)
824
1273
 
825
1274
  def _do_pwd(self) -> None:
1275
+ """Print the current working directory."""
826
1276
  print(self.cwd)
827
1277
 
828
1278
  def _do_link(self, target: str, source: str) -> None:
829
- self.afc.link(self.relative_path(target), self.relative_path(source), afc_link_type_t.SYMLINK)
1279
+ """
1280
+ Create a symbolic link on the device.
1281
+
1282
+ :param target: Target path that the link will point to
1283
+ :param source: Path where the link will be created
1284
+ """
1285
+ self.afc.link(self.relative_path(target), self.relative_path(source), AfcLinkType.SYMLINK)
830
1286
 
831
1287
  def _do_cd(self, directory: Annotated[str, Arg(completer=dir_completer)]) -> None:
1288
+ """
1289
+ Change the current working directory.
1290
+
1291
+ :param directory: Directory path to change to (relative or absolute)
1292
+ """
832
1293
  directory = self.relative_path(directory)
833
1294
  directory = posixpath.normpath(directory)
834
1295
  if self.afc.exists(directory):
835
1296
  self.cwd = directory
836
1297
  self._update_prompt()
837
1298
  else:
838
- print(f'[ERROR] {directory} does not exist')
1299
+ print(f"[ERROR] {directory} does not exist")
839
1300
 
840
1301
  def do_ls(self, args, stdin, stdout, stderr):
841
- """ list files """
1302
+ """
1303
+ List directory contents with Unix ls-like formatting.
1304
+
1305
+ Supports various ls options for formatting and filtering.
1306
+ """
842
1307
  try:
843
- with ls_cli.make_context('ls', args) as ctx:
844
- files = list(map(self._relative_path, ctx.params.pop('files')))
1308
+ with ls_cli.make_context("ls", args) as ctx:
1309
+ files = list(map(self._relative_path, ctx.params.pop("files")))
845
1310
  files = files if files else [self.cwd]
846
1311
  Ls(AfcLsStub(self, stdout))(*files, **ctx.params)
847
1312
  except Exit:
848
1313
  pass
849
1314
 
850
1315
  def _do_walk(self, directory: Annotated[str, Arg(completer=dir_completer)]):
1316
+ """
1317
+ Recursively walk a directory tree and print all paths.
1318
+
1319
+ :param directory: Root directory to walk
1320
+ """
851
1321
  for root, dirs, files in self.afc.walk(self.relative_path(directory)):
852
1322
  for name in files:
853
1323
  print(posixpath.join(root, name))
@@ -855,67 +1325,170 @@ class AfcShell:
855
1325
  print(posixpath.join(root, name))
856
1326
 
857
1327
  def _do_cat(self, filename: str):
1328
+ """
1329
+ Display the contents of a file.
1330
+
1331
+ :param filename: Path to the file to display
1332
+ """
858
1333
  print(try_decode(self.afc.get_file_contents(self.relative_path(filename))))
859
1334
 
860
- def _do_rm(self, file: Annotated[list[str], Arg(nargs='+', completer=path_completer)]):
1335
+ def _do_rm(self, file: Annotated[list[str], Arg(nargs="+", completer=path_completer)]):
1336
+ """
1337
+ Remove one or more files or directories.
1338
+
1339
+ :param file: List of file/directory paths to remove
1340
+ """
861
1341
  for filename in file:
862
1342
  self.afc.rm(self.relative_path(filename))
863
1343
 
864
- def _do_pull(self, remote_path: Annotated[str, Arg(completer=path_completer)], local_path: str):
865
- def log(src, dst):
866
- print(f'{src} --> {dst}')
1344
+ def _do_pull(
1345
+ self,
1346
+ remote_path: Annotated[str, Arg(completer=path_completer)],
1347
+ local_path: str,
1348
+ ignore_errors: Annotated[bool, Arg("--ignore-errors", action="store_true")] = False,
1349
+ progress_bar: Annotated[bool, Arg("--progress-bar", action="store_true")] = False,
1350
+ ) -> None:
1351
+ """
1352
+ Pull a file or directory from device to local machine.
1353
+
1354
+ Parameters
1355
+ ----------
1356
+ remote_path : str
1357
+ Path on the device to pull from
1358
+ local_path : str
1359
+ Local destination path
1360
+ ignore_errors : bool, optional
1361
+ Ignore errors and continue (--ignore-errors flag)
1362
+ progress_bar : bool, optional
1363
+ Show progress bar for large files (--progress_bar flag)
1364
+ """
867
1365
 
868
- self.afc.pull(remote_path, local_path, callback=log, src_dir=self.cwd)
1366
+ def log(src, dst):
1367
+ print(f"{src} --> {dst}")
1368
+
1369
+ self.afc.pull(
1370
+ remote_path,
1371
+ local_path,
1372
+ callback=log,
1373
+ src_dir=self.cwd,
1374
+ ignore_errors=ignore_errors,
1375
+ progress_bar=progress_bar,
1376
+ )
869
1377
 
870
1378
  def _do_push(self, local_path: str, remote_path: Annotated[str, Arg(completer=path_completer)]):
1379
+ """
1380
+ Push a file or directory from local machine to device.
1381
+
1382
+ :param local_path: Local source path
1383
+ :param remote_path: Destination path on the device
1384
+ """
1385
+
871
1386
  def log(src, dst):
872
- print(f'{src} --> {dst}')
1387
+ print(f"{src} --> {dst}")
873
1388
 
874
1389
  self.afc.push(local_path, self.relative_path(remote_path), callback=log)
875
1390
 
876
1391
  def _do_head(self, filename: Annotated[str, Arg(completer=path_completer)]):
1392
+ """
1393
+ Display the first 32 bytes of a file.
1394
+
1395
+ :param filename: Path to the file
1396
+ """
877
1397
  print(try_decode(self.afc.get_file_contents(self.relative_path(filename))[:32]))
878
1398
 
879
1399
  def _do_hexdump(self, filename: Annotated[str, Arg(completer=path_completer)]):
880
- print(hexdump.hexdump(self.afc.get_file_contents(self.relative_path(filename)), result='return'))
1400
+ """
1401
+ Display a hexadecimal dump of a file's contents.
1402
+
1403
+ :param filename: Path to the file
1404
+ """
1405
+ print(hexdump.hexdump(self.afc.get_file_contents(self.relative_path(filename)), result="return"))
881
1406
 
882
1407
  def _do_mkdir(self, filename: Annotated[str, Arg(completer=path_completer)]):
1408
+ """
1409
+ Create a directory on the device.
1410
+
1411
+ :param filename: Path of the directory to create
1412
+ """
883
1413
  self.afc.makedirs(self.relative_path(filename))
884
1414
 
885
1415
  def _do_info(self):
1416
+ """Display device file system information."""
886
1417
  for k, v in self.afc.get_device_info().items():
887
- print(f'{k}: {v}')
1418
+ print(f"{k}: {v}")
1419
+
1420
+ def _do_mv(
1421
+ self, source: Annotated[str, Arg(completer=path_completer)], dest: Annotated[str, Arg(completer=path_completer)]
1422
+ ):
1423
+ """
1424
+ Move or rename a file or directory.
888
1425
 
889
- def _do_mv(self, source: Annotated[str, Arg(completer=path_completer)],
890
- dest: Annotated[str, Arg(completer=path_completer)]):
1426
+ :param source: Source path
1427
+ :param dest: Destination path
1428
+ """
891
1429
  return self.afc.rename(self.relative_path(source), self.relative_path(dest))
892
1430
 
893
1431
  def _do_stat(self, filename: Annotated[str, Arg(completer=path_completer)]):
1432
+ """
1433
+ Display detailed file or directory statistics.
1434
+
1435
+ :param filename: Path to the file or directory
1436
+ """
894
1437
  for k, v in self.afc.stat(self.relative_path(filename)).items():
895
- print(f'{k}: {v}')
1438
+ print(f"{k}: {v}")
896
1439
 
897
1440
  def relative_path(self, filename: str) -> str:
1441
+ """
1442
+ Convert a relative path to an absolute path based on cwd.
1443
+
1444
+ :param filename: Relative or absolute path
1445
+ :return: Absolute path
1446
+ """
898
1447
  return posixpath.join(self.cwd, filename)
899
1448
 
900
1449
  def _update_prompt(self) -> None:
901
- self.prompt = highlight(f'[{self.afc.service_name}:{self.cwd}]$ ', lexers.BashSessionLexer(),
902
- formatters.Terminal256Formatter(style='solarized-dark')).strip()
1450
+ """Update the shell prompt with syntax highlighting."""
1451
+ self.prompt = highlight(
1452
+ f"[{self.afc.service_name}:{self.cwd}]$ ",
1453
+ lexers.BashSessionLexer(),
1454
+ formatters.Terminal256Formatter(style="solarized-dark"),
1455
+ ).strip()
903
1456
 
904
1457
  def _complete(self, text, line, begidx, endidx):
1458
+ """
1459
+ Provide path completion for commands (internal method).
1460
+
1461
+ :param text: Current text being completed
1462
+ :param line: Full command line
1463
+ :param begidx: Beginning index of text
1464
+ :param endidx: Ending index of text
1465
+ :return: List of completion options
1466
+ """
905
1467
  curdir_diff = posixpath.dirname(text)
906
1468
  dirname = posixpath.join(self.cwd, curdir_diff)
907
1469
  prefix = posixpath.basename(text)
908
1470
  return [
909
- str(posixpath.join(curdir_diff, filename)) for filename in self.afc.listdir(dirname)
1471
+ str(posixpath.join(curdir_diff, filename))
1472
+ for filename in self.afc.listdir(dirname)
910
1473
  if filename.startswith(prefix)
911
1474
  ]
912
1475
 
913
1476
  def _complete_first_arg(self, text, line, begidx, endidx):
1477
+ """
1478
+ Complete only the first argument of a command.
1479
+
1480
+ :return: Completion options for first arg, empty list otherwise
1481
+ """
914
1482
  if self._count_completion_parts(line, begidx) > 1:
915
1483
  return []
916
1484
  return self._complete(text, line, begidx, endidx)
917
1485
 
918
1486
  def _complete_push_arg(self, text, line, begidx, endidx):
1487
+ """
1488
+ Completion for push command (local path, then remote path).
1489
+
1490
+ :return: Local completions for first arg, remote for second
1491
+ """
919
1492
  count = self._count_completion_parts(line, begidx)
920
1493
  if count == 1:
921
1494
  return self._complete_local(text)
@@ -925,6 +1498,11 @@ class AfcShell:
925
1498
  return []
926
1499
 
927
1500
  def _complete_pull_arg(self, text, line, begidx, endidx):
1501
+ """
1502
+ Completion for pull command (remote path, then local path).
1503
+
1504
+ :return: Remote completions for first arg, local for second
1505
+ """
928
1506
  count = self._count_completion_parts(line, begidx)
929
1507
  if count == 1:
930
1508
  return self._complete(text, line, begidx, endidx)
@@ -935,20 +1513,39 @@ class AfcShell:
935
1513
 
936
1514
  @staticmethod
937
1515
  def _complete_local(text: str):
1516
+ """
1517
+ Provide local file system path completions.
1518
+
1519
+ :param text: Current text being completed
1520
+ :return: List of local path completions
1521
+ """
938
1522
  path = pathlib.Path(text)
939
1523
  path_iter = path.iterdir() if text.endswith(os.path.sep) else path.parent.iterdir()
940
1524
  return [str(p) for p in path_iter if str(p).startswith(text)]
941
1525
 
942
1526
  @staticmethod
943
1527
  def _count_completion_parts(line, begidx):
1528
+ """
1529
+ Count the number of space-separated parts in a command line.
1530
+
1531
+ :param line: Command line text
1532
+ :param begidx: Index to count parts up to
1533
+ :return: Number of parts
1534
+ """
944
1535
  # Strip the " for paths including spaces.
945
1536
  return len(shlex.split(line[:begidx].rstrip('"')))
946
1537
 
947
1538
 
948
1539
  if __name__ == str(pathlib.Path(__file__).absolute()):
949
- rc = XSH.ctx['_class'](XSH.ctx['_lockdown'], XSH.ctx['_service'])
1540
+ """
1541
+ Entry point for xonsh RC script.
1542
+
1543
+ This block is executed when the file is loaded as an xonsh RC script,
1544
+ initializing the AFC shell with the context provided by AfcShell.create().
1545
+ """
1546
+ rc = XSH.ctx["_class"](XSH.ctx["_lockdown"], XSH.ctx["_service"])
950
1547
  # fix fzf conflicts
951
- XSH.env['fzf_history_binding'] = "" # Ctrl+R
952
- XSH.env['fzf_ssh_binding'] = "" # Ctrl+S
953
- XSH.env['fzf_file_binding'] = "" # Ctrl+T
954
- XSH.env['fzf_dir_binding'] = "" # Ctrl+G
1548
+ XSH.env["fzf_history_binding"] = "" # Ctrl+R
1549
+ XSH.env["fzf_ssh_binding"] = "" # Ctrl+S
1550
+ XSH.env["fzf_file_binding"] = "" # Ctrl+T
1551
+ XSH.env["fzf_dir_binding"] = "" # Ctrl+G