mac-android-file-transfer 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,6 @@
1
+ maft/__init__.py,sha256=RiFAM3tBbJZXGnZOEnPWWfIhC0F-bqMV9HDHs0roBBI,89
2
+ maft/cli.py,sha256=HftSme0oL99FP-s6NvRFd8wnbeWTI87w1GyPXIgjs9U,12839
3
+ mac_android_file_transfer-0.1.0.dist-info/METADATA,sha256=DUL40w_Gte-zuRaJYNVxWpQthdoi_1TvWn4QvWChNYg,1103
4
+ mac_android_file_transfer-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
5
+ mac_android_file_transfer-0.1.0.dist-info/entry_points.txt,sha256=ZvOFMVVPE6mF03L3ufUy-iCRyTJWOgTDYK7uPyNNGpQ,39
6
+ mac_android_file_transfer-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ maft = maft.cli:main
maft/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """macOS Android file transfer CLI."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.1.0"
maft/cli.py ADDED
@@ -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())