mac-android-file-transfer 0.1.0__tar.gz
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.
- mac_android_file_transfer-0.1.0/.gitignore +6 -0
- mac_android_file_transfer-0.1.0/PKG-INFO +41 -0
- mac_android_file_transfer-0.1.0/README.md +34 -0
- mac_android_file_transfer-0.1.0/pyproject.toml +29 -0
- mac_android_file_transfer-0.1.0/src/maft/__init__.py +5 -0
- mac_android_file_transfer-0.1.0/src/maft/cli.py +385 -0
- mac_android_file_transfer-0.1.0/tests/e2e/test_cli.py +224 -0
- mac_android_file_transfer-0.1.0/uv.lock +8 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mac-android-file-transfer
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: macOS CLI wrapper around go-mtpfs for Android file transfer
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
|
|
8
|
+
# mac-android-file-transfer
|
|
9
|
+
|
|
10
|
+
`maft` is a macOS CLI wrapper around
|
|
11
|
+
[`github.com/ganeshrvel/go-mtpfs`](https://github.com/ganeshrvel/go-mtpfs).
|
|
12
|
+
It mounts Android MTP storage to a local folder, then provides explicit `cp`,
|
|
13
|
+
`mv`, and `rm` commands over that mounted filesystem.
|
|
14
|
+
|
|
15
|
+
## Requirements
|
|
16
|
+
|
|
17
|
+
- macOS
|
|
18
|
+
- macFUSE
|
|
19
|
+
- `go-mtpfs` available in `PATH`
|
|
20
|
+
- `diskutil` or `/sbin/umount` for unmounting
|
|
21
|
+
|
|
22
|
+
Run:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
maft doctor
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
maft mount ~/Android
|
|
32
|
+
maft cp --mount ~/Android ~/Downloads/photo.jpg DCIM/photo.jpg
|
|
33
|
+
maft cp --mount ~/Android -r DCIM ~/Desktop/DCIM
|
|
34
|
+
maft mv --mount ~/Android DCIM/photo.jpg Pictures/photo.jpg
|
|
35
|
+
maft rm --mount ~/Android Pictures/photo.jpg
|
|
36
|
+
maft rm --mount ~/Android -r Pictures/old-folder
|
|
37
|
+
maft unmount ~/Android
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
For file commands, absolute paths and `~/...` paths are treated as host paths.
|
|
41
|
+
Other paths are treated as Android paths relative to the mount folder.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# mac-android-file-transfer
|
|
2
|
+
|
|
3
|
+
`maft` is a macOS CLI wrapper around
|
|
4
|
+
[`github.com/ganeshrvel/go-mtpfs`](https://github.com/ganeshrvel/go-mtpfs).
|
|
5
|
+
It mounts Android MTP storage to a local folder, then provides explicit `cp`,
|
|
6
|
+
`mv`, and `rm` commands over that mounted filesystem.
|
|
7
|
+
|
|
8
|
+
## Requirements
|
|
9
|
+
|
|
10
|
+
- macOS
|
|
11
|
+
- macFUSE
|
|
12
|
+
- `go-mtpfs` available in `PATH`
|
|
13
|
+
- `diskutil` or `/sbin/umount` for unmounting
|
|
14
|
+
|
|
15
|
+
Run:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
maft doctor
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
maft mount ~/Android
|
|
25
|
+
maft cp --mount ~/Android ~/Downloads/photo.jpg DCIM/photo.jpg
|
|
26
|
+
maft cp --mount ~/Android -r DCIM ~/Desktop/DCIM
|
|
27
|
+
maft mv --mount ~/Android DCIM/photo.jpg Pictures/photo.jpg
|
|
28
|
+
maft rm --mount ~/Android Pictures/photo.jpg
|
|
29
|
+
maft rm --mount ~/Android -r Pictures/old-folder
|
|
30
|
+
maft unmount ~/Android
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
For file commands, absolute paths and `~/...` paths are treated as host paths.
|
|
34
|
+
Other paths are treated as Android paths relative to the mount folder.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "mac-android-file-transfer"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "macOS CLI wrapper around go-mtpfs for Android file transfer"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
dependencies = []
|
|
8
|
+
|
|
9
|
+
[project.scripts]
|
|
10
|
+
maft = "maft.cli:main"
|
|
11
|
+
|
|
12
|
+
[build-system]
|
|
13
|
+
requires = ["hatchling"]
|
|
14
|
+
build-backend = "hatchling.build"
|
|
15
|
+
|
|
16
|
+
[tool.hatch.build.targets.wheel]
|
|
17
|
+
packages = ["src/maft"]
|
|
18
|
+
|
|
19
|
+
[tool.ruff]
|
|
20
|
+
line-length = 100
|
|
21
|
+
target-version = "py312"
|
|
22
|
+
|
|
23
|
+
[tool.ruff.lint]
|
|
24
|
+
select = ["E", "F", "I", "B", "UP", "SIM", "RUF"]
|
|
25
|
+
|
|
26
|
+
[tool.basedpyright]
|
|
27
|
+
include = ["src", "tests"]
|
|
28
|
+
pythonVersion = "3.12"
|
|
29
|
+
typeCheckingMode = "strict"
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import asdict, dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import cast
|
|
13
|
+
|
|
14
|
+
from maft import __version__
|
|
15
|
+
|
|
16
|
+
APP_NAME = "maft"
|
|
17
|
+
DEFAULT_STATE_DIR = Path.home() / "Library" / "Application Support" / APP_NAME
|
|
18
|
+
DEFAULT_MACFUSE_PATHS = (
|
|
19
|
+
Path("/Library/Filesystems/macfuse.fs"),
|
|
20
|
+
Path("/Library/Filesystems/osxfuse.fs"),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class MountRecord:
|
|
26
|
+
mountpoint: str
|
|
27
|
+
pid: int
|
|
28
|
+
command: list[str]
|
|
29
|
+
created_at: float
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CliError(Exception):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main(argv: list[str] | None = None) -> int:
|
|
37
|
+
parser = build_parser()
|
|
38
|
+
args = parser.parse_args(argv)
|
|
39
|
+
try:
|
|
40
|
+
return args.func(args)
|
|
41
|
+
except CliError as exc:
|
|
42
|
+
print(f"maft: {exc}", file=sys.stderr)
|
|
43
|
+
return 1
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
47
|
+
parser = argparse.ArgumentParser(
|
|
48
|
+
prog="maft",
|
|
49
|
+
description="Mount and manage Android MTP files on macOS via go-mtpfs.",
|
|
50
|
+
)
|
|
51
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
52
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
53
|
+
|
|
54
|
+
doctor = subparsers.add_parser("doctor", help="check local dependencies")
|
|
55
|
+
doctor.set_defaults(func=cmd_doctor)
|
|
56
|
+
|
|
57
|
+
mount = subparsers.add_parser("mount", help="mount an Android device")
|
|
58
|
+
mount.add_argument("mountpoint")
|
|
59
|
+
mount.add_argument("--dev", help="go-mtpfs device selector")
|
|
60
|
+
mount.add_argument("--storage", help="go-mtpfs storage selector")
|
|
61
|
+
mount.add_argument("--android", dest="android", action="store_true", default=True)
|
|
62
|
+
mount.add_argument("--no-android", dest="android", action="store_false")
|
|
63
|
+
mount.add_argument("--allow-other", action="store_true")
|
|
64
|
+
mount.add_argument("--debug", action="store_true")
|
|
65
|
+
mount.add_argument("--usb-timeout", type=int, help="go-mtpfs USB timeout")
|
|
66
|
+
mount.set_defaults(func=cmd_mount)
|
|
67
|
+
|
|
68
|
+
unmount = subparsers.add_parser("unmount", help="unmount an Android mount folder")
|
|
69
|
+
unmount.add_argument("mountpoint")
|
|
70
|
+
unmount.set_defaults(func=cmd_unmount)
|
|
71
|
+
|
|
72
|
+
cp = subparsers.add_parser("cp", help="copy files to, from, or within a mounted device")
|
|
73
|
+
add_mount_arg(cp)
|
|
74
|
+
cp.add_argument("-r", "--recursive", action="store_true", help="copy directories recursively")
|
|
75
|
+
cp.add_argument("src")
|
|
76
|
+
cp.add_argument("dst")
|
|
77
|
+
cp.set_defaults(func=cmd_cp)
|
|
78
|
+
|
|
79
|
+
mv = subparsers.add_parser("mv", help="move files to, from, or within a mounted device")
|
|
80
|
+
add_mount_arg(mv)
|
|
81
|
+
mv.add_argument("src")
|
|
82
|
+
mv.add_argument("dst")
|
|
83
|
+
mv.set_defaults(func=cmd_mv)
|
|
84
|
+
|
|
85
|
+
rm = subparsers.add_parser("rm", help="remove files from a mounted device")
|
|
86
|
+
add_mount_arg(rm)
|
|
87
|
+
rm.add_argument("-r", "--recursive", action="store_true", help="remove directories recursively")
|
|
88
|
+
rm.add_argument("paths", nargs="+")
|
|
89
|
+
rm.set_defaults(func=cmd_rm)
|
|
90
|
+
|
|
91
|
+
return parser
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def add_mount_arg(parser: argparse.ArgumentParser) -> None:
|
|
95
|
+
parser.add_argument(
|
|
96
|
+
"--mount",
|
|
97
|
+
required=True,
|
|
98
|
+
dest="mountpoint",
|
|
99
|
+
help="mounted Android folder",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def cmd_doctor(_args: argparse.Namespace) -> int:
|
|
104
|
+
checks = [
|
|
105
|
+
("go-mtpfs", shutil.which("go-mtpfs") is not None, install_go_mtpfs_help()),
|
|
106
|
+
("diskutil", shutil.which("diskutil") is not None, "Provided by macOS."),
|
|
107
|
+
("/sbin/umount", Path("/sbin/umount").exists(), "Provided by macOS."),
|
|
108
|
+
("macFUSE", macfuse_available(), "Install from https://macfuse.github.io/."),
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
failed = False
|
|
112
|
+
for name, ok, help_text in checks:
|
|
113
|
+
status = "ok" if ok else "missing"
|
|
114
|
+
print(f"{name}: {status}")
|
|
115
|
+
if not ok:
|
|
116
|
+
failed = True
|
|
117
|
+
print(f" {help_text}")
|
|
118
|
+
return 1 if failed else 0
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def cmd_mount(args: argparse.Namespace) -> int:
|
|
122
|
+
go_mtpfs = shutil.which("go-mtpfs")
|
|
123
|
+
if go_mtpfs is None:
|
|
124
|
+
raise CliError(f"go-mtpfs not found in PATH. {install_go_mtpfs_help()}")
|
|
125
|
+
if not macfuse_available():
|
|
126
|
+
raise CliError("macFUSE was not detected. Install it from https://macfuse.github.io/.")
|
|
127
|
+
|
|
128
|
+
mountpoint = expand_path(args.mountpoint)
|
|
129
|
+
if mountpoint.exists() and not mountpoint.is_dir():
|
|
130
|
+
raise CliError(f"mountpoint exists and is not a directory: {mountpoint}")
|
|
131
|
+
mountpoint.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
|
|
133
|
+
command = [go_mtpfs]
|
|
134
|
+
if args.dev:
|
|
135
|
+
command.extend(["-dev", args.dev])
|
|
136
|
+
if args.storage:
|
|
137
|
+
command.extend(["-storage", args.storage])
|
|
138
|
+
if not args.android:
|
|
139
|
+
command.append("-android=false")
|
|
140
|
+
if args.allow_other:
|
|
141
|
+
command.append("-allow-other")
|
|
142
|
+
if args.debug:
|
|
143
|
+
command.append("-debug")
|
|
144
|
+
if args.usb_timeout is not None:
|
|
145
|
+
command.extend(["-usb-timeout", str(args.usb_timeout)])
|
|
146
|
+
command.append(str(mountpoint))
|
|
147
|
+
|
|
148
|
+
stdout = None if args.debug else subprocess.DEVNULL
|
|
149
|
+
stderr = None if args.debug else subprocess.DEVNULL
|
|
150
|
+
process = subprocess.Popen(command, stdout=stdout, stderr=stderr, start_new_session=True)
|
|
151
|
+
write_mount_record(
|
|
152
|
+
MountRecord(
|
|
153
|
+
mountpoint=str(mountpoint),
|
|
154
|
+
pid=process.pid,
|
|
155
|
+
command=command,
|
|
156
|
+
created_at=time.time(),
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
print(f"mounted {mountpoint} with go-mtpfs pid {process.pid}")
|
|
160
|
+
return 0
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def cmd_unmount(args: argparse.Namespace) -> int:
|
|
164
|
+
mountpoint = require_dir(expand_path(args.mountpoint), "mountpoint")
|
|
165
|
+
errors: list[str] = []
|
|
166
|
+
diskutil = shutil.which("diskutil")
|
|
167
|
+
if diskutil is not None:
|
|
168
|
+
result = subprocess.run(
|
|
169
|
+
[diskutil, "unmount", str(mountpoint)],
|
|
170
|
+
check=False,
|
|
171
|
+
capture_output=True,
|
|
172
|
+
text=True,
|
|
173
|
+
)
|
|
174
|
+
if result.returncode == 0:
|
|
175
|
+
remove_mount_record(mountpoint)
|
|
176
|
+
print(f"unmounted {mountpoint}")
|
|
177
|
+
return 0
|
|
178
|
+
errors.append(result.stderr.strip() or result.stdout.strip() or "diskutil unmount failed")
|
|
179
|
+
|
|
180
|
+
umount = shutil.which("umount") or "/sbin/umount"
|
|
181
|
+
result = subprocess.run(
|
|
182
|
+
[umount, str(mountpoint)],
|
|
183
|
+
check=False,
|
|
184
|
+
capture_output=True,
|
|
185
|
+
text=True,
|
|
186
|
+
)
|
|
187
|
+
if result.returncode == 0:
|
|
188
|
+
remove_mount_record(mountpoint)
|
|
189
|
+
print(f"unmounted {mountpoint}")
|
|
190
|
+
return 0
|
|
191
|
+
errors.append(result.stderr.strip() or result.stdout.strip() or "umount failed")
|
|
192
|
+
|
|
193
|
+
joined = "; ".join(error for error in errors if error)
|
|
194
|
+
raise CliError(f"could not unmount {mountpoint}: {joined}")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def cmd_cp(args: argparse.Namespace) -> int:
|
|
198
|
+
mountpoint = require_mountpoint(args.mountpoint)
|
|
199
|
+
src = resolve_operation_path(mountpoint, args.src)
|
|
200
|
+
dst = resolve_operation_path(mountpoint, args.dst)
|
|
201
|
+
|
|
202
|
+
if src.path.is_dir():
|
|
203
|
+
if not args.recursive:
|
|
204
|
+
raise CliError(f"{src.original} is a directory; pass --recursive")
|
|
205
|
+
copy_directory(src.path, destination_for_copy(dst.path, src.path.name))
|
|
206
|
+
else:
|
|
207
|
+
copy_file(src.path, destination_for_copy(dst.path, src.path.name))
|
|
208
|
+
return 0
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def cmd_mv(args: argparse.Namespace) -> int:
|
|
212
|
+
mountpoint = require_mountpoint(args.mountpoint)
|
|
213
|
+
src = resolve_operation_path(mountpoint, args.src)
|
|
214
|
+
dst = resolve_operation_path(mountpoint, args.dst)
|
|
215
|
+
target = destination_for_copy(dst.path, src.path.name)
|
|
216
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
217
|
+
shutil.move(str(src.path), str(target))
|
|
218
|
+
return 0
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def cmd_rm(args: argparse.Namespace) -> int:
|
|
222
|
+
mountpoint = require_mountpoint(args.mountpoint)
|
|
223
|
+
for raw_path in args.paths:
|
|
224
|
+
if is_host_path(raw_path):
|
|
225
|
+
raise CliError(f"rm only accepts Android-relative paths, got host path: {raw_path}")
|
|
226
|
+
target = resolve_android_path(mountpoint, raw_path)
|
|
227
|
+
if target.is_dir():
|
|
228
|
+
if not args.recursive:
|
|
229
|
+
raise CliError(f"{raw_path} is a directory; pass --recursive")
|
|
230
|
+
shutil.rmtree(target)
|
|
231
|
+
elif target.exists():
|
|
232
|
+
target.unlink()
|
|
233
|
+
else:
|
|
234
|
+
raise CliError(f"path does not exist: {raw_path}")
|
|
235
|
+
return 0
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@dataclass(frozen=True)
|
|
239
|
+
class OperationPath:
|
|
240
|
+
original: str
|
|
241
|
+
path: Path
|
|
242
|
+
is_host: bool
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def resolve_operation_path(mountpoint: Path, value: str) -> OperationPath:
|
|
246
|
+
if is_host_path(value):
|
|
247
|
+
return OperationPath(value, expand_path(value), True)
|
|
248
|
+
return OperationPath(value, resolve_android_path(mountpoint, value), False)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def resolve_android_path(mountpoint: Path, value: str) -> Path:
|
|
252
|
+
if value in {"", "."}:
|
|
253
|
+
return mountpoint
|
|
254
|
+
candidate = (mountpoint / value).resolve(strict=False)
|
|
255
|
+
try:
|
|
256
|
+
candidate.relative_to(mountpoint)
|
|
257
|
+
except ValueError as exc:
|
|
258
|
+
raise CliError(f"Android path escapes mountpoint: {value}") from exc
|
|
259
|
+
return candidate
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def is_host_path(value: str) -> bool:
|
|
263
|
+
return value.startswith("/") or value.startswith("~/") or value == "~"
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def destination_for_copy(destination: Path, source_name: str) -> Path:
|
|
267
|
+
if destination.exists() and destination.is_dir():
|
|
268
|
+
return destination / source_name
|
|
269
|
+
if str(destination).endswith(os.sep):
|
|
270
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
271
|
+
return destination / source_name
|
|
272
|
+
return destination
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def copy_file(src: Path, dst: Path) -> None:
|
|
276
|
+
if not src.is_file():
|
|
277
|
+
raise CliError(f"source file does not exist: {src}")
|
|
278
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
279
|
+
shutil.copy2(src, dst)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def copy_directory(src: Path, dst: Path) -> None:
|
|
283
|
+
if not src.is_dir():
|
|
284
|
+
raise CliError(f"source directory does not exist: {src}")
|
|
285
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
286
|
+
shutil.copytree(src, dst, dirs_exist_ok=True)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def require_mountpoint(value: str) -> Path:
|
|
290
|
+
mountpoint = require_dir(expand_path(value), "mountpoint")
|
|
291
|
+
return mountpoint.resolve(strict=True)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def require_dir(path: Path, label: str) -> Path:
|
|
295
|
+
if not path.exists():
|
|
296
|
+
raise CliError(f"{label} does not exist: {path}")
|
|
297
|
+
if not path.is_dir():
|
|
298
|
+
raise CliError(f"{label} is not a directory: {path}")
|
|
299
|
+
return path
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def expand_path(value: str) -> Path:
|
|
303
|
+
return Path(value).expanduser().resolve(strict=False)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def state_dir() -> Path:
|
|
307
|
+
override = os.environ.get("MAFT_STATE_DIR")
|
|
308
|
+
return Path(override).expanduser() if override else DEFAULT_STATE_DIR
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def state_file() -> Path:
|
|
312
|
+
return state_dir() / "mounts.json"
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def load_mount_records() -> dict[str, MountRecord]:
|
|
316
|
+
path = state_file()
|
|
317
|
+
if not path.exists():
|
|
318
|
+
return {}
|
|
319
|
+
data: object = json.loads(path.read_text(encoding="utf-8"))
|
|
320
|
+
if not isinstance(data, dict):
|
|
321
|
+
raise CliError(f"invalid state file: {path}")
|
|
322
|
+
raw_records = cast("dict[object, object]", data)
|
|
323
|
+
records: dict[str, MountRecord] = {}
|
|
324
|
+
for mountpoint, record in raw_records.items():
|
|
325
|
+
if not isinstance(mountpoint, str) or not isinstance(record, dict):
|
|
326
|
+
continue
|
|
327
|
+
raw_record = cast("dict[object, object]", record)
|
|
328
|
+
raw_command = raw_record.get("command")
|
|
329
|
+
raw_mountpoint = raw_record.get("mountpoint")
|
|
330
|
+
raw_pid = raw_record.get("pid")
|
|
331
|
+
raw_created_at = raw_record.get("created_at")
|
|
332
|
+
if (
|
|
333
|
+
not isinstance(raw_mountpoint, str)
|
|
334
|
+
or not isinstance(raw_pid, int)
|
|
335
|
+
or not isinstance(raw_created_at, int | float)
|
|
336
|
+
or not isinstance(raw_command, list)
|
|
337
|
+
):
|
|
338
|
+
continue
|
|
339
|
+
command = cast("list[object]", raw_command)
|
|
340
|
+
records[mountpoint] = MountRecord(
|
|
341
|
+
mountpoint=raw_mountpoint,
|
|
342
|
+
pid=raw_pid,
|
|
343
|
+
command=[str(part) for part in command],
|
|
344
|
+
created_at=float(raw_created_at),
|
|
345
|
+
)
|
|
346
|
+
return records
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def save_mount_records(records: dict[str, MountRecord]) -> None:
|
|
350
|
+
directory = state_dir()
|
|
351
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
352
|
+
state_file().write_text(
|
|
353
|
+
json.dumps({key: asdict(value) for key, value in records.items()}, indent=2) + "\n",
|
|
354
|
+
encoding="utf-8",
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def write_mount_record(record: MountRecord) -> None:
|
|
359
|
+
records = load_mount_records()
|
|
360
|
+
records[record.mountpoint] = record
|
|
361
|
+
save_mount_records(records)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def remove_mount_record(mountpoint: Path) -> None:
|
|
365
|
+
records = load_mount_records()
|
|
366
|
+
records.pop(str(mountpoint.resolve(strict=False)), None)
|
|
367
|
+
save_mount_records(records)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def macfuse_available() -> bool:
|
|
371
|
+
override = os.environ.get("MAFT_MACFUSE_PATHS")
|
|
372
|
+
paths = [Path(path).expanduser() for path in override.split(os.pathsep)] if override else list(
|
|
373
|
+
DEFAULT_MACFUSE_PATHS
|
|
374
|
+
)
|
|
375
|
+
return any(path.exists() for path in paths)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def install_go_mtpfs_help() -> str:
|
|
379
|
+
return (
|
|
380
|
+
"Install Go and go-mtpfs, for example: "
|
|
381
|
+
"brew install go libusb pkg-config && go install github.com/ganeshrvel/go-mtpfs@latest"
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
if __name__ == "__main__":
|
|
385
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import stat
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
ROOT = Path(__file__).resolve().parents[2]
|
|
12
|
+
PYTHONPATH = str(ROOT / "src")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def run_maft(
|
|
16
|
+
tmp_path: Path,
|
|
17
|
+
*args: str,
|
|
18
|
+
path: str | None = None,
|
|
19
|
+
) -> subprocess.CompletedProcess[str]:
|
|
20
|
+
env = os.environ.copy()
|
|
21
|
+
env["PYTHONPATH"] = PYTHONPATH
|
|
22
|
+
env["MAFT_STATE_DIR"] = str(tmp_path / "state")
|
|
23
|
+
env["MAFT_MACFUSE_PATHS"] = str(tmp_path / "macfuse.fs")
|
|
24
|
+
if path is not None:
|
|
25
|
+
env["PATH"] = path
|
|
26
|
+
return subprocess.run(
|
|
27
|
+
[sys.executable, "-m", "maft.cli", *args],
|
|
28
|
+
cwd=ROOT,
|
|
29
|
+
env=env,
|
|
30
|
+
check=False,
|
|
31
|
+
capture_output=True,
|
|
32
|
+
text=True,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def make_executable(path: Path, content: str) -> None:
|
|
37
|
+
path.write_text(content, encoding="utf-8")
|
|
38
|
+
path.chmod(path.stat().st_mode | stat.S_IXUSR)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_version_flag_prints_current_version(tmp_path: Path) -> None:
|
|
42
|
+
result = run_maft(tmp_path, "--version")
|
|
43
|
+
|
|
44
|
+
assert result.returncode == 0
|
|
45
|
+
assert result.stdout == "maft 0.1.0\n"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_doctor_reports_missing_backend(tmp_path: Path) -> None:
|
|
49
|
+
result = run_maft(tmp_path, "doctor", path="/usr/bin:/bin")
|
|
50
|
+
|
|
51
|
+
assert result.returncode == 1
|
|
52
|
+
assert "go-mtpfs: missing" in result.stdout
|
|
53
|
+
assert "macFUSE: missing" in result.stdout
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_mount_invokes_go_mtpfs_and_records_metadata(tmp_path: Path) -> None:
|
|
57
|
+
bin_dir = tmp_path / "bin"
|
|
58
|
+
bin_dir.mkdir()
|
|
59
|
+
log = tmp_path / "go-mtpfs.log"
|
|
60
|
+
make_executable(
|
|
61
|
+
bin_dir / "go-mtpfs",
|
|
62
|
+
f"#!/bin/sh\nprintf '%s\\n' \"$@\" > {log}\nexit 0\n",
|
|
63
|
+
)
|
|
64
|
+
(tmp_path / "macfuse.fs").mkdir()
|
|
65
|
+
mountpoint = tmp_path / "Android"
|
|
66
|
+
|
|
67
|
+
result = run_maft(
|
|
68
|
+
tmp_path,
|
|
69
|
+
"mount",
|
|
70
|
+
str(mountpoint),
|
|
71
|
+
"--dev",
|
|
72
|
+
"1",
|
|
73
|
+
"--storage",
|
|
74
|
+
"2",
|
|
75
|
+
"--allow-other",
|
|
76
|
+
"--usb-timeout",
|
|
77
|
+
"10",
|
|
78
|
+
path=f"{bin_dir}:/usr/bin:/bin",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
assert result.returncode == 0, result.stderr
|
|
82
|
+
assert mountpoint.is_dir()
|
|
83
|
+
wait_for_path(log)
|
|
84
|
+
invocation = log.read_text(encoding="utf-8")
|
|
85
|
+
assert "-dev\n1\n-storage\n2\n-allow-other\n-usb-timeout\n10" in invocation
|
|
86
|
+
state = json.loads((tmp_path / "state" / "mounts.json").read_text(encoding="utf-8"))
|
|
87
|
+
assert str(mountpoint.resolve()) in state
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_unmount_uses_available_command_and_cleans_metadata(tmp_path: Path) -> None:
|
|
91
|
+
bin_dir = tmp_path / "bin"
|
|
92
|
+
bin_dir.mkdir()
|
|
93
|
+
log = tmp_path / "umount.log"
|
|
94
|
+
make_executable(bin_dir / "umount", f"#!/bin/sh\nprintf '%s\\n' \"$@\" > {log}\nexit 0\n")
|
|
95
|
+
mountpoint = tmp_path / "Android"
|
|
96
|
+
mountpoint.mkdir()
|
|
97
|
+
state_dir = tmp_path / "state"
|
|
98
|
+
state_dir.mkdir()
|
|
99
|
+
(state_dir / "mounts.json").write_text(
|
|
100
|
+
json.dumps(
|
|
101
|
+
{
|
|
102
|
+
str(mountpoint.resolve()): {
|
|
103
|
+
"mountpoint": str(mountpoint.resolve()),
|
|
104
|
+
"pid": 123,
|
|
105
|
+
"command": ["go-mtpfs", str(mountpoint.resolve())],
|
|
106
|
+
"created_at": 1.0,
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
),
|
|
110
|
+
encoding="utf-8",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
result = run_maft(tmp_path, "unmount", str(mountpoint), path=f"{bin_dir}:/usr/bin:/bin")
|
|
114
|
+
|
|
115
|
+
assert result.returncode == 0, result.stderr
|
|
116
|
+
assert str(mountpoint) in log.read_text(encoding="utf-8")
|
|
117
|
+
state = json.loads((state_dir / "mounts.json").read_text(encoding="utf-8"))
|
|
118
|
+
assert state == {}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_cp_mv_rm_file_operations_against_mount_folder(tmp_path: Path) -> None:
|
|
122
|
+
mountpoint = tmp_path / "Android"
|
|
123
|
+
host = tmp_path / "host"
|
|
124
|
+
mountpoint.mkdir()
|
|
125
|
+
host.mkdir()
|
|
126
|
+
(host / "photo.jpg").write_text("photo", encoding="utf-8")
|
|
127
|
+
|
|
128
|
+
copy_to_device = run_maft(
|
|
129
|
+
tmp_path,
|
|
130
|
+
"cp",
|
|
131
|
+
"--mount",
|
|
132
|
+
str(mountpoint),
|
|
133
|
+
str(host / "photo.jpg"),
|
|
134
|
+
"DCIM/photo.jpg",
|
|
135
|
+
)
|
|
136
|
+
assert copy_to_device.returncode == 0, copy_to_device.stderr
|
|
137
|
+
assert (mountpoint / "DCIM" / "photo.jpg").read_text(encoding="utf-8") == "photo"
|
|
138
|
+
|
|
139
|
+
move_on_device = run_maft(
|
|
140
|
+
tmp_path,
|
|
141
|
+
"mv",
|
|
142
|
+
"--mount",
|
|
143
|
+
str(mountpoint),
|
|
144
|
+
"DCIM/photo.jpg",
|
|
145
|
+
"Pictures/photo.jpg",
|
|
146
|
+
)
|
|
147
|
+
assert move_on_device.returncode == 0, move_on_device.stderr
|
|
148
|
+
assert not (mountpoint / "DCIM" / "photo.jpg").exists()
|
|
149
|
+
assert (mountpoint / "Pictures" / "photo.jpg").exists()
|
|
150
|
+
|
|
151
|
+
copy_to_host = run_maft(
|
|
152
|
+
tmp_path,
|
|
153
|
+
"cp",
|
|
154
|
+
"--mount",
|
|
155
|
+
str(mountpoint),
|
|
156
|
+
"Pictures/photo.jpg",
|
|
157
|
+
str(host / "copy.jpg"),
|
|
158
|
+
)
|
|
159
|
+
assert copy_to_host.returncode == 0, copy_to_host.stderr
|
|
160
|
+
assert (host / "copy.jpg").read_text(encoding="utf-8") == "photo"
|
|
161
|
+
|
|
162
|
+
remove_file = run_maft(tmp_path, "rm", "--mount", str(mountpoint), "Pictures/photo.jpg")
|
|
163
|
+
assert remove_file.returncode == 0, remove_file.stderr
|
|
164
|
+
assert not (mountpoint / "Pictures" / "photo.jpg").exists()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_recursive_operations_and_path_safety(tmp_path: Path) -> None:
|
|
168
|
+
mountpoint = tmp_path / "Android"
|
|
169
|
+
host = tmp_path / "host"
|
|
170
|
+
mountpoint.mkdir()
|
|
171
|
+
host.mkdir()
|
|
172
|
+
(host / "album").mkdir()
|
|
173
|
+
(host / "album" / "a.txt").write_text("a", encoding="utf-8")
|
|
174
|
+
|
|
175
|
+
refused_directory_copy = run_maft(
|
|
176
|
+
tmp_path,
|
|
177
|
+
"cp",
|
|
178
|
+
"--mount",
|
|
179
|
+
str(mountpoint),
|
|
180
|
+
str(host / "album"),
|
|
181
|
+
"Albums/album",
|
|
182
|
+
)
|
|
183
|
+
assert refused_directory_copy.returncode == 1
|
|
184
|
+
assert "pass --recursive" in refused_directory_copy.stderr
|
|
185
|
+
|
|
186
|
+
copied_directory = run_maft(
|
|
187
|
+
tmp_path,
|
|
188
|
+
"cp",
|
|
189
|
+
"--mount",
|
|
190
|
+
str(mountpoint),
|
|
191
|
+
"--recursive",
|
|
192
|
+
str(host / "album"),
|
|
193
|
+
"Albums/album",
|
|
194
|
+
)
|
|
195
|
+
assert copied_directory.returncode == 0, copied_directory.stderr
|
|
196
|
+
assert (mountpoint / "Albums" / "album" / "a.txt").exists()
|
|
197
|
+
|
|
198
|
+
refused_escape = run_maft(tmp_path, "rm", "--mount", str(mountpoint), "../outside")
|
|
199
|
+
assert refused_escape.returncode == 1
|
|
200
|
+
assert "escapes mountpoint" in refused_escape.stderr
|
|
201
|
+
|
|
202
|
+
refused_directory_remove = run_maft(tmp_path, "rm", "--mount", str(mountpoint), "Albums")
|
|
203
|
+
assert refused_directory_remove.returncode == 1
|
|
204
|
+
assert "pass --recursive" in refused_directory_remove.stderr
|
|
205
|
+
|
|
206
|
+
removed_directory = run_maft(
|
|
207
|
+
tmp_path,
|
|
208
|
+
"rm",
|
|
209
|
+
"--mount",
|
|
210
|
+
str(mountpoint),
|
|
211
|
+
"--recursive",
|
|
212
|
+
"Albums",
|
|
213
|
+
)
|
|
214
|
+
assert removed_directory.returncode == 0, removed_directory.stderr
|
|
215
|
+
assert not (mountpoint / "Albums").exists()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def wait_for_path(path: Path) -> None:
|
|
219
|
+
deadline = time.monotonic() + 2
|
|
220
|
+
while time.monotonic() < deadline:
|
|
221
|
+
if path.exists():
|
|
222
|
+
return
|
|
223
|
+
time.sleep(0.01)
|
|
224
|
+
raise AssertionError(f"path was not created: {path}")
|