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.
- misc/plist_sniffer.py +15 -15
- misc/remotexpc_sniffer.py +29 -28
- misc/understanding_idevice_protocol_layers.md +15 -10
- pymobiledevice3/__main__.py +317 -127
- pymobiledevice3/_version.py +22 -4
- pymobiledevice3/bonjour.py +358 -113
- pymobiledevice3/ca.py +253 -16
- pymobiledevice3/cli/activation.py +31 -23
- pymobiledevice3/cli/afc.py +49 -40
- pymobiledevice3/cli/amfi.py +16 -21
- pymobiledevice3/cli/apps.py +87 -42
- pymobiledevice3/cli/backup.py +160 -90
- pymobiledevice3/cli/bonjour.py +44 -40
- pymobiledevice3/cli/cli_common.py +204 -198
- pymobiledevice3/cli/companion_proxy.py +14 -14
- pymobiledevice3/cli/crash.py +105 -56
- pymobiledevice3/cli/developer/__init__.py +62 -0
- pymobiledevice3/cli/developer/accessibility/__init__.py +65 -0
- pymobiledevice3/cli/developer/accessibility/settings.py +43 -0
- pymobiledevice3/cli/developer/arbitration.py +50 -0
- pymobiledevice3/cli/developer/condition.py +33 -0
- pymobiledevice3/cli/developer/core_device.py +294 -0
- pymobiledevice3/cli/developer/debugserver.py +244 -0
- pymobiledevice3/cli/developer/dvt/__init__.py +438 -0
- pymobiledevice3/cli/developer/dvt/core_profile_session.py +295 -0
- pymobiledevice3/cli/developer/dvt/simulate_location.py +56 -0
- pymobiledevice3/cli/developer/dvt/sysmon/__init__.py +69 -0
- pymobiledevice3/cli/developer/dvt/sysmon/process.py +188 -0
- pymobiledevice3/cli/developer/fetch_symbols.py +108 -0
- pymobiledevice3/cli/developer/simulate_location.py +51 -0
- pymobiledevice3/cli/diagnostics/__init__.py +75 -0
- pymobiledevice3/cli/diagnostics/battery.py +47 -0
- pymobiledevice3/cli/idam.py +42 -0
- pymobiledevice3/cli/lockdown.py +108 -103
- pymobiledevice3/cli/mounter.py +158 -99
- pymobiledevice3/cli/notification.py +38 -26
- pymobiledevice3/cli/pcap.py +45 -24
- pymobiledevice3/cli/power_assertion.py +18 -17
- pymobiledevice3/cli/processes.py +17 -23
- pymobiledevice3/cli/profile.py +165 -109
- pymobiledevice3/cli/provision.py +35 -34
- pymobiledevice3/cli/remote.py +217 -129
- pymobiledevice3/cli/restore.py +159 -143
- pymobiledevice3/cli/springboard.py +63 -53
- pymobiledevice3/cli/syslog.py +193 -86
- pymobiledevice3/cli/usbmux.py +73 -33
- pymobiledevice3/cli/version.py +5 -7
- pymobiledevice3/cli/webinspector.py +376 -214
- pymobiledevice3/common.py +3 -1
- pymobiledevice3/exceptions.py +182 -58
- pymobiledevice3/irecv.py +52 -53
- pymobiledevice3/irecv_devices.py +1489 -464
- pymobiledevice3/lockdown.py +473 -275
- pymobiledevice3/lockdown_service_provider.py +15 -8
- pymobiledevice3/osu/os_utils.py +27 -9
- pymobiledevice3/osu/posix_util.py +34 -15
- pymobiledevice3/osu/win_util.py +14 -8
- pymobiledevice3/pair_records.py +102 -21
- pymobiledevice3/remote/common.py +8 -4
- pymobiledevice3/remote/core_device/app_service.py +94 -67
- pymobiledevice3/remote/core_device/core_device_service.py +17 -14
- pymobiledevice3/remote/core_device/device_info.py +5 -5
- pymobiledevice3/remote/core_device/diagnostics_service.py +19 -4
- pymobiledevice3/remote/core_device/file_service.py +53 -23
- pymobiledevice3/remote/remote_service_discovery.py +79 -45
- pymobiledevice3/remote/remotexpc.py +73 -44
- pymobiledevice3/remote/tunnel_service.py +442 -317
- pymobiledevice3/remote/utils.py +14 -13
- pymobiledevice3/remote/xpc_message.py +145 -125
- pymobiledevice3/resources/dsc_uuid_map.py +19 -19
- pymobiledevice3/resources/firmware_notifications.py +20 -16
- pymobiledevice3/resources/notifications.txt +144 -0
- pymobiledevice3/restore/asr.py +27 -27
- pymobiledevice3/restore/base_restore.py +110 -21
- pymobiledevice3/restore/consts.py +87 -66
- pymobiledevice3/restore/device.py +59 -12
- pymobiledevice3/restore/fdr.py +46 -48
- pymobiledevice3/restore/ftab.py +19 -19
- pymobiledevice3/restore/img4.py +163 -0
- pymobiledevice3/restore/mbn.py +587 -0
- pymobiledevice3/restore/recovery.py +151 -151
- pymobiledevice3/restore/restore.py +562 -544
- pymobiledevice3/restore/restore_options.py +131 -110
- pymobiledevice3/restore/restored_client.py +51 -31
- pymobiledevice3/restore/tss.py +385 -267
- pymobiledevice3/service_connection.py +252 -59
- pymobiledevice3/services/accessibilityaudit.py +202 -120
- pymobiledevice3/services/afc.py +962 -365
- pymobiledevice3/services/amfi.py +24 -30
- pymobiledevice3/services/companion.py +23 -19
- pymobiledevice3/services/crash_reports.py +71 -47
- pymobiledevice3/services/debugserver_applist.py +3 -3
- pymobiledevice3/services/device_arbitration.py +8 -8
- pymobiledevice3/services/device_link.py +101 -79
- pymobiledevice3/services/diagnostics.py +973 -967
- pymobiledevice3/services/dtfetchsymbols.py +8 -8
- pymobiledevice3/services/dvt/dvt_secure_socket_proxy.py +4 -4
- pymobiledevice3/services/dvt/dvt_testmanaged_proxy.py +4 -4
- pymobiledevice3/services/dvt/instruments/activity_trace_tap.py +85 -74
- pymobiledevice3/services/dvt/instruments/application_listing.py +2 -3
- pymobiledevice3/services/dvt/instruments/condition_inducer.py +7 -6
- pymobiledevice3/services/dvt/instruments/core_profile_session_tap.py +466 -384
- pymobiledevice3/services/dvt/instruments/device_info.py +20 -11
- pymobiledevice3/services/dvt/instruments/energy_monitor.py +1 -1
- pymobiledevice3/services/dvt/instruments/graphics.py +1 -1
- pymobiledevice3/services/dvt/instruments/location_simulation.py +1 -1
- pymobiledevice3/services/dvt/instruments/location_simulation_base.py +10 -10
- pymobiledevice3/services/dvt/instruments/network_monitor.py +17 -17
- pymobiledevice3/services/dvt/instruments/notifications.py +1 -1
- pymobiledevice3/services/dvt/instruments/process_control.py +35 -10
- pymobiledevice3/services/dvt/instruments/screenshot.py +2 -2
- pymobiledevice3/services/dvt/instruments/sysmontap.py +15 -15
- pymobiledevice3/services/dvt/testmanaged/xcuitest.py +42 -52
- pymobiledevice3/services/file_relay.py +10 -10
- pymobiledevice3/services/heartbeat.py +9 -8
- pymobiledevice3/services/house_arrest.py +16 -15
- pymobiledevice3/services/idam.py +20 -0
- pymobiledevice3/services/installation_proxy.py +173 -81
- pymobiledevice3/services/lockdown_service.py +20 -10
- pymobiledevice3/services/misagent.py +22 -19
- pymobiledevice3/services/mobile_activation.py +147 -64
- pymobiledevice3/services/mobile_config.py +331 -294
- pymobiledevice3/services/mobile_image_mounter.py +141 -113
- pymobiledevice3/services/mobilebackup2.py +203 -145
- pymobiledevice3/services/notification_proxy.py +11 -11
- pymobiledevice3/services/os_trace.py +134 -74
- pymobiledevice3/services/pcapd.py +314 -302
- pymobiledevice3/services/power_assertion.py +10 -9
- pymobiledevice3/services/preboard.py +4 -4
- pymobiledevice3/services/remote_fetch_symbols.py +21 -14
- pymobiledevice3/services/remote_server.py +176 -146
- pymobiledevice3/services/restore_service.py +16 -16
- pymobiledevice3/services/screenshot.py +15 -12
- pymobiledevice3/services/simulate_location.py +7 -7
- pymobiledevice3/services/springboard.py +15 -15
- pymobiledevice3/services/syslog.py +5 -5
- pymobiledevice3/services/web_protocol/alert.py +11 -11
- pymobiledevice3/services/web_protocol/automation_session.py +251 -239
- pymobiledevice3/services/web_protocol/cdp_screencast.py +46 -37
- pymobiledevice3/services/web_protocol/cdp_server.py +19 -19
- pymobiledevice3/services/web_protocol/cdp_target.py +411 -373
- pymobiledevice3/services/web_protocol/driver.py +114 -111
- pymobiledevice3/services/web_protocol/element.py +124 -111
- pymobiledevice3/services/web_protocol/inspector_session.py +106 -102
- pymobiledevice3/services/web_protocol/selenium_api.py +49 -49
- pymobiledevice3/services/web_protocol/session_protocol.py +18 -12
- pymobiledevice3/services/web_protocol/switch_to.py +30 -27
- pymobiledevice3/services/webinspector.py +189 -155
- pymobiledevice3/tcp_forwarder.py +87 -69
- pymobiledevice3/tunneld/__init__.py +0 -0
- pymobiledevice3/tunneld/api.py +63 -0
- pymobiledevice3/tunneld/server.py +603 -0
- pymobiledevice3/usbmux.py +198 -147
- pymobiledevice3/utils.py +14 -11
- {pymobiledevice3-4.14.6.dist-info → pymobiledevice3-7.0.6.dist-info}/METADATA +55 -28
- pymobiledevice3-7.0.6.dist-info/RECORD +188 -0
- {pymobiledevice3-4.14.6.dist-info → pymobiledevice3-7.0.6.dist-info}/WHEEL +1 -1
- pymobiledevice3/cli/developer.py +0 -1215
- pymobiledevice3/cli/diagnostics.py +0 -99
- pymobiledevice3/tunneld.py +0 -524
- pymobiledevice3-4.14.6.dist-info/RECORD +0 -168
- {pymobiledevice3-4.14.6.dist-info → pymobiledevice3-7.0.6.dist-info}/entry_points.txt +0 -0
- {pymobiledevice3-4.14.6.dist-info → pymobiledevice3-7.0.6.dist-info/licenses}/LICENSE +0 -0
- {pymobiledevice3-4.14.6.dist-info → pymobiledevice3-7.0.6.dist-info}/top_level.txt +0 -0
pymobiledevice3/services/afc.py
CHANGED
|
@@ -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,
|
|
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
|
|
46
|
+
MAXIMUM_READ_SIZE = 4 * 1024**2 # 4 MB
|
|
37
47
|
MODE_MASK = 0o0000777
|
|
38
48
|
|
|
39
|
-
StatResult = namedtuple(
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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 #
|
|
130
|
-
AFC_LOCK_EX = 2 | 4 #
|
|
131
|
-
AFC_LOCK_UN = 8 | 4 #
|
|
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
|
|
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
|
-
|
|
147
|
-
|
|
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
|
-
|
|
155
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
174
|
-
|
|
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
|
-
|
|
182
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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(
|
|
227
|
-
|
|
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,
|
|
235
|
-
src_size = self.stat(src)[
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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)[
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
"""
|
|
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(
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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 !=
|
|
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[
|
|
424
|
-
stat[
|
|
425
|
-
stat[
|
|
426
|
-
stat[
|
|
427
|
-
stat[
|
|
428
|
-
stat[
|
|
429
|
-
stat[
|
|
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 [
|
|
437
|
-
if stat[
|
|
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,
|
|
441
|
-
|
|
442
|
-
|
|
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_=
|
|
447
|
-
|
|
448
|
-
|
|
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 =
|
|
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
|
|
682
|
+
raise ArgumentError(f"mode can be only one of: {AFC_FOPEN_TEXTUAL_MODES.keys()}")
|
|
454
683
|
|
|
455
|
-
data = self._do_operation(
|
|
456
|
-
|
|
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
|
-
|
|
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
|
-
|
|
466
|
-
|
|
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(
|
|
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
|
-
|
|
473
|
-
|
|
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
|
-
|
|
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 !=
|
|
482
|
-
raise AfcException(
|
|
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
|
-
|
|
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(
|
|
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,
|
|
499
|
-
if status !=
|
|
500
|
-
raise AfcException(f
|
|
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(
|
|
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
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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[
|
|
518
|
-
target = info[
|
|
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[
|
|
532
|
-
raise AfcException(f
|
|
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[
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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
|
|
919
|
+
if header.operation == AfcOpcode.STATUS:
|
|
603
920
|
if length != 8:
|
|
604
|
-
self.logger.error(
|
|
605
|
-
status =
|
|
606
|
-
elif
|
|
607
|
-
|
|
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:
|
|
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 !=
|
|
616
|
-
if status ==
|
|
951
|
+
if status != AfcError.SUCCESS:
|
|
952
|
+
if status == AfcError.OBJECT_NOT_FOUND:
|
|
617
953
|
exception = AfcFileNotFoundError
|
|
618
954
|
|
|
619
|
-
|
|
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
|
|
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=
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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[
|
|
1143
|
+
XSH.ctx["_service"] = service
|
|
736
1144
|
else:
|
|
737
|
-
XSH.ctx[
|
|
1145
|
+
XSH.ctx["_service"] = AfcService(service_provider, service_name=service_name)
|
|
738
1146
|
|
|
739
1147
|
try:
|
|
740
|
-
logging.getLogger(
|
|
741
|
-
logging.getLogger(
|
|
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[
|
|
750
|
-
self.cwd = XSH.ctx.get(
|
|
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[
|
|
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[
|
|
1212
|
+
XSH.env["PATH"].clear()
|
|
778
1213
|
# adding "file" just to fix xonsh errors
|
|
779
|
-
for cmd in [
|
|
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(
|
|
785
|
-
self._register_arg_parse_alias(
|
|
786
|
-
self._register_arg_parse_alias(
|
|
787
|
-
self._register_arg_parse_alias(
|
|
788
|
-
self._register_arg_parse_alias(
|
|
789
|
-
self._register_arg_parse_alias(
|
|
790
|
-
self._register_arg_parse_alias(
|
|
791
|
-
self._register_arg_parse_alias(
|
|
792
|
-
self._register_arg_parse_alias(
|
|
793
|
-
self._register_arg_parse_alias(
|
|
794
|
-
self._register_arg_parse_alias(
|
|
795
|
-
self._register_arg_parse_alias(
|
|
796
|
-
self._register_arg_parse_alias(
|
|
797
|
-
self._register_arg_parse_alias(
|
|
798
|
-
self._register_arg_parse_alias(
|
|
799
|
-
self._register_arg_parse_alias(
|
|
800
|
-
|
|
801
|
-
XSH.env[
|
|
802
|
-
XSH.env[
|
|
803
|
-
XSH.env[
|
|
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
|
|
808
|
-
return
|
|
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
|
-
|
|
819
|
-
|
|
820
|
-
|
|
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
|
-
|
|
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
|
|
1299
|
+
print(f"[ERROR] {directory} does not exist")
|
|
839
1300
|
|
|
840
1301
|
def do_ls(self, args, stdin, stdout, stderr):
|
|
841
|
-
"""
|
|
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(
|
|
844
|
-
files = list(map(self._relative_path, ctx.params.pop(
|
|
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=
|
|
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(
|
|
865
|
-
|
|
866
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
890
|
-
|
|
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
|
|
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
|
-
|
|
902
|
-
|
|
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))
|
|
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
|
-
|
|
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[
|
|
952
|
-
XSH.env[
|
|
953
|
-
XSH.env[
|
|
954
|
-
XSH.env[
|
|
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
|