Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e224ac193 | ||
|
|
604e7d60f0 | ||
|
|
8a1eebcb02 | ||
|
|
a48db482a8 | ||
|
|
9672f63cd9 | ||
|
|
e56629bcc2 | ||
|
|
78bbbc1303 | ||
|
|
e141bc000f | ||
|
|
c59cd2da52 | ||
|
|
0e11bd85ff | ||
|
|
898238b3e0 | ||
|
|
f0bfb952e8 | ||
|
|
bf4a268526 | ||
|
|
0591362f8f | ||
|
|
2024daf22e | ||
|
|
97fe83cd3a | ||
|
|
08b532236d |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -4,3 +4,7 @@ Dockerfile
|
|||||||
houdini/
|
houdini/
|
||||||
magisk/
|
magisk/
|
||||||
mindthegapps/
|
mindthegapps/
|
||||||
|
# offline/downloads/*
|
||||||
|
# !offline/downloads/.gitkeep
|
||||||
|
# offline/images/*
|
||||||
|
# !offline/images/.gitkeep
|
||||||
|
|||||||
253
README.md
253
README.md
@@ -1,48 +1,140 @@
|
|||||||
# redroid-android13
|
# redroid-android13
|
||||||
|
|
||||||
这个仓库只保留了 `5570` 那套优化过的 Android 13 redroid 最小集,不再带 `gapps/litegapps/ndk/widevine` 这些无关入口。
|
This repo is the minimal Android 13 redroid bundle that matches the optimized `5570` instance.
|
||||||
|
|
||||||
目标很明确:
|
It keeps only the parts that were actually used:
|
||||||
|
|
||||||
- 基于 `redroid/redroid:13.0.0`
|
- `redroid/redroid:13.0.0`
|
||||||
- 集成 `MindTheGapps`
|
- `MindTheGapps`
|
||||||
- 集成 `libhoudini`
|
- `libhoudini`
|
||||||
- 集成 `Kitsune Mask / Magisk Delta`
|
- `Kitsune Mask / Magisk Delta`
|
||||||
- 启动后伪装成 `Pixel 7 Pro`
|
- `Pixel 7 Pro` device profile
|
||||||
- 修复 `Magisk Installed N/A` 和设置页不可用的问题
|
- the `post-fs-data` fix that keeps `Magisk Installed` and the settings page healthy
|
||||||
|
|
||||||
## 保留的文件
|
## Included files
|
||||||
|
|
||||||
- `redroid.py`
|
- `redroid.py`
|
||||||
- `run_redroid.py`
|
- `run_redroid.py`
|
||||||
- `bootstrap_redroid.py`
|
- `bootstrap_redroid.py`
|
||||||
|
- `verify_offline_bundle.py`
|
||||||
|
- `tools/compat.py` (host / GPU / runtime checks)
|
||||||
|
- `tools/ssh_remote.py` (Paramiko SSH: remote `docker` / `adb` diagnostics)
|
||||||
|
- `offline/README.md`
|
||||||
|
- `offline/manifest.json`
|
||||||
- `stuff/mindthegapps.py`
|
- `stuff/mindthegapps.py`
|
||||||
- `stuff/houdini.py`
|
- `stuff/houdini.py`
|
||||||
- `stuff/houdini_hack.py`
|
- `stuff/houdini_hack.py`
|
||||||
- `stuff/magisk.py`
|
- `stuff/magisk.py`
|
||||||
- `assets/redroid_extreme_post_fs_data.sh`
|
- `assets/redroid_extreme_post_fs_data.sh`
|
||||||
|
- local `offline/downloads/`
|
||||||
|
- local `offline/images/`
|
||||||
|
|
||||||
## 依赖
|
## Offline archive
|
||||||
|
|
||||||
|
The repo tracks the offline bundle metadata under [offline](offline). The large zip/apk/tar payloads are intentionally kept out of Git and should be stored locally under `offline/downloads/` and `offline/images/`.
|
||||||
|
|
||||||
|
Use [offline/manifest.json](offline/manifest.json) for checksums and source metadata, and [offline/README.md](offline/README.md) for the expected local layout.
|
||||||
|
|
||||||
|
Expected local files:
|
||||||
|
|
||||||
|
- `offline/downloads/mindthegapps.zip`
|
||||||
|
- `offline/downloads/libhoudini.zip`
|
||||||
|
- `offline/downloads/libhoudini_hack.zip`
|
||||||
|
- `offline/downloads/magisk.apk`
|
||||||
|
- `offline/downloads/magisk.version`
|
||||||
|
- `offline/images/redroid-redroid-13.0.0-latest.tar` (optional if the base image is already in local Docker storage)
|
||||||
|
|
||||||
|
### Build resource order (`redroid.py`)
|
||||||
|
|
||||||
|
- **Base image:** prefers an existing local `redroid/redroid:<ver>-latest`, then `offline/images/redroid-redroid-13.0.0-latest.tar` (`docker load`), then a registry pull (skipped when offline-only).
|
||||||
|
- **Zips / APK:** prefers files under `offline/downloads/` whose **MD5** matches the pinned upstream artifacts; downloads only if missing or mismatch (skipped when offline-only).
|
||||||
|
- **Strict no-network builds:** pass `--offline` or set `REDROID_OFFLINE=1` so missing bundles fail fast instead of downloading.
|
||||||
|
|
||||||
|
### Verify script notes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python verify_offline_bundle.py
|
||||||
|
```
|
||||||
|
|
||||||
|
- `magisk.version` is checked against exact **size** and **sha256** from `manifest.json`. A short version string without the expected newline/padding will report `size mismatch` even when `magisk.apk` is fine for the image build.
|
||||||
|
- A missing `offline/images/*.tar` is expected to **FAIL** here if you rely on a pre-pulled Docker image instead; that does not block `redroid.py` when the base image already exists locally.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m pip install -r requirements.txt
|
python -m pip install -r requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
容器侧默认按 `docker` 写,`podman` 也能用。
|
## Compatibility checks
|
||||||
|
|
||||||
## 构建 5570 对应镜像
|
The project probes the **host** before critical steps (no separate manual checklist required):
|
||||||
|
|
||||||
|
| Step | What it checks | Opt-out |
|
||||||
|
|------|----------------|---------|
|
||||||
|
| `redroid.py` | Container runtime (`docker`/`podman`) responds; OS is plausible for redroid | `--no-compat-check` |
|
||||||
|
| `run_redroid.py` | Same + Linux **`/dev/dri`** for GPU; **`binder_linux`** module hint on Linux | `--no-compat-check` |
|
||||||
|
|
||||||
|
**GPU mode (`run_redroid.py`):**
|
||||||
|
|
||||||
|
- **`--gpu-mode guest` (default):** software GLES — most stable on headless / server GPUs where host EGL inside the guest often breaks boot or **adbd**.
|
||||||
|
- **`--gpu-mode auto`:** stays **`guest`** unless **`REDROID_USE_HOST_GPU=1`** (or `true`/`yes`/`on`) **and** `/dev/dri` nodes exist; then **`host`** and `docker run --device …` per DRI node.
|
||||||
|
- **`--gpu-mode host`:** pass-through when DRI exists; otherwise **falls back to `guest`** with a warning.
|
||||||
|
|
||||||
|
Quick probe only:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tools/compat.py
|
||||||
|
python tools/compat.py -c podman --gpu-mode guest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build the 5570 image
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python redroid.py -mtg -i -m
|
python redroid.py -mtg -i -m
|
||||||
```
|
```
|
||||||
|
|
||||||
这会生成并构建:
|
Build flags (short options):
|
||||||
|
|
||||||
|
| Flag | Long form | Meaning |
|
||||||
|
|------|-----------|---------|
|
||||||
|
| **`-mtg`** | `--install-mindthegapps` | Bake **MindTheGapps** (Google apps framework) into the image |
|
||||||
|
| **`-i`** | `--install-houdini` | Bake **libhoudini** + **redroid houdini hack** (ARM translation on x86) |
|
||||||
|
| **`-m`** | `--install-magisk` | Bake **Kitsune Mask / Magisk** bootless payload into the image |
|
||||||
|
|
||||||
|
Together they produce `redroid/redroid:13.0.0_mindthegapps_houdini_magisk`. None of these control the **screen**; display is set when you **run** the container (`run_redroid.py`).
|
||||||
|
|
||||||
|
Air-gapped (requires a complete `offline/` tree):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
REDROID_OFFLINE=1 python redroid.py -mtg -i -m
|
||||||
|
# or
|
||||||
|
python redroid.py -mtg -i -m --offline
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected image tag:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
redroid/redroid:13.0.0_mindthegapps_houdini_magisk
|
redroid/redroid:13.0.0_mindthegapps_houdini_magisk
|
||||||
```
|
```
|
||||||
|
|
||||||
## 启动容器
|
## Launch the container
|
||||||
|
|
||||||
|
Headless servers have **no screen** to approve the USB debugging RSA dialog. Always pass **`--adb-insecure`** so boot props disable ADB RSA checks and apply **before** the Pixel user profile (`androidboot.adb.secure=0`, `ro.adb.secure=0`, `ro.secure=0`, `ro.debuggable=1`). **Do not expose the ADB port on a public network.**
|
||||||
|
|
||||||
|
### Persist after reboot (default)
|
||||||
|
|
||||||
|
By default **`run_redroid.py` does not pass `--rm`**: the container stays on disk when it stops, and uses **`--restart unless-stopped`** so **Docker/Podman will start it again after the daemon or host reboots** (unless you ran `docker stop <name>`).
|
||||||
|
|
||||||
|
- **Always restart** even after explicit stop: `--restart always`
|
||||||
|
- **No auto-restart** but still keep the container (no `--rm`): `--restart no`
|
||||||
|
- **One-off / dev only** (old behavior, removed on stop): pass **`--rm`**
|
||||||
|
|
||||||
|
On Ubuntu, ensure the engine starts on boot:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl enable docker --now
|
||||||
|
# or: sudo systemctl enable podman.socket --now # if you use Podman rootful similarly
|
||||||
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python run_redroid.py \
|
python run_redroid.py \
|
||||||
@@ -51,10 +143,97 @@ python run_redroid.py \
|
|||||||
--device-profile pixel-7-pro \
|
--device-profile pixel-7-pro \
|
||||||
--data-dir ~/data-redroid13 \
|
--data-dir ~/data-redroid13 \
|
||||||
--port 5570 \
|
--port 5570 \
|
||||||
--replace
|
--replace \
|
||||||
|
--adb-insecure
|
||||||
```
|
```
|
||||||
|
|
||||||
## 启动后做 5570 硬化
|
On the **same machine as Docker**, one-shot status + logs + `logcat` after start:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python run_redroid.py ... --adb-insecure --diagnose --diagnose-settle 20
|
||||||
|
```
|
||||||
|
|
||||||
|
Then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
adb kill-server && adb start-server
|
||||||
|
adb connect 127.0.0.1:5570
|
||||||
|
adb devices
|
||||||
|
```
|
||||||
|
|
||||||
|
If you still see `unauthorized` after an upgrade, stop the container, remove persisted keys on the host, and retry:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -rf ~/data-redroid13/misc/adb
|
||||||
|
```
|
||||||
|
|
||||||
|
## Remote diagnostics over SSH (Paramiko)
|
||||||
|
|
||||||
|
When redroid runs on **another Linux machine**, you can drive checks from your PC with **`tools/ssh_remote.py`** (uses [Paramiko](https://www.paramiko.org/), declared in `requirements.txt`).
|
||||||
|
|
||||||
|
**Do not put passwords in Git.** Use a key file or a one-time prompt:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
export REDROID_SSH_HOST=192.168.2.179
|
||||||
|
export REDROID_SSH_USER=eric
|
||||||
|
python tools/ssh_remote.py --password-prompt diagnose
|
||||||
|
```
|
||||||
|
|
||||||
|
Key-based:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export REDROID_SSH_HOST=... REDROID_SSH_USER=eric REDROID_SSH_KEY=$HOME/.ssh/id_ed25519
|
||||||
|
python tools/ssh_remote.py diagnose
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional env: **`REDROID_CONTAINER`** (default `redroid_13.0.0_mindthegapps_houdini_magisk`), **`REDROID_ADB_PORT`** (default `5570`). You can also pass **`--container-name`** / **`--adb-port`**.
|
||||||
|
|
||||||
|
Arbitrary remote command (note the **`--`** before the shell snippet):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tools/ssh_remote.py -H 192.168.2.179 -U eric --password-prompt exec -- bash -lc 'docker ps -a | head -20'
|
||||||
|
```
|
||||||
|
|
||||||
|
The `shell` subcommand is a minimal pseudo-shell only; for a full TTY, use normal OpenSSH **`ssh`**.
|
||||||
|
|
||||||
|
## ADB `failed to connect` / connection refused
|
||||||
|
|
||||||
|
This means **nothing is listening on the host port** (not the same as `unauthorized`). Typical causes:
|
||||||
|
|
||||||
|
1. **Container exited** — if you used **`--rm`**, a crash removes the instance. By default the container is **kept**; use `docker ps -a` and `docker logs` to inspect. Ensure **`--restart`** is not `no` if you want auto-start after reboot.
|
||||||
|
2. **`androidboot.redroid_gpu_mode=host`** — Mesa/EGL or DRI inside the guest can fail on some hosts; Android never reaches **adbd**. **Workaround:** force software rendering:
|
||||||
|
```bash
|
||||||
|
python run_redroid.py ... --adb-insecure --gpu-mode guest
|
||||||
|
```
|
||||||
|
3. **Still booting** — wait longer, or use **`--wait-tcp 90`** on `run_redroid.py` to block until TCP opens and print logs if it does not.
|
||||||
|
|
||||||
|
## Black screen or flickering
|
||||||
|
|
||||||
|
With **`--gpu-mode guest`** (the default), the display uses **software GLES** (SwiftShader). On many **headless servers**, SurfaceFlinger may still show **black screen** or **flicker** — try **`--soft-display`** and more RAM. Host GPU needs **`REDROID_USE_HOST_GPU=1`** with **`--gpu-mode auto`** or **`--gpu-mode host`** when DRI works inside the guest.
|
||||||
|
|
||||||
|
**What to try**
|
||||||
|
|
||||||
|
1. **GPU on the host:** set **`REDROID_USE_HOST_GPU=1`** and **`--gpu-mode auto`**, or **`--gpu-mode host`**. If **ADB never connects**, host EGL is likely broken — use default **`--gpu-mode guest`**. Manual devices example:
|
||||||
|
```bash
|
||||||
|
docker run ... --device /dev/dri ...
|
||||||
|
```
|
||||||
|
The exact device path varies (Intel/AMD/NVIDIA, `nvidia-docker`, etc.); see [redroid-doc](https://github.com/remote-android/redroid-doc) GPU notes.
|
||||||
|
|
||||||
|
2. **Software GLES (`guest`)** — force low load on the virtual display (often reduces black screen / flicker):
|
||||||
|
```bash
|
||||||
|
python run_redroid.py ... --adb-insecure --gpu-mode guest --soft-display --memory 4g
|
||||||
|
```
|
||||||
|
`--soft-display` sets 720×1280, 15 FPS, dpi 280. You can tune further with `--extra-prop` if needed.
|
||||||
|
|
||||||
|
3. **First boot / SetupWizard** can sit on a dark screen for a while. Run **`bootstrap_redroid.py`** once `adb` shows `device` so provisioning and wizard are skipped as intended.
|
||||||
|
|
||||||
|
4. **Debug with logcat** (on the host, after `adb connect`):
|
||||||
|
```bash
|
||||||
|
adb -s 127.0.0.1:5570 logcat -d | grep -iE 'SurfaceFlinger|EGL|GLES|redroid'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Apply the 5570 hardening
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python bootstrap_redroid.py \
|
python bootstrap_redroid.py \
|
||||||
@@ -64,39 +243,39 @@ python bootstrap_redroid.py \
|
|||||||
--install-extreme-script
|
--install-extreme-script
|
||||||
```
|
```
|
||||||
|
|
||||||
这一步会做 5 件事:
|
This post-boot step:
|
||||||
|
|
||||||
- 跳过 SetupWizard
|
- disables SetupWizard
|
||||||
- 标记系统已完成初始化
|
- marks the device provisioned
|
||||||
- 开启高精度定位模式
|
- enables high accuracy location mode
|
||||||
- 允许 `com.android.shell` 使用 mock location
|
- allows `com.android.shell` to use mock location
|
||||||
- 应用 `Pixel 7 Pro / user / release-keys / locked / green` 画像
|
- applies the `Pixel 7 Pro / user / release-keys / locked / green` identity
|
||||||
|
|
||||||
如果带上 `--install-extreme-script`,还会把 [`assets/redroid_extreme_post_fs_data.sh`](C:\Users\admin\Desktop\redroid-android13\assets\redroid_extreme_post_fs_data.sh) 安装到 `/data/adb/post-fs-data.d/20-redroid-extreme.sh`,让下面两件事在重启后继续成立:
|
With `--install-extreme-script`, the validated script [assets/redroid_extreme_post_fs_data.sh](assets/redroid_extreme_post_fs_data.sh) is copied to `/data/adb/post-fs-data.d/20-redroid-extreme.sh` so that:
|
||||||
|
|
||||||
- `vendor/bin/su` 指向 Magisk applet,应用命名空间可见
|
- `vendor/bin/su` stays visible to app namespaces after reboot
|
||||||
- `Pixel 7 Pro` 属性在每次开机时重新注入
|
- `Pixel 7 Pro` properties are re-applied on every boot
|
||||||
|
|
||||||
## 已验证结果
|
## Verified 5570 state
|
||||||
|
|
||||||
这套仓库对应的 `5570` 实例已经验证过:
|
The matching `5570` instance was already validated with:
|
||||||
|
|
||||||
- `ro.product.model=Pixel 7 Pro`
|
- `ro.product.model=Pixel 7 Pro`
|
||||||
- `ro.build.type=user`
|
- `ro.build.type=user`
|
||||||
- `ro.build.tags=release-keys`
|
- `ro.build.tags=release-keys`
|
||||||
- `ro.boot.vbmeta.device_state=locked`
|
- `ro.boot.vbmeta.device_state=locked`
|
||||||
- `ro.boot.verifiedbootstate=green`
|
- `ro.boot.verifiedbootstate=green`
|
||||||
- 应用 UID 下 `su -V` 返回 `27001`
|
- app UID `su -V = 27001`
|
||||||
- 应用 UID 下 `su -mm -c id` 能拿到 `uid=0(root)`
|
- app UID `su -mm -c id = uid=0(root)`
|
||||||
- `Kitsune` 首页的 `Installed` 正常显示
|
- `Kitsune` home page shows a normal `Installed` state
|
||||||
- `Kitsune` 设置页正常打开,`SuList / MagiskHide` 可配置
|
- `Kitsune` settings page opens normally and `SuList / MagiskHide` is configurable
|
||||||
|
|
||||||
## 当前范围
|
## Current scope
|
||||||
|
|
||||||
这个仓库只负责把 `5570` 的系统环境复现出来,不包含:
|
This repo only reproduces the system environment for the optimized `5570` build.
|
||||||
|
|
||||||
- 虚拟摄像头
|
It does not yet include:
|
||||||
|
|
||||||
|
- virtual camera
|
||||||
- camera HAL
|
- camera HAL
|
||||||
- TikTok Live 专用补丁
|
- TikTok Live specific patches
|
||||||
|
|
||||||
后续如果要继续推进直播链路,直接在这个仓库往上叠加即可。
|
|
||||||
|
|||||||
@@ -43,20 +43,47 @@ class Backend:
|
|||||||
if self.mode == "adb" and ":" in self.serial:
|
if self.mode == "adb" and ":" in self.serial:
|
||||||
run(["adb", "connect", self.serial], check=False)
|
run(["adb", "connect", self.serial], check=False)
|
||||||
|
|
||||||
|
def _root_probe_args(self):
|
||||||
|
return [
|
||||||
|
["shell", "su", "root", "id"],
|
||||||
|
["shell", "su", "0", "id"],
|
||||||
|
]
|
||||||
|
|
||||||
|
def _root_shell_args(self, command):
|
||||||
|
return [
|
||||||
|
["shell", "su", "root", "sh", "-c", command],
|
||||||
|
["shell", "su", "0", "sh", "-c", command],
|
||||||
|
]
|
||||||
|
|
||||||
def has_root(self):
|
def has_root(self):
|
||||||
if self.mode == "container":
|
if self.mode == "container":
|
||||||
return True
|
return True
|
||||||
if not self._root_checked:
|
if not self._root_checked or not self._root_available:
|
||||||
result = adb(
|
for args in self._root_probe_args():
|
||||||
self.serial,
|
result = adb(
|
||||||
["shell", "su", "-mm", "-c", "id"],
|
self.serial,
|
||||||
check=False,
|
args,
|
||||||
capture_output=True,
|
check=False,
|
||||||
)
|
capture_output=True,
|
||||||
self._root_available = result.returncode == 0 and "uid=0(" in result.stdout
|
)
|
||||||
self._root_checked = True
|
if result.returncode == 0 and "uid=0(" in result.stdout:
|
||||||
|
self._root_available = True
|
||||||
|
self._root_checked = True
|
||||||
|
return True
|
||||||
|
self._root_available = False
|
||||||
|
self._root_checked = False
|
||||||
return self._root_available
|
return self._root_available
|
||||||
|
|
||||||
|
def wait_for_root(self, timeout):
|
||||||
|
if self.mode == "container":
|
||||||
|
return True
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
if self.has_root():
|
||||||
|
return True
|
||||||
|
time.sleep(2)
|
||||||
|
return False
|
||||||
|
|
||||||
def exec_shell(self, command, check=True, capture_output=False, root=False):
|
def exec_shell(self, command, check=True, capture_output=False, root=False):
|
||||||
if self.mode == "container":
|
if self.mode == "container":
|
||||||
return run(
|
return run(
|
||||||
@@ -72,12 +99,20 @@ class Backend:
|
|||||||
capture_output=capture_output,
|
capture_output=capture_output,
|
||||||
)
|
)
|
||||||
if root and self.has_root():
|
if root and self.has_root():
|
||||||
return adb(
|
last_result = None
|
||||||
self.serial,
|
for args in self._root_shell_args(command):
|
||||||
["shell", "su", "-mm", "-c", command],
|
result = adb(
|
||||||
check=check,
|
self.serial,
|
||||||
capture_output=capture_output,
|
args,
|
||||||
)
|
check=False,
|
||||||
|
capture_output=capture_output,
|
||||||
|
)
|
||||||
|
last_result = result
|
||||||
|
if result.returncode == 0:
|
||||||
|
return result
|
||||||
|
if check and last_result is not None:
|
||||||
|
last_result.check_returncode()
|
||||||
|
return last_result
|
||||||
return adb(
|
return adb(
|
||||||
self.serial,
|
self.serial,
|
||||||
["shell", command],
|
["shell", command],
|
||||||
@@ -160,6 +195,8 @@ def shell_quote(value):
|
|||||||
def apply_device_profile(backend, profile):
|
def apply_device_profile(backend, profile):
|
||||||
if profile == "none":
|
if profile == "none":
|
||||||
return "skipped"
|
return "skipped"
|
||||||
|
if backend.mode == "adb" and not backend.has_root():
|
||||||
|
return "skipped (root unavailable)"
|
||||||
props = DEVICE_PROFILES[profile]
|
props = DEVICE_PROFILES[profile]
|
||||||
resetprop = backend.exec_shell(
|
resetprop = backend.exec_shell(
|
||||||
"if [ -x /debug_ramdisk/resetprop ]; then echo /debug_ramdisk/resetprop; "
|
"if [ -x /debug_ramdisk/resetprop ]; then echo /debug_ramdisk/resetprop; "
|
||||||
@@ -263,6 +300,12 @@ def main():
|
|||||||
default="none",
|
default="none",
|
||||||
help="Apply a runtime device profile through resetprop when available",
|
help="Apply a runtime device profile through resetprop when available",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--root-timeout",
|
||||||
|
type=int,
|
||||||
|
default=120,
|
||||||
|
help="Seconds to wait for Magisk root in ADB mode before root-only tweaks",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--install-extreme-script",
|
"--install-extreme-script",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
@@ -278,14 +321,25 @@ def main():
|
|||||||
backend.connect()
|
backend.connect()
|
||||||
backend.wait_for_boot(args.timeout)
|
backend.wait_for_boot(args.timeout)
|
||||||
|
|
||||||
|
if backend.mode == "adb" and (args.device_profile != "none" or args.install_extreme_script):
|
||||||
|
root_ready = backend.wait_for_root(min(args.root_timeout, args.timeout))
|
||||||
|
if not root_ready:
|
||||||
|
print("Root: unavailable after waiting; continuing without root-only tweaks")
|
||||||
|
|
||||||
put_setting(backend, "global", "device_provisioned", "1")
|
put_setting(backend, "global", "device_provisioned", "1")
|
||||||
put_setting(backend, "secure", "user_setup_complete", "1")
|
put_setting(backend, "secure", "user_setup_complete", "1")
|
||||||
put_setting(backend, "secure", "location_mode", "3")
|
put_setting(backend, "secure", "location_mode", "3")
|
||||||
put_setting(backend, "global", "package_verifier_enable", "0")
|
put_setting(backend, "global", "package_verifier_enable", "0")
|
||||||
put_setting(backend, "global", "verifier_verify_adb_installs", "0")
|
put_setting(backend, "global", "verifier_verify_adb_installs", "0")
|
||||||
|
|
||||||
try_shell(backend, "pm disable-user --user 0 com.android.setupwizard")
|
for package in ("com.android.setupwizard", "com.google.android.setupwizard"):
|
||||||
try_shell(backend, "pm disable-user --user 0 com.google.android.setupwizard")
|
packages = backend.exec_shell(
|
||||||
|
"pm list packages --user 0",
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
).stdout
|
||||||
|
if package in packages:
|
||||||
|
try_shell(backend, "pm disable-user --user 0 {}".format(shell_quote(package)))
|
||||||
try_shell(backend, "appops set com.android.shell android:mock_location allow")
|
try_shell(backend, "appops set com.android.shell android:mock_location allow")
|
||||||
|
|
||||||
proxy_state = configure_proxy(backend, args.proxy_host, args.proxy_port)
|
proxy_state = configure_proxy(backend, args.proxy_host, args.proxy_port)
|
||||||
|
|||||||
31
offline/README.md
Normal file
31
offline/README.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Offline Bundle
|
||||||
|
|
||||||
|
This directory describes the local offline bundle required by the current Android 13 `5570` build path.
|
||||||
|
|
||||||
|
The large bundle payloads are intentionally not tracked in Git. Keep them in the local `downloads/` and `images/` directories and use `manifest.json` plus `verify_offline_bundle.py` to validate what is on disk.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
- `downloads/`
|
||||||
|
- `mindthegapps.zip`
|
||||||
|
- `libhoudini.zip`
|
||||||
|
- `libhoudini_hack.zip`
|
||||||
|
- `magisk.apk`
|
||||||
|
- `magisk.version`
|
||||||
|
- `images/`
|
||||||
|
- `redroid-redroid-13.0.0-latest.tar`
|
||||||
|
- `manifest.json`
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python verify_offline_bundle.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Offline build flow
|
||||||
|
|
||||||
|
1. Make sure your container runtime can load local image archives.
|
||||||
|
2. Populate `downloads/` and `images/` with the files listed in `manifest.json`.
|
||||||
|
3. Run `python redroid.py -mtg -i -m` (add `--offline` or set `REDROID_OFFLINE=1` to forbid any network use).
|
||||||
|
4. The build prefers a local container image, then `images/redroid-redroid-13.0.0-latest.tar`, then a registry pull (skipped when offline-only).
|
||||||
|
5. Archives in `downloads/` are preferred when present and match the expected checksum; otherwise the build downloads from upstream (skipped when offline-only).
|
||||||
1
offline/downloads/.gitkeep
Normal file
1
offline/downloads/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
BIN
offline/downloads/libhoudini.zip
Normal file
BIN
offline/downloads/libhoudini.zip
Normal file
Binary file not shown.
BIN
offline/downloads/libhoudini_hack.zip
Normal file
BIN
offline/downloads/libhoudini_hack.zip
Normal file
Binary file not shown.
BIN
offline/downloads/magisk.apk
Normal file
BIN
offline/downloads/magisk.apk
Normal file
Binary file not shown.
1
offline/downloads/magisk.version
Normal file
1
offline/downloads/magisk.version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
b149cd26
|
||||||
BIN
offline/downloads/mindthegapps.zip
Normal file
BIN
offline/downloads/mindthegapps.zip
Normal file
Binary file not shown.
53
offline/manifest.json
Normal file
53
offline/manifest.json
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"generated_at": "2026-04-03",
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"name": "mindthegapps.zip",
|
||||||
|
"relative_path": "offline/downloads/mindthegapps.zip",
|
||||||
|
"size": 197927264,
|
||||||
|
"md5": "eee87a540b6e778f3a114fff29e133aa",
|
||||||
|
"sha256": "9fcb1531cb72df521bd49af5443f7a1b269ebb9c06d8f7dfa37b975da9ec0af1",
|
||||||
|
"source": "https://github.com/s1204IT/MindTheGappsBuilder/releases/download/20240226/MindTheGapps-13.0.0-x86_64-20240226.zip"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "libhoudini.zip",
|
||||||
|
"relative_path": "offline/downloads/libhoudini.zip",
|
||||||
|
"size": 75100163,
|
||||||
|
"md5": "cb7ffac26d47ec7c89df43818e126b47",
|
||||||
|
"sha256": "7c78abbcced1f7c5c1cc794a0afb4010a2e5bb0bb18eadd6de3af2fe13e9dc25",
|
||||||
|
"source": "https://github.com/rote66/vendor_intel_proprietary_houdini/archive/debc3dc91cf12b5c5b8a1c546a5b0b7bf7f838a8.zip"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "libhoudini_hack.zip",
|
||||||
|
"relative_path": "offline/downloads/libhoudini_hack.zip",
|
||||||
|
"size": 88394,
|
||||||
|
"md5": "8f71a58f3e54eca879a2f7de64dbed58",
|
||||||
|
"sha256": "5c904d6db5e15adc37ffadde5f21437ccabf86c1e94a3a0e4f77371a90776983",
|
||||||
|
"source": "https://github.com/rote66/redroid_libhoudini_hack/archive/a2194c5e294cbbfdfe87e51eb9eddb4c3621d8c3.zip"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "magisk.apk",
|
||||||
|
"relative_path": "offline/downloads/magisk.apk",
|
||||||
|
"size": 21099562,
|
||||||
|
"md5": "bb730e78db0bb9dde2fccfa7bfb1b35c",
|
||||||
|
"sha256": "af43697f67a229cf824494686e8ee07cb5668b57c9671a555e2a869c5bb64649",
|
||||||
|
"source": "Pulled from the validated 5570 instance: /vendor/etc/magisk/magisk.apk"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "magisk.version",
|
||||||
|
"relative_path": "offline/downloads/magisk.version",
|
||||||
|
"size": 10,
|
||||||
|
"md5": "57de9facce3180844a0460e9680c3836",
|
||||||
|
"sha256": "e82ddd5337dd7a7da9f2215c7272f5f26a4fd3b50d9676dea23d6d69a62c46ee",
|
||||||
|
"source": "Version name from the validated 5570 instance: b149cd26"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "redroid-redroid-13.0.0-latest.tar",
|
||||||
|
"relative_path": "offline/images/redroid-redroid-13.0.0-latest.tar",
|
||||||
|
"size": 2028835328,
|
||||||
|
"md5": "a2c44b2d90fb3445be9c2d6df867f960",
|
||||||
|
"sha256": "4474a84a78c62a793ca24c20b7159dc678196bd88a1ef4d9511b83e7090d1179",
|
||||||
|
"source": "docker save redroid/redroid:13.0.0-latest"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
84
redroid.py
84
redroid.py
@@ -1,18 +1,75 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
from stuff.houdini import Houdini
|
from stuff.houdini import Houdini
|
||||||
from stuff.houdini_hack import Houdini_Hack
|
from stuff.houdini_hack import Houdini_Hack
|
||||||
from stuff.magisk import Magisk
|
from stuff.magisk import Magisk
|
||||||
from stuff.mindthegapps import MindTheGapps
|
from stuff.mindthegapps import MindTheGapps
|
||||||
|
import tools.compat as compat
|
||||||
import tools.helper as helper
|
import tools.helper as helper
|
||||||
|
|
||||||
|
|
||||||
SUPPORTED_ANDROID_VERSIONS = ["13.0.0"]
|
SUPPORTED_ANDROID_VERSIONS = ["13.0.0"]
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_base_image(container_runtime, android_version):
|
||||||
|
base_image = "redroid/redroid:{}-latest".format(android_version)
|
||||||
|
inspect = subprocess.run(
|
||||||
|
[container_runtime, "image", "inspect", base_image],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
local_present = inspect.returncode == 0
|
||||||
|
|
||||||
|
tar_name = "redroid-redroid-{}-latest.tar".format(android_version)
|
||||||
|
tar_path = os.path.join(helper.get_image_dir(), tar_name)
|
||||||
|
tar_exists = os.path.isfile(tar_path)
|
||||||
|
offline_only = helper.is_offline_only()
|
||||||
|
|
||||||
|
if local_present:
|
||||||
|
helper.print_color(
|
||||||
|
"Using existing local base image {} ...".format(base_image),
|
||||||
|
helper.bcolors.GREEN,
|
||||||
|
)
|
||||||
|
return "present"
|
||||||
|
|
||||||
|
if tar_exists:
|
||||||
|
helper.print_color(
|
||||||
|
"Loading base image from offline archive {} ...".format(tar_path),
|
||||||
|
helper.bcolors.GREEN,
|
||||||
|
)
|
||||||
|
subprocess.run([container_runtime, "load", "-i", tar_path], check=True)
|
||||||
|
return tar_path
|
||||||
|
|
||||||
|
if offline_only:
|
||||||
|
raise FileNotFoundError(
|
||||||
|
"REDROID_OFFLINE is set but {} is not in local storage and {} was not found".format(
|
||||||
|
base_image,
|
||||||
|
tar_path,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
helper.print_color(
|
||||||
|
"No local base image or offline .tar; pulling {} ...".format(base_image),
|
||||||
|
helper.bcolors.YELLOW,
|
||||||
|
)
|
||||||
|
pull = subprocess.run([container_runtime, "pull", base_image], check=False)
|
||||||
|
if pull.returncode == 0:
|
||||||
|
return "pulled"
|
||||||
|
|
||||||
|
raise FileNotFoundError(
|
||||||
|
"Pull failed for {} and offline archive {} was not found".format(
|
||||||
|
base_image,
|
||||||
|
tar_path,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_layers(args):
|
def build_layers(args):
|
||||||
dockerfile = "FROM redroid/redroid:{}-latest\n".format(args.android)
|
dockerfile = "FROM redroid/redroid:{}-latest\n".format(args.android)
|
||||||
tags = [args.android]
|
tags = [args.android]
|
||||||
@@ -32,6 +89,7 @@ def build_layers(args):
|
|||||||
tags.append("houdini")
|
tags.append("houdini")
|
||||||
|
|
||||||
if args.magisk:
|
if args.magisk:
|
||||||
|
os.environ.setdefault("REDROID_MAGISK_BOOT_COMPLETE", "1")
|
||||||
Magisk().install()
|
Magisk().install()
|
||||||
dockerfile += "COPY magisk /\n"
|
dockerfile += "COPY magisk /\n"
|
||||||
tags.append("magisk")
|
tags.append("magisk")
|
||||||
@@ -40,7 +98,7 @@ def build_layers(args):
|
|||||||
|
|
||||||
|
|
||||||
def print_recommended_flow(image_name):
|
def print_recommended_flow(image_name):
|
||||||
print("\nRecommended 5570 launch")
|
print("\nRecommended 5570 launch (headless: always pass --adb-insecure)")
|
||||||
print(
|
print(
|
||||||
"python run_redroid.py "
|
"python run_redroid.py "
|
||||||
"--image {} "
|
"--image {} "
|
||||||
@@ -48,7 +106,8 @@ def print_recommended_flow(image_name):
|
|||||||
"--device-profile pixel-7-pro "
|
"--device-profile pixel-7-pro "
|
||||||
"--data-dir ~/data-redroid13 "
|
"--data-dir ~/data-redroid13 "
|
||||||
"--port 5570 "
|
"--port 5570 "
|
||||||
"--replace\n".format(image_name)
|
"--replace "
|
||||||
|
"--adb-insecure\n".format(image_name)
|
||||||
)
|
)
|
||||||
print("Recommended post-boot hardening")
|
print("Recommended post-boot hardening")
|
||||||
print(
|
print(
|
||||||
@@ -101,8 +160,28 @@ def main():
|
|||||||
choices=["docker", "podman"],
|
choices=["docker", "podman"],
|
||||||
help="Container runtime",
|
help="Container runtime",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--offline",
|
||||||
|
dest="offline",
|
||||||
|
action="store_true",
|
||||||
|
help="Do not pull images or download archives; require offline/downloads and offline/images",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-compat-check",
|
||||||
|
dest="no_compat_check",
|
||||||
|
action="store_true",
|
||||||
|
help="Skip container-runtime / OS compatibility checks before build",
|
||||||
|
)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
if args.offline:
|
||||||
|
os.environ["REDROID_OFFLINE"] = "1"
|
||||||
|
if not args.no_compat_check:
|
||||||
|
build_report = compat.analyze_for_build(args.container)
|
||||||
|
compat.emit_compat_report(build_report)
|
||||||
|
if build_report.errors:
|
||||||
|
sys.exit(1)
|
||||||
|
base_image_source = ensure_base_image(args.container, args.android)
|
||||||
dockerfile, tags = build_layers(args)
|
dockerfile, tags = build_layers(args)
|
||||||
|
|
||||||
print("\nDockerfile\n{}".format(dockerfile))
|
print("\nDockerfile\n{}".format(dockerfile))
|
||||||
@@ -115,6 +194,7 @@ def main():
|
|||||||
"Successfully built {}".format(image_name),
|
"Successfully built {}".format(image_name),
|
||||||
helper.bcolors.GREEN,
|
helper.bcolors.GREEN,
|
||||||
)
|
)
|
||||||
|
print("Base image source: {}".format(base_image_source))
|
||||||
|
|
||||||
if args.houdini:
|
if args.houdini:
|
||||||
print_recommended_flow(image_name)
|
print_recommended_flow(image_name)
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
requests==2.28.1
|
requests==2.28.1
|
||||||
tqdm==4.64.1
|
tqdm==4.64.1
|
||||||
|
paramiko>=3.4.0,<4
|
||||||
236
run_redroid.py
236
run_redroid.py
@@ -4,8 +4,12 @@ import argparse
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shlex
|
import shlex
|
||||||
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
import tools.compat as compat
|
||||||
from tools.helper import bcolors, print_color
|
from tools.helper import bcolors, print_color
|
||||||
|
|
||||||
|
|
||||||
@@ -179,6 +183,70 @@ def quote_cmd(args):
|
|||||||
return " ".join(shlex.quote(part) for part in args)
|
return " ".join(shlex.quote(part) for part in args)
|
||||||
|
|
||||||
|
|
||||||
|
def wait_tcp_port(host, port, timeout_sec, interval=1.0):
|
||||||
|
"""Return True once TCP connect succeeds, False on timeout."""
|
||||||
|
deadline = time.monotonic() + timeout_sec
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
try:
|
||||||
|
with socket.create_connection((host, port), timeout=2.0):
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
time.sleep(interval)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def local_post_diagnose(container_runtime, name, port, settle_sec):
|
||||||
|
"""On the Docker host: container status, logs, adb, short logcat."""
|
||||||
|
print_color(
|
||||||
|
"\n========== --diagnose (local): waiting {}s ==========".format(settle_sec),
|
||||||
|
bcolors.YELLOW,
|
||||||
|
)
|
||||||
|
time.sleep(settle_sec)
|
||||||
|
serial = "127.0.0.1:{}".format(port)
|
||||||
|
print_color("--- {} ps -a ---".format(container_runtime), bcolors.GREEN)
|
||||||
|
subprocess.run(
|
||||||
|
[container_runtime, "ps", "-a", "--filter", "name={}".format(name), "--no-trunc"],
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
print_color("--- {} inspect ---".format(container_runtime), bcolors.GREEN)
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
container_runtime,
|
||||||
|
"inspect",
|
||||||
|
"--format",
|
||||||
|
"status={{.State.Status}} exit={{.State.ExitCode}} err={{.State.Error}}",
|
||||||
|
name,
|
||||||
|
],
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
print_color("--- {} logs (tail 120) ---".format(container_runtime), bcolors.GREEN)
|
||||||
|
subprocess.run([container_runtime, "logs", "--tail", "120", name], check=False)
|
||||||
|
print_color("--- adb ---", bcolors.GREEN)
|
||||||
|
subprocess.run(["adb", "connect", serial], check=False)
|
||||||
|
subprocess.run(["adb", "devices", "-l"], check=False)
|
||||||
|
subprocess.run(["adb", "-s", serial, "shell", "getprop", "sys.boot_completed"], check=False)
|
||||||
|
print_color("--- adb logcat (display/GLES, last 80 lines) ---", bcolors.GREEN)
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"adb",
|
||||||
|
"-s",
|
||||||
|
serial,
|
||||||
|
"logcat",
|
||||||
|
"-d",
|
||||||
|
"-t",
|
||||||
|
"80",
|
||||||
|
"*:S",
|
||||||
|
"SurfaceFlinger:V",
|
||||||
|
"EGL:V",
|
||||||
|
"OpenGLRenderer:V",
|
||||||
|
"redroid:V",
|
||||||
|
"AndroidRuntime:E",
|
||||||
|
],
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
print_color("========== end --diagnose ==========\n", bcolors.YELLOW)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Launch a redroid container with a tested native bridge configuration."
|
description="Launch a redroid container with a tested native bridge configuration."
|
||||||
@@ -225,7 +293,11 @@ def main():
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--gpu-mode",
|
"--gpu-mode",
|
||||||
default="guest",
|
default="guest",
|
||||||
help="Value for androidboot.redroid_gpu_mode",
|
help=(
|
||||||
|
"androidboot.redroid_gpu_mode: default guest (stable on headless). "
|
||||||
|
"auto=guest unless REDROID_USE_HOST_GPU=1 and DRI exists, then host. "
|
||||||
|
"host=GPU (adds --device per DRI node when present)"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--device-profile",
|
"--device-profile",
|
||||||
@@ -242,22 +314,80 @@ def main():
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="Remove an existing container with the same name before running",
|
help="Remove an existing container with the same name before running",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--rm",
|
||||||
|
dest="ephemeral",
|
||||||
|
action="store_true",
|
||||||
|
help="Ephemeral: delete container when it stops (disables auto-restart; not for production)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--restart",
|
||||||
|
dest="restart_policy",
|
||||||
|
default="unless-stopped",
|
||||||
|
choices=("no", "always", "unless-stopped", "on-failure"),
|
||||||
|
help=(
|
||||||
|
"Docker/Podman restart policy when not using --rm (default: unless-stopped = "
|
||||||
|
"start after daemon reboot unless you docker stop'd it)"
|
||||||
|
),
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--keep-container",
|
"--keep-container",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Do not pass --rm to the runtime",
|
help="Legacy: containers are persistent by default; this flag is a no-op",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--dry-run",
|
"--dry-run",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Print the generated command without executing it",
|
help="Print the generated command without executing it",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--soft-display",
|
||||||
|
action="store_true",
|
||||||
|
help=(
|
||||||
|
"Lower software-GLES load: 720x1280, fps=15, dpi=280 (helps black screen / flicker with guest GPU)"
|
||||||
|
),
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--extra-prop",
|
"--extra-prop",
|
||||||
action="append",
|
action="append",
|
||||||
default=[],
|
default=[],
|
||||||
help="Additional redroid property, repeatable",
|
help="Additional redroid property, repeatable",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--adb-insecure",
|
||||||
|
action="store_true",
|
||||||
|
help=(
|
||||||
|
"Headless ADB: early androidboot.adb.secure=0, ro.adb.secure=0, ro.secure=0, ro.debuggable=1 "
|
||||||
|
"(before device profile; see redroid-doc README / #579). Overrides user-build secure props."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-compat-check",
|
||||||
|
action="store_true",
|
||||||
|
help="Skip compatibility messages and docker/podman probe (still resolves --gpu-mode auto from DRI)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--wait-tcp",
|
||||||
|
type=int,
|
||||||
|
default=0,
|
||||||
|
metavar="SEC",
|
||||||
|
help=(
|
||||||
|
"After start, wait up to SEC seconds for 127.0.0.1:PORT to accept TCP (Android adbd). "
|
||||||
|
"If it never opens, print container logs. Use 90 when debugging failed adb connect."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--diagnose",
|
||||||
|
action="store_true",
|
||||||
|
help="After start: wait, then print docker ps/inspect/logs + adb + logcat (same host as Docker)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--diagnose-settle",
|
||||||
|
type=int,
|
||||||
|
default=15,
|
||||||
|
metavar="SEC",
|
||||||
|
help="Seconds to sleep before --diagnose probes (default 15)",
|
||||||
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
image = args.image
|
image = args.image
|
||||||
@@ -270,6 +400,16 @@ def main():
|
|||||||
|
|
||||||
os.makedirs(data_dir, exist_ok=True)
|
os.makedirs(data_dir, exist_ok=True)
|
||||||
|
|
||||||
|
run_report, effective_gpu_mode, dri_device_args = compat.analyze_for_run(
|
||||||
|
args.container,
|
||||||
|
args.gpu_mode,
|
||||||
|
skip_check=args.no_compat_check,
|
||||||
|
)
|
||||||
|
if not args.no_compat_check:
|
||||||
|
compat.emit_compat_report(run_report)
|
||||||
|
if run_report.errors:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
cmd = [
|
cmd = [
|
||||||
args.container,
|
args.container,
|
||||||
"run",
|
"run",
|
||||||
@@ -284,17 +424,38 @@ def main():
|
|||||||
]
|
]
|
||||||
if memory:
|
if memory:
|
||||||
cmd.extend(["--memory", memory])
|
cmd.extend(["--memory", memory])
|
||||||
if not args.keep_container:
|
if args.ephemeral:
|
||||||
cmd.append("--rm")
|
cmd.append("--rm")
|
||||||
cmd.extend(
|
elif args.restart_policy != "no":
|
||||||
[
|
cmd.extend(["--restart", args.restart_policy])
|
||||||
image,
|
cmd.extend(dri_device_args)
|
||||||
"androidboot.redroid_gpu_mode={}".format(args.gpu_mode),
|
cmd.append(image)
|
||||||
]
|
# Init applies read-only props in order; later ro.adb.secure=0 is ignored if adb.secure
|
||||||
)
|
# was already set (redroid-doc #579). Put these immediately after the image name.
|
||||||
|
if args.adb_insecure:
|
||||||
|
# Must precede device_profile_props (ro.secure=1 / ro.debuggable=0): init keeps first ro.* value.
|
||||||
|
# redroid-doc README: ro.secure=0 yields working adb shell without RSA prompt.
|
||||||
|
cmd.extend(
|
||||||
|
[
|
||||||
|
"androidboot.adb.secure=0",
|
||||||
|
"ro.adb.secure=0",
|
||||||
|
"ro.secure=0",
|
||||||
|
"ro.debuggable=1",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
cmd.append("androidboot.redroid_gpu_mode={}".format(effective_gpu_mode))
|
||||||
cmd.extend(bridge_props(bridge))
|
cmd.extend(bridge_props(bridge))
|
||||||
cmd.extend(preset_props(preset))
|
cmd.extend(preset_props(preset))
|
||||||
cmd.extend(device_profile_props(device_profile))
|
cmd.extend(device_profile_props(device_profile))
|
||||||
|
if args.soft_display:
|
||||||
|
cmd.extend(
|
||||||
|
[
|
||||||
|
"androidboot.redroid_width=720",
|
||||||
|
"androidboot.redroid_height=1280",
|
||||||
|
"androidboot.redroid_fps=15",
|
||||||
|
"androidboot.redroid_dpi=280",
|
||||||
|
]
|
||||||
|
)
|
||||||
cmd.extend(args.extra_prop)
|
cmd.extend(args.extra_prop)
|
||||||
|
|
||||||
if args.replace and not args.dry_run:
|
if args.replace and not args.dry_run:
|
||||||
@@ -305,11 +466,23 @@ def main():
|
|||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
print_color(
|
||||||
|
"Selected GPU mode: {} (androidboot.redroid_gpu_mode)".format(effective_gpu_mode),
|
||||||
|
bcolors.GREEN,
|
||||||
|
)
|
||||||
print_color("Selected native bridge: {}".format(bridge), bcolors.GREEN)
|
print_color("Selected native bridge: {}".format(bridge), bcolors.GREEN)
|
||||||
if preset != "none":
|
if preset != "none":
|
||||||
print_color("Selected launch preset: {}".format(preset), bcolors.GREEN)
|
print_color("Selected launch preset: {}".format(preset), bcolors.GREEN)
|
||||||
if device_profile != "none":
|
if device_profile != "none":
|
||||||
print_color("Selected device profile: {}".format(device_profile), bcolors.GREEN)
|
print_color("Selected device profile: {}".format(device_profile), bcolors.GREEN)
|
||||||
|
if args.soft_display:
|
||||||
|
print_color("Soft display: 720x1280 @ 15fps (reduced GLES load)", bcolors.GREEN)
|
||||||
|
if args.adb_insecure:
|
||||||
|
print_color(
|
||||||
|
"ADB insecure: injecting boot props immediately after image name "
|
||||||
|
"(check Command: line starts with androidboot.adb.secure=0 …).",
|
||||||
|
bcolors.GREEN,
|
||||||
|
)
|
||||||
print("Command:\n{}\n".format(quote_cmd(cmd)))
|
print("Command:\n{}\n".format(quote_cmd(cmd)))
|
||||||
|
|
||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
@@ -317,7 +490,52 @@ def main():
|
|||||||
|
|
||||||
subprocess.run(cmd, check=True)
|
subprocess.run(cmd, check=True)
|
||||||
print_color("Container started as {}".format(name), bcolors.GREEN)
|
print_color("Container started as {}".format(name), bcolors.GREEN)
|
||||||
|
if args.ephemeral:
|
||||||
|
print_color("Ephemeral (--rm): container is removed when it stops.", bcolors.YELLOW)
|
||||||
|
else:
|
||||||
|
print_color(
|
||||||
|
"Persistent: restart policy is {!r} (survives Docker daemon restart; "
|
||||||
|
"use `docker stop {}` to opt out of auto-start).".format(args.restart_policy, name),
|
||||||
|
bcolors.GREEN,
|
||||||
|
)
|
||||||
|
if args.diagnose:
|
||||||
|
local_post_diagnose(args.container, name, args.port, args.diagnose_settle)
|
||||||
|
if args.wait_tcp > 0:
|
||||||
|
print_color(
|
||||||
|
"Waiting up to {}s for 127.0.0.1:{} (adbd TCP) ...".format(args.wait_tcp, args.port),
|
||||||
|
bcolors.GREEN,
|
||||||
|
)
|
||||||
|
if not wait_tcp_port("127.0.0.1", args.port, args.wait_tcp):
|
||||||
|
print_color(
|
||||||
|
"Port {} never opened. Common with androidboot.redroid_gpu_mode=host when EGL/GPU fails inside "
|
||||||
|
"the guest — try: python run_redroid.py ... --gpu-mode guest (and drop DRI if needed).".format(
|
||||||
|
args.port
|
||||||
|
),
|
||||||
|
bcolors.RED,
|
||||||
|
)
|
||||||
|
print_color("Last logs from container {!r}:".format(name), bcolors.YELLOW)
|
||||||
|
subprocess.run([args.container, "logs", "--tail", "100", name], check=False)
|
||||||
|
print(
|
||||||
|
"\nIf the container already exited with --rm, logs are gone; re-run without --rm "
|
||||||
|
"for a persistent container."
|
||||||
|
)
|
||||||
print("ADB: adb connect 127.0.0.1:{}".format(args.port))
|
print("ADB: adb connect 127.0.0.1:{}".format(args.port))
|
||||||
|
if effective_gpu_mode == "host":
|
||||||
|
print(
|
||||||
|
"Note: host GPU mode can prevent full boot; if connect fails, use --gpu-mode guest or "
|
||||||
|
"--wait-tcp 90 to diagnose."
|
||||||
|
)
|
||||||
|
if not args.adb_insecure:
|
||||||
|
print(
|
||||||
|
"If the device shows as unauthorized, stop the container and re-run with --adb-insecure "
|
||||||
|
"(headless instances cannot tap the USB debugging authorization dialog)."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
"If adb still shows unauthorized: stop the container, delete host folder "
|
||||||
|
"{}/misc/adb (persisted keys from an older boot), then start again. "
|
||||||
|
"Also try: adb kill-server && adb start-server".format(data_dir)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,24 +1,54 @@
|
|||||||
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import zipfile
|
import zipfile
|
||||||
import hashlib
|
|
||||||
|
|
||||||
from tools.helper import bcolors, download_file, print_color
|
from tools.helper import bcolors, download_file, file_md5, is_offline_only, print_color
|
||||||
|
|
||||||
class General:
|
class General:
|
||||||
|
def _local_md5(self):
|
||||||
|
if not os.path.isfile(self.dl_file_name):
|
||||||
|
return ""
|
||||||
|
return file_md5(self.dl_file_name)
|
||||||
|
|
||||||
|
def _has_valid_local_file(self):
|
||||||
|
return os.path.isfile(self.dl_file_name) and self._local_md5() == self.act_md5
|
||||||
|
|
||||||
def download(self):
|
def download(self):
|
||||||
loc_md5 = ""
|
temp_path = self.dl_file_name + ".tmp"
|
||||||
if os.path.isfile(self.dl_file_name):
|
local_valid = self._has_valid_local_file()
|
||||||
with open(self.dl_file_name,"rb") as f:
|
|
||||||
bytes = f.read()
|
if local_valid:
|
||||||
loc_md5 = hashlib.md5(bytes).hexdigest()
|
print_color(
|
||||||
while not os.path.isfile(self.dl_file_name) or loc_md5 != self.act_md5:
|
"Using validated offline bundle {} ...".format(self.dl_file_name),
|
||||||
if os.path.isfile(self.dl_file_name):
|
bcolors.GREEN,
|
||||||
os.remove(self.dl_file_name)
|
)
|
||||||
print_color("md5 mismatches, redownloading now ....",bcolors.YELLOW)
|
return
|
||||||
loc_md5 = download_file(self.dl_link, self.dl_file_name)
|
|
||||||
|
if is_offline_only():
|
||||||
|
raise FileNotFoundError(
|
||||||
|
"REDROID_OFFLINE is set but offline file is missing or MD5 mismatch: {}".format(
|
||||||
|
self.dl_file_name
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if os.path.isfile(temp_path):
|
||||||
|
os.remove(temp_path)
|
||||||
|
print_color("Local bundle not found or MD5 mismatch; downloading ...", bcolors.YELLOW)
|
||||||
|
remote_md5 = download_file(self.dl_link, temp_path)
|
||||||
|
if remote_md5 != self.act_md5:
|
||||||
|
raise ValueError(
|
||||||
|
"Downloaded file md5 mismatch: expected {}, got {}".format(
|
||||||
|
self.act_md5,
|
||||||
|
remote_md5,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
os.replace(temp_path, self.dl_file_name)
|
||||||
|
except Exception:
|
||||||
|
if os.path.isfile(temp_path):
|
||||||
|
os.remove(temp_path)
|
||||||
|
raise
|
||||||
|
|
||||||
def extract(self):
|
def extract(self):
|
||||||
print_color("Extracting archive...", bcolors.GREEN)
|
print_color("Extracting archive...", bcolors.GREEN)
|
||||||
print(self.dl_file_name)
|
print(self.dl_file_name)
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ on property:sys.boot_completed=1
|
|||||||
"No available libhoudini for Android {}".format(version))
|
"No available libhoudini for Android {}".format(version))
|
||||||
|
|
||||||
def download(self):
|
def download(self):
|
||||||
print_color("Downloading libhoudini now .....", bcolors.GREEN)
|
print_color("Resolving libhoudini bundle ...", bcolors.GREEN)
|
||||||
super().download()
|
super().download()
|
||||||
|
|
||||||
def copy(self):
|
def copy(self):
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class Houdini_Hack(General):
|
|||||||
self.act_md5 = "8f71a58f3e54eca879a2f7de64dbed58"
|
self.act_md5 = "8f71a58f3e54eca879a2f7de64dbed58"
|
||||||
|
|
||||||
def download(self):
|
def download(self):
|
||||||
print_color("Downloading libhoudini_hack now .....", bcolors.GREEN)
|
print_color("Resolving libhoudini_hack bundle ...", bcolors.GREEN)
|
||||||
super().download()
|
super().download()
|
||||||
|
|
||||||
def copy(self):
|
def copy(self):
|
||||||
|
|||||||
109
stuff/magisk.py
109
stuff/magisk.py
@@ -4,14 +4,31 @@ import re
|
|||||||
import zipfile
|
import zipfile
|
||||||
import requests
|
import requests
|
||||||
from stuff.general import General
|
from stuff.general import General
|
||||||
from tools.helper import bcolors, download_file, host, print_color, run, get_download_dir
|
from tools.helper import (
|
||||||
|
bcolors,
|
||||||
|
download_file,
|
||||||
|
get_download_dir,
|
||||||
|
host,
|
||||||
|
is_offline_only,
|
||||||
|
print_color,
|
||||||
|
run,
|
||||||
|
)
|
||||||
|
|
||||||
class Magisk(General):
|
class Magisk(General):
|
||||||
stable_json = "https://raw.githubusercontent.com/os-fork/HuskyDG-magisk-files/main/stable.json"
|
_default_stable_json = (
|
||||||
|
"https://raw.githubusercontent.com/os-fork/HuskyDG-magisk-files/main/stable.json"
|
||||||
|
)
|
||||||
|
# HuskyDG/download was removed from GitHub; stable.json still points there.
|
||||||
|
_kitsune_26_4_mirror_apk = (
|
||||||
|
"https://raw.githubusercontent.com/Scratch2033Alt/KitsuneMagisk/main/26.4-kitsune.apk"
|
||||||
|
)
|
||||||
machine = host()
|
machine = host()
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.download_loc = get_download_dir()
|
self.download_loc = get_download_dir()
|
||||||
|
self.stable_json_url = (
|
||||||
|
os.environ.get("REDROID_MAGISK_STABLE_JSON", "").strip() or self._default_stable_json
|
||||||
|
)
|
||||||
self.dl_file_name = os.path.join(self.download_loc, "magisk.apk")
|
self.dl_file_name = os.path.join(self.download_loc, "magisk.apk")
|
||||||
self.release_meta_file = os.path.join(self.download_loc, "magisk.version")
|
self.release_meta_file = os.path.join(self.download_loc, "magisk.version")
|
||||||
self.extract_to = os.path.join(self.download_loc, "magisk_unpack")
|
self.extract_to = os.path.join(self.download_loc, "magisk_unpack")
|
||||||
@@ -137,16 +154,55 @@ on property:init.svc.zygote=stopped
|
|||||||
with open(self.release_meta_file, "w", encoding="utf-8") as meta_file:
|
with open(self.release_meta_file, "w", encoding="utf-8") as meta_file:
|
||||||
meta_file.write(self.release_version)
|
meta_file.write(self.release_version)
|
||||||
|
|
||||||
def _validate_archive(self):
|
def _validate_archive(self, path=None):
|
||||||
if not zipfile.is_zipfile(self.dl_file_name):
|
archive_path = path or self.dl_file_name
|
||||||
|
if not zipfile.is_zipfile(archive_path):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Downloaded Magisk/Kitsune archive is not a valid APK/ZIP. "
|
"Downloaded Magisk/Kitsune archive is not a valid APK/ZIP. "
|
||||||
"Set REDROID_MAGISK_APK to a local APK or REDROID_MAGISK_URL to a working direct APK URL."
|
"Set REDROID_MAGISK_APK to a local APK or REDROID_MAGISK_URL to a working direct APK URL."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _has_valid_local_archive(self):
|
||||||
|
if not os.path.isfile(self.dl_file_name):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
self._validate_archive()
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _magisk_download_candidates(self):
|
||||||
|
primary = self.dl_link
|
||||||
|
if "HuskyDG/download" in primary and str(self.release_version).startswith("26.4"):
|
||||||
|
return [self._kitsune_26_4_mirror_apk, primary]
|
||||||
|
return [primary]
|
||||||
|
|
||||||
|
def _download_remote_archive(self):
|
||||||
|
temp_path = self.dl_file_name + ".tmp"
|
||||||
|
last_error = None
|
||||||
|
print_color(
|
||||||
|
"Trying online download for Kitsune Mask {} ...".format(self.release_version),
|
||||||
|
bcolors.GREEN,
|
||||||
|
)
|
||||||
|
for candidate_url in self._magisk_download_candidates():
|
||||||
|
try:
|
||||||
|
if os.path.isfile(temp_path):
|
||||||
|
os.remove(temp_path)
|
||||||
|
print_color(" from {} ...".format(candidate_url), bcolors.GREEN)
|
||||||
|
download_file(candidate_url, temp_path, timeout=120)
|
||||||
|
self._validate_archive(temp_path)
|
||||||
|
os.replace(temp_path, self.dl_file_name)
|
||||||
|
self._write_version()
|
||||||
|
return
|
||||||
|
except Exception as exc:
|
||||||
|
last_error = exc
|
||||||
|
if os.path.isfile(temp_path):
|
||||||
|
os.remove(temp_path)
|
||||||
|
raise last_error
|
||||||
|
|
||||||
def resolve_release(self):
|
def resolve_release(self):
|
||||||
print_color("Resolving Kitsune Mask stable release ...", bcolors.GREEN)
|
print_color("Resolving Kitsune Mask stable release ...", bcolors.GREEN)
|
||||||
response = requests.get(self.stable_json, timeout=30)
|
response = requests.get(self.stable_json_url, timeout=30)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
payload = response.json()
|
payload = response.json()
|
||||||
magisk_info = payload["magisk"]
|
magisk_info = payload["magisk"]
|
||||||
@@ -174,32 +230,35 @@ on property:init.svc.zygote=stopped
|
|||||||
self._validate_archive()
|
self._validate_archive()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
local_valid = self._has_valid_local_archive()
|
||||||
|
local_version = self._cached_version() or "bundled"
|
||||||
|
|
||||||
|
if local_valid:
|
||||||
|
self.release_version = local_version
|
||||||
|
print_color(
|
||||||
|
"Using offline Magisk/Kitsune APK {} ({}) ...".format(
|
||||||
|
self.dl_file_name,
|
||||||
|
self.release_version,
|
||||||
|
),
|
||||||
|
bcolors.GREEN,
|
||||||
|
)
|
||||||
|
self._validate_archive()
|
||||||
|
return
|
||||||
|
|
||||||
|
if is_offline_only():
|
||||||
|
raise FileNotFoundError(
|
||||||
|
"REDROID_OFFLINE is set but no valid offline magisk.apk at {}".format(
|
||||||
|
self.dl_file_name
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if self.custom_url:
|
if self.custom_url:
|
||||||
self.dl_link = self.custom_url
|
self.dl_link = self.custom_url
|
||||||
self.release_version = "url:" + os.path.basename(self.custom_url)
|
self.release_version = "url:" + os.path.basename(self.custom_url)
|
||||||
else:
|
else:
|
||||||
self.resolve_release()
|
self.resolve_release()
|
||||||
|
|
||||||
if (
|
self._download_remote_archive()
|
||||||
os.path.isfile(self.dl_file_name)
|
|
||||||
and self._cached_version() == self.release_version
|
|
||||||
):
|
|
||||||
print_color(
|
|
||||||
"Using cached Kitsune Mask {} ...".format(self.release_version),
|
|
||||||
bcolors.GREEN,
|
|
||||||
)
|
|
||||||
self._validate_archive()
|
|
||||||
return
|
|
||||||
|
|
||||||
print_color(
|
|
||||||
"Downloading Kitsune Mask {} now .....".format(self.release_version),
|
|
||||||
bcolors.GREEN,
|
|
||||||
)
|
|
||||||
if os.path.isfile(self.dl_file_name):
|
|
||||||
os.remove(self.dl_file_name)
|
|
||||||
download_file(self.dl_link, self.dl_file_name)
|
|
||||||
self._validate_archive()
|
|
||||||
self._write_version()
|
|
||||||
|
|
||||||
def extract(self):
|
def extract(self):
|
||||||
print_color("Extracting archive...", bcolors.GREEN)
|
print_color("Extracting archive...", bcolors.GREEN)
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ class MindTheGapps(General):
|
|||||||
self.act_md5 = self.dl_links[self.version][self.arch[0]][1]
|
self.act_md5 = self.dl_links[self.version][self.arch[0]][1]
|
||||||
|
|
||||||
def download(self):
|
def download(self):
|
||||||
print_color("Downloading MindTheGapps now .....", bcolors.GREEN)
|
print_color("Resolving MindTheGapps bundle ...", bcolors.GREEN)
|
||||||
super().download()
|
super().download()
|
||||||
|
|
||||||
def copy(self):
|
def copy(self):
|
||||||
|
|||||||
188
tools/compat.py
Normal file
188
tools/compat.py
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
"""
|
||||||
|
Host and container-runtime compatibility checks for redroid-android13.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Tuple
|
||||||
|
|
||||||
|
_REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
if str(_REPO_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_REPO_ROOT))
|
||||||
|
|
||||||
|
from tools.helper import bcolors, print_color
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CompatReport:
|
||||||
|
errors: List[str] = field(default_factory=list)
|
||||||
|
warnings: List[str] = field(default_factory=list)
|
||||||
|
notes: List[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def _runtime_available(runtime: str) -> bool:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[runtime, "version"],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
timeout=30,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
return result.returncode == 0
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def detect_dri_devices() -> List[str]:
|
||||||
|
"""Paths suitable for docker/podman --device (Linux DRM)."""
|
||||||
|
found: List[str] = []
|
||||||
|
for pattern in ("/dev/dri/card*", "/dev/dri/renderD*"):
|
||||||
|
found.extend(glob.glob(pattern))
|
||||||
|
return sorted(set(found))
|
||||||
|
|
||||||
|
|
||||||
|
def _linux_binder_notes() -> List[str]:
|
||||||
|
if platform.system() != "Linux":
|
||||||
|
return []
|
||||||
|
notes: List[str] = []
|
||||||
|
try:
|
||||||
|
with open("/proc/modules", "r", encoding="utf-8", errors="ignore") as handle:
|
||||||
|
loaded = handle.read()
|
||||||
|
if "binder_linux" not in loaded:
|
||||||
|
notes.append(
|
||||||
|
"binder_linux not loaded; redroid usually needs: "
|
||||||
|
"modprobe binder_linux devices=binder,hwbinder,vndbinder"
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return notes
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_for_build(container_runtime: str) -> CompatReport:
|
||||||
|
report = CompatReport()
|
||||||
|
if not _runtime_available(container_runtime):
|
||||||
|
report.errors.append(
|
||||||
|
"Container runtime {!r} not found or not working (install Docker/Podman and check PATH).".format(
|
||||||
|
container_runtime
|
||||||
|
)
|
||||||
|
)
|
||||||
|
system = platform.system()
|
||||||
|
if system == "Windows":
|
||||||
|
report.warnings.append(
|
||||||
|
"Running on Windows: redroid containers are meant for Linux hosts (binder/ashmem); "
|
||||||
|
"building files here is fine, running the container typically happens on Linux."
|
||||||
|
)
|
||||||
|
elif system != "Linux":
|
||||||
|
report.warnings.append(
|
||||||
|
"Host OS is not Linux; upstream redroid expects Linux with binder and related kernel pieces."
|
||||||
|
)
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_gpu_mode(requested: str, dri_devices: List[str]) -> Tuple[str, List[str]]:
|
||||||
|
"""
|
||||||
|
Map user --gpu-mode to an effective redroid mode and docker --device arguments.
|
||||||
|
|
||||||
|
auto -> guest by default (many servers have /dev/dri but host-EGL still breaks boot/adbd).
|
||||||
|
Set REDROID_USE_HOST_GPU=1 (or true/yes/on) with --gpu-mode auto to pass DRI + use host when nodes exist.
|
||||||
|
"""
|
||||||
|
req = (requested or "auto").strip().lower()
|
||||||
|
if req == "auto":
|
||||||
|
want_host = os.environ.get("REDROID_USE_HOST_GPU", "").strip().lower() in (
|
||||||
|
"1",
|
||||||
|
"true",
|
||||||
|
"yes",
|
||||||
|
"on",
|
||||||
|
)
|
||||||
|
effective = "host" if (want_host and dri_devices) else "guest"
|
||||||
|
elif req == "host":
|
||||||
|
effective = "host" if dri_devices else "guest"
|
||||||
|
elif req == "guest":
|
||||||
|
effective = "guest"
|
||||||
|
else:
|
||||||
|
effective = "guest"
|
||||||
|
|
||||||
|
device_args: List[str] = []
|
||||||
|
if effective == "host" and dri_devices:
|
||||||
|
for path in dri_devices:
|
||||||
|
device_args.extend(["--device", path])
|
||||||
|
return effective, device_args
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_for_run(
|
||||||
|
container_runtime: str,
|
||||||
|
gpu_mode: str,
|
||||||
|
skip_check: bool,
|
||||||
|
) -> Tuple[CompatReport, str, List[str]]:
|
||||||
|
"""
|
||||||
|
Returns (report, effective_gpu_mode, flat docker --device args).
|
||||||
|
"""
|
||||||
|
report = CompatReport()
|
||||||
|
dri = detect_dri_devices()
|
||||||
|
|
||||||
|
if not skip_check:
|
||||||
|
build = analyze_for_build(container_runtime)
|
||||||
|
report.errors.extend(build.errors)
|
||||||
|
report.warnings.extend(build.warnings)
|
||||||
|
report.notes.extend(build.notes)
|
||||||
|
if dri:
|
||||||
|
report.notes.append(
|
||||||
|
"DRI present: {} — for host GPU use --gpu-mode host or REDROID_USE_HOST_GPU=1 with --gpu-mode auto.".format(
|
||||||
|
", ".join(dri)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif platform.system() == "Linux":
|
||||||
|
report.warnings.append(
|
||||||
|
"No /dev/dri render nodes: software GLES (guest) often black-screens or flickers on headless servers."
|
||||||
|
)
|
||||||
|
report.notes.extend(_linux_binder_notes())
|
||||||
|
|
||||||
|
effective, device_args = resolve_gpu_mode(gpu_mode, dri)
|
||||||
|
req = (gpu_mode or "auto").strip().lower()
|
||||||
|
if req == "host" and effective == "guest":
|
||||||
|
report.warnings.append(
|
||||||
|
"--gpu-mode host requested but no DRI devices found; falling back to guest."
|
||||||
|
)
|
||||||
|
if req == "auto" and not skip_check:
|
||||||
|
report.notes.append(
|
||||||
|
"Resolved --gpu-mode auto -> {} (set REDROID_USE_HOST_GPU=1 to prefer host when DRI exists).".format(
|
||||||
|
effective
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if effective == "host" and not skip_check:
|
||||||
|
report.notes.append(
|
||||||
|
"GPU host mode: if ADB cannot connect (connection refused), Android may not have finished boot "
|
||||||
|
"or init crashed (EGL/driver mismatch); try --gpu-mode guest or docker logs on the container."
|
||||||
|
)
|
||||||
|
|
||||||
|
return report, effective, device_args
|
||||||
|
|
||||||
|
|
||||||
|
def emit_compat_report(report: CompatReport, prefix: str = "[compat] ") -> None:
|
||||||
|
for msg in report.errors:
|
||||||
|
print_color(prefix + msg, bcolors.RED)
|
||||||
|
for msg in report.warnings:
|
||||||
|
print_color(prefix + msg, bcolors.YELLOW)
|
||||||
|
for msg in report.notes:
|
||||||
|
print_color(prefix + msg, bcolors.GREEN)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
ap = argparse.ArgumentParser(description="Host/runtime compatibility probe (same logic as run_redroid.py)")
|
||||||
|
ap.add_argument("-c", "--container", default="docker", help="docker or podman")
|
||||||
|
ap.add_argument("--gpu-mode", default="guest", help="guest, host, or auto")
|
||||||
|
ns = ap.parse_args()
|
||||||
|
rep, gpu_eff, dev_args = analyze_for_run(ns.container, ns.gpu_mode, skip_check=False)
|
||||||
|
emit_compat_report(rep)
|
||||||
|
print("effective_gpu_mode={}".format(gpu_eff))
|
||||||
|
print("flat_device_args={}".format(dev_args))
|
||||||
101
tools/helper.py
101
tools/helper.py
@@ -1,39 +1,44 @@
|
|||||||
|
import hashlib
|
||||||
import os
|
import os
|
||||||
|
from pathlib import Path
|
||||||
import platform
|
import platform
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from tqdm import tqdm
|
from tqdm import tqdm
|
||||||
import hashlib
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
OFFLINE_ROOT = REPO_ROOT / "offline"
|
||||||
|
DOWNLOAD_ROOT = OFFLINE_ROOT / "downloads"
|
||||||
|
IMAGE_ROOT = OFFLINE_ROOT / "images"
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_dir(path):
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return str(path)
|
||||||
|
|
||||||
def get_download_dir():
|
def get_download_dir():
|
||||||
download_loc = ""
|
return ensure_dir(DOWNLOAD_ROOT)
|
||||||
current_user = (
|
|
||||||
os.environ.get("SUDO_USER")
|
|
||||||
or os.environ.get("USER")
|
def get_image_dir():
|
||||||
or os.environ.get("USERNAME")
|
return ensure_dir(IMAGE_ROOT)
|
||||||
or "user"
|
|
||||||
)
|
|
||||||
if os.environ.get("XDG_CACHE_HOME", None) is None:
|
def is_offline_only():
|
||||||
if os.name == "nt":
|
"""If true, redroid build must not use the network (env REDROID_OFFLINE or CLI --offline)."""
|
||||||
download_loc = os.path.join(
|
v = os.environ.get("REDROID_OFFLINE", "").strip().lower()
|
||||||
os.environ.get("LOCALAPPDATA", os.path.expanduser("~")),
|
return v in ("1", "true", "yes", "on")
|
||||||
"redroid",
|
|
||||||
"downloads",
|
|
||||||
)
|
def file_md5(path, block_size=1024 * 1024):
|
||||||
else:
|
hasher = hashlib.md5()
|
||||||
download_loc = os.path.join(
|
with open(path, "rb") as handle:
|
||||||
'/',
|
for chunk in iter(lambda: handle.read(block_size), b""):
|
||||||
"home",
|
hasher.update(chunk)
|
||||||
current_user,
|
return hasher.hexdigest()
|
||||||
".cache",
|
|
||||||
"redroid",
|
|
||||||
"downloads",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
download_loc = os.path.join(os.environ["XDG_CACHE_HOME"], "redroid", "downloads")
|
|
||||||
if not os.path.exists(download_loc):
|
|
||||||
os.makedirs(download_loc)
|
|
||||||
return download_loc
|
|
||||||
|
|
||||||
def run(args):
|
def run(args):
|
||||||
result = subprocess.run(args=args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
result = subprocess.run(args=args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
@@ -46,23 +51,31 @@ def run(args):
|
|||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def download_file(url, f_name):
|
|
||||||
md5 = ""
|
def download_file(url, f_name, timeout=60):
|
||||||
response = requests.get(url, stream=True)
|
response = requests.get(url, stream=True, timeout=timeout)
|
||||||
total_size_in_bytes = int(response.headers.get('content-length', 0))
|
response.raise_for_status()
|
||||||
block_size = 1024 # 1 Kibibyte
|
|
||||||
progress_bar = tqdm(total=total_size_in_bytes, unit='iB', unit_scale=True)
|
total_size_in_bytes = int(response.headers.get("content-length", 0))
|
||||||
with open(f_name, 'wb') as file:
|
block_size = 1024 * 1024
|
||||||
for data in response.iter_content(block_size):
|
progress_bar = tqdm(total=total_size_in_bytes, unit="iB", unit_scale=True)
|
||||||
progress_bar.update(len(data))
|
hasher = hashlib.md5()
|
||||||
file.write(data)
|
|
||||||
progress_bar.close()
|
try:
|
||||||
with open(f_name, "rb") as f:
|
with open(f_name, "wb") as file:
|
||||||
bytes = f.read()
|
for data in response.iter_content(block_size):
|
||||||
md5 = hashlib.md5(bytes).hexdigest()
|
if not data:
|
||||||
|
continue
|
||||||
|
progress_bar.update(len(data))
|
||||||
|
file.write(data)
|
||||||
|
hasher.update(data)
|
||||||
|
finally:
|
||||||
|
progress_bar.close()
|
||||||
|
response.close()
|
||||||
|
|
||||||
if total_size_in_bytes != 0 and progress_bar.n != total_size_in_bytes:
|
if total_size_in_bytes != 0 and progress_bar.n != total_size_in_bytes:
|
||||||
raise ValueError("Something went wrong while downloading")
|
raise ValueError("Something went wrong while downloading")
|
||||||
return md5
|
return hasher.hexdigest()
|
||||||
|
|
||||||
def host():
|
def host():
|
||||||
machine = platform.machine().lower()
|
machine = platform.machine().lower()
|
||||||
|
|||||||
205
tools/ssh_remote.py
Normal file
205
tools/ssh_remote.py
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
SSH to a Linux host (where Docker + redroid run) and run diagnostics or shell commands.
|
||||||
|
|
||||||
|
Authentication (pick one): private key (--key-file / REDROID_SSH_KEY), password
|
||||||
|
(REDROID_SSH_PASSWORD), or --password-prompt. Do not commit secrets.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
REDROID_SSH_HOST=192.168.2.179 REDROID_SSH_USER=eric python tools/ssh_remote.py --password-prompt diagnose
|
||||||
|
|
||||||
|
python tools/ssh_remote.py -H 192.168.2.179 -U eric --key-file ~/.ssh/id_ed25519 diagnose
|
||||||
|
|
||||||
|
python tools/ssh_remote.py -H ... -U eric --password-prompt exec -- bash -lc 'docker ps -a | head'
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import getpass
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
if str(_REPO_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_REPO_ROOT))
|
||||||
|
|
||||||
|
import paramiko
|
||||||
|
|
||||||
|
|
||||||
|
def connect_client(args: argparse.Namespace) -> paramiko.SSHClient:
|
||||||
|
host = (args.host or "").strip()
|
||||||
|
user = (args.user or "").strip()
|
||||||
|
if not host or not user:
|
||||||
|
print("Set -H/--host and -U/--user (or REDROID_SSH_HOST / REDROID_SSH_USER).", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
password = (getattr(args, "password", None) or os.environ.get("REDROID_SSH_PASSWORD", "")).strip() or None
|
||||||
|
key_file = (args.key_file or os.environ.get("REDROID_SSH_KEY", "")).strip() or None
|
||||||
|
|
||||||
|
if getattr(args, "password_prompt", False):
|
||||||
|
password = getpass.getpass("SSH password: ") or None
|
||||||
|
if not password:
|
||||||
|
print("Empty password.", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
if not password and not key_file:
|
||||||
|
print(
|
||||||
|
"Need auth: --key-file / REDROID_SSH_KEY, or REDROID_SSH_PASSWORD, or --password-prompt.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
kw: dict = {
|
||||||
|
"hostname": host,
|
||||||
|
"port": int(args.port),
|
||||||
|
"username": user,
|
||||||
|
"timeout": float(args.timeout),
|
||||||
|
"allow_agent": bool(key_file),
|
||||||
|
"look_for_keys": bool(key_file),
|
||||||
|
}
|
||||||
|
if key_file:
|
||||||
|
kw["key_filename"] = os.path.expanduser(key_file)
|
||||||
|
if password:
|
||||||
|
kw["password"] = password
|
||||||
|
|
||||||
|
client.connect(**kw)
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def run_remote(client: paramiko.SSHClient, bash_script: str, timeout: float = 300.0) -> int:
|
||||||
|
cmd = "bash -lc {}".format(shlex.quote(bash_script))
|
||||||
|
stdin, stdout, stderr = client.exec_command(cmd)
|
||||||
|
stdout.channel.settimeout(timeout)
|
||||||
|
out = stdout.read().decode("utf-8", errors="replace")
|
||||||
|
err = stderr.read().decode("utf-8", errors="replace")
|
||||||
|
if out:
|
||||||
|
sys.stdout.write(out)
|
||||||
|
if not out.endswith("\n"):
|
||||||
|
sys.stdout.write("\n")
|
||||||
|
if err:
|
||||||
|
sys.stderr.write(err)
|
||||||
|
if not err.endswith("\n"):
|
||||||
|
sys.stderr.write("\n")
|
||||||
|
return int(stdout.channel.recv_exit_status())
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_diagnose(args: argparse.Namespace) -> int:
|
||||||
|
cname = args.container_name
|
||||||
|
port = int(args.adb_port)
|
||||||
|
qc = shlex.quote(cname)
|
||||||
|
script = """
|
||||||
|
set +e
|
||||||
|
echo "=== docker ps (name filter) ==="
|
||||||
|
docker ps -a --filter name={qc} --no-trunc 2>/dev/null | head -35
|
||||||
|
echo "=== docker inspect ==="
|
||||||
|
docker inspect --format 'status={{{{.State.Status}}}} exit={{{{.State.ExitCode}}}} err={{{{.State.Error}}}}' {qc} 2>/dev/null || echo "(no such container)"
|
||||||
|
echo "=== docker logs (tail 150) ==="
|
||||||
|
docker logs --tail 150 {qc} 2>/dev/null || echo "(no logs)"
|
||||||
|
echo "=== adb ==="
|
||||||
|
adb connect 127.0.0.1:{port}
|
||||||
|
adb devices -l
|
||||||
|
adb -s 127.0.0.1:{port} shell getprop sys.boot_completed 2>/dev/null || true
|
||||||
|
echo "=== adb logcat (display/GLES, last 80) ==="
|
||||||
|
adb -s 127.0.0.1:{port} logcat -d -t 80 '*:S' SurfaceFlinger:V EGL:V OpenGLRenderer:V redroid:V AndroidRuntime:E 2>/dev/null || true
|
||||||
|
""".format(
|
||||||
|
qc=qc,
|
||||||
|
port=port,
|
||||||
|
)
|
||||||
|
client = connect_client(args)
|
||||||
|
try:
|
||||||
|
return run_remote(client, script)
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_exec(args: argparse.Namespace) -> int:
|
||||||
|
remote = " ".join(args.remote_words).strip()
|
||||||
|
if not remote:
|
||||||
|
print("exec: provide a remote command after --", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
client = connect_client(args)
|
||||||
|
try:
|
||||||
|
return run_remote(client, remote, timeout=float(args.timeout))
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_shell(args: argparse.Namespace) -> int:
|
||||||
|
"""Minimal interactive shell (no full PTY); useful for quick typing. Ctrl+D to exit."""
|
||||||
|
client = connect_client(args)
|
||||||
|
try:
|
||||||
|
chan = client.invoke_shell()
|
||||||
|
chan.send("export TERM=dumb\n")
|
||||||
|
|
||||||
|
def read_loop():
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = chan.recv(4096)
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
sys.stdout.buffer.write(data)
|
||||||
|
sys.stdout.buffer.flush()
|
||||||
|
except EOFError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
t = threading.Thread(target=read_loop, daemon=True)
|
||||||
|
t.start()
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
line = sys.stdin.readline()
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
chan.send(line)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
chan.close()
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
ap = argparse.ArgumentParser(description="SSH helper for remote Docker/adb diagnostics.")
|
||||||
|
ap.add_argument("-H", "--host", default=os.environ.get("REDROID_SSH_HOST", ""))
|
||||||
|
ap.add_argument("-U", "--user", default=os.environ.get("REDROID_SSH_USER", ""))
|
||||||
|
ap.add_argument("-P", "--port", type=int, default=int(os.environ.get("REDROID_SSH_PORT", "22")))
|
||||||
|
ap.add_argument("--key-file", default=os.environ.get("REDROID_SSH_KEY", ""))
|
||||||
|
ap.add_argument(
|
||||||
|
"--password",
|
||||||
|
default="",
|
||||||
|
help="Unsafe on shell history; prefer REDROID_SSH_PASSWORD or --password-prompt.",
|
||||||
|
)
|
||||||
|
ap.add_argument("--password-prompt", action="store_true")
|
||||||
|
ap.add_argument("--timeout", type=float, default=60.0, help="SSH connect / exec timeout (seconds)")
|
||||||
|
|
||||||
|
sub = ap.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
d = sub.add_parser("diagnose", help="docker ps/inspect/logs + adb on the remote host")
|
||||||
|
d.add_argument(
|
||||||
|
"--container-name",
|
||||||
|
default=os.environ.get("REDROID_CONTAINER", "redroid_13.0.0_mindthegapps_houdini_magisk"),
|
||||||
|
)
|
||||||
|
d.add_argument("--adb-port", type=int, default=int(os.environ.get("REDROID_ADB_PORT", "5570")))
|
||||||
|
|
||||||
|
e = sub.add_parser("exec", help="Run one remote bash -lc string (pass command after --)")
|
||||||
|
e.add_argument("remote_words", nargs=argparse.REMAINDER, help="e.g. -- bash -lc 'docker ps'")
|
||||||
|
|
||||||
|
s = sub.add_parser("shell", help="Interactive SSH shell (basic; prefer OpenSSH ssh for full TTY)")
|
||||||
|
|
||||||
|
ns = ap.parse_args()
|
||||||
|
if ns.command == "diagnose":
|
||||||
|
sys.exit(cmd_diagnose(ns))
|
||||||
|
if ns.command == "exec":
|
||||||
|
sys.exit(cmd_exec(ns))
|
||||||
|
if ns.command == "shell":
|
||||||
|
sys.exit(cmd_shell(ns))
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
80
verify_offline_bundle.py
Normal file
80
verify_offline_bundle.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent
|
||||||
|
MANIFEST = ROOT / "offline" / "manifest.json"
|
||||||
|
BASE_IMAGE = "redroid/redroid:13.0.0-latest"
|
||||||
|
|
||||||
|
|
||||||
|
def digest(path, algorithm):
|
||||||
|
hasher = hashlib.new(algorithm)
|
||||||
|
with path.open("rb") as handle:
|
||||||
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||||
|
hasher.update(chunk)
|
||||||
|
return hasher.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def runtime_available(runtime):
|
||||||
|
return shutil.which(runtime) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def local_base_image_present():
|
||||||
|
for runtime in ("docker", "podman"):
|
||||||
|
if not runtime_available(runtime):
|
||||||
|
continue
|
||||||
|
result = subprocess.run(
|
||||||
|
[runtime, "image", "inspect", BASE_IMAGE],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
return True, runtime
|
||||||
|
return False, ""
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
|
||||||
|
failures = []
|
||||||
|
base_image_present, base_runtime = local_base_image_present()
|
||||||
|
for entry in manifest["artifacts"]:
|
||||||
|
path = ROOT / entry["relative_path"]
|
||||||
|
if path.name == "redroid-redroid-13.0.0-latest.tar" and base_image_present:
|
||||||
|
print("SKIP {} ({} already present locally)".format(entry["relative_path"], BASE_IMAGE))
|
||||||
|
continue
|
||||||
|
if not path.is_file():
|
||||||
|
if path.name == "redroid-redroid-13.0.0-latest.tar":
|
||||||
|
failures.append(
|
||||||
|
"{} missing (and {} not found in local {} storage)".format(
|
||||||
|
entry["relative_path"], BASE_IMAGE, base_runtime or "docker/podman"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
failures.append("{} missing".format(entry["relative_path"]))
|
||||||
|
continue
|
||||||
|
if path.stat().st_size != entry["size"]:
|
||||||
|
failures.append("{} size mismatch".format(entry["relative_path"]))
|
||||||
|
continue
|
||||||
|
sha256 = digest(path, "sha256")
|
||||||
|
if sha256 != entry["sha256"]:
|
||||||
|
failures.append("{} sha256 mismatch".format(entry["relative_path"]))
|
||||||
|
continue
|
||||||
|
print("OK {}".format(entry["relative_path"]))
|
||||||
|
|
||||||
|
if failures:
|
||||||
|
for item in failures:
|
||||||
|
print("FAIL {}".format(item))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print("Offline bundle is complete.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user