re1sid-lib 0.1.3__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: re1sid-lib
3
- Version: 0.1.3
3
+ Version: 0.2.0
4
4
  Summary: Add your description here
5
5
  Author: 09u2h4n
6
6
  Author-email: 09u2h4n <09u2h4n.y1lm42@gmail.com>
@@ -8,10 +8,17 @@ License: Apache-2.0 license
8
8
  Requires-Dist: httpx>=0.28.1
9
9
  Requires-Dist: lxml>=6.1.1
10
10
  Requires-Dist: pyaxmlparser>=0.3.31
11
+ Requires-Dist: httpx>=0.28.1 ; extra == 'all'
12
+ Requires-Dist: lxml>=6.1.1 ; extra == 'all'
13
+ Requires-Dist: pyaxmlparser>=0.3.31 ; extra == 'all'
14
+ Requires-Dist: textual>=8.2.8 ; extra == 'all'
15
+ Requires-Dist: textual>=8.2.8 ; extra == 'tui'
11
16
  Requires-Python: >=3.12
12
17
  Project-URL: homepage, https://github.com/09u2h4n/re1sid-lib
13
18
  Project-URL: repository, https://github.com/09u2h4n/re1sid-lib
14
19
  Project-URL: documentation, https://github.com/09u2h4n/re1sid-lib#readme
20
+ Provides-Extra: all
21
+ Provides-Extra: tui
15
22
  Description-Content-Type: text/markdown
16
23
 
17
24
  # re1sid-lib
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "re1sid-lib"
3
- version = "0.1.3"
3
+ version = "0.2.0"
4
4
  description = "Add your description here"
5
5
  readme = "README.md"
6
6
  license = { text = "Apache-2.0 license" }
@@ -13,7 +13,21 @@ dependencies = [
13
13
  "lxml>=6.1.1",
14
14
  "pyaxmlparser>=0.3.31",
15
15
  ]
16
- urls = { homepage = "https://github.com/09u2h4n/re1sid-lib", repository = "https://github.com/09u2h4n/re1sid-lib", documentation = "https://github.com/09u2h4n/re1sid-lib#readme" }
16
+
17
+ [project.optional-dependencies]
18
+ tui = ["textual>=8.2.8"]
19
+
20
+ all = [
21
+ "httpx>=0.28.1",
22
+ "lxml>=6.1.1",
23
+ "pyaxmlparser>=0.3.31",
24
+ "textual>=8.2.8"
25
+ ]
26
+
27
+ [project.urls]
28
+ homepage = "https://github.com/09u2h4n/re1sid-lib"
29
+ repository = "https://github.com/09u2h4n/re1sid-lib"
30
+ documentation = "https://github.com/09u2h4n/re1sid-lib#readme"
17
31
 
18
32
  [project.scripts]
19
33
  re1sid-lib = "re1sid_lib:main"
@@ -0,0 +1,5 @@
1
+ from .downloader import Downloader
2
+ from .patcher import Patcher
3
+ from .tui import ReVancedTUI
4
+
5
+ __all__ = ["Downloader", "Patcher", "ReVancedTUI"]
@@ -0,0 +1,609 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ tui.py - A Textual terminal UI for re1sid-lib
4
+ https://pypi.org/project/re1sid-lib/
5
+
6
+ re1sid-lib wraps the ReVanced CLI to download assets (revanced-cli.jar +
7
+ patches.rvp) and patch APKs programmatically. This TUI puts a keyboard-driven
8
+ front end on top of it.
9
+
10
+ Features
11
+ --------
12
+ - Download the ReVanced CLI jar and patches.rvp bundle
13
+ - Browse for an APK (auto-detects its package name via Patcher.get_apk_info)
14
+ - List patches filtered to that package, with live filtering by name
15
+ - Toggle each patch on/off (overrides the default enabled state)
16
+ - Edit per-patch options in a modal (respects Type / Default / Possible values)
17
+ - Run the patch job with the CLI's output streamed live into a log panel
18
+
19
+ Requirements
20
+ ------------
21
+ pip install re1sid-lib textual
22
+
23
+ Also needs a Java runtime on PATH (required by re1sid-lib itself).
24
+
25
+ Run
26
+ ---
27
+ python tui.py
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import os
33
+ from pathlib import Path
34
+ from typing import Any, Iterable, Optional
35
+
36
+ from textual import work
37
+ from textual.app import App, ComposeResult
38
+ from textual.binding import Binding
39
+ from textual.containers import Horizontal, Vertical, VerticalScroll
40
+ from textual.screen import ModalScreen
41
+ from textual.widgets import (
42
+ Button,
43
+ Checkbox,
44
+ DataTable,
45
+ DirectoryTree,
46
+ Footer,
47
+ Header,
48
+ Input,
49
+ Label,
50
+ RichLog,
51
+ Static,
52
+ )
53
+
54
+ from re1sid_lib import Downloader, Patcher
55
+ from re1sid_lib.common import CLI_PATH, OUTPUT_DIR, PATCHES_PATH
56
+
57
+ # --------------------------------------------------------------------------- #
58
+ # Helpers
59
+ # --------------------------------------------------------------------------- #
60
+
61
+ # Cycle order for the per-patch enable/disable override.
62
+ # None -> use whatever the patch bundle says by default
63
+ # True -> force-enable (-e / --ei)
64
+ # False -> force-disable (-d / --di)
65
+ STATE_CYCLE = [None, True, False]
66
+ STATE_ICON = {None: " ", True: "[green]✔[/]", False: "[red]✘[/]"}
67
+
68
+
69
+ def state_label(overridden: Optional[bool], default_enabled: bool) -> str:
70
+ if overridden is None:
71
+ return "default (on)" if default_enabled else "default (off)"
72
+ return "force ON" if overridden else "force OFF"
73
+
74
+
75
+ class ApkFileTree(DirectoryTree):
76
+ """A DirectoryTree that only shows directories and .apk files."""
77
+
78
+ def filter_paths(self, paths: Iterable[Path]) -> Iterable[Path]:
79
+ return [
80
+ p
81
+ for p in paths
82
+ if p.is_dir() or p.suffix.lower() == ".apk"
83
+ ]
84
+
85
+
86
+ # --------------------------------------------------------------------------- #
87
+ # Modal: APK file picker
88
+ # --------------------------------------------------------------------------- #
89
+
90
+
91
+ class ApkPickerScreen(ModalScreen[Optional[str]]):
92
+ """Browse the filesystem and pick an .apk file."""
93
+
94
+ BINDINGS = [Binding("escape", "cancel", "Cancel")]
95
+
96
+ CSS = """
97
+ ApkPickerScreen {
98
+ align: center middle;
99
+ }
100
+ #picker-box {
101
+ width: 80%;
102
+ height: 80%;
103
+ border: heavy $accent;
104
+ background: $surface;
105
+ padding: 1 2;
106
+ }
107
+ #picker-box Label {
108
+ margin-bottom: 1;
109
+ }
110
+ """
111
+
112
+ def compose(self) -> ComposeResult:
113
+ with Vertical(id="picker-box"):
114
+ yield Label("Select an APK file (Esc to cancel)")
115
+ yield ApkFileTree(str(Path.cwd()))
116
+
117
+ def action_cancel(self) -> None:
118
+ self.dismiss(None)
119
+
120
+ def on_directory_tree_file_selected(
121
+ self, event: DirectoryTree.FileSelected
122
+ ) -> None:
123
+ self.dismiss(str(event.path))
124
+
125
+
126
+ # --------------------------------------------------------------------------- #
127
+ # Modal: edit options for a single patch
128
+ # --------------------------------------------------------------------------- #
129
+
130
+
131
+ class OptionsModal(ModalScreen[Optional[dict[str, Any]]]):
132
+ """Edit the option values for one patch."""
133
+
134
+ BINDINGS = [Binding("escape", "cancel", "Cancel")]
135
+
136
+ CSS = """
137
+ OptionsModal {
138
+ align: center middle;
139
+ }
140
+ #opt-box {
141
+ width: 90%;
142
+ height: 90%;
143
+ border: heavy $accent;
144
+ background: $surface;
145
+ padding: 1 2;
146
+ }
147
+ .opt-name {
148
+ text-style: bold;
149
+ color: $accent;
150
+ margin-top: 1;
151
+ }
152
+ .opt-desc {
153
+ color: $text-muted;
154
+ margin-bottom: 1;
155
+ }
156
+ #opt-buttons {
157
+ height: auto;
158
+ align: right middle;
159
+ margin-top: 1;
160
+ }
161
+ #opt-buttons Button {
162
+ margin-left: 1;
163
+ }
164
+ """
165
+
166
+ def __init__(self, patch: dict[str, Any], current: dict[str, Any]) -> None:
167
+ super().__init__()
168
+ self.patch = patch
169
+ self.current = dict(current)
170
+ self._widget_ids: dict[str, str] = {}
171
+
172
+ def compose(self) -> ComposeResult:
173
+ with Vertical(id="opt-box"):
174
+ yield Label(f"Options - {self.patch.get('Name', '?')}", classes="opt-name")
175
+ with VerticalScroll():
176
+ options = self.patch.get("Options") or []
177
+ if not options:
178
+ yield Static("This patch has no configurable options.")
179
+ for i, opt in enumerate(options):
180
+ key = opt.get("Name", f"opt{i}")
181
+ widget_id = f"opt-input-{i}"
182
+ self._widget_ids[key] = widget_id
183
+
184
+ desc = opt.get("Description") or ""
185
+ required = " (required)" if opt.get("Required") else ""
186
+ possible = opt.get("Possible values") or []
187
+ hint = f" values: {', '.join(possible)}" if possible else ""
188
+ yield Label(f"{key}{required}", classes="opt-name")
189
+ if desc or hint:
190
+ yield Static(f"{desc}{hint}", classes="opt-desc")
191
+
192
+ default_val = opt.get("Default")
193
+ value = self.current.get(key, default_val)
194
+
195
+ if isinstance(default_val, bool):
196
+ yield Checkbox(
197
+ key, value=bool(value), id=widget_id
198
+ )
199
+ else:
200
+ yield Input(
201
+ value="" if value is None else str(value),
202
+ placeholder=str(default_val) if default_val is not None else "",
203
+ id=widget_id,
204
+ )
205
+ with Horizontal(id="opt-buttons"):
206
+ yield Button("Reset to defaults", id="opt-reset")
207
+ yield Button("Cancel", id="opt-cancel")
208
+ yield Button("Save", id="opt-save", variant="primary")
209
+
210
+ def action_cancel(self) -> None:
211
+ self.dismiss(None)
212
+
213
+ def on_button_pressed(self, event: Button.Pressed) -> None:
214
+ if event.button.id == "opt-cancel":
215
+ self.dismiss(None)
216
+ elif event.button.id == "opt-reset":
217
+ self.dismiss({})
218
+ elif event.button.id == "opt-save":
219
+ result: dict[str, Any] = {}
220
+ for opt in self.patch.get("Options") or []:
221
+ key = opt.get("Name")
222
+ widget_id = self._widget_ids.get(key)
223
+ if widget_id is None:
224
+ continue
225
+ widget = self.query_one(f"#{widget_id}")
226
+ if isinstance(widget, Checkbox):
227
+ result[key] = widget.value
228
+ elif isinstance(widget, Input):
229
+ text = widget.value.strip()
230
+ if text == "":
231
+ continue
232
+ result[key] = _coerce(text)
233
+ self.dismiss(result)
234
+
235
+
236
+ def _coerce(text: str) -> Any:
237
+ """Best-effort turn typed text into bool/int/list/str for -O option values."""
238
+ low = text.lower()
239
+ if low in ("true", "false"):
240
+ return low == "true"
241
+ if text.startswith("[") and text.endswith("]"):
242
+ inner = text[1:-1]
243
+ return [item.strip() for item in inner.split(",") if item.strip()]
244
+ try:
245
+ return int(text)
246
+ except ValueError:
247
+ pass
248
+ return text
249
+
250
+
251
+ # --------------------------------------------------------------------------- #
252
+ # Main application
253
+ # --------------------------------------------------------------------------- #
254
+
255
+
256
+ class ReVancedTUI(App):
257
+ """Textual front end for re1sid-lib."""
258
+
259
+ TITLE = "re1sid-lib TUI"
260
+ SUB_TITLE = "ReVanced patcher & downloader"
261
+
262
+ CSS = """
263
+ #status-bar {
264
+ height: auto;
265
+ padding: 0 1;
266
+ background: $panel;
267
+ color: $text-muted;
268
+ }
269
+ #main {
270
+ height: 1fr;
271
+ }
272
+ #left-pane {
273
+ width: 2fr;
274
+ border-right: solid $accent;
275
+ padding: 0 1;
276
+ }
277
+ #right-pane {
278
+ width: 1fr;
279
+ padding: 0 1;
280
+ }
281
+ #filter-row, #action-row, #patch-row {
282
+ height: auto;
283
+ margin-bottom: 1;
284
+ }
285
+ #filter-row Input {
286
+ width: 1fr;
287
+ }
288
+ DataTable {
289
+ height: 1fr;
290
+ }
291
+ RichLog {
292
+ height: 1fr;
293
+ border: solid $accent;
294
+ }
295
+ #output-row Input {
296
+ width: 1fr;
297
+ }
298
+ """
299
+
300
+ BINDINGS = [
301
+ Binding("b", "browse_apk", "Browse APK"),
302
+ Binding("l", "load_patches", "Load patches"),
303
+ Binding("d", "download_assets", "Download assets"),
304
+ Binding("o", "edit_options", "Edit options"),
305
+ Binding("space,enter", "toggle_patch", "Toggle patch", show=False),
306
+ Binding("p", "run_patch", "Patch APK"),
307
+ Binding("q", "quit", "Quit"),
308
+ ]
309
+
310
+ def __init__(self) -> None:
311
+ super().__init__()
312
+ self.downloader = Downloader()
313
+ self.patcher = Patcher()
314
+
315
+ self.apk_path: Optional[str] = None
316
+ self.package_name: Optional[str] = None
317
+ self.patches: list[dict[str, Any]] = []
318
+ # Both dicts are keyed by patch Index (unique), not Name (which can
319
+ # repeat across an unfiltered patch list).
320
+ self.overrides: dict[int, Optional[bool]] = {}
321
+ self.patch_options: dict[int, dict[str, Any]] = {}
322
+
323
+ # -- layout ------------------------------------------------------- #
324
+
325
+ def compose(self) -> ComposeResult:
326
+ yield Header()
327
+ yield Static(self._status_text(), id="status-bar")
328
+ with Horizontal(id="main"):
329
+ with Vertical(id="left-pane"):
330
+ with Horizontal(id="filter-row"):
331
+ yield Input(placeholder="Filter package name (optional)", id="filter-input")
332
+ yield Button("Load patches", id="btn-load")
333
+ with Horizontal(id="action-row"):
334
+ yield Button("Browse APK...", id="btn-browse")
335
+ yield Button("Download assets", id="btn-download")
336
+ yield Button("Edit options", id="btn-options")
337
+ table = DataTable(id="patch-table", cursor_type="row", zebra_stripes=True)
338
+ table.add_columns("On", "Idx", "Name", "Options", "Compatible")
339
+ yield table
340
+ with Horizontal(id="output-row"):
341
+ yield Input(placeholder="Output path (optional)", id="output-input")
342
+ with Horizontal(id="patch-row"):
343
+ yield Checkbox("exclusive", id="chk-exclusive")
344
+ yield Checkbox("force", id="chk-force")
345
+ yield Checkbox("bypass verification", value=True, id="chk-bypass")
346
+ yield Checkbox("purge", value=True, id="chk-purge")
347
+ yield Button("Patch APK [p]", id="btn-patch", variant="primary")
348
+ with Vertical(id="right-pane"):
349
+ yield Label("Log")
350
+ yield RichLog(id="log", wrap=True, highlight=True, markup=True)
351
+ yield Footer()
352
+
353
+ def on_mount(self) -> None:
354
+ self.log_line(f"CLI jar: {'found' if os.path.exists(CLI_PATH) else 'missing'} -> {CLI_PATH}")
355
+ self.log_line(f"patches.rvp: {'found' if os.path.exists(PATCHES_PATH) else 'missing'} -> {PATCHES_PATH}")
356
+ self.log_line("Press [b:browse] to pick an APK, then [l:load] to list patches.")
357
+
358
+ # -- small helpers -------------------------------------------------- #
359
+
360
+ def _status_text(self) -> str:
361
+ apk = self.apk_path or "(none selected)"
362
+ pkg = self.package_name or "(unknown)"
363
+ return f"APK: {apk} Package: {pkg} Patches loaded: {len(self.patches)}"
364
+
365
+ def refresh_status(self) -> None:
366
+ self.query_one("#status-bar", Static).update(self._status_text())
367
+
368
+ def log_line(self, text: str) -> None:
369
+ self.query_one("#log", RichLog).write(text)
370
+
371
+ def _patch_by_index(self, index: int) -> Optional[dict[str, Any]]:
372
+ for p in self.patches:
373
+ if p.get("Index") == index:
374
+ return p
375
+ return None
376
+
377
+ def _selected_patch(self) -> Optional[dict[str, Any]]:
378
+ table = self.query_one("#patch-table", DataTable)
379
+ if table.row_count == 0:
380
+ return None
381
+ try:
382
+ row_key, _ = table.coordinate_to_cell_key(table.cursor_coordinate)
383
+ except Exception:
384
+ return None
385
+ if row_key is None or row_key.value is None:
386
+ return None
387
+ try:
388
+ index = int(row_key.value)
389
+ except (TypeError, ValueError):
390
+ return None
391
+ return self._patch_by_index(index)
392
+
393
+ def _rebuild_table(self) -> None:
394
+ # Patch names are not guaranteed unique across an unfiltered patch
395
+ # list (multiple bundled patches can share a display name), so the
396
+ # patch Index -- which re1sid-lib guarantees is unique -- is used as
397
+ # the row key and as the identity for overrides/options everywhere.
398
+ table = self.query_one("#patch-table", DataTable)
399
+
400
+ # Remember which row was selected so re-toggling/editing right after
401
+ # a rebuild doesn't silently jump the cursor back to row 0.
402
+ previous_row_index: Optional[int] = None
403
+ if table.row_count > 0:
404
+ try:
405
+ row_key, _ = table.coordinate_to_cell_key(table.cursor_coordinate)
406
+ if row_key is not None and row_key.value is not None:
407
+ previous_row_index = int(row_key.value)
408
+ except Exception:
409
+ previous_row_index = None
410
+
411
+ table.clear()
412
+ new_cursor_row = 0
413
+ for i, p in enumerate(self.patches):
414
+ index = p.get("Index")
415
+ name = p.get("Name", "")
416
+ override = self.overrides.get(index)
417
+ icon = STATE_ICON[override] if override is not None else (
418
+ "[green]on[/]" if p.get("Enabled") else "[dim]off[/]"
419
+ )
420
+ n_opts = len(p.get("Options") or [])
421
+ opts_set = len(self.patch_options.get(index, {}))
422
+ opts_text = f"{n_opts}" + (f" ({opts_set} set)" if opts_set else "")
423
+ compat = ", ".join(
424
+ pkg.get("Package name", "") for pkg in (p.get("Compatible packages") or [])
425
+ ) or "any"
426
+ table.add_row(icon, str(index), name, opts_text, compat, key=str(index))
427
+ if previous_row_index is not None and index == previous_row_index:
428
+ new_cursor_row = i
429
+
430
+ if table.row_count > 0:
431
+ table.move_cursor(row=new_cursor_row)
432
+
433
+ # -- button dispatch ------------------------------------------------- #
434
+
435
+ def on_button_pressed(self, event: Button.Pressed) -> None:
436
+ bid = event.button.id
437
+ if bid == "btn-browse":
438
+ self.action_browse_apk()
439
+ elif bid == "btn-download":
440
+ self.action_download_assets()
441
+ elif bid == "btn-load":
442
+ self.action_load_patches()
443
+ elif bid == "btn-options":
444
+ self.action_edit_options()
445
+ elif bid == "btn-patch":
446
+ self.action_run_patch()
447
+
448
+ # -- actions ---------------------------------------------------------- #
449
+
450
+ def action_browse_apk(self) -> None:
451
+ def on_result(path: Optional[str]) -> None:
452
+ if not path:
453
+ return
454
+ self.apk_path = path
455
+ self.log_line(f"Selected APK: {path}")
456
+ try:
457
+ info = self.patcher.get_apk_info(path)
458
+ self.package_name = info.get("package_name")
459
+ self.log_line(
460
+ f"Detected package: {self.package_name} "
461
+ f"(version {info.get('version_name')})"
462
+ )
463
+ self.query_one("#filter-input", Input).value = self.package_name or ""
464
+ except Exception as exc: # noqa: BLE001
465
+ self.log_line(f"[red]Could not read APK info: {exc}[/]")
466
+ self.refresh_status()
467
+
468
+ self.push_screen(ApkPickerScreen(), on_result)
469
+
470
+ @work(thread=True)
471
+ def action_download_assets(self) -> None:
472
+ self.call_from_thread(self.log_line, "Downloading ReVanced CLI + patches.rvp ...")
473
+ try:
474
+ self.downloader.download_all()
475
+ self.call_from_thread(
476
+ self.log_line, "[green]Download complete.[/]"
477
+ )
478
+ self.call_from_thread(
479
+ self.log_line,
480
+ f"CLI: {CLI_PATH}\npatches: {PATCHES_PATH}",
481
+ )
482
+ except Exception as exc: # noqa: BLE001
483
+ self.call_from_thread(self.log_line, f"[red]Download failed: {exc}[/]")
484
+
485
+ @work(thread=True)
486
+ def action_load_patches(self) -> None:
487
+ filter_text = self.query_one("#filter-input", Input).value.strip() or None
488
+ apk_path = self.apk_path
489
+
490
+ self.call_from_thread(self.log_line, "Loading patches ...")
491
+ try:
492
+ if apk_path and not filter_text:
493
+ patches = self.patcher.list_patches(apk_path=apk_path)
494
+ else:
495
+ patches = self.patcher.list_patches(package_name=filter_text)
496
+ except Exception as exc: # noqa: BLE001
497
+ self.call_from_thread(self.log_line, f"[red]Failed to load patches: {exc}[/]")
498
+ return
499
+
500
+ def apply() -> None:
501
+ self.patches = patches
502
+ self.overrides = {}
503
+ self.patch_options = {}
504
+ self._rebuild_table()
505
+ self.refresh_status()
506
+ self.log_line(f"[green]Loaded {len(patches)} patch(es).[/]")
507
+
508
+ self.call_from_thread(apply)
509
+
510
+ def action_toggle_patch(self) -> None:
511
+ patch = self._selected_patch()
512
+ if patch is None:
513
+ return
514
+ index = patch.get("Index")
515
+ name = patch.get("Name", "")
516
+ current = self.overrides.get(index)
517
+ idx = STATE_CYCLE.index(current) if current in STATE_CYCLE else 0
518
+ new_state = STATE_CYCLE[(idx + 1) % len(STATE_CYCLE)]
519
+ self.overrides[index] = new_state
520
+ self._rebuild_table()
521
+ self.log_line(f"[{index}] {name}: {state_label(new_state, patch.get('Enabled', False))}")
522
+
523
+ def action_edit_options(self) -> None:
524
+ patch = self._selected_patch()
525
+ if patch is None:
526
+ self.log_line("[yellow]Select a patch first.[/]")
527
+ return
528
+ index = patch.get("Index")
529
+ name = patch.get("Name", "")
530
+
531
+ def on_result(values: Optional[dict[str, Any]]) -> None:
532
+ if values is None:
533
+ return
534
+ if values == {}:
535
+ self.patch_options.pop(index, None)
536
+ self.log_line(f"[{index}] {name}: options reset to defaults.")
537
+ else:
538
+ self.patch_options[index] = values
539
+ self.log_line(f"[{index}] {name}: options updated -> {values}")
540
+ self._rebuild_table()
541
+
542
+ self.push_screen(OptionsModal(patch, self.patch_options.get(index, {})), on_result)
543
+
544
+ def action_run_patch(self) -> None:
545
+ if not self.apk_path:
546
+ self.log_line("[red]No APK selected. Press b to browse for one.[/]")
547
+ return
548
+ if not os.path.exists(CLI_PATH) or not os.path.exists(PATCHES_PATH):
549
+ self.log_line("[red]CLI jar or patches.rvp missing. Press d to download assets.[/]")
550
+ return
551
+ self._run_patch_worker()
552
+
553
+ @work(thread=True)
554
+ def _run_patch_worker(self) -> None:
555
+ # enabled_patches/disabled_patches accept names or indices; indices
556
+ # are used here since they're guaranteed unique per patch.
557
+ enabled: list[Any] = [index for index, v in self.overrides.items() if v is True]
558
+ disabled: list[Any] = [index for index, v in self.overrides.items() if v is False]
559
+
560
+ combined_options: dict[str, Any] = {}
561
+ for index in self.patch_options:
562
+ combined_options.update(self.patch_options[index])
563
+
564
+ output_path = self.query_one("#output-input", Input).value.strip() or None
565
+ if output_path is None:
566
+ os.makedirs(OUTPUT_DIR, exist_ok=True)
567
+ base = Path(self.apk_path).stem
568
+ output_path = str(Path(OUTPUT_DIR) / f"{base}-patched.apk")
569
+
570
+ exclusive = self.query_one("#chk-exclusive", Checkbox).value
571
+ force = self.query_one("#chk-force", Checkbox).value
572
+ bypass = self.query_one("#chk-bypass", Checkbox).value
573
+ purge = self.query_one("#chk-purge", Checkbox).value
574
+
575
+ self.call_from_thread(
576
+ self.log_line,
577
+ f"[bold]Patching[/] {self.apk_path} -> {output_path}\n"
578
+ f"enabled={enabled or '-'} disabled={disabled or '-'} "
579
+ f"options={combined_options or '-'}",
580
+ )
581
+
582
+ try:
583
+ stream = self.patcher.patch_apk(
584
+ apk_path=self.apk_path,
585
+ output_path=output_path,
586
+ enabled_patches=enabled or None,
587
+ disabled_patches=disabled or None,
588
+ options=combined_options or None,
589
+ exclusive=exclusive,
590
+ force=force,
591
+ bypass_verification=bypass,
592
+ purge=purge,
593
+ stream_output=True,
594
+ )
595
+ for line in stream:
596
+ self.call_from_thread(self.log_line, line)
597
+ self.call_from_thread(
598
+ self.log_line, f"[green]Done. Patched APK written to {output_path}[/]"
599
+ )
600
+ except Exception as exc: # noqa: BLE001
601
+ self.call_from_thread(self.log_line, f"[red]Patch failed: {exc}[/]")
602
+
603
+
604
+ def main() -> None:
605
+ ReVancedTUI().run()
606
+
607
+
608
+ if __name__ == "__main__":
609
+ main()
@@ -1,4 +0,0 @@
1
- from .downloader import Downloader
2
- from .patcher import Patcher
3
-
4
- __all__ = ["Downloader", "Patcher"]
File without changes