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.
- {re1sid_lib-0.1.2 → re1sid_lib-0.2.0}/PKG-INFO +13 -22
- {re1sid_lib-0.1.2 → re1sid_lib-0.2.0}/README.md +5 -21
- {re1sid_lib-0.1.2 → re1sid_lib-0.2.0}/pyproject.toml +16 -2
- re1sid_lib-0.2.0/src/re1sid_lib/__init__.py +5 -0
- {re1sid_lib-0.1.2 → re1sid_lib-0.2.0}/src/re1sid_lib/downloader.py +1 -1
- {re1sid_lib-0.1.2 → re1sid_lib-0.2.0}/src/re1sid_lib/patcher.py +1 -1
- re1sid_lib-0.2.0/src/re1sid_lib/tui.py +609 -0
- re1sid_lib-0.1.2/src/re1sid_lib/__init__.py +0 -4
- {re1sid_lib-0.1.2 → re1sid_lib-0.2.0}/src/re1sid_lib/common.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: re1sid-lib
|
|
3
|
-
Version: 0.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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,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()
|
|
File without changes
|