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.
@@ -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)