termux-sync 1.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.
termux_sync/__init__.py
ADDED
|
@@ -0,0 +1,1535 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
OPENSOURCE
|
|
4
|
+
|
|
5
|
+
termux-sync — Termux Backup & Restore Tool
|
|
6
|
+
==========================================
|
|
7
|
+
Backup and restore your entire Termux environment across devices.
|
|
8
|
+
Supports Local, Google Drive, and GitHub (private repo) storage.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import json
|
|
14
|
+
import time
|
|
15
|
+
import shutil
|
|
16
|
+
import hashlib
|
|
17
|
+
import tarfile
|
|
18
|
+
import argparse
|
|
19
|
+
import datetime
|
|
20
|
+
import threading
|
|
21
|
+
import subprocess
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Optional
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
from rich.console import Console
|
|
27
|
+
from rich.panel import Panel
|
|
28
|
+
from rich.progress import (
|
|
29
|
+
Progress, SpinnerColumn, BarColumn,
|
|
30
|
+
TextColumn, TimeElapsedColumn, FileSizeColumn,
|
|
31
|
+
TransferSpeedColumn, TaskProgressColumn,
|
|
32
|
+
)
|
|
33
|
+
from rich.table import Table
|
|
34
|
+
from rich.prompt import Prompt, Confirm
|
|
35
|
+
from rich.text import Text
|
|
36
|
+
from rich import box
|
|
37
|
+
from rich.live import Live
|
|
38
|
+
from rich.columns import Columns
|
|
39
|
+
from rich.rule import Rule
|
|
40
|
+
from rich.syntax import Syntax
|
|
41
|
+
from rich.align import Align
|
|
42
|
+
RICH_AVAILABLE = True
|
|
43
|
+
except ImportError:
|
|
44
|
+
RICH_AVAILABLE = False
|
|
45
|
+
|
|
46
|
+
VERSION = "1.1.0"
|
|
47
|
+
APP_NAME = "termux-sync"
|
|
48
|
+
CONFIG_DIR = Path.home() / ".config" / APP_NAME
|
|
49
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
50
|
+
CRON_FILE = CONFIG_DIR / "cron.json"
|
|
51
|
+
LOG_FILE = CONFIG_DIR / "sync.log"
|
|
52
|
+
DEFAULT_DEST = Path.home() / "termux-backups"
|
|
53
|
+
|
|
54
|
+
TERMUX_HOME = Path(os.environ.get("PREFIX", "/data/data/com.termux/files/usr"))
|
|
55
|
+
TERMUX_FILES = Path(os.environ.get("HOME", str(Path.home())))
|
|
56
|
+
|
|
57
|
+
BACKUP_ITEMS = [
|
|
58
|
+
("packages", "Installed package list", None),
|
|
59
|
+
("home", "Home directory (~)", TERMUX_FILES),
|
|
60
|
+
("usr_etc", "Termux config (/usr/etc)", TERMUX_HOME / "etc"),
|
|
61
|
+
("usr_lib", "Shared libraries (/usr/lib)", TERMUX_HOME / "lib"),
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
BANNER = r"""
|
|
65
|
+
████████╗███████╗██████╗ ███╗ ███╗██╗ ██╗██╗ ██╗
|
|
66
|
+
╚══██╔══╝██╔════╝██╔══██╗████╗ ████║██║ ██║╚██╗██╔╝
|
|
67
|
+
██║ █████╗ ██████╔╝██╔████╔██║██║ ██║ ╚███╔╝
|
|
68
|
+
██║ ██╔══╝ ██╔══██╗██║╚██╔╝██║██║ ██║ ██╔██╗
|
|
69
|
+
██║ ███████╗██║ ██║██║ ╚═╝ ██║╚██████╔╝██╔╝ ██╗
|
|
70
|
+
╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def log(level: str, message: str):
|
|
74
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
ts = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
76
|
+
with open(LOG_FILE, "a") as f:
|
|
77
|
+
f.write(f"[{ts}] [{level.upper()}] {message}\n")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
DEFAULT_CONFIG = {
|
|
81
|
+
"storage": "local",
|
|
82
|
+
"local_path": str(DEFAULT_DEST),
|
|
83
|
+
"gdrive_folder_id": "",
|
|
84
|
+
"github_token": "",
|
|
85
|
+
"github_repo": "",
|
|
86
|
+
"github_branch": "main",
|
|
87
|
+
"compression": "gz",
|
|
88
|
+
"exclude_patterns": [
|
|
89
|
+
".cache", ".npm", "__pycache__", "node_modules", ".gradle",
|
|
90
|
+
".android", ".thumbnails", "DCIM", "Movies", "Music",
|
|
91
|
+
".local/share/Trash", ".local/lib", ".java",
|
|
92
|
+
".m2", ".ivy2", "go/pkg", ".rustup/toolchains",
|
|
93
|
+
".cargo/registry", ".cargo/git"
|
|
94
|
+
],
|
|
95
|
+
"max_backups": 5,
|
|
96
|
+
"encrypt": False,
|
|
97
|
+
"encrypt_password": "",
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def load_config() -> dict:
|
|
102
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
if CONFIG_FILE.exists():
|
|
104
|
+
with open(CONFIG_FILE) as f:
|
|
105
|
+
data = json.load(f)
|
|
106
|
+
for k, v in DEFAULT_CONFIG.items():
|
|
107
|
+
data.setdefault(k, v)
|
|
108
|
+
return data
|
|
109
|
+
return dict(DEFAULT_CONFIG)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def save_config(cfg: dict):
|
|
113
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
with open(CONFIG_FILE, "w") as f:
|
|
115
|
+
json.dump(cfg, f, indent=2)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
console = Console()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def print_banner():
|
|
122
|
+
console.print(
|
|
123
|
+
Align.center(
|
|
124
|
+
f"[bold green]{BANNER}[/bold green]"))
|
|
125
|
+
console.print(
|
|
126
|
+
Align.center(
|
|
127
|
+
f"[dim]Version: [/dim][bold white]v{VERSION}[/bold white]\n"
|
|
128
|
+
f"[dim]Source: [/dim][bold white]https://github.com/djunekz/termux-app-store[/bold white]\n"
|
|
129
|
+
" • [dim]Termux Backup & Restore[/dim]\n"
|
|
130
|
+
" • [dim]Opensource & License MIT[/dim]"
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
console.print()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def print_section(title: str, subtitle: str = ""):
|
|
137
|
+
console.print()
|
|
138
|
+
console.print(Rule(f"[bold yellow] {title} [/bold yellow]", style="yellow dim"))
|
|
139
|
+
if subtitle:
|
|
140
|
+
console.print(f" [dim]{subtitle}[/dim]")
|
|
141
|
+
console.print()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def status_icon(ok: bool) -> str:
|
|
145
|
+
return "[bold green]✓[/bold green]" if ok else "[bold red]✗[/bold red]"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def human_size(size_bytes: int) -> str:
|
|
149
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
150
|
+
if size_bytes < 1024:
|
|
151
|
+
return f"{size_bytes:.1f} {unit}"
|
|
152
|
+
size_bytes /= 1024
|
|
153
|
+
return f"{size_bytes:.1f} TB"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def sha256(path: Path, chunk=1 << 20) -> str:
|
|
157
|
+
h = hashlib.sha256()
|
|
158
|
+
with open(path, "rb") as f:
|
|
159
|
+
while chunk_data := f.read(chunk):
|
|
160
|
+
h.update(chunk_data)
|
|
161
|
+
return h.hexdigest()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def get_installed_packages() -> list[str]:
|
|
165
|
+
try:
|
|
166
|
+
result = subprocess.run(
|
|
167
|
+
["dpkg", "--get-selections"],
|
|
168
|
+
capture_output=True, text=True, check=True
|
|
169
|
+
)
|
|
170
|
+
pkgs = []
|
|
171
|
+
for line in result.stdout.splitlines():
|
|
172
|
+
parts = line.split()
|
|
173
|
+
if len(parts) >= 2 and parts[1] == "install":
|
|
174
|
+
pkgs.append(parts[0])
|
|
175
|
+
return pkgs
|
|
176
|
+
except Exception:
|
|
177
|
+
return []
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def install_packages(pkg_list: list[str], console: Console):
|
|
181
|
+
if not pkg_list:
|
|
182
|
+
console.print(" [yellow]No packages to install.[/yellow]")
|
|
183
|
+
return
|
|
184
|
+
with Progress(
|
|
185
|
+
SpinnerColumn(),
|
|
186
|
+
TextColumn("[progress.description]{task.description}"),
|
|
187
|
+
BarColumn(),
|
|
188
|
+
TaskProgressColumn(),
|
|
189
|
+
console=console,
|
|
190
|
+
) as progress:
|
|
191
|
+
task = progress.add_task("[cyan]Installing packages…", total=len(pkg_list))
|
|
192
|
+
for pkg in pkg_list:
|
|
193
|
+
try:
|
|
194
|
+
subprocess.run(
|
|
195
|
+
["pkg", "install", "-y", pkg],
|
|
196
|
+
capture_output=True, check=True
|
|
197
|
+
)
|
|
198
|
+
except Exception:
|
|
199
|
+
pass
|
|
200
|
+
progress.advance(task)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def create_archive(
|
|
204
|
+
source_path: Path,
|
|
205
|
+
archive_path: Path,
|
|
206
|
+
compression: str,
|
|
207
|
+
excludes: list[str],
|
|
208
|
+
label: str,
|
|
209
|
+
progress: Progress,
|
|
210
|
+
) -> Path:
|
|
211
|
+
mode = f"w:{compression}"
|
|
212
|
+
ext = f".tar.{compression}"
|
|
213
|
+
out = archive_path.with_suffix("").with_suffix(ext) if not str(archive_path).endswith(ext) else archive_path
|
|
214
|
+
|
|
215
|
+
task = progress.add_task(f"[cyan]{label}", total=None)
|
|
216
|
+
|
|
217
|
+
def _filter(tarinfo):
|
|
218
|
+
for pat in excludes:
|
|
219
|
+
if pat in tarinfo.name:
|
|
220
|
+
return None
|
|
221
|
+
return tarinfo
|
|
222
|
+
|
|
223
|
+
unlocked: list[tuple[Path, int]] = []
|
|
224
|
+
|
|
225
|
+
def _unlock(root: Path):
|
|
226
|
+
for dp, dirs, files in os.walk(root):
|
|
227
|
+
d = Path(dp)
|
|
228
|
+
try:
|
|
229
|
+
if not os.access(d, os.R_OK | os.X_OK):
|
|
230
|
+
m = d.stat().st_mode
|
|
231
|
+
d.chmod(m | 0o500)
|
|
232
|
+
unlocked.append((d, m))
|
|
233
|
+
except Exception:
|
|
234
|
+
pass
|
|
235
|
+
for fname in files:
|
|
236
|
+
fp = d / fname
|
|
237
|
+
try:
|
|
238
|
+
if not os.access(fp, os.R_OK):
|
|
239
|
+
m = fp.stat().st_mode
|
|
240
|
+
fp.chmod(m | 0o400)
|
|
241
|
+
unlocked.append((fp, m))
|
|
242
|
+
except Exception:
|
|
243
|
+
pass
|
|
244
|
+
|
|
245
|
+
def _relock():
|
|
246
|
+
for path, orig in reversed(unlocked):
|
|
247
|
+
try:
|
|
248
|
+
path.chmod(orig)
|
|
249
|
+
except Exception:
|
|
250
|
+
pass
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
if source_path.is_dir():
|
|
254
|
+
_unlock(source_path)
|
|
255
|
+
with tarfile.open(out, mode) as tf:
|
|
256
|
+
if source_path.is_dir():
|
|
257
|
+
tf.add(source_path, arcname=source_path.name, filter=_filter)
|
|
258
|
+
else:
|
|
259
|
+
tf.add(source_path, arcname=source_path.name)
|
|
260
|
+
finally:
|
|
261
|
+
_relock()
|
|
262
|
+
|
|
263
|
+
progress.update(task, total=1, completed=1, description=f"[green]{label} ✓")
|
|
264
|
+
return out
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def extract_archive(archive_path: Path, dest_path: Path, label: str, progress: Progress):
|
|
268
|
+
task = progress.add_task(f"[cyan]{label}", total=None)
|
|
269
|
+
dest_path.mkdir(parents=True, exist_ok=True)
|
|
270
|
+
with tarfile.open(archive_path, "r:*") as tf:
|
|
271
|
+
for member in tf.getmembers():
|
|
272
|
+
try:
|
|
273
|
+
tf.extract(member, dest_path, set_attrs=True)
|
|
274
|
+
except Exception:
|
|
275
|
+
try:
|
|
276
|
+
member.uid = os.getuid()
|
|
277
|
+
member.gid = os.getgid()
|
|
278
|
+
tf.extract(member, dest_path, set_attrs=True)
|
|
279
|
+
except Exception:
|
|
280
|
+
pass
|
|
281
|
+
progress.update(task, total=1, completed=1, description=f"[green]{label} ✓")
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def write_manifest(backup_dir: Path, meta: dict):
|
|
285
|
+
manifest_path = backup_dir / "manifest.json"
|
|
286
|
+
with open(manifest_path, "w") as f:
|
|
287
|
+
json.dump(meta, f, indent=2)
|
|
288
|
+
return manifest_path
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def read_manifest(backup_dir: Path) -> Optional[dict]:
|
|
292
|
+
p = backup_dir / "manifest.json"
|
|
293
|
+
if p.exists():
|
|
294
|
+
with open(p) as f:
|
|
295
|
+
return json.load(f)
|
|
296
|
+
return None
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
class LocalStorage:
|
|
300
|
+
def __init__(self, cfg: dict):
|
|
301
|
+
self.root = Path(cfg["local_path"])
|
|
302
|
+
|
|
303
|
+
def upload(self, backup_dir: Path, backup_name: str, progress: Progress) -> str:
|
|
304
|
+
dest = self.root / backup_name
|
|
305
|
+
task = progress.add_task("[cyan]Saving to local storage…", total=None)
|
|
306
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
307
|
+
for f in backup_dir.iterdir():
|
|
308
|
+
shutil.copy2(f, dest / f.name)
|
|
309
|
+
progress.update(task, total=1, completed=1, description="[green]Saved locally ✓")
|
|
310
|
+
return str(dest)
|
|
311
|
+
|
|
312
|
+
def list_backups(self) -> list[dict]:
|
|
313
|
+
if not self.root.exists():
|
|
314
|
+
return []
|
|
315
|
+
backups = []
|
|
316
|
+
for d in sorted(self.root.iterdir(), reverse=True):
|
|
317
|
+
if d.is_dir():
|
|
318
|
+
manifest = read_manifest(d)
|
|
319
|
+
if manifest:
|
|
320
|
+
backups.append(manifest)
|
|
321
|
+
return backups
|
|
322
|
+
|
|
323
|
+
def download(self, backup_name: str, dest: Path, progress: Progress):
|
|
324
|
+
src = self.root / backup_name
|
|
325
|
+
task = progress.add_task("[cyan]Loading from local storage…", total=None)
|
|
326
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
327
|
+
for f in src.iterdir():
|
|
328
|
+
shutil.copy2(f, dest / f.name)
|
|
329
|
+
progress.update(task, total=1, completed=1, description="[green]Loaded ✓")
|
|
330
|
+
|
|
331
|
+
def delete_old(self, max_keep: int):
|
|
332
|
+
if not self.root.exists():
|
|
333
|
+
return
|
|
334
|
+
dirs = sorted(self.root.iterdir(), key=lambda d: d.stat().st_mtime, reverse=True)
|
|
335
|
+
dirs = [d for d in dirs if d.is_dir()]
|
|
336
|
+
for old in dirs[max_keep:]:
|
|
337
|
+
shutil.rmtree(old)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
class GDriveStorage:
|
|
341
|
+
REMOTE = "termux-sync-gdrive"
|
|
342
|
+
|
|
343
|
+
def __init__(self, cfg: dict):
|
|
344
|
+
self.folder_id = cfg.get("gdrive_folder_id", "")
|
|
345
|
+
self._check_rclone()
|
|
346
|
+
|
|
347
|
+
def _check_rclone(self):
|
|
348
|
+
if shutil.which("rclone") is None:
|
|
349
|
+
console.print("[red]rclone not found. Install: pkg install rclone[/red]")
|
|
350
|
+
sys.exit(1)
|
|
351
|
+
|
|
352
|
+
def _remote_path(self, name: str = "") -> str:
|
|
353
|
+
base = f"{self.REMOTE}:"
|
|
354
|
+
if self.folder_id:
|
|
355
|
+
base += self.folder_id
|
|
356
|
+
return f"{base}/{name}" if name else base
|
|
357
|
+
|
|
358
|
+
def upload(self, backup_dir: Path, backup_name: str, progress: Progress) -> str:
|
|
359
|
+
task = progress.add_task("[cyan]Uploading to Google Drive…", total=None)
|
|
360
|
+
remote = self._remote_path(backup_name)
|
|
361
|
+
subprocess.run(
|
|
362
|
+
["rclone", "copy", str(backup_dir), remote, "--progress"],
|
|
363
|
+
check=True, capture_output=True
|
|
364
|
+
)
|
|
365
|
+
progress.update(task, total=1, completed=1, description="[green]Uploaded to Google Drive ✓")
|
|
366
|
+
return remote
|
|
367
|
+
|
|
368
|
+
def list_backups(self) -> list[dict]:
|
|
369
|
+
try:
|
|
370
|
+
result = subprocess.run(
|
|
371
|
+
["rclone", "lsjson", self._remote_path(), "--dirs-only"],
|
|
372
|
+
capture_output=True, text=True, check=True
|
|
373
|
+
)
|
|
374
|
+
items = json.loads(result.stdout)
|
|
375
|
+
backups = []
|
|
376
|
+
for item in sorted(items, key=lambda x: x.get("ModTime", ""), reverse=True):
|
|
377
|
+
try:
|
|
378
|
+
r = subprocess.run(
|
|
379
|
+
["rclone", "cat", self._remote_path(f"{item['Name']}/manifest.json")],
|
|
380
|
+
capture_output=True, text=True, check=True
|
|
381
|
+
)
|
|
382
|
+
backups.append(json.loads(r.stdout))
|
|
383
|
+
except Exception:
|
|
384
|
+
backups.append({"name": item["Name"], "date": item.get("ModTime", "")})
|
|
385
|
+
return backups
|
|
386
|
+
except Exception:
|
|
387
|
+
return []
|
|
388
|
+
|
|
389
|
+
def download(self, backup_name: str, dest: Path, progress: Progress):
|
|
390
|
+
task = progress.add_task("[cyan]Downloading from Google Drive…", total=None)
|
|
391
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
392
|
+
subprocess.run(
|
|
393
|
+
["rclone", "copy", self._remote_path(backup_name), str(dest)],
|
|
394
|
+
check=True, capture_output=True
|
|
395
|
+
)
|
|
396
|
+
progress.update(task, total=1, completed=1, description="[green]Downloaded ✓")
|
|
397
|
+
|
|
398
|
+
def delete_old(self, max_keep: int):
|
|
399
|
+
pass
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
class GitHubStorage:
|
|
403
|
+
API = "https://api.github.com"
|
|
404
|
+
|
|
405
|
+
def __init__(self, cfg: dict):
|
|
406
|
+
self.token = cfg.get("github_token", "")
|
|
407
|
+
self.repo = cfg.get("github_repo", "")
|
|
408
|
+
self.branch = cfg.get("github_branch", "main")
|
|
409
|
+
if not self.token or not self.repo:
|
|
410
|
+
console.print("[red]GitHub token and repo are required. Run: termux-sync setup[/red]")
|
|
411
|
+
sys.exit(1)
|
|
412
|
+
|
|
413
|
+
def _headers(self) -> dict:
|
|
414
|
+
return {
|
|
415
|
+
"Authorization": f"token {self.token}",
|
|
416
|
+
"Accept": "application/vnd.github.v3+json",
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
def _api(self, method: str, endpoint: str, **kwargs):
|
|
420
|
+
import urllib.request, urllib.error
|
|
421
|
+
url = f"{self.API}{endpoint}"
|
|
422
|
+
data = json.dumps(kwargs.get("json", {})).encode() if "json" in kwargs else None
|
|
423
|
+
req = urllib.request.Request(url, data=data, headers=self._headers(), method=method)
|
|
424
|
+
try:
|
|
425
|
+
with urllib.request.urlopen(req) as resp:
|
|
426
|
+
return json.loads(resp.read())
|
|
427
|
+
except urllib.error.HTTPError as e:
|
|
428
|
+
body = e.read().decode()
|
|
429
|
+
raise RuntimeError(f"GitHub API error {e.code}: {body}") from e
|
|
430
|
+
|
|
431
|
+
def _push_file(self, local_path: Path, remote_path: str, message: str):
|
|
432
|
+
import base64
|
|
433
|
+
content = base64.b64encode(local_path.read_bytes()).decode()
|
|
434
|
+
sha = None
|
|
435
|
+
try:
|
|
436
|
+
r = self._api("GET", f"/repos/{self.repo}/contents/{remote_path}?ref={self.branch}")
|
|
437
|
+
sha = r.get("sha")
|
|
438
|
+
except Exception:
|
|
439
|
+
pass
|
|
440
|
+
|
|
441
|
+
payload = {
|
|
442
|
+
"message": message,
|
|
443
|
+
"content": content,
|
|
444
|
+
"branch": self.branch,
|
|
445
|
+
}
|
|
446
|
+
if sha:
|
|
447
|
+
payload["sha"] = sha
|
|
448
|
+
|
|
449
|
+
self._api("PUT", f"/repos/{self.repo}/contents/{remote_path}", json=payload)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _create_release(self, backup_name: str) -> dict:
|
|
453
|
+
return self._api("POST", f"/repos/{self.repo}/releases", json={
|
|
454
|
+
"tag_name": backup_name,
|
|
455
|
+
"name": backup_name,
|
|
456
|
+
"body": f"termux-sync backup — {backup_name}",
|
|
457
|
+
"draft": False,
|
|
458
|
+
"prerelease": False,
|
|
459
|
+
"target_commitish": self.branch,
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
def _get_release_by_tag(self, tag: str) -> Optional[dict]:
|
|
463
|
+
try:
|
|
464
|
+
return self._api("GET", f"/repos/{self.repo}/releases/tags/{tag}")
|
|
465
|
+
except Exception:
|
|
466
|
+
return None
|
|
467
|
+
|
|
468
|
+
def _list_releases(self) -> list[dict]:
|
|
469
|
+
try:
|
|
470
|
+
return self._api("GET", f"/repos/{self.repo}/releases?per_page=100")
|
|
471
|
+
except Exception:
|
|
472
|
+
return []
|
|
473
|
+
|
|
474
|
+
CHUNK_SIZE = 200 * 1024 * 1024
|
|
475
|
+
MAX_RETRIES = 8
|
|
476
|
+
|
|
477
|
+
def _split_file(self, src: Path, tmp_dir: Path) -> list:
|
|
478
|
+
size = src.stat().st_size
|
|
479
|
+
if size <= self.CHUNK_SIZE:
|
|
480
|
+
return [src]
|
|
481
|
+
chunks = []
|
|
482
|
+
idx = 0
|
|
483
|
+
with open(src, "rb") as f:
|
|
484
|
+
while True:
|
|
485
|
+
data = f.read(self.CHUNK_SIZE)
|
|
486
|
+
if not data:
|
|
487
|
+
break
|
|
488
|
+
chunk_path = tmp_dir / f"{src.name}.part{idx:04d}"
|
|
489
|
+
chunk_path.write_bytes(data)
|
|
490
|
+
chunks.append(chunk_path)
|
|
491
|
+
idx += 1
|
|
492
|
+
return chunks
|
|
493
|
+
|
|
494
|
+
def _upload_one_asset(self, release_id: int, local_path: Path,
|
|
495
|
+
progress_task, progress: Progress):
|
|
496
|
+
import urllib.request, urllib.error, time as _time
|
|
497
|
+
upload_url = (
|
|
498
|
+
f"https://uploads.github.com/repos/{self.repo}/releases/{release_id}/assets"
|
|
499
|
+
f"?name={local_path.name}"
|
|
500
|
+
)
|
|
501
|
+
file_size = local_path.stat().st_size
|
|
502
|
+
progress.update(progress_task, total=file_size, completed=0)
|
|
503
|
+
|
|
504
|
+
last_err = None
|
|
505
|
+
for attempt in range(self.MAX_RETRIES):
|
|
506
|
+
try:
|
|
507
|
+
with open(local_path, "rb") as fobj:
|
|
508
|
+
data = fobj.read()
|
|
509
|
+
headers = dict(self._headers())
|
|
510
|
+
headers["Content-Type"] = "application/octet-stream"
|
|
511
|
+
headers["Content-Length"] = str(len(data))
|
|
512
|
+
req = urllib.request.Request(
|
|
513
|
+
upload_url, data=data, headers=headers, method="POST"
|
|
514
|
+
)
|
|
515
|
+
with urllib.request.urlopen(req, timeout=120) as resp:
|
|
516
|
+
result = json.loads(resp.read())
|
|
517
|
+
progress.update(progress_task, completed=file_size)
|
|
518
|
+
return result
|
|
519
|
+
except (urllib.error.URLError, OSError, ConnectionError) as e:
|
|
520
|
+
last_err = e
|
|
521
|
+
wait = 15 * (attempt + 1)
|
|
522
|
+
progress.update(progress_task,
|
|
523
|
+
description=f"[yellow]Retry {attempt+1}/{self.MAX_RETRIES} ({local_path.name})...")
|
|
524
|
+
_time.sleep(wait)
|
|
525
|
+
except urllib.error.HTTPError as e:
|
|
526
|
+
body = e.read().decode()
|
|
527
|
+
if e.code == 422:
|
|
528
|
+
progress.update(progress_task, completed=file_size,
|
|
529
|
+
description=f"[yellow]{local_path.name} already uploaded, skipping")
|
|
530
|
+
return {}
|
|
531
|
+
raise RuntimeError(f"Asset upload failed {e.code}: {body}") from e
|
|
532
|
+
|
|
533
|
+
raise RuntimeError(
|
|
534
|
+
f"Failed to upload {local_path.name} after {self.MAX_RETRIES} attempts: {last_err}"
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def upload(self, backup_dir: Path, backup_name: str, progress: Progress) -> str:
|
|
539
|
+
task = progress.add_task("[cyan]Creating GitHub Release...", total=None)
|
|
540
|
+
existing = self._get_release_by_tag(backup_name)
|
|
541
|
+
if existing:
|
|
542
|
+
release = existing
|
|
543
|
+
else:
|
|
544
|
+
release = self._create_release(backup_name)
|
|
545
|
+
release_id = release["id"]
|
|
546
|
+
release_url = release["html_url"]
|
|
547
|
+
progress.update(task, total=1, completed=1,
|
|
548
|
+
description="[green]Release ready")
|
|
549
|
+
|
|
550
|
+
existing_assets = {a["name"] for a in release.get("assets", [])}
|
|
551
|
+
|
|
552
|
+
tmp_split = Path(os.environ.get("TMPDIR", "/data/data/com.termux/files/usr/tmp")) / f"termux-sync-split-{backup_name}"
|
|
553
|
+
tmp_split.mkdir(parents=True, exist_ok=True)
|
|
554
|
+
try:
|
|
555
|
+
for f in sorted(backup_dir.iterdir()):
|
|
556
|
+
if not f.is_file():
|
|
557
|
+
continue
|
|
558
|
+
chunks = self._split_file(f, tmp_split)
|
|
559
|
+
for chunk in chunks:
|
|
560
|
+
if chunk.name in existing_assets:
|
|
561
|
+
continue
|
|
562
|
+
asset_task = progress.add_task(
|
|
563
|
+
f"[cyan]{chunk.name}...", total=chunk.stat().st_size
|
|
564
|
+
)
|
|
565
|
+
self._upload_one_asset(release_id, chunk, asset_task, progress)
|
|
566
|
+
progress.update(asset_task, description=f"[green]{chunk.name} done")
|
|
567
|
+
finally:
|
|
568
|
+
shutil.rmtree(tmp_split, ignore_errors=True)
|
|
569
|
+
|
|
570
|
+
progress.update(task, description="[green]Uploaded to GitHub Releases")
|
|
571
|
+
return release_url
|
|
572
|
+
|
|
573
|
+
def list_backups(self) -> list[dict]:
|
|
574
|
+
backups = []
|
|
575
|
+
for rel in self._list_releases():
|
|
576
|
+
tag = rel.get("tag_name", "")
|
|
577
|
+
if not tag.startswith("termux_backup_"):
|
|
578
|
+
continue
|
|
579
|
+
manifest = None
|
|
580
|
+
for asset in rel.get("assets", []):
|
|
581
|
+
if asset["name"] == "manifest.json":
|
|
582
|
+
try:
|
|
583
|
+
import urllib.request
|
|
584
|
+
req = urllib.request.Request(
|
|
585
|
+
asset["browser_download_url"],
|
|
586
|
+
headers=self._headers()
|
|
587
|
+
)
|
|
588
|
+
with urllib.request.urlopen(req, timeout=30) as r:
|
|
589
|
+
manifest = json.loads(r.read())
|
|
590
|
+
except Exception:
|
|
591
|
+
pass
|
|
592
|
+
break
|
|
593
|
+
backups.append(manifest or {
|
|
594
|
+
"name": tag,
|
|
595
|
+
"date": rel.get("created_at", ""),
|
|
596
|
+
"label": rel.get("name", tag),
|
|
597
|
+
})
|
|
598
|
+
return backups
|
|
599
|
+
|
|
600
|
+
def download(self, backup_name: str, dest: Path, progress: Progress):
|
|
601
|
+
import urllib.request, re as _re, time as _time
|
|
602
|
+
release = self._get_release_by_tag(backup_name)
|
|
603
|
+
if not release:
|
|
604
|
+
raise RuntimeError(f"No GitHub Release found for tag: {backup_name}")
|
|
605
|
+
assets = sorted(release.get("assets", []), key=lambda a: a["name"])
|
|
606
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
607
|
+
|
|
608
|
+
dl_task = progress.add_task("[cyan]Downloading from GitHub...", total=len(assets))
|
|
609
|
+
|
|
610
|
+
for asset in assets:
|
|
611
|
+
url = asset["browser_download_url"]
|
|
612
|
+
out = dest / asset["name"]
|
|
613
|
+
size = asset.get("size", 0)
|
|
614
|
+
f_task = progress.add_task(f"[cyan]{asset['name']}...", total=max(size, 1))
|
|
615
|
+
last_err = None
|
|
616
|
+
for attempt in range(8):
|
|
617
|
+
try:
|
|
618
|
+
req = urllib.request.Request(url, headers=self._headers())
|
|
619
|
+
with urllib.request.urlopen(req, timeout=120) as r:
|
|
620
|
+
data = r.read()
|
|
621
|
+
out.write_bytes(data)
|
|
622
|
+
progress.update(f_task, completed=size,
|
|
623
|
+
description=f"[green]{asset['name']} done")
|
|
624
|
+
last_err = None
|
|
625
|
+
break
|
|
626
|
+
except Exception as e:
|
|
627
|
+
last_err = e
|
|
628
|
+
progress.update(f_task,
|
|
629
|
+
description=f"[yellow]Retry {attempt+1}/8 ({asset['name']})...")
|
|
630
|
+
_time.sleep(15 * (attempt + 1))
|
|
631
|
+
if last_err:
|
|
632
|
+
raise RuntimeError(
|
|
633
|
+
f"Failed to download {asset['name']} after 8 attempts: {last_err}"
|
|
634
|
+
)
|
|
635
|
+
progress.advance(dl_task)
|
|
636
|
+
|
|
637
|
+
chunk_re = _re.compile(r"^(.+)\.part(\d{4})$")
|
|
638
|
+
groups: dict = {}
|
|
639
|
+
for f in sorted(dest.iterdir()):
|
|
640
|
+
m = chunk_re.match(f.name)
|
|
641
|
+
if m:
|
|
642
|
+
groups.setdefault(m.group(1), []).append(f)
|
|
643
|
+
|
|
644
|
+
if groups:
|
|
645
|
+
assemble_task = progress.add_task(
|
|
646
|
+
"[cyan]Reassembling chunks...", total=len(groups)
|
|
647
|
+
)
|
|
648
|
+
for original_name, parts in groups.items():
|
|
649
|
+
parts.sort(key=lambda p: p.name)
|
|
650
|
+
out_file = dest / original_name
|
|
651
|
+
with open(out_file, "wb") as fout:
|
|
652
|
+
for part in parts:
|
|
653
|
+
fout.write(part.read_bytes())
|
|
654
|
+
part.unlink()
|
|
655
|
+
progress.update(assemble_task,
|
|
656
|
+
description=f"[green]Reassembled {original_name}",
|
|
657
|
+
advance=1)
|
|
658
|
+
progress.update(assemble_task, description="[green]Chunks reassembled")
|
|
659
|
+
|
|
660
|
+
progress.update(dl_task, description="[green]Downloaded from GitHub")
|
|
661
|
+
|
|
662
|
+
def delete_old(self, max_keep: int):
|
|
663
|
+
releases = [
|
|
664
|
+
r for r in self._list_releases()
|
|
665
|
+
if r.get("tag_name", "").startswith("termux_backup_")
|
|
666
|
+
]
|
|
667
|
+
releases.sort(key=lambda r: r.get("created_at", ""), reverse=True)
|
|
668
|
+
for old in releases[max_keep:]:
|
|
669
|
+
try:
|
|
670
|
+
self._api("DELETE", f"/repos/{self.repo}/releases/{old['id']}")
|
|
671
|
+
self._api("DELETE", f"/repos/{self.repo}/git/refs/tags/{old['tag_name']}")
|
|
672
|
+
except Exception:
|
|
673
|
+
pass
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def get_storage(cfg: dict):
|
|
677
|
+
s = cfg.get("storage", "local")
|
|
678
|
+
if s == "gdrive":
|
|
679
|
+
return GDriveStorage(cfg)
|
|
680
|
+
if s == "github":
|
|
681
|
+
return GitHubStorage(cfg)
|
|
682
|
+
return LocalStorage(cfg)
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def cmd_backup(cfg: dict, label: str = ""):
|
|
686
|
+
print_banner()
|
|
687
|
+
print_section("BACKUP", "Creating a full snapshot of your Termux environment")
|
|
688
|
+
|
|
689
|
+
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
690
|
+
backup_name = f"termux_backup_{timestamp}"
|
|
691
|
+
if label:
|
|
692
|
+
backup_name += f"_{label.replace(' ', '_')}"
|
|
693
|
+
|
|
694
|
+
tmp_dir = Path(os.environ.get("TMPDIR", "/data/data/com.termux/files/usr/tmp")) / backup_name
|
|
695
|
+
tmp_dir.mkdir(parents=True, exist_ok=True)
|
|
696
|
+
|
|
697
|
+
def _cleanup_tmp():
|
|
698
|
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
699
|
+
|
|
700
|
+
compression = cfg.get("compression", "gz")
|
|
701
|
+
excludes = cfg.get("exclude_patterns", [])
|
|
702
|
+
storage = get_storage(cfg)
|
|
703
|
+
|
|
704
|
+
meta = {
|
|
705
|
+
"name": backup_name,
|
|
706
|
+
"date": datetime.datetime.now().isoformat(),
|
|
707
|
+
"label": label,
|
|
708
|
+
"termux_home": str(TERMUX_FILES),
|
|
709
|
+
"termux_usr": str(TERMUX_HOME),
|
|
710
|
+
"storage": cfg["storage"],
|
|
711
|
+
"compression": compression,
|
|
712
|
+
"files": {},
|
|
713
|
+
"packages": [],
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
console.print(" [bold]Step 1/4[/bold] Collecting installed packages…")
|
|
717
|
+
pkgs = get_installed_packages()
|
|
718
|
+
meta["packages"] = pkgs
|
|
719
|
+
pkg_file = tmp_dir / "packages.txt"
|
|
720
|
+
pkg_file.write_text("\n".join(pkgs))
|
|
721
|
+
console.print(f" {status_icon(True)} Found [bold green]{len(pkgs)}[/bold green] installed packages")
|
|
722
|
+
log("INFO", f"Collected {len(pkgs)} packages")
|
|
723
|
+
|
|
724
|
+
console.print()
|
|
725
|
+
console.print(" [bold]Step 2/4[/bold] Archiving directories…")
|
|
726
|
+
console.print()
|
|
727
|
+
|
|
728
|
+
with Progress(
|
|
729
|
+
SpinnerColumn(),
|
|
730
|
+
TextColumn(" {task.description}"),
|
|
731
|
+
BarColumn(bar_width=30),
|
|
732
|
+
TextColumn("[dim]{task.percentage:>3.0f}%"),
|
|
733
|
+
TimeElapsedColumn(),
|
|
734
|
+
console=console,
|
|
735
|
+
expand=False,
|
|
736
|
+
) as progress:
|
|
737
|
+
archive_items = [
|
|
738
|
+
("home", "Home directory (~)", TERMUX_FILES),
|
|
739
|
+
("usr_etc", "Termux config (/usr/etc)", TERMUX_HOME / "etc"),
|
|
740
|
+
]
|
|
741
|
+
for key, label_text, src in archive_items:
|
|
742
|
+
if not src.exists():
|
|
743
|
+
console.print(f" [yellow]⚠ Skipping {src} (not found)[/yellow]")
|
|
744
|
+
continue
|
|
745
|
+
out_path = tmp_dir / f"{key}.tar.{compression}"
|
|
746
|
+
try:
|
|
747
|
+
create_archive(src, out_path, compression, excludes, label_text, progress)
|
|
748
|
+
checksum = sha256(out_path)
|
|
749
|
+
meta["files"][key] = {
|
|
750
|
+
"archive": out_path.name,
|
|
751
|
+
"size": out_path.stat().st_size,
|
|
752
|
+
"checksum": checksum,
|
|
753
|
+
"source": str(src),
|
|
754
|
+
}
|
|
755
|
+
log("INFO", f"Archived {src} → {out_path.name} ({human_size(out_path.stat().st_size)})")
|
|
756
|
+
except Exception as e:
|
|
757
|
+
console.print(f" [red]✗ Failed to archive {src}: {e}[/red]")
|
|
758
|
+
log("ERROR", f"Archive failed for {src}: {e}")
|
|
759
|
+
|
|
760
|
+
console.print()
|
|
761
|
+
console.print(" [bold]Step 3/4[/bold] Writing manifest…")
|
|
762
|
+
write_manifest(tmp_dir, meta)
|
|
763
|
+
console.print(f" {status_icon(True)} Manifest written")
|
|
764
|
+
|
|
765
|
+
console.print()
|
|
766
|
+
console.print(f" [bold]Step 4/4[/bold] Uploading to [bold cyan]{cfg['storage'].upper()}[/bold cyan]…")
|
|
767
|
+
console.print()
|
|
768
|
+
|
|
769
|
+
location = ""
|
|
770
|
+
with Progress(
|
|
771
|
+
SpinnerColumn(),
|
|
772
|
+
TextColumn(" {task.description}"),
|
|
773
|
+
BarColumn(bar_width=30),
|
|
774
|
+
TimeElapsedColumn(),
|
|
775
|
+
console=console,
|
|
776
|
+
) as progress:
|
|
777
|
+
try:
|
|
778
|
+
location = storage.upload(tmp_dir, backup_name, progress)
|
|
779
|
+
storage.delete_old(cfg.get("max_backups", 5))
|
|
780
|
+
except KeyboardInterrupt:
|
|
781
|
+
_cleanup_tmp()
|
|
782
|
+
raise
|
|
783
|
+
except Exception as e:
|
|
784
|
+
console.print(f" [red]✗ Upload failed: {e}[/red]")
|
|
785
|
+
log("ERROR", f"Upload failed: {e}")
|
|
786
|
+
_cleanup_tmp()
|
|
787
|
+
return
|
|
788
|
+
|
|
789
|
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
790
|
+
|
|
791
|
+
total_size = sum(v["size"] for v in meta["files"].values())
|
|
792
|
+
|
|
793
|
+
console.print()
|
|
794
|
+
console.print(Panel(
|
|
795
|
+
f"[bold green]✓ Backup completed successfully![/bold green]\n\n"
|
|
796
|
+
f" [dim]Name :[/dim] [white]{backup_name}[/white]\n"
|
|
797
|
+
f" [dim]Date :[/dim] [white]{meta['date'][:19].replace('T', ' ')}[/white]\n"
|
|
798
|
+
f" [dim]Packages :[/dim] [white]{len(pkgs)}[/white]\n"
|
|
799
|
+
f" [dim]Total size:[/dim] [white]{human_size(total_size)}[/white]\n"
|
|
800
|
+
f" [dim]Storage :[/dim] [white]{cfg['storage'].upper()}[/white]\n"
|
|
801
|
+
f" [dim]Location :[/dim] [white]{location}[/white]",
|
|
802
|
+
title="[bold yellow] Backup Summary [/bold yellow]",
|
|
803
|
+
border_style="green",
|
|
804
|
+
padding=(1, 2),
|
|
805
|
+
))
|
|
806
|
+
log("INFO", f"Backup completed: {backup_name} ({human_size(total_size)})")
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def cmd_restore(cfg: dict, backup_name: str = ""):
|
|
810
|
+
print_banner()
|
|
811
|
+
print_section("RESTORE", "Restoring your Termux environment from a backup")
|
|
812
|
+
|
|
813
|
+
storage = get_storage(cfg)
|
|
814
|
+
|
|
815
|
+
console.print(" [bold]Fetching available backups…[/bold]")
|
|
816
|
+
backups = storage.list_backups()
|
|
817
|
+
|
|
818
|
+
if not backups:
|
|
819
|
+
console.print(" [red]No backups found in the configured storage.[/red]")
|
|
820
|
+
return
|
|
821
|
+
|
|
822
|
+
if not backup_name:
|
|
823
|
+
table = Table(
|
|
824
|
+
box=box.ROUNDED,
|
|
825
|
+
title="[bold yellow]Available Backups[/bold yellow]",
|
|
826
|
+
show_header=True,
|
|
827
|
+
header_style="bold cyan",
|
|
828
|
+
padding=(0, 1),
|
|
829
|
+
)
|
|
830
|
+
table.add_column("#", style="dim", width=4)
|
|
831
|
+
table.add_column("Name", style="bold white", min_width=30)
|
|
832
|
+
table.add_column("Date", style="green")
|
|
833
|
+
table.add_column("Label", style="yellow")
|
|
834
|
+
table.add_column("Packages",style="cyan", justify="right")
|
|
835
|
+
table.add_column("Storage", style="blue")
|
|
836
|
+
|
|
837
|
+
for i, b in enumerate(backups, 1):
|
|
838
|
+
table.add_row(
|
|
839
|
+
str(i),
|
|
840
|
+
b.get("name", "—"),
|
|
841
|
+
b.get("date", "—")[:19].replace("T", " "),
|
|
842
|
+
b.get("label", "") or "—",
|
|
843
|
+
str(len(b.get("packages", []))),
|
|
844
|
+
b.get("storage", cfg["storage"]).upper(),
|
|
845
|
+
)
|
|
846
|
+
|
|
847
|
+
console.print(table)
|
|
848
|
+
console.print()
|
|
849
|
+
|
|
850
|
+
choice = Prompt.ask(" [bold]Enter backup number or name[/bold]", default="1")
|
|
851
|
+
try:
|
|
852
|
+
idx = int(choice) - 1
|
|
853
|
+
backup_name = backups[idx]["name"]
|
|
854
|
+
except (ValueError, IndexError):
|
|
855
|
+
backup_name = choice
|
|
856
|
+
|
|
857
|
+
tmp_dir = Path(os.environ.get("TMPDIR", "/data/data/com.termux/files/usr/tmp")) / f"restore_{backup_name}"
|
|
858
|
+
tmp_dir.mkdir(parents=True, exist_ok=True)
|
|
859
|
+
|
|
860
|
+
console.print()
|
|
861
|
+
console.print(" [bold]Step 1/4[/bold] Downloading backup…")
|
|
862
|
+
with Progress(
|
|
863
|
+
SpinnerColumn(),
|
|
864
|
+
TextColumn(" {task.description}"),
|
|
865
|
+
BarColumn(bar_width=30),
|
|
866
|
+
TimeElapsedColumn(),
|
|
867
|
+
console=console,
|
|
868
|
+
) as progress:
|
|
869
|
+
try:
|
|
870
|
+
storage.download(backup_name, tmp_dir, progress)
|
|
871
|
+
except Exception as e:
|
|
872
|
+
console.print(f" [red]✗ Download failed: {e}[/red]")
|
|
873
|
+
log("ERROR", f"Download failed: {e}")
|
|
874
|
+
return
|
|
875
|
+
|
|
876
|
+
manifest = read_manifest(tmp_dir)
|
|
877
|
+
if not manifest:
|
|
878
|
+
console.print(" [red]✗ Could not read manifest. Backup may be corrupted.[/red]")
|
|
879
|
+
return
|
|
880
|
+
|
|
881
|
+
console.print()
|
|
882
|
+
console.print(" [bold]Step 2/4[/bold] Verifying checksums…")
|
|
883
|
+
all_ok = True
|
|
884
|
+
for key, info in manifest.get("files", {}).items():
|
|
885
|
+
archive = tmp_dir / info["archive"]
|
|
886
|
+
if not archive.exists():
|
|
887
|
+
console.print(f" [red]✗ Missing archive: {info['archive']}[/red]")
|
|
888
|
+
all_ok = False
|
|
889
|
+
continue
|
|
890
|
+
cs = sha256(archive)
|
|
891
|
+
ok = cs == info.get("checksum", cs)
|
|
892
|
+
icon = status_icon(ok)
|
|
893
|
+
console.print(f" {icon} {info['archive']} [dim]{cs[:16]}…[/dim]")
|
|
894
|
+
if not ok:
|
|
895
|
+
all_ok = False
|
|
896
|
+
|
|
897
|
+
if not all_ok:
|
|
898
|
+
if not Confirm.ask(" [yellow]Some checksums failed. Continue anyway?[/yellow]"):
|
|
899
|
+
return
|
|
900
|
+
|
|
901
|
+
console.print()
|
|
902
|
+
console.print(Panel(
|
|
903
|
+
f" [bold]About to restore:[/bold]\n\n"
|
|
904
|
+
f" Backup : [white]{backup_name}[/white]\n"
|
|
905
|
+
f" Date : [white]{manifest.get('date', '—')[:19].replace('T', ' ')}[/white]\n"
|
|
906
|
+
f" Packages : [white]{len(manifest.get('packages', []))}[/white]\n"
|
|
907
|
+
f" Archives : [white]{len(manifest.get('files', {}))}[/white]",
|
|
908
|
+
title="[bold yellow] Restore Preview [/bold yellow]",
|
|
909
|
+
border_style="yellow",
|
|
910
|
+
padding=(1, 2),
|
|
911
|
+
))
|
|
912
|
+
|
|
913
|
+
if not Confirm.ask("\n [bold yellow]Proceed with restore? This will overwrite existing files.[/bold yellow]"):
|
|
914
|
+
console.print(" [dim]Restore cancelled.[/dim]")
|
|
915
|
+
return
|
|
916
|
+
|
|
917
|
+
console.print()
|
|
918
|
+
console.print(" [bold]Step 3/4[/bold] Extracting archives…")
|
|
919
|
+
with Progress(
|
|
920
|
+
SpinnerColumn(),
|
|
921
|
+
TextColumn(" {task.description}"),
|
|
922
|
+
BarColumn(bar_width=30),
|
|
923
|
+
TimeElapsedColumn(),
|
|
924
|
+
console=console,
|
|
925
|
+
) as progress:
|
|
926
|
+
for key, info in manifest.get("files", {}).items():
|
|
927
|
+
archive = tmp_dir / info["archive"]
|
|
928
|
+
src_dir = Path(info.get("source", str(TERMUX_FILES))).parent
|
|
929
|
+
try:
|
|
930
|
+
extract_archive(archive, src_dir, info["archive"], progress)
|
|
931
|
+
log("INFO", f"Extracted {info['archive']} → {src_dir}")
|
|
932
|
+
except Exception as e:
|
|
933
|
+
console.print(f" [red]✗ Extract failed: {e}[/red]")
|
|
934
|
+
log("ERROR", f"Extract failed for {info['archive']}: {e}")
|
|
935
|
+
|
|
936
|
+
pkgs = manifest.get("packages", [])
|
|
937
|
+
if pkgs:
|
|
938
|
+
console.print()
|
|
939
|
+
console.print(f" [bold]Step 4/4[/bold] Reinstalling [bold green]{len(pkgs)}[/bold green] packages…")
|
|
940
|
+
install_packages(pkgs, console)
|
|
941
|
+
log("INFO", f"Reinstalled {len(pkgs)} packages")
|
|
942
|
+
|
|
943
|
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
944
|
+
|
|
945
|
+
console.print()
|
|
946
|
+
console.print(Panel(
|
|
947
|
+
"[bold green]✓ Restore completed successfully![/bold green]\n\n"
|
|
948
|
+
" Please restart Termux for all changes to take effect.",
|
|
949
|
+
title="[bold yellow] Restore Summary [/bold yellow]",
|
|
950
|
+
border_style="green",
|
|
951
|
+
padding=(1, 2),
|
|
952
|
+
))
|
|
953
|
+
log("INFO", f"Restore completed from: {backup_name}")
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def cmd_list(cfg: dict):
|
|
957
|
+
print_banner()
|
|
958
|
+
print_section("BACKUP LIST", f"Storage: {cfg['storage'].upper()}")
|
|
959
|
+
|
|
960
|
+
storage = get_storage(cfg)
|
|
961
|
+
backups = storage.list_backups()
|
|
962
|
+
|
|
963
|
+
if not backups:
|
|
964
|
+
console.print(" [yellow]No backups found.[/yellow]")
|
|
965
|
+
return
|
|
966
|
+
|
|
967
|
+
table = Table(
|
|
968
|
+
box=box.DOUBLE_EDGE,
|
|
969
|
+
show_header=True,
|
|
970
|
+
header_style="bold cyan",
|
|
971
|
+
padding=(0, 2),
|
|
972
|
+
title=f"[bold yellow] {len(backups)} Backup(s) Found [/bold yellow]",
|
|
973
|
+
)
|
|
974
|
+
table.add_column("#", style="dim", width=4)
|
|
975
|
+
table.add_column("Backup Name",style="bold white", min_width=32)
|
|
976
|
+
table.add_column("Date", style="green", min_width=19)
|
|
977
|
+
table.add_column("Label", style="yellow")
|
|
978
|
+
table.add_column("Packages", style="cyan", justify="right")
|
|
979
|
+
table.add_column("Files", style="magenta", justify="right")
|
|
980
|
+
|
|
981
|
+
for i, b in enumerate(backups, 1):
|
|
982
|
+
table.add_row(
|
|
983
|
+
str(i),
|
|
984
|
+
b.get("name", "—"),
|
|
985
|
+
b.get("date", "—")[:19].replace("T", " "),
|
|
986
|
+
b.get("label", "") or "—",
|
|
987
|
+
str(len(b.get("packages", []))),
|
|
988
|
+
str(len(b.get("files", {}))),
|
|
989
|
+
)
|
|
990
|
+
|
|
991
|
+
console.print(table)
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def cmd_setup():
|
|
995
|
+
print_banner()
|
|
996
|
+
print_section("SETUP", "Configure termux-sync storage and preferences")
|
|
997
|
+
|
|
998
|
+
cfg = load_config()
|
|
999
|
+
|
|
1000
|
+
console.print(" Choose your [bold]storage backend[/bold]:\n")
|
|
1001
|
+
console.print(" [bold cyan][1][/bold cyan] Local storage (saves on device)")
|
|
1002
|
+
console.print(" [bold cyan][2][/bold cyan] Google Drive (via rclone)")
|
|
1003
|
+
console.print(" [bold cyan][3][/bold cyan] GitHub (private repository) [bold yellow]★ Recommended[/bold yellow]")
|
|
1004
|
+
console.print()
|
|
1005
|
+
|
|
1006
|
+
choice = Prompt.ask(" Storage", choices=["1", "2", "3"], default="3")
|
|
1007
|
+
storage_map = {"1": "local", "2": "gdrive", "3": "github"}
|
|
1008
|
+
cfg["storage"] = storage_map[choice]
|
|
1009
|
+
|
|
1010
|
+
if cfg["storage"] == "local":
|
|
1011
|
+
cfg["local_path"] = Prompt.ask(
|
|
1012
|
+
" Local backup path", default=cfg.get("local_path", str(DEFAULT_DEST))
|
|
1013
|
+
)
|
|
1014
|
+
|
|
1015
|
+
elif cfg["storage"] == "gdrive":
|
|
1016
|
+
console.print()
|
|
1017
|
+
console.print(" [dim]Make sure rclone is configured with a remote named[/dim] [bold]termux-sync-gdrive[/bold]")
|
|
1018
|
+
console.print(" [dim]Run: [/dim][white]rclone config[/white]")
|
|
1019
|
+
cfg["gdrive_folder_id"] = Prompt.ask(
|
|
1020
|
+
" Google Drive folder ID (leave blank for root)",
|
|
1021
|
+
default=cfg.get("gdrive_folder_id", "")
|
|
1022
|
+
)
|
|
1023
|
+
|
|
1024
|
+
elif cfg["storage"] == "github":
|
|
1025
|
+
console.print()
|
|
1026
|
+
console.print(
|
|
1027
|
+
" [dim]Create a [bold]private repository[/bold] on GitHub and generate a"
|
|
1028
|
+
" Personal Access Token with [white]repo[/white] scope.[/dim]"
|
|
1029
|
+
)
|
|
1030
|
+
console.print(" [dim]Token URL:[/dim] [white]https://github.com/settings/tokens[/white]")
|
|
1031
|
+
console.print()
|
|
1032
|
+
cfg["github_token"] = Prompt.ask(
|
|
1033
|
+
" GitHub Personal Access Token", default=cfg.get("github_token", ""), password=True
|
|
1034
|
+
)
|
|
1035
|
+
cfg["github_repo"] = Prompt.ask(
|
|
1036
|
+
" GitHub repo (owner/repo)", default=cfg.get("github_repo", "")
|
|
1037
|
+
)
|
|
1038
|
+
cfg["github_branch"] = Prompt.ask(
|
|
1039
|
+
" Branch", default=cfg.get("github_branch", "main")
|
|
1040
|
+
)
|
|
1041
|
+
|
|
1042
|
+
console.print()
|
|
1043
|
+
cfg["compression"] = Prompt.ask(
|
|
1044
|
+
" Compression (gz=fast, bz2=balanced, xz=best)",
|
|
1045
|
+
choices=["gz", "bz2", "xz"], default=cfg.get("compression", "gz")
|
|
1046
|
+
)
|
|
1047
|
+
cfg["max_backups"] = int(Prompt.ask(
|
|
1048
|
+
" Max backups to keep", default=str(cfg.get("max_backups", 5))
|
|
1049
|
+
))
|
|
1050
|
+
|
|
1051
|
+
save_config(cfg)
|
|
1052
|
+
console.print()
|
|
1053
|
+
console.print(Panel(
|
|
1054
|
+
f"[bold green]✓ Configuration saved![/bold green]\n\n"
|
|
1055
|
+
f" Config file: [white]{CONFIG_FILE}[/white]\n"
|
|
1056
|
+
f" Storage : [white]{cfg['storage'].upper()}[/white]",
|
|
1057
|
+
border_style="green", padding=(1, 2)
|
|
1058
|
+
))
|
|
1059
|
+
log("INFO", f"Setup complete. Storage: {cfg['storage']}")
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
def load_schedule() -> dict:
|
|
1063
|
+
if CRON_FILE.exists():
|
|
1064
|
+
with open(CRON_FILE) as f:
|
|
1065
|
+
return json.load(f)
|
|
1066
|
+
return {"enabled": False, "hour": 2, "minute": 0, "label": "auto"}
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
def save_schedule(sched: dict):
|
|
1070
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
1071
|
+
with open(CRON_FILE, "w") as f:
|
|
1072
|
+
json.dump(sched, f, indent=2)
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
def cmd_schedule():
|
|
1076
|
+
print_banner()
|
|
1077
|
+
print_section("AUTO-BACKUP", "Schedule automatic backups")
|
|
1078
|
+
|
|
1079
|
+
sched = load_schedule()
|
|
1080
|
+
|
|
1081
|
+
console.print(f" Current schedule: [bold]{'ENABLED' if sched['enabled'] else 'DISABLED'}[/bold] "
|
|
1082
|
+
f"@ [cyan]{sched['hour']:02d}:{sched['minute']:02d}[/cyan] daily")
|
|
1083
|
+
console.print()
|
|
1084
|
+
|
|
1085
|
+
enable = Confirm.ask(" Enable auto-backup?", default=sched["enabled"])
|
|
1086
|
+
sched["enabled"] = enable
|
|
1087
|
+
|
|
1088
|
+
if enable:
|
|
1089
|
+
hour = int(Prompt.ask(" Hour (0–23)", default=str(sched["hour"])))
|
|
1090
|
+
minute = int(Prompt.ask(" Minute (0–59)", default=str(sched["minute"])))
|
|
1091
|
+
sched["hour"] = max(0, min(23, hour))
|
|
1092
|
+
sched["minute"] = max(0, min(59, minute))
|
|
1093
|
+
sched["label"] = Prompt.ask(" Backup label", default=sched.get("label", "auto"))
|
|
1094
|
+
|
|
1095
|
+
save_schedule(sched)
|
|
1096
|
+
|
|
1097
|
+
_write_daemon_script()
|
|
1098
|
+
|
|
1099
|
+
console.print()
|
|
1100
|
+
if enable:
|
|
1101
|
+
console.print(Panel(
|
|
1102
|
+
f"[bold green]✓ Auto-backup scheduled![/bold green]\n\n"
|
|
1103
|
+
f" Time : [white]{sched['hour']:02d}:{sched['minute']:02d}[/white] daily\n"
|
|
1104
|
+
f" Label : [white]{sched['label']}[/white]\n\n"
|
|
1105
|
+
f" [dim]Start daemon with:[/dim] [white]termux-sync daemon &[/white]\n"
|
|
1106
|
+
f" [dim]Or add to Termux:Boot:[/dim] [white]~/.termux/boot/start-sync.sh[/white]",
|
|
1107
|
+
border_style="green", padding=(1, 2)
|
|
1108
|
+
))
|
|
1109
|
+
else:
|
|
1110
|
+
console.print(" [yellow]Auto-backup disabled.[/yellow]")
|
|
1111
|
+
|
|
1112
|
+
log("INFO", f"Schedule updated: enabled={enable}, time={sched['hour']:02d}:{sched['minute']:02d}")
|
|
1113
|
+
|
|
1114
|
+
|
|
1115
|
+
def _write_daemon_script():
|
|
1116
|
+
boot_dir = Path.home() / ".termux" / "boot"
|
|
1117
|
+
boot_dir.mkdir(parents=True, exist_ok=True)
|
|
1118
|
+
script = boot_dir / "termux-sync-daemon.sh"
|
|
1119
|
+
this_script = Path(os.path.abspath(__file__))
|
|
1120
|
+
script.write_text(
|
|
1121
|
+
"#!/data/data/com.termux/files/usr/bin/sh\n"
|
|
1122
|
+
"# Auto-generated by termux-sync\n"
|
|
1123
|
+
f"python3 \"{this_script}\" daemon &\n"
|
|
1124
|
+
)
|
|
1125
|
+
script.chmod(0o755)
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
def cmd_daemon(cfg: dict):
|
|
1129
|
+
sched = load_schedule()
|
|
1130
|
+
if not sched.get("enabled"):
|
|
1131
|
+
console.print("[yellow]Auto-backup is not enabled. Run: termux-sync schedule[/yellow]")
|
|
1132
|
+
return
|
|
1133
|
+
|
|
1134
|
+
console.print(f"[dim]termux-sync daemon started. Scheduled at "
|
|
1135
|
+
f"{sched['hour']:02d}:{sched['minute']:02d} daily.[/dim]")
|
|
1136
|
+
log("INFO", "Daemon started")
|
|
1137
|
+
|
|
1138
|
+
while True:
|
|
1139
|
+
now = datetime.datetime.now()
|
|
1140
|
+
target = now.replace(hour=sched["hour"], minute=sched["minute"], second=0, microsecond=0)
|
|
1141
|
+
if target <= now:
|
|
1142
|
+
target += datetime.timedelta(days=1)
|
|
1143
|
+
sleep_sec = (target - now).total_seconds()
|
|
1144
|
+
log("INFO", f"Next backup in {sleep_sec/3600:.1f}h at {target.strftime('%H:%M')}")
|
|
1145
|
+
time.sleep(sleep_sec)
|
|
1146
|
+
log("INFO", "Auto-backup triggered by daemon")
|
|
1147
|
+
cfg = load_config()
|
|
1148
|
+
cmd_backup(cfg, label=sched.get("label", "auto"))
|
|
1149
|
+
|
|
1150
|
+
|
|
1151
|
+
def cmd_status(cfg: dict):
|
|
1152
|
+
print_banner()
|
|
1153
|
+
print_section("STATUS", "Current configuration and environment")
|
|
1154
|
+
|
|
1155
|
+
sched = load_schedule()
|
|
1156
|
+
|
|
1157
|
+
cfg_table = Table(box=box.ROUNDED, show_header=False, padding=(0, 2))
|
|
1158
|
+
cfg_table.add_column("Key", style="dim cyan", min_width=20)
|
|
1159
|
+
cfg_table.add_column("Value", style="bold white")
|
|
1160
|
+
|
|
1161
|
+
cfg_table.add_row("Storage backend", cfg.get("storage", "local").upper())
|
|
1162
|
+
if cfg["storage"] == "local":
|
|
1163
|
+
cfg_table.add_row("Local path", cfg.get("local_path", "—"))
|
|
1164
|
+
elif cfg["storage"] == "gdrive":
|
|
1165
|
+
cfg_table.add_row("Drive folder", cfg.get("gdrive_folder_id", "(root)") or "(root)")
|
|
1166
|
+
elif cfg["storage"] == "github":
|
|
1167
|
+
cfg_table.add_row("GitHub repo", cfg.get("github_repo", "—"))
|
|
1168
|
+
cfg_table.add_row("Branch", cfg.get("github_branch", "main"))
|
|
1169
|
+
cfg_table.add_row("Compression", cfg.get("compression", "gz").upper())
|
|
1170
|
+
cfg_table.add_row("Max backups", str(cfg.get("max_backups", 5)))
|
|
1171
|
+
cfg_table.add_row("Auto-backup", "[green]ENABLED[/green]" if sched.get("enabled") else "[red]DISABLED[/red]")
|
|
1172
|
+
if sched.get("enabled"):
|
|
1173
|
+
cfg_table.add_row("Schedule", f"Daily at {sched['hour']:02d}:{sched['minute']:02d}")
|
|
1174
|
+
cfg_table.add_row("Config file", str(CONFIG_FILE))
|
|
1175
|
+
cfg_table.add_row("Log file", str(LOG_FILE))
|
|
1176
|
+
|
|
1177
|
+
console.print(cfg_table)
|
|
1178
|
+
console.print()
|
|
1179
|
+
|
|
1180
|
+
env_table = Table(box=box.ROUNDED, show_header=False, padding=(0, 2),
|
|
1181
|
+
title="[bold yellow]Environment[/bold yellow]")
|
|
1182
|
+
env_table.add_column("Tool", style="dim cyan", min_width=20)
|
|
1183
|
+
env_table.add_column("Status")
|
|
1184
|
+
|
|
1185
|
+
checks = [
|
|
1186
|
+
("python3", shutil.which("python3")),
|
|
1187
|
+
("dpkg", shutil.which("dpkg")),
|
|
1188
|
+
("tar", shutil.which("tar")),
|
|
1189
|
+
("rclone", shutil.which("rclone")),
|
|
1190
|
+
("git", shutil.which("git")),
|
|
1191
|
+
("curl", shutil.which("curl")),
|
|
1192
|
+
]
|
|
1193
|
+
for name, path in checks:
|
|
1194
|
+
if path:
|
|
1195
|
+
env_table.add_row(name, f"[green]✓ {path}[/green]")
|
|
1196
|
+
else:
|
|
1197
|
+
env_table.add_row(name, "[red]✗ not found[/red]")
|
|
1198
|
+
|
|
1199
|
+
console.print(env_table)
|
|
1200
|
+
|
|
1201
|
+
|
|
1202
|
+
def cmd_logs(lines: int = 40):
|
|
1203
|
+
print_banner()
|
|
1204
|
+
print_section("LOGS", str(LOG_FILE))
|
|
1205
|
+
if not LOG_FILE.exists():
|
|
1206
|
+
console.print(" [dim]No log file yet.[/dim]")
|
|
1207
|
+
return
|
|
1208
|
+
log_lines = LOG_FILE.read_text().splitlines()[-lines:]
|
|
1209
|
+
for line in log_lines:
|
|
1210
|
+
if "[ERROR]" in line:
|
|
1211
|
+
console.print(f" [red]{line}[/red]")
|
|
1212
|
+
elif "[WARN]" in line:
|
|
1213
|
+
console.print(f" [yellow]{line}[/yellow]")
|
|
1214
|
+
else:
|
|
1215
|
+
console.print(f" [dim]{line}[/dim]")
|
|
1216
|
+
|
|
1217
|
+
|
|
1218
|
+
|
|
1219
|
+
def dir_size(path: Path) -> int:
|
|
1220
|
+
total = 0
|
|
1221
|
+
try:
|
|
1222
|
+
for entry in os.scandir(path):
|
|
1223
|
+
try:
|
|
1224
|
+
if entry.is_symlink():
|
|
1225
|
+
continue
|
|
1226
|
+
if entry.is_dir(follow_symlinks=False):
|
|
1227
|
+
total += dir_size(Path(entry.path))
|
|
1228
|
+
else:
|
|
1229
|
+
total += entry.stat(follow_symlinks=False).st_size
|
|
1230
|
+
except (PermissionError, OSError):
|
|
1231
|
+
pass
|
|
1232
|
+
except (PermissionError, OSError):
|
|
1233
|
+
pass
|
|
1234
|
+
return total
|
|
1235
|
+
|
|
1236
|
+
|
|
1237
|
+
def _pct_bar(part: int, total: int, width: int = 24) -> str:
|
|
1238
|
+
pct = part / total if total else 0
|
|
1239
|
+
filled = int(width * pct)
|
|
1240
|
+
color = "green" if pct < 0.5 else ("yellow" if pct < 0.8 else "red")
|
|
1241
|
+
return f"[{color}]{'█' * filled}{'░' * (width - filled)}[/{color}] [dim]{pct*100:.1f}%[/dim]"
|
|
1242
|
+
|
|
1243
|
+
|
|
1244
|
+
def cmd_check(target: str = ""):
|
|
1245
|
+
print_banner()
|
|
1246
|
+
print_section("DISK CHECK", "Storage usage overview")
|
|
1247
|
+
|
|
1248
|
+
PREFIX = Path(os.environ.get("PREFIX", "/data/data/com.termux/files/usr"))
|
|
1249
|
+
HOME = Path.home()
|
|
1250
|
+
TERMUX_ROOT = Path("/data/data/com.termux/files")
|
|
1251
|
+
|
|
1252
|
+
from rich.table import Table
|
|
1253
|
+
from rich import box as rbox
|
|
1254
|
+
|
|
1255
|
+
if not target:
|
|
1256
|
+
paths = [
|
|
1257
|
+
("Termux root", TERMUX_ROOT),
|
|
1258
|
+
("Home (~)", HOME),
|
|
1259
|
+
("$PREFIX", PREFIX),
|
|
1260
|
+
]
|
|
1261
|
+
else:
|
|
1262
|
+
t = target.strip()
|
|
1263
|
+
if t in ("~", "$HOME", "home"):
|
|
1264
|
+
resolved, label = HOME, "Home (~)"
|
|
1265
|
+
elif t in ("$PREFIX", "prefix", "usr"):
|
|
1266
|
+
resolved, label = PREFIX, "$PREFIX"
|
|
1267
|
+
elif t in ("/data/data/com.termux/files", "termux", "root"):
|
|
1268
|
+
resolved, label = TERMUX_ROOT, "Termux root"
|
|
1269
|
+
else:
|
|
1270
|
+
resolved = Path(t).expanduser().resolve()
|
|
1271
|
+
label = str(resolved)
|
|
1272
|
+
|
|
1273
|
+
if not resolved.exists():
|
|
1274
|
+
console.print(f" [red]Path not found:[/red] {resolved}\n")
|
|
1275
|
+
return
|
|
1276
|
+
paths = [(label, resolved)]
|
|
1277
|
+
|
|
1278
|
+
sizes = {}
|
|
1279
|
+
with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"),
|
|
1280
|
+
console=console, transient=True) as prog:
|
|
1281
|
+
for label, path in paths:
|
|
1282
|
+
task = prog.add_task(f"[dim]Scanning {label}…", total=None)
|
|
1283
|
+
sizes[label] = dir_size(path)
|
|
1284
|
+
prog.remove_task(task)
|
|
1285
|
+
|
|
1286
|
+
total_ref = sum(sizes.values()) or 1
|
|
1287
|
+
|
|
1288
|
+
tbl = Table(box=rbox.ROUNDED, padding=(0, 2), show_header=True)
|
|
1289
|
+
tbl.add_column("Size", style="bold white", min_width=10, justify="right")
|
|
1290
|
+
tbl.add_column("Usage", min_width=36)
|
|
1291
|
+
|
|
1292
|
+
for label, path in paths:
|
|
1293
|
+
sz = sizes[label]
|
|
1294
|
+
tbl.add_row(human_size(sz), _pct_bar(sz, total_ref))
|
|
1295
|
+
|
|
1296
|
+
console.print(tbl)
|
|
1297
|
+
|
|
1298
|
+
if len(paths) == 1:
|
|
1299
|
+
label, path = paths[0]
|
|
1300
|
+
_check_subdir_table(path, sizes[label])
|
|
1301
|
+
|
|
1302
|
+
console.print()
|
|
1303
|
+
log("INFO", f"check ran on: {[str(p) for _, p in paths]}")
|
|
1304
|
+
|
|
1305
|
+
|
|
1306
|
+
def _check_subdir_table(path: Path, parent_total: int):
|
|
1307
|
+
from rich.table import Table
|
|
1308
|
+
from rich import box as rbox
|
|
1309
|
+
|
|
1310
|
+
try:
|
|
1311
|
+
children = sorted(
|
|
1312
|
+
[c for c in path.iterdir() if not c.is_symlink()],
|
|
1313
|
+
key=lambda c: dir_size(c) if c.is_dir() else c.stat().st_size,
|
|
1314
|
+
reverse=True
|
|
1315
|
+
)[:12]
|
|
1316
|
+
except PermissionError:
|
|
1317
|
+
return
|
|
1318
|
+
|
|
1319
|
+
if not children:
|
|
1320
|
+
return
|
|
1321
|
+
|
|
1322
|
+
child_sizes = {}
|
|
1323
|
+
with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"),
|
|
1324
|
+
console=console, transient=True) as prog:
|
|
1325
|
+
for c in children:
|
|
1326
|
+
task = prog.add_task(f"[dim] {c.name}…", total=None)
|
|
1327
|
+
child_sizes[c] = dir_size(c) if c.is_dir() else c.stat().st_size
|
|
1328
|
+
prog.remove_task(task)
|
|
1329
|
+
|
|
1330
|
+
console.print()
|
|
1331
|
+
sub = Table(box=rbox.SIMPLE, padding=(0, 2), show_header=True)
|
|
1332
|
+
sub.add_column("Name", style="dim cyan", min_width=26)
|
|
1333
|
+
sub.add_column("Size", style="bold white", min_width=10, justify="right")
|
|
1334
|
+
sub.add_column("Usage", min_width=30)
|
|
1335
|
+
|
|
1336
|
+
for c, sz in sorted(child_sizes.items(), key=lambda x: x[1], reverse=True):
|
|
1337
|
+
icon = "📁 " if c.is_dir() else "📄 "
|
|
1338
|
+
sub.add_row(icon + c.name, human_size(sz), _pct_bar(sz, parent_total))
|
|
1339
|
+
|
|
1340
|
+
console.print(sub)
|
|
1341
|
+
|
|
1342
|
+
|
|
1343
|
+
CACHE_TARGETS = [
|
|
1344
|
+
("$PREFIX/tmp", Path(os.environ.get("PREFIX", "/data/data/com.termux/files/usr")) / "tmp"),
|
|
1345
|
+
("~/.cache", Path.home() / ".cache"),
|
|
1346
|
+
("~/.npm", Path.home() / ".npm"),
|
|
1347
|
+
("~/.cargo/registry", Path.home() / ".cargo" / "registry"),
|
|
1348
|
+
("~/.cargo/git", Path.home() / ".cargo" / "git"),
|
|
1349
|
+
]
|
|
1350
|
+
|
|
1351
|
+
|
|
1352
|
+
def cmd_clear_cache():
|
|
1353
|
+
print_banner()
|
|
1354
|
+
print_section("CLEAR CACHE", "Remove temporary and cached files")
|
|
1355
|
+
|
|
1356
|
+
existing = []
|
|
1357
|
+
for label, path in CACHE_TARGETS:
|
|
1358
|
+
if path.exists():
|
|
1359
|
+
size = dir_size(path)
|
|
1360
|
+
existing.append((label, path, size))
|
|
1361
|
+
|
|
1362
|
+
if not existing:
|
|
1363
|
+
console.print(" [green]Nothing to clear — all cache directories are already empty.[/green]\n")
|
|
1364
|
+
return
|
|
1365
|
+
|
|
1366
|
+
from rich.table import Table
|
|
1367
|
+
from rich import box as rbox
|
|
1368
|
+
|
|
1369
|
+
tbl = Table(box=rbox.ROUNDED, padding=(0, 2))
|
|
1370
|
+
tbl.add_column("Cache directory", style="cyan", min_width=20)
|
|
1371
|
+
tbl.add_column("Size", style="bold white", min_width=10, justify="right")
|
|
1372
|
+
|
|
1373
|
+
total_size = 0
|
|
1374
|
+
for label, path, size in existing:
|
|
1375
|
+
tbl.add_row(label, human_size(size))
|
|
1376
|
+
total_size += size
|
|
1377
|
+
|
|
1378
|
+
console.print(tbl)
|
|
1379
|
+
console.print(f"\n Total reclaimable: [bold yellow]{human_size(total_size)}[/bold yellow]\n")
|
|
1380
|
+
|
|
1381
|
+
first = Confirm.ask(" [bold]Proceed with clearing cache?[/bold]", default=False)
|
|
1382
|
+
if not first:
|
|
1383
|
+
console.print("\n [yellow]Aborted.[/yellow]\n")
|
|
1384
|
+
return
|
|
1385
|
+
|
|
1386
|
+
console.print()
|
|
1387
|
+
console.print(" [bold yellow]The following will be permanently deleted:[/bold yellow]\n")
|
|
1388
|
+
for label, path, size in existing:
|
|
1389
|
+
console.print(f" [red]✗[/red] {label} [dim]({human_size(size)})[/dim]")
|
|
1390
|
+
console.print()
|
|
1391
|
+
|
|
1392
|
+
second = Confirm.ask(" [bold red]Are you sure? This cannot be undone.[/bold red]", default=False)
|
|
1393
|
+
if not second:
|
|
1394
|
+
console.print("\n [yellow]Aborted.[/yellow]\n")
|
|
1395
|
+
return
|
|
1396
|
+
|
|
1397
|
+
console.print()
|
|
1398
|
+
|
|
1399
|
+
with Progress(
|
|
1400
|
+
SpinnerColumn(),
|
|
1401
|
+
TextColumn("[progress.description]{task.description}"),
|
|
1402
|
+
console=console,
|
|
1403
|
+
) as prog:
|
|
1404
|
+
cleared = 0
|
|
1405
|
+
for label, path, size in existing:
|
|
1406
|
+
task = prog.add_task(f"[cyan]Clearing {label}…", total=None)
|
|
1407
|
+
try:
|
|
1408
|
+
if path.is_dir():
|
|
1409
|
+
for child in path.iterdir():
|
|
1410
|
+
try:
|
|
1411
|
+
if child.is_dir() and not child.is_symlink():
|
|
1412
|
+
shutil.rmtree(child)
|
|
1413
|
+
else:
|
|
1414
|
+
child.unlink(missing_ok=True)
|
|
1415
|
+
except Exception as e:
|
|
1416
|
+
log("WARN", f"Could not remove {child}: {e}")
|
|
1417
|
+
prog.update(task, total=1, completed=1,
|
|
1418
|
+
description=f"[green]{label} cleared ({human_size(size)}) ✓")
|
|
1419
|
+
cleared += size
|
|
1420
|
+
log("INFO", f"Cleared cache: {path} ({human_size(size)})")
|
|
1421
|
+
except Exception as e:
|
|
1422
|
+
prog.update(task, total=1, completed=1,
|
|
1423
|
+
description=f"[red]{label} failed: {e}")
|
|
1424
|
+
log("ERROR", f"Failed to clear {path}: {e}")
|
|
1425
|
+
|
|
1426
|
+
console.print()
|
|
1427
|
+
console.print(f" [bold green]Done![/bold green] Freed approximately [bold]{human_size(cleared)}[/bold]\n")
|
|
1428
|
+
|
|
1429
|
+
|
|
1430
|
+
def ensure_dependencies():
|
|
1431
|
+
try:
|
|
1432
|
+
import rich
|
|
1433
|
+
except ImportError:
|
|
1434
|
+
print("Installing required Python packages (rich)…")
|
|
1435
|
+
subprocess.run([sys.executable, "-m", "pip", "install", "--quiet", "rich"], check=True)
|
|
1436
|
+
print("Done. Please re-run termux-sync.")
|
|
1437
|
+
sys.exit(0)
|
|
1438
|
+
|
|
1439
|
+
|
|
1440
|
+
def main():
|
|
1441
|
+
ensure_dependencies()
|
|
1442
|
+
|
|
1443
|
+
parser = argparse.ArgumentParser(
|
|
1444
|
+
prog="termux-sync",
|
|
1445
|
+
description="Termux Backup & Restore — sync your environment across devices",
|
|
1446
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1447
|
+
epilog="""
|
|
1448
|
+
Commands:
|
|
1449
|
+
backup Create a new backup
|
|
1450
|
+
restore Restore from a backup
|
|
1451
|
+
list List all available backups
|
|
1452
|
+
setup Interactive configuration wizard
|
|
1453
|
+
schedule Configure auto-backup schedule
|
|
1454
|
+
daemon Run background scheduler (called by Termux:Boot)
|
|
1455
|
+
status Show current config and environment
|
|
1456
|
+
logs Show recent log entries
|
|
1457
|
+
check Show disk usage (check ~, $PREFIX, or a custom path)
|
|
1458
|
+
clear-cache Remove temporary and cached files
|
|
1459
|
+
|
|
1460
|
+
Examples:
|
|
1461
|
+
termux-sync setup
|
|
1462
|
+
termux-sync backup --label "before-new-phone"
|
|
1463
|
+
termux-sync list
|
|
1464
|
+
termux-sync restore
|
|
1465
|
+
termux-sync schedule
|
|
1466
|
+
termux-sync status
|
|
1467
|
+
termux-sync check
|
|
1468
|
+
termux-sync check ~
|
|
1469
|
+
termux-sync check $PREFIX
|
|
1470
|
+
termux-sync check /data/data/com.termux/files
|
|
1471
|
+
termux-sync clear-cache
|
|
1472
|
+
""",
|
|
1473
|
+
)
|
|
1474
|
+
parser.add_argument("command", nargs="?", default="help",
|
|
1475
|
+
choices=["backup","restore","list","setup","schedule",
|
|
1476
|
+
"daemon","status","logs","check","clear-cache","help"])
|
|
1477
|
+
parser.add_argument("target", nargs="?", default="", help="Optional path for check command")
|
|
1478
|
+
parser.add_argument("--label", "-l", default="", help="Label for this backup")
|
|
1479
|
+
parser.add_argument("--name", "-n", default="", help="Backup name to restore")
|
|
1480
|
+
parser.add_argument("--lines", default=40, type=int, help="Lines to show in logs")
|
|
1481
|
+
parser.add_argument("--version", "-v", action="store_true")
|
|
1482
|
+
|
|
1483
|
+
args = parser.parse_args()
|
|
1484
|
+
|
|
1485
|
+
if args.version:
|
|
1486
|
+
print(f"termux-sync v{VERSION}")
|
|
1487
|
+
return
|
|
1488
|
+
|
|
1489
|
+
if args.command == "help" or args.command is None:
|
|
1490
|
+
print_banner()
|
|
1491
|
+
parser.print_help()
|
|
1492
|
+
return
|
|
1493
|
+
|
|
1494
|
+
cfg = load_config()
|
|
1495
|
+
|
|
1496
|
+
if args.command == "setup":
|
|
1497
|
+
cmd_setup()
|
|
1498
|
+
elif args.command == "backup":
|
|
1499
|
+
cmd_backup(cfg, label=args.label)
|
|
1500
|
+
elif args.command == "restore":
|
|
1501
|
+
cmd_restore(cfg, backup_name=args.name)
|
|
1502
|
+
elif args.command == "list":
|
|
1503
|
+
cmd_list(cfg)
|
|
1504
|
+
elif args.command == "schedule":
|
|
1505
|
+
cmd_schedule()
|
|
1506
|
+
elif args.command == "daemon":
|
|
1507
|
+
cmd_daemon(cfg)
|
|
1508
|
+
elif args.command == "status":
|
|
1509
|
+
cmd_status(cfg)
|
|
1510
|
+
elif args.command == "logs":
|
|
1511
|
+
cmd_logs(args.lines)
|
|
1512
|
+
elif args.command == "check":
|
|
1513
|
+
cmd_check(args.target or "")
|
|
1514
|
+
elif args.command == "clear-cache":
|
|
1515
|
+
cmd_clear_cache()
|
|
1516
|
+
|
|
1517
|
+
|
|
1518
|
+
if __name__ == "__main__":
|
|
1519
|
+
try:
|
|
1520
|
+
main()
|
|
1521
|
+
except KeyboardInterrupt:
|
|
1522
|
+
console = Console()
|
|
1523
|
+
console.print()
|
|
1524
|
+
console.print()
|
|
1525
|
+
console.print(
|
|
1526
|
+
Panel(
|
|
1527
|
+
"[bold yellow]Operation cancelled[/bold yellow]\n\n"
|
|
1528
|
+
" You pressed [bold]Ctrl+C[/bold] -- termux-sync was stopped.\n"
|
|
1529
|
+
" [dim]Any partial temporary files have been cleaned up.[/dim]\n\n"
|
|
1530
|
+
" Run the command again whenever you're ready.",
|
|
1531
|
+
border_style="yellow",
|
|
1532
|
+
padding=(1, 2),
|
|
1533
|
+
)
|
|
1534
|
+
)
|
|
1535
|
+
sys.exit(0)
|