re1sid-lib 0.1.2__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.2
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
@@ -36,18 +43,8 @@ A small Python library for working with ReVanced CLI and patch bundles. It provi
36
43
 
37
44
  ## Installation
38
45
 
39
- 1. Clone the repository.
40
- 2. Install Python dependencies:
41
-
42
46
  ```bash
43
- pip install httpx lxml pyaxmlparser
44
- ```
45
-
46
- 3. Configure paths in `src/re1sid_lib/common.py`:
47
-
48
- ```python
49
- PATCHES_PATH = ".revanced_res/patches.rvp"
50
- CLI_PATH = ".revanced_res/revanced-cli.jar"
47
+ pip install re1sid-lib
51
48
  ```
52
49
 
53
50
  ## Usage
@@ -57,7 +54,7 @@ CLI_PATH = ".revanced_res/revanced-cli.jar"
57
54
  Use the downloader helper to fetch the latest CLI jar and patches bundle.
58
55
 
59
56
  ```python
60
- from re1sid_lib.downloader import Downloader
57
+ from re1sid_lib import Downloader
61
58
 
62
59
  downloader = Downloader()
63
60
  downloader.download_all()
@@ -66,7 +63,7 @@ downloader.download_all()
66
63
  Or download individual assets:
67
64
 
68
65
  ```python
69
- from re1sid_lib.downloader import Downloader
66
+ from re1sid_lib import Downloader
70
67
 
71
68
  downloader = Downloader()
72
69
  downloader.download_cli()
@@ -78,7 +75,7 @@ downloader.download_patches_rvp()
78
75
  Use the patcher helper to parse the patch list and inspect options.
79
76
 
80
77
  ```python
81
- from re1sid_lib.patcher import Patcher
78
+ from re1sid_lib import Patcher
82
79
 
83
80
  patcher = Patcher()
84
81
  patches = patcher.list_patches(package_name="com.spotify.music")
@@ -91,7 +88,7 @@ for patch in patches:
91
88
  Patch an APK file with specific enabled/disabled patches and options.
92
89
 
93
90
  ```python
94
- from re1sid_lib.patcher import Patcher
91
+ from re1sid_lib import Patcher
95
92
 
96
93
  patcher = Patcher()
97
94
  output = patcher.patch_apk(
@@ -114,12 +111,6 @@ print(output)
114
111
  - `Patcher.list_patches()` can also accept an APK path and will read its package name automatically.
115
112
  - The library expects the ReVanced CLI jar and patches bundle to exist at the configured paths.
116
113
 
117
- ## Project structure
118
-
119
- - `src/re1sid_lib/downloader.py` - Download helper for ReVanced CLI and patches.
120
- - `src/re1sid_lib/patcher.py` - Patch helper and CLI output parser.
121
- - `src/re1sid_lib/common.py` - Common path configuration.
122
-
123
114
  ## License
124
115
 
125
116
  Use this project according to the appropriate license terms for ReVanced and its dependencies.
@@ -20,18 +20,8 @@ A small Python library for working with ReVanced CLI and patch bundles. It provi
20
20
 
21
21
  ## Installation
22
22
 
23
- 1. Clone the repository.
24
- 2. Install Python dependencies:
25
-
26
23
  ```bash
27
- pip install httpx lxml pyaxmlparser
28
- ```
29
-
30
- 3. Configure paths in `src/re1sid_lib/common.py`:
31
-
32
- ```python
33
- PATCHES_PATH = ".revanced_res/patches.rvp"
34
- CLI_PATH = ".revanced_res/revanced-cli.jar"
24
+ pip install re1sid-lib
35
25
  ```
36
26
 
37
27
  ## Usage
@@ -41,7 +31,7 @@ CLI_PATH = ".revanced_res/revanced-cli.jar"
41
31
  Use the downloader helper to fetch the latest CLI jar and patches bundle.
42
32
 
43
33
  ```python
44
- from re1sid_lib.downloader import Downloader
34
+ from re1sid_lib import Downloader
45
35
 
46
36
  downloader = Downloader()
47
37
  downloader.download_all()
@@ -50,7 +40,7 @@ downloader.download_all()
50
40
  Or download individual assets:
51
41
 
52
42
  ```python
53
- from re1sid_lib.downloader import Downloader
43
+ from re1sid_lib import Downloader
54
44
 
55
45
  downloader = Downloader()
56
46
  downloader.download_cli()
@@ -62,7 +52,7 @@ downloader.download_patches_rvp()
62
52
  Use the patcher helper to parse the patch list and inspect options.
63
53
 
64
54
  ```python
65
- from re1sid_lib.patcher import Patcher
55
+ from re1sid_lib import Patcher
66
56
 
67
57
  patcher = Patcher()
68
58
  patches = patcher.list_patches(package_name="com.spotify.music")
@@ -75,7 +65,7 @@ for patch in patches:
75
65
  Patch an APK file with specific enabled/disabled patches and options.
76
66
 
77
67
  ```python
78
- from re1sid_lib.patcher import Patcher
68
+ from re1sid_lib import Patcher
79
69
 
80
70
  patcher = Patcher()
81
71
  output = patcher.patch_apk(
@@ -98,12 +88,6 @@ print(output)
98
88
  - `Patcher.list_patches()` can also accept an APK path and will read its package name automatically.
99
89
  - The library expects the ReVanced CLI jar and patches bundle to exist at the configured paths.
100
90
 
101
- ## Project structure
102
-
103
- - `src/re1sid_lib/downloader.py` - Download helper for ReVanced CLI and patches.
104
- - `src/re1sid_lib/patcher.py` - Patch helper and CLI output parser.
105
- - `src/re1sid_lib/common.py` - Common path configuration.
106
-
107
91
  ## License
108
92
 
109
93
  Use this project according to the appropriate license terms for ReVanced and its dependencies.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "re1sid-lib"
3
- version = "0.1.2"
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"]
@@ -4,7 +4,7 @@ import os
4
4
  import shutil
5
5
  from lxml import html
6
6
 
7
- from common import PATCHES_PATH, CLI_PATH
7
+ from .common import PATCHES_PATH, CLI_PATH
8
8
 
9
9
  class Downloader:
10
10
  def __init__(self) -> None:
@@ -3,7 +3,7 @@ import os
3
3
  from typing import List, Dict, Any, Union, Optional, Generator
4
4
  from pyaxmlparser import APK
5
5
 
6
- from common import PATCHES_PATH, CLI_PATH
6
+ from .common import PATCHES_PATH, CLI_PATH
7
7
 
8
8
 
9
9
  class Patcher:
@@ -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"]