Your phone is a Linux workstation โ ~30s to a working desktop, not 30 minutes of apt.
A full Debian 13 + XFCE desktop inside Termux, no root โ installed and driven from a touch-friendly TUI, not a wall of shell scripts.
curl -sL https://raw.githubusercontent.com/arinadi/arinanoLabs/main/install.sh | bash
Debian 13 ย ยทย Xfce4 ย ยทย Firefox ESR ย ยทย Dev tools
TermuX โ X11 โ LinuX โ Trixie โ Xfce4
Your Android phone is a pocket PC with 8GB+ RAM and an ARM64 CPU โ it deserves a real desktop. If you've already fought your way through a manual proot install, you know where the pain is. arinanoLabs fixes it declaratively:
| Problem | arinanoLabs Solution |
|---|---|
| Chrome sleeps tabs | Firefox ESR desktop browser โ stays alive |
| No glibc apps | Debian 13 proot โ standard glibc |
| 30 min of apt + config | Pre-built OCI image, pulled in one step |
| Fiddly X11 + audio + dbus startup | One menu entry, with cleanup on stop |
| Teardown that leaves stale locks | One teardown path, verified before it reports success |
What this can't do: no Docker, no systemd services, no native x86, no root (proot emulates root-like behavior, not real root). Full details in Limitations.
arinanoLabs started as a fork of DroidDesk
and has since diverged completely. The install path is a from-scratch Python
Textual TUI instead of shell scripts โ
idempotent, resumable, with more than 30 automated tests
run on every push. The system image is declarative: a Dockerfile, built and
published by CI, not a setup script run against whatever state the device
happens to be in. And Doctor diagnoses and repairs specific, named failure
modes โ the Debian security-archive bug, DNS, timezone, Electron's sandbox
under proot, per-device GPU and audio method selection โ rather than leaving
you to re-run an install script and hope.
DroidDesk covers more ground: a choice of desktop environment, Adreno-specific GPU paths, monitor/VNC bridging. arinanoLabs deliberately narrows to one path โ Debian 13 and XFCE4 โ and puts the effort into that path working reliably instead. Both are GPLv3; credit and thanks to DroidDesk for the idea and the starting point.
curl -sL https://raw.githubusercontent.com/arinadi/arinanoLabs/main/install.sh | bashinstall.sh is deliberately thin โ it gets git, Python, and this repo onto the
machine, then hands over to ~/arinanoLabs/install.py, which does the rest:
Python libraries, Termux packages (proot-distro, Termux:X11, PulseAudio,
graphics), the Debian container, and the alabs launcher.
The launcher is symlinked into $PREFIX/bin, which is Termux's entire default
PATH โ so alabs works immediately, in the session that ran the installer, with
no shell startup file touched. Off Termux it falls back to ~/bin and adds that
to PATH in both .bashrc and .profile.
It runs unattended and is safe to re-run โ each step skips work already done, so a partial install can be resumed by running it again.
One thing it cannot do for you: the desktop renders inside the
Termux:X11 Android app,
which has to be sideloaded โ pkg only provides the Termux half. The installer
checks for it and says so at the end if it is missing, and Doctor reports it too.
Then open a new terminal session:
alabs # Launch the TUIThe menu is sized for a thumb:
| Start Desktop | Stop Desktop | |
| Update | Tools | Settings |
| Doctor | Backup | |
| Reset | Cache |
Everything is tappable โ Termux delivers touches as mouse events. Full reference below in The TUI, screen by screen.
Two layers. The core is declarative โ defined by image/Dockerfile, built in
CI, published to ghcr.io/arinadi/arinanolabs. The user layer is whatever
you install inside the running container; it survives across desktop restarts,
but a Reset wipes it.
Every pull tries GHCR first and falls back to Docker Hub automatically if that fails. GHCR has no rate limit for a public package, which matters more than raw speed: most installs happen over mobile data behind carrier-grade NAT, where Docker Hub's anonymous-pull limit (10/hour as of 2026) is shared with every other subscriber on the same IP, not just this install. Docker Hub is genuinely faster for some ISPs โ ghcr.io routes through Fastly's AnyCast CDN, which some ISPs peer with badly โ so it stays as a fallback rather than being dropped.
Starting the desktop is a chain, and each link is cleaned up in reverse on stop:
flowchart LR
A[PulseAudio] --> B["Shared socket<br/>$PREFIX/tmp/pulse-socket"]
B --> C[virgl renderer]
C --> D[termux-x11 :0]
D --> E{X11 socket<br/>ready?}
E -->|yes| F["proot-distro login<br/>--user admin --shared-tmp"]
E -->|timeout 5s| F
F --> G["dbus-launch --exit-with-session<br/>startxfce4"]
Built with Textual. Every action runs in a thread worker, so a ten-minute image pull streams into a log pane while the interface stays live.
Every screen returns to the menu, and anything destructive is gated behind a confirmation first:
stateDiagram-v2
[*] --> MainScreen
MainScreen --> ActionScreen: Start ยท Stop ยท Update
MainScreen --> DoctorScreen: Doctor
MainScreen --> ToolsScreen: Tools
MainScreen --> BackupScreen: Backup
MainScreen --> SettingsScreen: Settings
MainScreen --> ConfirmScreen: Reset ยท Cache
ToolsScreen --> ConfirmScreen: Install
DoctorScreen --> DupesScreen: Dupes
DupesScreen --> ConfirmScreen: Remove
BackupScreen --> ConfirmScreen: Backup now ยท Restore ยท Delete
ConfirmScreen --> MainScreen: Cancel
ConfirmScreen --> ActionScreen: Confirm
DoctorScreen --> ActionScreen: Fix ยท Diagnose
ToolsScreen --> MainScreen: Back
DoctorScreen --> MainScreen: Back
DupesScreen --> DoctorScreen: Back
BackupScreen --> MainScreen: Back
SettingsScreen --> MainScreen: Back
ActionScreen --> MainScreen: Back, once idle
ActionScreen is where the long work happens. It hands the job to a thread so
the terminal keeps redrawing, and refuses to be dismissed until that thread is
done โ leaving mid image pull would strand a half-installed container:
sequenceDiagram
autonumber
actor User
participant Screen as ActionScreen
participant Worker as Thread worker
participant Proc as Subprocess
User->>Screen: choose an action
Screen->>Screen: mount ยท busy = true ยท Back disabled
Screen->>Worker: run_task()
Worker->>Proc: stream_cmd(cmd)
loop each output line
Proc-->>Worker: stdout
Worker-->>Screen: call_from_thread(write)
end
Note over Worker,Proc: a watchdog kills the whole<br/>process tree at the timeout
Proc-->>Worker: exit code
Worker->>Screen: finish ยท busy = false ยท Back enabled
User->>Screen: Back or Escape
Screen-->>User: return to the menu
Built for a thumb, not a mouse: full-width buttons, generous spacing between them, and every Back sits at the bottom of its screen โ reachable one-handed, in the same place every time.
One thing no TUI can control from inside itself: a button's physical touch size depends on Termux's terminal font size, since height is measured in character cells, not pixels. At Termux's default font size a 3-row button comfortably clears the 44-48dp minimum most mobile guidelines recommend; below roughly 10sp it starts getting tight. If buttons feel small enough to mistap, that is what to raise โ pinch-to-zoom in the terminal, or Termux's Style menu โ not something arinanoLabs can fix from the Python side.
Brings the whole stack up in order, streaming each step:
- Wake lock โ
termux-wake-lock, so Android does not freeze the session - Audio server โ PulseAudio plus a Unix socket in the shared tmp, which is what the container opens
- virgl renderer โ first one that exists, or software rendering
- ICE socket directory โ
/tmp/.ICE-unix, whichxfce4-sessionneeds to accept its own children and which nothing else on this stack creates - X11 server โ
termux-x11 :0, then opens the Termux:X11 app - Wait for the socket โ until it accepts a connection, not merely exists
- Session โ
dbus-launch --exit-with-session xfce4-sessionasadmin
It always runs a full stop first, even when nothing appears to be running.
Leftovers outlive the session that made them, and a stale .X0-lock or an
orphaned proot is exactly what breaks the next start.
If the desktop does not come up, a full diagnostic report is collected automatically โ no need to go and ask for it. With no container installed, Start offers to pull one instead of failing further down.
Innermost first, which is the opposite of what feels natural:
TERMthenKILLthis container's proot tree โ proot runs with--kill-on-exit, so that takes everything inside with it- X11 down, then the Android app force-stopped
pulseaudio --kill, virgl down- Sweep anything that outlived its parent
- Remove sockets, lock files and runtime directories
There is no polite logout step. xfce4-session-logout reaches the session
manager over its D-Bus and ICE sockets, and a fresh proot-distro login has
neither, so the request never arrived โ every stop just waited eight seconds
and then killed anyway. TERM before KILL still lets the tree exit on its
own.
Then it verifies with pgrep and tells you what survived, rather than
printing "Stopped" regardless.
git pull --ff-only, falling back to a hard reset on origin/main when the
fast-forward is refused. When it finishes, a Restart button relaunches
alabs on the code that was just pulled โ pulling into a running process
otherwise does nothing, since the old modules are already loaded.
A package browser for the container.
Type a name and press Enter to search the container's package lists. Results show an I against anything already installed, then highlight a row and press Install. apt runs inside the container with its output streamed; Termux is not touched.
The first search fetches the package lists, which takes a moment. The image
ships without them โ every Dockerfile layer ends with
rm -rf /var/lib/apt/lists/* to keep the download small โ so without this
every search would match nothing and read as "no such package".
An output pane below the results shows the command and its raw output, because a failed search and an empty one otherwise look identical.
Mirror switches which Debian mirror the container fetches from. Debian
13's debian.sources carries the main archive and security as two separate
stanzas at different paths; repointing both at a mirror sends security
requests somewhere it likely does not carry, and apt update exits 100 โ the
usual meaning of that error right after a switch. Security is identified by
its Suites: field and always forced back to security.debian.org, never to
the chosen mirror. If the new mirror still fails, the previous sources are
restored automatically.
Identifying the security stanza by its Suites: field rather than by
inspecting its URI matters once a container has already been broken by an
older switch: a URI that has been repointed at an unrelated mirror carries no
hint that it was ever security, so the earlier fix repaired the file the first
time and then quietly re-broke it on every switch after โ restoring whatever
value it found, which was the corrupted one. Suites: does not change, so it
is the only signal used now, and Doctor reports the security archive on its
own besides. The list is
not hardcoded: Refresh pulls Debian's own deb822 masterlist and keeps the
nearby countries, and Measure times a real download from each and sorts by
throughput. netselect-apt does something similar but ranks by latency and
writes the old sources format, neither of which suits a phone on mobile data.
Repos adds third-party apt repositories, each with its own signing key
under /etc/apt/keyrings so apt verifies it the way it verifies Debian:
| Repo | What it gives you |
|---|---|
backports |
Newer packages from Debian itself โ no new key |
mozilla |
Firefox tracking release rather than ESR |
vscode |
Visual Studio Code |
Add takes any repository: name, URI, suites, components, and a signing key
URL. The key is mandatory โ a repository signed by nothing apt already trusts
cannot be verified, and pointing it at Debian's own key would not help, since
the signature simply would not match. Every field is validated before
anything is written: the name cannot collide with a built-in repo or one
already added, and none of the fields may contain a newline, which could
otherwise smuggle a second directive into the stanza. Repos added by an
earlier session, or by hand under the arinanolabs- naming convention, show
up in the list too.
Search terms and package names are validated against the Debian package-name shape and rejected rather than escaped โ they end up in a shell command.
There used to be a separate Status screen. It duplicated more than half of Doctor's own checks with none of the fixes, so it is gone โ its facts are folded in here instead, alongside a Diagnose/Fix that Status never had.
Internet, Python, repository checkout, launcher target, Textual, proot-distro, PulseAudio, Termux:X11, the GPU renderer, the X11 app, the container, storage, DNS, timezone, audio, the security archive, Electron apps' sandbox, Firefox's video defaults, and stale X11 sockets โ plus a line up top with whatever does not fit the ok/broken shape of an Issue: whether the desktop is running, image cache size, and the version.
Three states, not two: โ present, โ missing, and ? for could not tell. Querying installed Android apps is unreliable from Termux, and reporting "missing" when the honest answer is "unknown" sends you fixing the wrong thing.
GPU renderer is virglrenderer-android. Without it the desktop runs on
llvmpipe โ everything is drawn on the CPU.
Storage reuses the same free-space check the installer runs before
pulling the image, at a lower floor: once a container exists, apt's own
cache is the only space a repair can free without deleting something you
put there yourself, so that is all the fix does โ apt-get clean and
autoremove.
DNS failures inside the container read exactly like a dead mirror โ
apt-get update fails with "Temporary failure in name resolution" even
though the connection itself is fine. The usual cause is resolv.conf
being empty or a dangling symlink (some images ship one pointing at
systemd-resolved, which does not run under proot). The repair replaces it
with 1.1.1.1 and 8.8.8.8.
Timezone is UTC in the image and nothing ever points it at the device's
own zone, so file timestamps and a terminal's clock inside the container
silently disagree with Android's. The repair reads the device's zone with
getprop persist.sys.timezone and symlinks /etc/localtime to match.
Electron apps (VS Code, and anything else built on Electron) open nothing
at all under proot: their SUID sandbox needs unprivileged user namespaces,
which proot only fakes without a kernel behind them, so Chromium's zygote
sandbox init fails before the window ever appears. The repair finds every
installed Electron app by its chrome-sandbox helper โ not by name, so
whatever gets installed later is caught too โ and adds --no-sandbox to its
.desktop launcher. proot is already the outer isolation boundary on a
personal device, so there is nothing behind the sandbox worth protecting.
Firefox's video defaults are the fix for stuttering YouTube. There is no
VA-API through proot, so VP9 and AV1 are decoded on the CPU โ and that, not
rendering, is what makes playback bad. The repair drops a preferences file
into the container that turns both off, so YouTube falls back to H.264. They
are defaults rather than locks: about:config still wins.
VirGL does not solve this. It accelerates OpenGL, and the cost of a video is in decoding it.
Audio works the same way as Bench, because no single method works everywhere. Three have failed here already: TCP where the module loaded but nothing listened, a Unix socket that connected and then failed its handshake, and shared memory that does not survive the proot boundary.
So the methods are declared and tested end to end, and the one that plays is
written to .env and used by every later start:
| Method | Transport |
|---|---|
unix |
Socket in the shared tmp, shared memory off |
unix-shm |
Same socket, shared memory on |
tcp |
Loopback TCP, shared memory off |
tcp-shm |
Loopback TCP, shared memory on |
It plays from Termux first. If that fails, nothing in the container will help and it stops there rather than testing four methods against a dead sink.
Bench answers a question this project could not answer from the outside. The published guidance is written for Qualcomm hardware โ zink is reported to work only there, Turnip is Adreno-only โ so on an Exynos or Mali device the right configuration is genuinely unknown.
So it measures. glmark2 runs under each configuration in turn and the winner
is written to .env, to be used by every later start:
| Preset | Path |
|---|---|
software |
llvmpipe โ the baseline that says whether acceleration helped |
virgl |
virgl_test_server_android, works on most devices |
angle |
virgl โ ANGLE โ Vulkan, the Mali path |
angle-null |
ANGLE with the null Vulkan loader |
zink |
OpenGL on Vulkan, reported to work only on Qualcomm |
It needs termux-x11 running, but not the desktop.
Recording is not possible, and no amount of configuration changes that. The
Termux app does not declare android.permission.RECORD_AUDIO, so PulseAudio
has no microphone source โ module-sles-source fails to initialise, and
forcing it yields silence rather than audio. termux-microphone-record from
Termux:API works, but it is a separate app and cannot feed the container.
| Button | What it does |
|---|---|
| Re-scan | Run the checks again |
| Fix (N) | Repair everything repairable, in one press |
| Diagnose | The same full report Start prints when it fails |
| Dupes | Tools installed in both Termux and the container |
| Audio | Play a test tone from Termux, then from the container |
| Bench | Measure each GPU configuration and keep the fastest |
Only genuinely fixable problems are counted in Fix. Anything needing you โ a missing APK, no checkout โ says so instead of offering a button that cannot help.
Diagnose reports host processes and sockets, then probes inside the
container: the admin user, the session binaries, the shared socket, xset q
against the display, and a foreground session run. It ends with a short guide
to reading it.
Dupes lists tools present on both sides and can uninstall the Termux copies, treating the container as primary. Only packages the container is confirmed to provide are offered, nothing arinanoLabs itself needs is ever a candidate, and it is deliberately not part of Fix โ removing packages from your Termux is a decision, not a repair.
Per-device preferences, saved to .env. Each value is owned by whatever
module actually uses it โ this screen only reads and writes them through
that module's own functions, so there is exactly one place that knows what
a value means.
| Setting | What it changes |
|---|---|
| Debian mirror | Shown, not edited โ pick one from Tools โ Mirror. Shown here so it does not need to be two places that could disagree. |
| Audio method | Overrides what Doctor โ Audio measured (unix / unix-shm / tcp / tcp-shm), for when auto-detection picked wrong or a method stops working after a Termux/Android update. |
| GPU profile | Overrides what Doctor โ Bench measured. Picking one manually clears the recorded benchmark score โ a score from a different profile would misreport the override as measured. |
| termux-x11 rendering | -legacy-drawing and -force-bgra, normally invisible to this app entirely. Some devices show a black screen (fixed by legacy drawing, which skips the modern Android hardware-buffer path) or swapped color channels (fixed by force-BGRA); neither is detectable from software, only by looking at the screen. |
Mirror gets special treatment beyond just being shown: choosing one from Tools โ Mirror now saves it, and it is automatically reapplied right after any fresh container install โ Reset would otherwise silently revert to the default mirror and throw away a measured choice every time.
Every change here needs a desktop restart to take effect โ none of these are read while a session is already running.
Archives and restores /home/admin โ dotfiles, the Firefox profile, editor
settings, the XFCE panel layout, anything installed or configured as the
regular user. Not apt packages: those come back with a normal install, and
re-archiving them would just be a slower way to redo what apt already does.
Both directions run tar through the container, not as a host-side file
copy. proot fakes ownership and backs some files with a hardlink-emulation
store (rootfs/.l2s) that a raw copy of the underlying files would not
reproduce โ tar running inside a proot login sees the same logical
filesystem any other program in the container does.
A backup is a timestamped .tar.gz under ~/arinanoLabs-backups on Termux's
own storage โ outside the container, so a Reset cannot take it down too.
Restore replaces /home/admin with the archive's contents; the home it
replaces is kept as /home/admin.bak inside the container rather than
deleted, in case the wrong archive gets picked.
The natural pairing is Backup before Reset: back up, wipe and reinstall the container, then Restore once it is back up.
Reset deletes the container and pulls a fresh image. Cache drops downloaded OCI layers and keeps the container. Both go through a confirmation that spells out what is lost.
Every screen that produces output โ logs, Doctor, Dupes, Tools, Backup, Settings โ has a
C button and a c key. It tries the Android clipboard via
termux-clipboard-set, then the terminal's own OSC 52, and always mirrors
the text to a file as well, because the usual reason to copy a failure is to
paste it somewhere for help and neither clipboard is guaranteed.
The button is a letter rather than a clipboard glyph โ Termux's font cannot be relied on to have one.
| Key | Where |
|---|---|
q |
Quit, from the menu |
Escape |
Back โ refused while an action is still running |
c |
Copy the screen's output |
Enter |
Run the search, in Tools |
The layout holds down to a 36-column terminal, well under a typical Termux portrait width. A test drives every screen at 40, 45 and 60 columns.
The image is currently a vanilla baseline, deliberately. It matches the established Termux + proot + XFCE recipe and adds nothing beyond a browser:
| Category | Packages |
|---|---|
| ๐ฅ๏ธ Desktop | xfce4, xfce4-terminal, dbus-x11 |
| ๐ Browser | firefox-esr |
| ๐ฎ Graphics | Mesa userspace, x11-xserver-utils, mesa-utils |
| ๐ Audio | pulseaudio-utils (client; the server runs in Termux) |
| ๐งฑ Base | ca-certificates, locales, sudo |
Installed with recommends, as every published guide does. An earlier image
used --no-install-recommends throughout and produced a container where
xfce4-session started but never launched xfwm4 or xfdesktop.
xfce4's own Recommends โ as opposed to the Depends that make up the session
itself โ are desktop-base, mate-polkit, tango-icon-theme,
thunar-volman and xfce4-notifyd. Two are excluded: desktop-base
(Debian's splash/wallpaper artwork, purely cosmetic) and thunar-volman
(auto-mounts removable media โ nothing to mount here). The other three stay:
they are either visible (notifications, panel/file-manager icons, the
privilege-elevation dialog) or close enough to the recommends that fixed the
launch bug above that trimming them needs a real device test, not a guess
from a dependency list.
The dev toolchain, Zsh, on-screen keyboard, theming and the pre-baked panel layout were removed to get back to a build of known-good shape. They are in git history and come back once the baseline is confirmed working.
Anything else is a search away in Tools.
There is no GPU vendor detection. The start sequence probes for a virgl renderer and takes the first one that exists:
| Probe | Used when |
|---|---|
virgl_test_server_android |
present on PATH (Termux virglrenderer-android) |
ANGLE vulkan-null backend |
$PREFIX/opt/angle-android/vulkan-null exists |
ANGLE vulkan backend |
$PREFIX/opt/angle-android/vulkan exists |
| none | falls back to software rendering |
The container ships Mesa userspace (libgl1-mesa-dri, libglx-mesa0,
mesa-utils) so OpenGL works either way.
arinanoLabs/
โโโ install.sh โ Bootstrap: git, Python, repo checkout
โโโ install.py โ Full installer
โโโ alabs โ TUI launcher
โโโ installer/ โ TUI package
โ โโโ app.py โ Textual app: screens, runners
โ โโโ app.tcss โ Styling
โ โโโ start.py โ Desktop lifecycle
โ โโโ preflight.py โ Environment checks (pure stdlib)
โ โโโ system.py โ Subprocess helpers
โ โโโ const.py โ Paths and names
โ โโโ doctor.py โ Diagnosis and repair
โ โโโ bench.py โ GPU benchmark and profile
โ โโโ config.py โ .env, per-device settings
โโโ image/ โ System definition (Dockerfile + configs)
โโโ tests/ โ Headless TUI tests, run by CI
โโโ docker/dev/ โ Local TUI test harness
โโโ docs/ โ Debugging notes and references
This is by design, and worth knowing before it confuses you. proot-distro binds
the Termux $PREFIX into the container at its original path and appends
$PREFIX/bin to the guest's PATH, so Termux's own binaries are reachable
from inside Debian.
Because the append puts Termux last, a tool present in both resolves to the
Debian copy. The Termux one only runs when Debian lacks the tool โ which is
precisely when you would not expect it. --shared-tmp extends the same overlap
to /tmp: the container's /tmp is the Termux temp directory.
Doctor โ Dupes lists tools installed on both sides and can uninstall the
Termux copies, on the assumption that the container is where you work. It only
offers packages the container is confirmed to provide, and it will not touch
anything arinanoLabs itself needs โ Python, git, proot-distro, Termux:X11,
PulseAudio or the graphics packages are not candidates.
| Limitation | Workaround |
|---|---|
| No root | proot provides root-like environment |
| No systemd | Start services manually |
| No GPU passthrough | virgl renderer, software fallback |
| ARM64 only | QEMU for cross-arch (slow) |
| No native X11 | Termux:X11 app required |
| No Docker | proot lacks kernel features |
Background processes can get killed by Android. Disable it:
- Android 14+: Developer Options โ Disable child process restrictions
- Android 12โ13:
adb shell settings put global settings_enable_monitor_phantom_procs false
GPLv3 โ see LICENSE.