AIGC标识 Android开发笔记[22]-开箱即用的xfce桌面及novnc访问

摘要

记录一个装上 APK、点开就是 Linux 桌面的离线 Android 应用 AlpineDesktop:把完整的 Alpine Linux 3.20.10 + XFCE 4.18 根文件系统(rootfs.bin,294,058,676 字节的 tar.gz,解出约 673MB)整个塞进 APK 的 assets,首次启动用零依赖的手写 tar.gz 解包器释放到应用私有目录;proot 直接采用 Termux 生态的预编译套件(libproot.so + 外置 libloader.so + libtalloc + busybox + bash,以 jniLibs 形式释放到 nativeLibraryDir 再用符号链接引导),以 proot-distro login 同款命令行(--sysvipc -L --link2symlink --root-id)进入 guest,在里面拉起 TigerVNC 1.13.1 的 Xvnc(RFB 无密码、仅回环 5900)→ XFCE 会话 → websockify 0.11.0/noVNC 1.4.0(6080 WebSocket),最后由系统 WebView 加载 http://127.0.0.1:6080/vnc_lite.html 显示桌面。全程不需要 root、不申请存储权限、运行期零联网,arm64 真机(华为 TAS-AN00,Android 12)实测清数据后约 1 分钟进入完整桌面。同时记录两个只在 Android app 进程域里才会现形的坑:绝不能设 PROOT_NO_SECCOMP(纯 ptrace 路径下 getcwd() 返回值被改写,musl/python 集体报 ENOSYS),以及 dbus/websockify 不能双重 fork 守护化(孙进程脱离 proot 跟踪)。

声明

本文人类为第一作者, 龙虾为通讯作者. 本文有AI生成内容.

工程仓库

  • 工程链接:[https://github.com/qsbye/vectras-vm-zulu](分支 alpine-xfce-novnc,提交 d9da82f;分支根目录即本工程,与 main 上的 QEMU 工程并存)
  • proot 引导方式参考自 code_lfa(一个用 Flutter 封装 proot-distro + Ubuntu + code-server 的应用,包名 com.nightmare.code),本工程把它 jniLibs 里的 termux/proot 整套二进制原样复用,guest 侧由 code-server 换成 XFCE + VNC
  • 致谢:Termux(proot / proot-distro)、Alpine Linux、XFCE、TigerVNC、noVNC

资源下载

Release 页面:[https://github.com/qsbye/vectras-vm-zulu/releases/tag/alpine-desktop-v1.0]

文件 实测大小 说明
alpine-desktop-release-signed_20261006_235621.apk 295,912,780 字节(约 282MB) arm64-v8a 单 APK,Android 8.0+;debug 自签名(v2/v3),安装时允许"未知来源"即可

只下载这一个 APK 就能复现全部效果:rootfs 与 proot 套件都在包内,无需任何额外资源。仓库里的 rootfs.bin(约 280MB)走 Git LFS,git clone 后需 git lfs pull;只装 APK 的话不用管它。

关键信息

  • 开发语言:Java(17),4 个类共 943 行:MainActivity(174 行,启动屏 + WebView)、LinuxService(375 行,前台服务/引导/proot)、TarExtractor(340 行,零依赖 tar.gz 解包)、Status(54 行,进程级状态单例)
  • Gradle:gradle-8.10.2-bin.zip;com.android.application 8.5.1;compileSdk 34 / targetSdk 28 / minSdk 26;abiFilters 'arm64-v8a'
  • applicationId com.qsbye.alpinedesktop,versionCode 1,versionName 1.0;android.useAndroidX=false(整个 app 零外部依赖)
  • 权限只有 3 个:INTERNET(WebView 访问回环 http 也要)、FOREGROUND_SERVICE、WAKE_LOCK;不申请存储权限;usesCleartextTraffic="true"(回环明文 http)、extractNativeLibs="true"
  • 打包三要素:androidResources { noCompress 'bin' }(rootfs 资产原样存储)、jniLibs.useLegacyPackaging true(.so 解压到 nativeLibraryDir)、rootfs 资产扩展名用 .bin 而非 .gz(aapt 会对 .gz 做改名/解压特殊处理)
  • jniLibs(app/src/main/jniLibs/arm64-v8a/,5 个文件,合计约 3.6MB,文件名不可改):
文件 字节数 用途
libproot.so 214,536 Termux 版 proot(含上游没有的 sendmsg/SCM_CREDENTIALS 等补丁)
libloader.so 5,608 proot 的外置 loader,必须经 PROOT_LOADER 指定
liblibtalloc.so.2.so 31,128 proot 依赖的 talloc(SONAME 是 libtalloc.so.2,文件名对不上,要建符号链接)
libbusybox.so 1,498,688 宿主侧备用工具(sh/tar/cat/cp 等 applet)
libbash.so 2,026,008 bash
  • assets 只有 1 个文件:rootfs.bin = 294,058,676 字节(docker export | gzip -1),Git LFS 管理;解出后落在 files/rootfs/,以 .ready 空文件作为"已释放"哨兵
  • guest 实测版本:Alpine 3.20.10、xfce4-session 4.18.3、TigerVNC 1.13.1-r5、noVNC 1.4.0、websockify 0.11.0-r3、D-Bus 1.14.10、Python 3.12.13(websockify 依赖链带入,首启解压日志里能看到 numpy);中文字体 font-noto-cjk
  • 账户 qsbye / 密码 qsbye,sudo 免密;桌面进程实际以 proot fake-root(uid 0)运行,HOME=/home/qsbye
  • 端口:guest 内 5900 = Xvnc(-SecurityTypes None -localhost,无密码、仅回环),6080 = websockify 转 WebSocket;二者都绑 127.0.0.1,外部应用不可达
  • release 构建会因 targetSdk 28 被 lintVital 判 ExpiredTargetSdkVersion 致命错误,工程里显式 lint { abortOnError false; checkReleaseBuilds false };最终 APK 用 ~/.android/debug.keystore 经 apksigner 后签(v2/v3 均通过)

原理简介

总体链路

和上一篇 QEMU 工程一样,普通 Android 应用没有 CAP_SYS_CHROOT,只能靠 proot 在用户态伪造一个 Linux 根。区别在于这次 guest 不是再套一层 QEMU 去跑 Windows,而是直接跑与手机同架构(arm64)的 Alpine 原生程序——没有指令集翻译,桌面是"原生速度"的用户态仿真:

MainActivity(全屏 WebView)
   │  http://127.0.0.1:6080/vnc_lite.html?autoconnect=1&resize=remote
   ▼
websockify :6080(WebSocket ↔ TCP)──► Xvnc :5900(虚拟显示 :0)──► XFCE 会话
   ▲                                      ▲
   └──────── termux/proot(外置 loader)───┘   rootfs = assets/rootfs.bin 解出的 Alpine 3.20

VNC 是帧缓冲 + 输入事件的远程桌面协议;noVNC 是它的浏览器客户端(HTML5 Canvas + WebSocket);websockify 是 WebSocket 到原生 TCP socket 的桥。Xvnc 则是 TigerVNC 提供的"带 VNC 输出的虚拟 X 服务器"——它不需要真实屏幕,自己就是 :0 号显示,XFCE 画给它的每一帧都能通过 RFB 协议取走。选这条链路而不是 Termux-X11,是因为显示端零原生代码:Android 侧只用系统自带 WebView,不需要内置任何 X 客户端库。

为什么必须是 termux 版 proot,而且 loader 要外置

最初这一版用的是自己编译的上游静态 proot 5.4.0,桌面大体能起,但 xfsettingsd 稳定弹 Unable to contact settings server / Error sending credentials: Operation not permitted。根因是上游 proot 缺少 Termux 携带的一批 Android 补丁,其中 sendmsg/SCM_CREDENTIALS(Unix socket 传递进程凭据)相关补丁直接影响 D-Bus 握手。后来还试过在 Alpine 容器里自己编译 termux/proot(musl 缺 struct rlimit64/statfs64/MSG_COPY/TEMP_FAILURE_RETRY,loader 的 -m32 也要特殊处理,打了一堆补丁勉强编过),结果自编译 loader 无法执行 Alpine 的 musl 动态 ELF——直接 exec guest 程序报 ENOENT。此路废弃,直接用 code_lfa 里现成的 Termux 预编译套件,问题全部消失。

Termux 的 proot 是动态链接的,运行时需要两样东西:同目录的 libtalloc.so.2,以及一个外置 loader(proot 经 PROOT_LOADER 环境变量找到它,由它把 proot 自身载入并处理 seccomp/ptrace 装配)。这三个文件在 APK 里必须以 lib*.so 命名——Android 只把这种文件解压到 nativeLibraryDir(该目录在 targetSdk 28 下仍可执行),应用自己数据目录里的二进制在 W^X 策略下不可执行。应用能做的是在 files/bin 下建符号链接指过去,这正是 Termux 和 proot-distro 生态的标准引导姿势。

rootfs:docker export 打出来的完整系统

rootfs 不是 Alpine minirootfs 再联网 apk add,而是直接用 alpine:3.20 镜像 apk add 好全部桌面组件后 docker create + docker export 导出的完整根文件系统。选 docker export 而不是在容器里 tar czf 是因为 busybox tar 处理 Alpine /bin -> usr/bin 合并目录有 bug,会丢掉大量 busybox applet;docker export 保留的是镜像层合并后的正确目录树。导出流经 gzip -1(最快档,压缩率换速度),294MB 的包解出约 673MB。

实现

1. 引导工具链:files/bin 符号链接 + PROOT_LOADER

首启释放完 rootfs 后,LinuxService.boot() 做的第一件事是在 files/bin 里把 jniLibs 五件套链成 proot 生态认识的名字(LinuxService.java:219):

    private void setupToolBin(File binDir, String libDir) throws Exception {
        linkTool(binDir, "proot", libDir + "/libproot.so");
        linkTool(binDir, "loader", libDir + "/libloader.so");
        linkTool(binDir, "libtalloc.so.2", libDir + "/liblibtalloc.so.2.so");
        linkTool(binDir, "busybox", libDir + "/libbusybox.so");
        linkTool(binDir, "bash", libDir + "/libbash.so");
        File busybox = new File(binDir, "busybox");
        for (String applet : BUSYBOX_APPLETS) {
            linkTool(binDir, applet, busybox.getAbsolutePath());
        }
    }

BUSYBOX_APPLETS 是 sh/tar/cat/cp/rm/mkdir/ln/grep/sed/awk/head/tail/chmod/stat/realpath/id/uname 共 17 个宿主侧备用 applet。linkTool 无条件重建链接——断链符号链接 exists() 返回 false 但仍占路径,不先删 createSymbolicLink 会抛 FileAlreadyExistsException:

    private void linkTool(File binDir, String name, String target) throws Exception {
        File link = new File(binDir, name);
        // 无条件重建(断链符号链接 exists()=false 但仍占着路径)
        Files.deleteIfExists(link.toPath());
        Files.createSymbolicLink(link.toPath(), new File(target).toPath());
    }

libDir 不硬编码,运行时取 getApplicationInfo().nativeLibraryDir(重装后路径里的随机后缀会变)。

2. rootfs 首启释放:手写 tar.gz 解包器与 .bin 技巧

boot() 以 files/rootfs/.ready 为哨兵决定是否解压(LinuxService.java:90)。解压器 TarExtractor 零依赖(不用 commons-compress),直接 GZIPInputStream 流式读 ustar 头,支持常规文件、目录、符号链接、硬链接,并处理 GNU long name(L/K)和 pax 扩展头(x/g,从里面取 path/linkpath)——docker export 产出的 tar 里这两种长路径条目都会出现:

                // GNU long name / long link
                if (type == 'L' || type == 'K') { ... }
                // pax 扩展头(含可能的 path/linkpath)——解析后跳过
                if (type == 'x' || type == 'g') {
                    ...
                    if ("path".equals(key)) { pendingName = val; }
                    else if ("linkpath".equals(key)) { pendingLink = val; }
                }

符号链接直接 Files.createSymbolicLink,硬链接 Files.createLink(个别跨目录失败不致命),设备节点等其余类型跳过。条目路径还要防目录穿越(TarExtractor.java:196):

    private static Path resolve(Path dest, String name) {
        String clean = name;
        while (clean.startsWith("./")) { clean = clean.substring(2); }
        while (clean.startsWith("/")) { clean = clean.substring(1); }
        Path p = dest.resolve(clean).normalize();
        if (!p.startsWith(dest)) {
            throw new SecurityException("非法归档路径: " + name);
        }
        return p;
    }

进度按压缩字节流的已读/总量回调(assets.openFd().getLength() 取总长),每约 5MB 刷一行 [xx%] 文件名 到启动屏,所以首启能看到百分比和实时文件名。资产之所以叫 rootfs.bin 而不是 rootfs.tar.gz:aapt/aapt2 会识别 .gz 后缀并做特殊处理(改名或处理内容),改个无人认识的扩展名,再配 androidResources { noCompress 'bin' } 让它在 APK 内存储不压缩,运行时才能从正确的偏移流式 gunzip。

3. proot 命令行:对齐 proot-distro login

命令拼装在 LinuxService.java:126-156,参数与 proot-distro login 的调用一致:

            cmd.add("--sysvipc");                       // System V IPC 仿真
            cmd.add("-L");                              // 修复 lstat(symlink 大小)
            cmd.add("--link2symlink");                  // 用 symlink 模拟 hardlink
            cmd.add("--root-id");                       // guest 内 uid/gid 恒为 0
            cmd.add("--kill-on-exit");
            cmd.add("--rootfs=" + rootfs.getAbsolutePath());
            cmd.add("--cwd=/root");
            cmd.add("--bind=/dev");
            cmd.add("--bind=/dev/urandom:/dev/random");
            cmd.add("--bind=/proc");
            cmd.add("--bind=/proc/self/fd:/dev/fd");
            // Android 上这些 /proc、/sys 节点不可读/不存在,用 rootfs 内假文件覆盖
            cmd.add("--bind=" + new File(rootfs, "proc/.loadavg") + ":/proc/loadavg");
            cmd.add("--bind=" + new File(rootfs, "proc/.stat")   + ":/proc/stat");
            cmd.add("--bind=" + new File(rootfs, "proc/.uptime") + ":/proc/uptime");
            cmd.add("--bind=" + new File(rootfs, "proc/.version")+ ":/proc/version");
            cmd.add("--bind=" + new File(rootfs, "sys/.empty")   + ":/sys/fs/selinux");
            cmd.add("--bind=" + tmpDir.getAbsolutePath() + ":/tmp");

假 /proc 文件由 setupFakeSysData() 在释放阶段写好(loadavg/stat/uptime/version 的固定模板),做法与 proot-distro 的 setup_fake_sysdata 相同——桌面组件读这些节点失败会刷错误对话框。/tmp 绑到 files/tmp(宿主侧建好 1777),guest 所有日志都落在这里。

宿主机侧环境先 pb.environment().clear() 再白名单设置,杜绝 Android 变量泄漏:

            pb.environment().put("PATH", binDir.getAbsolutePath() + ":/system/bin");
            pb.environment().put("PROOT_LOADER", new File(binDir, "loader").getAbsolutePath());
            pb.environment().put("LD_LIBRARY_PATH", binDir.getAbsolutePath());
            pb.environment().put("TMPDIR", tmpDir.getAbsolutePath());
            pb.environment().put("PROOT_TMP_DIR", tmpDir.getAbsolutePath());

guest 入口则用 /usr/bin/env -i 再清一次,只放 HOME=/home/qsbye、固定 guest PATH、LANG=C.UTF-8、TERM=xterm-256color、TMPDIR=/tmp 和按屏幕实参生成的 GEOMETRY=<宽>x<高>(WindowManager.getRealSize),执行 /usr/local/bin/desktop-start.sh。

4. 两个只在 app 进程域现形的坑(全篇最关键)

坑一:不能设 PROOT_NO_SECCOMP。 Termux 版 proot 默认自动探测并走 seccomp 加速路径;很多老资料(以及上一版代码)出于"Android 内核 seccomp 必失败"的印象会加 PROOT_NO_SECCOMP=1 强制纯 ptrace。在 zygote 派生的 app 进程里,纯 ptrace 路径出现了确定性的返回值改写错误:guest 内裸 getcwd 系统调用缓冲区明明已被正确写入 /root,proot 却把返回值置成 -1(errno 还是 0);musl 只认返回长度 0/正值,于是 python、websockify 集体报 OSError: [Errno 38] Function not implemented,os.getgroups() 还会泄漏真实 Android 组(3003/9997 这类)而非 root 的 0。删掉这个环境变量、走默认 seccomp 路径后全部消失。

坑二:双重 fork 的守护进程会"跑出"proot。 桌面最初按常规做法用 dbus-launch 起总线、websockify -D 守护化。在 run-as 的 shell 域里一切正常,集成进 APK 后却必挂:dbus-daemon fork 出的孙进程 chdir/getgroups 直通 Android 宿主,报 Could not change to root directory: Function not implemented、Failed to get groups ... Function not implemented,最后 Memory allocation failure in message bus 自杀。app 进程域内 proot 无法可靠跟踪 daemon 双重 fork 出的孙进程。修法是所有守护进程一律前台运行再由 shell 放后台(见下节),不允许任何组件自己双 fork。

5. guest 入口:desktop-start.sh

rootfs-build/desktop-start.sh(共 70 行)就是 guest 的 /usr/local/bin/desktop-start.sh。开头固定环境与运行时目录(XDG_RUNTIME_DIR 必须 0700,/tmp/.X11-unix 必须 1777)。D-Bus 两条总线都 --nofork,会话总线用 --print-address=1 把地址写到文件,shell 轮询读出来导出——这是替代 dbus-launch 的关键:

dbus-daemon --system --nofork --nopidfile \
    >/tmp/dbus-system.log 2>&1 &
dbus-daemon --session --nofork --nopidfile --print-address=1 \
    >/tmp/.dbus-session-addr 2>/tmp/dbus-session.log &
i=0
while [ ! -s /tmp/.dbus-session-addr ] && [ $i -lt 100 ]; do
    i=$((i + 1)); sleep 0.1
done
export DBUS_SESSION_BUS_ADDRESS="$(cat /tmp/.dbus-session-addr 2>/dev/null)"

Xvnc 以 root 身份跑(-nolock 要求 uid 0),RFB 无密码且只听回环,-ac 关 X 授权、-nolock 绕开 proot 下 lock 文件 link() 失败:

Xvnc :0 -geometry "${GEOMETRY}" -depth 24 -rfbport 5900 -SecurityTypes None \
  -localhost -AlwaysShared -nolock -ac -desktop Alpine-XFCE \
  -AcceptKeyEvents -AcceptPointerEvents -AcceptCutText -SendCutText \
  > /tmp/vnc.log 2>&1 &

之后轮询 /tmp/.X11-unix/X0(最多 10 秒)确认 X 起来,再起 startxfce4 和 websockify。websockify 同样不能用 -D,普通 & 后台即可;最后 exec sleep infinity 让 proot 的 1 号进程常驻,--kill-on-exit 会在它结束时收掉整棵 guest 进程树:

websockify --web=/usr/share/novnc 6080 127.0.0.1:5900 > /tmp/websockify.log 2>&1 &
echo "=== Alpine XFCE desktop ready on 127.0.0.1:6080 ==="
exec sleep infinity

6. WebView 轮询接入

MainActivity 每 600ms 轮询一次 Status.stage(EXTRACTING/STARTING/RUNNING/...),RUNNING 后切到全屏 WebView。桌面 URL 带自动重连和远端自适应分辨率(MainActivity.java:26):

    private static final String DESKTOP_URL =
            "http://127.0.0.1:6080/vnc_lite.html?autoconnect=1&resize=remote"
            + "&reconnect=1&reconnect_delay=2000&show_dot=true";

RUNNING 的判据不是读 guest 日志里的 "ready",而是 LinuxService.waitForPort() 真去连 127.0.0.1:6080(150 秒超时,800ms 连接超时 + 700ms 间隔轮询),端口 accept 才置 RUNNING——比上一篇 QEMU 工程里"起服务后死等 2 秒再开画面"的时序凑合可靠得多。WebView 开了 JS/DOM storage,主帧加载失败只 Toast 提示"正在重连"(noVNC 自身也带 reconnect);返回键双击退到后台,Activity 真正销毁时 stopService,proot 整树退出。

7. rootfs 构建

rootfs-build/Dockerfile(24 行)基于 alpine:3.20,一条 apk add 装齐桌面与远程访问组件,并建好 qsbye 账户:

RUN apk add --no-cache \
      xfce4 xfce4-terminal xfce4-screenshooter mousepad \
      dbus dbus-x11 sudo coreutils procps \
      tigervnc novnc websockify \
      xkeyboard-config xkbcomp xauth \
      ttf-dejavu font-noto font-noto-cjk adwaita-icon-theme
RUN adduser -D -s /bin/sh qsbye \
 && echo 'qsbye:qsbye' | chpasswd \
 && echo 'qsbye ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/qsbye
COPY desktop-start.sh /usr/local/bin/desktop-start.sh

scripts/build-rootfs.sh(19 行)负责构建镜像并导出资产,关键就一行:

cid=$(docker create alpine-xfce-rootfs:latest)
docker export "$cid" | gzip -1 > "$ASSETS/rootfs.bin"

构建与测试

# 1)(可选)重建 rootfs:需要本机 Docker 为 arm64
./scripts/build-rootfs.sh        # → app/src/main/assets/rootfs.bin,约 280MB

# 2) 构建 APK(macOS 显式指定 JDK 17)
JAVA_HOME=/path/to/jbr-17 ./gradlew :app:assembleDebug
# → app/build/outputs/apk/debug/app-debug.apk(约 571MB;APK 内 rootfs 存储不压缩)

工程无单元测试,验证方式就是真机装包:清数据 → 启动看解压进度 → 约 1 分钟内 WebView 进入 XFCE → 检查 guest 内 Xvnc/xfce4-session/xfwm4/xfce4-panel/xfdesktop/xfsettingsd/Thunar/dbus-daemon/websockify 进程齐全、/tmp/*.log 无 Function not implemented 与 credentials 报错。Release 包的构建差异只有两点:lint 已在 app/build.gradle 关闭(targetSdk 28 是有意保留),产物用 build-tools 的 zipalign -p 4 + apksigner(debug keystore,v2/v3)后签。

复刻时三个容易翻车的点:

  • jniLibs 五个文件名一个都不能改,缺 PROOT_LOADER 或 libtalloc.so.2 链接名对不上时 proot 直接 exec 失败;files/bin 的链接要在每次启动时无条件重建;
  • rootfs.bin 必须存储不压缩(.bin 扩展名 + noCompress 'bin'),否则运行时 gunzip 读到的是被 aapt 处理过的数据;
  • 不要自作主张加 PROOT_NO_SECCOMP=1,也不要把 dbus/websockify 改回 dbus-launch/-D 守护化——这两条在普通 Linux/run-as shell 里都"看起来正常",只在 app 进程域翻车。

使用

  1. 安装 APK(约 282MB,arm64,Android 8.0+),无任何权限弹窗(仅有联网/前台服务/唤醒锁三个普通权限);
  2. 打开应用:启动屏显示"正在释放系统文件 NN%"和实时文件日志,实测华为 TAS-AN00(Android 12)约 1 分钟内完成;
  3. 自动进入全屏 XFCE:顶栏 Applications、桌面 File System/Home 图标、鼠标壁纸均可正常使用;双指/单指操作由 noVNC 映射为鼠标,右上角有 "Send CtrlAltDel" 按钮;
  4. 终端里 su - qsbye 或 login 可切到 qsbye 账户(密码 qsbye,sudo 免密);桌面会话本身以 fake-root 跑、HOME=/home/qsbye;
  5. 第二次启动秒进(.ready 哨兵在就跳过解压);双击返回键退后台,前台服务+24 小时 wakelock 保活,划掉应用则 guest 整树退出。

常见问题

Q: 启动日志出现大量 Function not implemented,python/websockify 起不来?

A: 检查宿主环境里是否被设置了 PROOT_NO_SECCOMP。这个变量在本方案里必须不设:termux/proot 在 app 进程中走纯 ptrace 路径会错误改写 getcwd() 返回值,musl 据此抛 ENOSYS。保持默认(自动探测 seccomp)即可。

Q: dbus 报 Memory allocation failure in message bus,或日志有 Could not change to root directory: Function not implemented?

A: 这是守护进程双重 fork 的孙进程脱离了 proot 跟踪。不要用 dbus-launch/dbus-daemon --fork,也不要 websockify -D;照 desktop-start.sh 用 dbus-daemon --nofork --print-address=1 ... &、websockify 普通 & 后台。dbus 日志里单独出现 Failed to set fd limit to 65536: Operation not permitted 是 setrlimit 被拒的无害提示,不影响使用。

Q: 为什么不直接用上游官方 proot?

A: 上游 proot 缺 Termux 的 sendmsg/SCM_CREDENTIALS 补丁,xfsettingsd 会报 Unable to contact settings server / Error sending credentials: Operation not permitted,桌面设置服务起不来。自己在 Alpine/musl 里编 termux/proot 又会踩 loader 无法执行 musl 动态 ELF 的坑。直接复用 Termux 生态(code_lfa 已验证的)预编译套件是最省事且已真机验证的路线。

Q: 5900/6080 端口会不会被别的应用连上?安全吗?

A: Xvnc 带 -localhost、RFB SecurityTypes None,websockify 也只转发到 127.0.0.1:5900,两个端口都只绑回环,同机其它应用理论上可访问本机端口但无系统级暴露面;WebView 之外没有任何入口。INTERNET 权限只是 WebView 访问 http://127.0.0.1 所必需,应用运行期不发起任何外网请求。

Q: 只有 arm64 吗?x86 手机/模拟器能用吗?

A: 是。abiFilters 只保留 arm64-v8a,jniLibs 里的 proot 套件也只有 arm64 一份。proot 是用户态仿真(同架构直接跑原生程序),桌面性能远好于 QEMU/TCG 方案;代价是 guest/host 架构必须一致。

Q: 为什么 targetSdk 停在 28?

A: targetSdk ≥ 29 后应用数据目录内二进制不可执行(W^X),而 proot 必须作为可执行文件直接跑。把二进制以 lib*.so 之名放进 jniLibs、释放到 nativeLibraryDir 后再从 files/bin 符号链接过去,是 Termux 同款解法;代价是无法上架 Google Play,本工程定位就是离线直装分发。

运行效果图

实拍环境:华为 TAS-AN00(TAS_AN00),arm64,Android 12;APK 即 Release alpine-desktop-v1.0,清数据后首次启动。

首启释放 rootfs(实时百分比与文件日志) WebView 内的 XFCE 桌面
android22-extract android22-desktop

Thunar 文件管理器(可见 root 警告与真实磁盘容量,guest 文件系统完整可写):
android22-thunar

小结

  • 282MB 单 APK、只申请 3 个普通权限(无存储/无敏感权限)、零运行期联网,点开即进完整 Alpine 3.20 + XFCE 4.18 桌面;proot 做同架构用户态仿真,桌面是原生速度而非 TCG 翻译。
  • Termux 生态的 proot 套件(外置 loader + jniLibs 命名 + 符号链接引导)是 Android 上跑桌面的成熟地基,比自编译上游 proot 少走大量弯路。
  • 两条血泪经验:app 进程域里不要设 PROOT_NO_SECCOMP,不要让守护进程双重 fork——run-as shell 域无法复现这两个问题,排查时务必以 app 自身进程为准。
  • 显示侧选 Xvnc + noVNC + 系统 WebView,Android 端不用写任何原生 X/VNC 代码;就绪判据用真实端口探测比固定 sleep 可靠。
  • 大资产打包含金量:docker export 出完整 rootfs、.bin 扩展名 + noCompress 避 aapt、Git LFS 管 280MB 资产,三者缺一复刻就会翻车。
posted @ 2026-10-07 00:27  qsBye  阅读(7)  评论(0)    收藏  举报