diff --git a/app/build.gradle.kts b/app/build.gradle.kts index aabf483..0d78244 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -18,6 +18,11 @@ val hasUpdateSigning = updateSigningKeyAlias, updateSigningKeyPassword, ).all { !it.isNullOrBlank() } +val x11GuestProbeFault = + providers.gradleProperty("udroidX11GuestProbeFault").orNull ?: "none" +require(x11GuestProbeFault in setOf("none", "deny")) { + "udroidX11GuestProbeFault must be none or deny" +} kotlin { compilerOptions { @@ -48,6 +53,7 @@ android { "UPDATE_RELEASES_API", "\"https://api.github.com/repos/RandomCoderOrg/udroid-app/releases?per_page=20\"", ) + buildConfigField("String", "X11_GUEST_PROBE_FAULT", "\"none\"") ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") @@ -66,6 +72,13 @@ android { } buildTypes { + debug { + buildConfigField( + "String", + "X11_GUEST_PROBE_FAULT", + "\"$x11GuestProbeFault\"", + ) + } release { isMinifyEnabled = true isShrinkResources = true diff --git a/app/src/main/assets/runtime/arm64-v8a/runtime_probe b/app/src/main/assets/runtime/arm64-v8a/runtime_probe index f5e847e..9c3b3b1 100755 Binary files a/app/src/main/assets/runtime/arm64-v8a/runtime_probe and b/app/src/main/assets/runtime/arm64-v8a/runtime_probe differ diff --git a/app/src/main/assets/runtime/armeabi-v7a/runtime_probe b/app/src/main/assets/runtime/armeabi-v7a/runtime_probe index cb2b551..548284b 100755 Binary files a/app/src/main/assets/runtime/armeabi-v7a/runtime_probe and b/app/src/main/assets/runtime/armeabi-v7a/runtime_probe differ diff --git a/app/src/main/assets/runtime/x86_64/runtime_probe b/app/src/main/assets/runtime/x86_64/runtime_probe index bcf3907..1c511d5 100755 Binary files a/app/src/main/assets/runtime/x86_64/runtime_probe and b/app/src/main/assets/runtime/x86_64/runtime_probe differ diff --git a/app/src/main/cpp/runtime_probe.c b/app/src/main/cpp/runtime_probe.c index 4c45310..d4213e0 100644 --- a/app/src/main/cpp/runtime_probe.c +++ b/app/src/main/cpp/runtime_probe.c @@ -1,10 +1,14 @@ #include #include +#include #include #include #include #include +#include +#include #include +#include #include #include @@ -23,7 +27,132 @@ static int64_t monotonic_ms(void) { return ((int64_t)ts.tv_sec * 1000) + (ts.tv_nsec / 1000000); } +static int read_exactly(int fd, uint8_t *buffer, size_t count) { + size_t offset = 0; + while (offset < count) { + ssize_t received = read(fd, buffer + offset, count - offset); + if (received < 0 && errno == EINTR) { + continue; + } + if (received <= 0) { + return -1; + } + offset += (size_t)received; + } + return 0; +} + +static int probe_x11_socket(const char *path, int force_denied, + int abstract_socket, const char *socket_namespace) { + static const uint8_t setup_request[] = { + 0x6c, 0x00, 0x0b, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + }; + uint8_t setup_header[8]; + struct sockaddr_un address; + socklen_t address_size; + struct timeval timeout = {.tv_sec = 1, .tv_usec = 0}; + const int64_t started_ms = monotonic_ms(); + + if (force_denied) { + printf("{\"event\":\"x11_guest_probe\",\"status\":\"connect_failed\"," + "\"socket_namespace\":\"%s\",\"errno\":%d," + "\"address_bytes\":0,\"elapsed_ms\":0," + "\"detail\":\"Permission denied (injected)\"}\n", + socket_namespace, EACCES); + return 20; + } + if (strlen(path) >= sizeof(address.sun_path)) { + printf("{\"event\":\"x11_guest_probe\",\"status\":\"invalid_path\"," + "\"socket_namespace\":\"%s\",\"errno\":%d," + "\"address_bytes\":0,\"elapsed_ms\":%lld," + "\"detail\":\"Socket path is too long\"}\n", + socket_namespace, ENAMETOOLONG, + (long long)(monotonic_ms() - started_ms)); + return 21; + } + + int fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (fd < 0) { + printf("{\"event\":\"x11_guest_probe\",\"status\":\"socket_failed\"," + "\"socket_namespace\":\"%s\",\"errno\":%d," + "\"address_bytes\":0,\"elapsed_ms\":%lld,\"detail\":\"%s\"}\n", + socket_namespace, errno, + (long long)(monotonic_ms() - started_ms), strerror(errno)); + return 22; + } + (void)setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); + (void)setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout)); + + memset(&address, 0, sizeof(address)); + address.sun_family = AF_UNIX; + if (abstract_socket) { + size_t path_length = strlen(path); + address.sun_path[0] = '\0'; + memcpy(address.sun_path + 1, path, path_length); + address_size = (socklen_t)(offsetof(struct sockaddr_un, sun_path) + 1 + path_length); + } else { + memcpy(address.sun_path, path, strlen(path) + 1); + /* libxcb 1.14 passes the complete structure for filesystem sockets. */ + address_size = sizeof(address); + } + if (connect(fd, (struct sockaddr *)&address, address_size) != 0) { + int saved_errno = errno; + printf("{\"event\":\"x11_guest_probe\",\"status\":\"connect_failed\"," + "\"socket_namespace\":\"%s\",\"errno\":%d," + "\"address_bytes\":%u,\"elapsed_ms\":%lld,\"detail\":\"%s\"}\n", + socket_namespace, saved_errno, (unsigned int)address_size, + (long long)(monotonic_ms() - started_ms), strerror(saved_errno)); + close(fd); + return 23; + } + if (write(fd, setup_request, sizeof(setup_request)) != (ssize_t)sizeof(setup_request) || + read_exactly(fd, setup_header, sizeof(setup_header)) != 0) { + int saved_errno = errno; + printf("{\"event\":\"x11_guest_probe\",\"status\":\"handshake_failed\"," + "\"socket_namespace\":\"%s\",\"errno\":%d," + "\"address_bytes\":%u,\"elapsed_ms\":%lld,\"detail\":\"%s\"}\n", + socket_namespace, saved_errno, (unsigned int)address_size, + (long long)(monotonic_ms() - started_ms), + saved_errno == 0 ? "Connection closed" : strerror(saved_errno)); + close(fd); + return 24; + } + close(fd); + + unsigned int protocol_major = + (unsigned int)setup_header[2] | ((unsigned int)setup_header[3] << 8U); + unsigned int protocol_minor = + (unsigned int)setup_header[4] | ((unsigned int)setup_header[5] << 8U); + if (setup_header[0] != 1) { + printf("{\"event\":\"x11_guest_probe\",\"status\":\"rejected\"," + "\"socket_namespace\":\"%s\",\"setup_status\":%u," + "\"protocol_major\":%u,\"protocol_minor\":%u," + "\"address_bytes\":%u,\"elapsed_ms\":%lld}\n", + socket_namespace, (unsigned int)setup_header[0], protocol_major, protocol_minor, + (unsigned int)address_size, (long long)(monotonic_ms() - started_ms)); + return 25; + } + printf("{\"event\":\"x11_guest_probe\",\"status\":\"ready\"," + "\"socket_namespace\":\"%s\",\"protocol_major\":%u," + "\"protocol_minor\":%u,\"address_bytes\":%u,\"elapsed_ms\":%lld}\n", + socket_namespace, protocol_major, protocol_minor, (unsigned int)address_size, + (long long)(monotonic_ms() - started_ms)); + return 0; +} + int main(int argc, char **argv) { + if (argc >= 3 && strcmp(argv[1], "--x11-abstract") == 0) { + setvbuf(stdout, NULL, _IOLBF, 0); + return probe_x11_socket(argv[2], 0, 1, "abstract"); + } + if (argc >= 3 && + (strcmp(argv[1], "--x11") == 0 || strcmp(argv[1], "--x11-deny") == 0)) { + setvbuf(stdout, NULL, _IOLBF, 0); + return probe_x11_socket(argv[2], strcmp(argv[1], "--x11-deny") == 0, + 0, "filesystem"); + } + const char *boot_id = argc > 1 ? argv[1] : "unknown"; const pid_t original_parent = getppid(); diff --git a/app/src/main/java/org/randomcoder/udroid/runtime/NativeProbeInstaller.kt b/app/src/main/java/org/randomcoder/udroid/runtime/NativeProbeInstaller.kt index 2b00b6c..4f2b1d4 100644 --- a/app/src/main/java/org/randomcoder/udroid/runtime/NativeProbeInstaller.kt +++ b/app/src/main/java/org/randomcoder/udroid/runtime/NativeProbeInstaller.kt @@ -6,7 +6,7 @@ import java.io.File import java.io.FileOutputStream object NativeProbeInstaller { - private const val PROBE_VERSION = "1" + private const val PROBE_VERSION = "4" private val supportedAbis = setOf("arm64-v8a", "armeabi-v7a", "x86_64") fun install(context: Context): File { diff --git a/app/src/main/java/org/randomcoder/udroid/runtime/ProotApplicationLaunch.kt b/app/src/main/java/org/randomcoder/udroid/runtime/ProotApplicationLaunch.kt index 7aacf85..ee6c847 100644 --- a/app/src/main/java/org/randomcoder/udroid/runtime/ProotApplicationLaunch.kt +++ b/app/src/main/java/org/randomcoder/udroid/runtime/ProotApplicationLaunch.kt @@ -4,6 +4,7 @@ import android.content.Context import org.randomcoder.udroid.audio.AudioEndpoint import org.randomcoder.udroid.install.ProotRuntime import org.randomcoder.udroid.linuxapps.LinuxApplication +import org.randomcoder.udroid.x11.X11DisplayEndpoint import java.io.File data class ProotApplicationLaunch( @@ -17,12 +18,12 @@ object ProotApplicationLaunchBuilder { context: Context, runtime: ProotRuntime, rootfs: File, - x11SocketDirectory: File, + x11Endpoint: X11DisplayEndpoint, application: LinuxApplication, audioEndpoint: AudioEndpoint? = null, ): ProotApplicationLaunch { require(application.executable.isNotBlank()) { "Application executable is empty" } - require(x11SocketDirectory.isDirectory) { "The X11 socket directory is unavailable" } + require(x11Endpoint.socketDirectory.isDirectory) { "The X11 socket directory is unavailable" } val guestHome = if (File(rootfs, "root").isDirectory) "/root" else "/" val guestWorkingDirectory = application.workingDirectory @@ -32,7 +33,8 @@ object ProotApplicationLaunchBuilder { buildArguments( prootPath = runtime.executable.absolutePath, rootfsPath = ProotPathContract.rootfsPath(context, rootfs), - x11SocketDirectory = x11SocketDirectory.absolutePath, + x11SocketDirectory = x11Endpoint.socketDirectory.absolutePath, + bindX11Socket = x11Endpoint.requiresGuestBind, guestHome = guestHome, guestWorkingDirectory = guestWorkingDirectory, applicationArguments = @@ -69,6 +71,7 @@ object ProotApplicationLaunchBuilder { prootPath: String, rootfsPath: String, x11SocketDirectory: String, + bindX11Socket: Boolean = true, guestHome: String, guestWorkingDirectory: String, applicationArguments: List, @@ -82,8 +85,10 @@ object ProotApplicationLaunchBuilder { add("--root-id") add("--rootfs=$rootfsPath") addAndroidProotBindMounts() - add("-b") - add("$x11SocketDirectory:/tmp/.X11-unix") + if (bindX11Socket) { + add("-b") + add("$x11SocketDirectory:/tmp/.X11-unix") + } if (audioAuthDirectory != null) { add("-b") add("$audioAuthDirectory:${AudioEndpoint.GUEST_AUTH_DIRECTORY}") diff --git a/app/src/main/java/org/randomcoder/udroid/runtime/ProotDesktopLaunch.kt b/app/src/main/java/org/randomcoder/udroid/runtime/ProotDesktopLaunch.kt index b480fd6..745c8a6 100644 --- a/app/src/main/java/org/randomcoder/udroid/runtime/ProotDesktopLaunch.kt +++ b/app/src/main/java/org/randomcoder/udroid/runtime/ProotDesktopLaunch.kt @@ -4,6 +4,7 @@ import android.content.Context import org.randomcoder.udroid.audio.AudioEndpoint import org.randomcoder.udroid.install.ProotRuntime import org.randomcoder.udroid.install.RootfsInstallationPipeline +import org.randomcoder.udroid.x11.X11DisplayEndpoint import java.io.File object ProotDesktopLaunchBuilder { @@ -11,7 +12,7 @@ object ProotDesktopLaunchBuilder { context: Context, runtime: ProotRuntime, rootfs: File, - x11SocketDirectory: File, + x11Endpoint: X11DisplayEndpoint, environment: DesktopEnvironment, configuration: DesktopConfiguration, audioEndpoint: AudioEndpoint? = null, @@ -19,13 +20,14 @@ object ProotDesktopLaunchBuilder { require(File(rootfs, RootfsInstallationPipeline.READY_MARKER).isFile) { "The selected Linux image is not ready" } - require(x11SocketDirectory.isDirectory) { "The X11 socket directory is unavailable" } + require(x11Endpoint.socketDirectory.isDirectory) { "The X11 socket directory is unavailable" } val guestHome = if (File(rootfs, "root").isDirectory) "/root" else "/" val arguments = buildArguments( prootPath = runtime.executable.absolutePath, rootfsPath = ProotPathContract.rootfsPath(context, rootfs), - x11SocketDirectory = x11SocketDirectory.absolutePath, + x11SocketDirectory = x11Endpoint.socketDirectory.absolutePath, + bindX11Socket = x11Endpoint.requiresGuestBind, guestHome = guestHome, environment = environment, configuration = configuration, @@ -64,6 +66,7 @@ object ProotDesktopLaunchBuilder { prootPath: String, rootfsPath: String, x11SocketDirectory: String, + bindX11Socket: Boolean = true, guestHome: String, environment: DesktopEnvironment, configuration: DesktopConfiguration, @@ -77,8 +80,10 @@ object ProotDesktopLaunchBuilder { add("--root-id") add("--rootfs=$rootfsPath") addAndroidProotBindMounts() - add("-b") - add("$x11SocketDirectory:/tmp/.X11-unix") + if (bindX11Socket) { + add("-b") + add("$x11SocketDirectory:/tmp/.X11-unix") + } if (audioAuthDirectory != null) { add("-b") add("$audioAuthDirectory:${AudioEndpoint.GUEST_AUTH_DIRECTORY}") diff --git a/app/src/main/java/org/randomcoder/udroid/runtime/ProotTerminalLaunch.kt b/app/src/main/java/org/randomcoder/udroid/runtime/ProotTerminalLaunch.kt index d03bb93..95d2903 100644 --- a/app/src/main/java/org/randomcoder/udroid/runtime/ProotTerminalLaunch.kt +++ b/app/src/main/java/org/randomcoder/udroid/runtime/ProotTerminalLaunch.kt @@ -4,6 +4,7 @@ import android.content.Context import org.randomcoder.udroid.audio.AudioEndpoint import org.randomcoder.udroid.install.ProotRuntime import org.randomcoder.udroid.install.RootfsInstallationPipeline +import org.randomcoder.udroid.x11.X11DisplayEndpoint import java.io.File import java.nio.file.Files @@ -27,7 +28,7 @@ object ProotTerminalLaunchBuilder { context: Context, runtime: ProotRuntime, rootfs: File = InstalledRootfsResolver.resolve(context), - x11SocketDirectory: File? = null, + x11Endpoint: X11DisplayEndpoint? = null, audioEndpoint: AudioEndpoint? = null, ): ProotTerminalLaunch { require(File(rootfs, RootfsInstallationPipeline.READY_MARKER).isFile) { @@ -57,7 +58,8 @@ object ProotTerminalLaunchBuilder { rootfsPath = ProotPathContract.rootfsPath(context, rootfs), guestHome = guestHome, guestShell = guestShell, - x11SocketDirectory = x11SocketDirectory?.absolutePath, + x11SocketDirectory = x11Endpoint?.socketDirectory?.absolutePath, + bindX11Socket = x11Endpoint?.requiresGuestBind == true, audioAuthDirectory = audioEndpoint?.hostAuthDirectory?.absolutePath, ) val environment = @@ -83,6 +85,7 @@ object ProotTerminalLaunchBuilder { guestHome: String, guestShell: String, x11SocketDirectory: String? = null, + bindX11Socket: Boolean = x11SocketDirectory != null, audioAuthDirectory: String? = null, ): Array = buildList { @@ -94,7 +97,7 @@ object ProotTerminalLaunchBuilder { add("--root-id") add("--rootfs=$rootfsPath") addAndroidProotBindMounts() - if (x11SocketDirectory != null) { + if (x11SocketDirectory != null && bindX11Socket) { add("-b") add("$x11SocketDirectory:/tmp/.X11-unix") } diff --git a/app/src/main/java/org/randomcoder/udroid/runtime/RuntimeSupervisorService.kt b/app/src/main/java/org/randomcoder/udroid/runtime/RuntimeSupervisorService.kt index e8567d5..ee4a834 100644 --- a/app/src/main/java/org/randomcoder/udroid/runtime/RuntimeSupervisorService.kt +++ b/app/src/main/java/org/randomcoder/udroid/runtime/RuntimeSupervisorService.kt @@ -18,6 +18,7 @@ import android.os.Handler import android.os.IBinder import android.os.Looper import android.os.ParcelFileDescriptor +import android.os.SystemClock import android.system.ErrnoException import android.system.Os import android.system.OsConstants @@ -38,6 +39,8 @@ import org.randomcoder.udroid.audio.AudioSessionSnapshot import org.randomcoder.udroid.install.ProotRuntimeInstaller import org.randomcoder.udroid.linuxapps.LinuxApplication import org.randomcoder.udroid.x11.X11ServerController +import org.randomcoder.udroid.x11.GuestX11TransportProbe +import org.randomcoder.udroid.x11.GuestX11TransportResult import java.io.BufferedReader import java.io.File import java.io.InputStreamReader @@ -57,6 +60,7 @@ class RuntimeSupervisorService : Service() { private val pendingDesktopRestart = AtomicReference(null) private val attachedViews = CopyOnWriteArraySet() private val applicationProcesses = ConcurrentHashMap() + private val guestX11TransportProbe by lazy { GuestX11TransportProbe(this) } private val applicationExecutor = Executors.newCachedThreadPool() private val x11Controller by lazy { X11ServerController(this, app.journal) } private val audioConfigurationStore by lazy { AudioConfigurationStore(this) } @@ -439,8 +443,8 @@ class RuntimeSupervisorService : Service() { return } - x11Controller.whenReady { socketDirectory -> - if (socketDirectory == null) { + x11Controller.whenReady { x11Endpoint -> + if (x11Endpoint == null) { callback(Result.failure(IllegalStateException("Embedded X11 failed to start"))) return@whenReady } @@ -453,7 +457,7 @@ class RuntimeSupervisorService : Service() { context = this, runtime = ProotRuntimeInstaller.install(this), rootfs = rootfs, - x11SocketDirectory = socketDirectory, + x11Endpoint = x11Endpoint, application = application, audioEndpoint = audioController.endpoint(), ) @@ -574,9 +578,10 @@ class RuntimeSupervisorService : Service() { "touch_scale" to request.configuration.touchScaleEnabled, ), ) - x11Controller.whenReady { socketDirectory -> + val desktopRequestedAtMs = SystemClock.elapsedRealtime() + x11Controller.whenReady { x11Endpoint -> if (desktopLaunchToken.get() != launchToken) return@whenReady - if (socketDirectory == null) { + if (x11Endpoint == null) { if (desktopLaunchToken.compareAndSet(launchToken, null)) { publishDesktopFailure(request, "Embedded X11 display :0 is unavailable") } @@ -586,12 +591,131 @@ class RuntimeSupervisorService : Service() { runCatching { if (desktopLaunchToken.get() != launchToken) return@runCatching null val rootfs = InstalledRootfsResolver.resolve(this, request.rootfsName) + val prootRuntime = ProotRuntimeInstaller.install(this) + val probeReport = + guestX11TransportProbe.query( + runtime = prootRuntime, + rootfs = rootfs, + endpoint = x11Endpoint, + ) + listOf( + "abstract" to probeReport.abstractSocket, + "filesystem" to probeReport.filesystemSocket, + ).forEach { (socketNamespace, result) -> + val resultFields = + mutableMapOf( + "rootfs" to request.rootfsName, + "display" to DISPLAY_NUMBER, + "endpoint_transport" to x11Endpoint.transport.journalValue, + "socket_namespace" to socketNamespace, + "guest_socket" to "/tmp/.X11-unix/X0", + ) + val message = + when (result) { + is GuestX11TransportResult.Ready -> { + resultFields["status"] = "ready" + resultFields["protocol_major"] = result.protocolMajor + resultFields["protocol_minor"] = result.protocolMinor + resultFields["address_bytes"] = result.addressBytes + resultFields["elapsed_ms"] = result.elapsedMs + "Guest $socketNamespace X11 probe completed protocol " + + "${result.protocolMajor}.${result.protocolMinor} setup" + } + + is GuestX11TransportResult.Failed -> { + resultFields["status"] = result.stage + resultFields["errno"] = result.errno + resultFields["detail"] = result.detail + resultFields["address_bytes"] = result.addressBytes + resultFields["elapsed_ms"] = result.elapsedMs + "Guest $socketNamespace X11 probe ${result.stage}: ${result.detail}" + } + } + app.journal.append( + component = "x11", + severity = + if ( + socketNamespace == "filesystem" && + result is GuestX11TransportResult.Failed + ) { + "error" + } else { + "info" + }, + event = "guest_transport_probe", + message = message, + bootId = runtime.bootId, + fields = resultFields, + ) + } + val clientProbe = probeReport.client + app.journal.append( + component = "x11", + severity = if (clientProbe.status == "ready") "info" else "warning", + event = "guest_xrdb_probe", + message = + when (clientProbe.status) { + "ready" -> "Guest xrdb connected through the distro X11 libraries" + "unavailable" -> "Guest xrdb is unavailable" + "timeout" -> "Guest xrdb connection timed out" + else -> "Guest xrdb connection failed: ${clientProbe.output.ifBlank { clientProbe.status }}" + }, + bootId = runtime.bootId, + fields = + buildMap { + put("rootfs", request.rootfsName) + put("display", DISPLAY_NUMBER) + put("endpoint_transport", x11Endpoint.transport.journalValue) + put("status", clientProbe.status) + put("probe_process_exit", clientProbe.exitCode) + put("elapsed_ms", clientProbe.elapsedMs) + clientProbe.fields.forEach { (key, value) -> put(key, value) } + }, + ) + when (val probe = probeReport.filesystemSocket) { + is GuestX11TransportResult.Ready -> + app.journal.append( + component = "x11", + severity = "info", + event = "guest_transport_ready", + message = + "PRoot guest completed X11 protocol " + + "${probe.protocolMajor}.${probe.protocolMinor} setup", + bootId = runtime.bootId, + fields = + mapOf( + "rootfs" to request.rootfsName, + "display" to DISPLAY_NUMBER, + "transport" to x11Endpoint.transport.journalValue, + ), + ) + + is GuestX11TransportResult.Failed -> { + app.journal.append( + component = "x11", + severity = "error", + event = "guest_transport_failed", + message = probe.userMessage, + bootId = runtime.bootId, + fields = + mapOf( + "rootfs" to request.rootfsName, + "display" to DISPLAY_NUMBER, + "transport" to x11Endpoint.transport.journalValue, + "stage" to probe.stage, + "errno" to probe.errno, + ), + ) + error(probe.userMessage) + } + } + if (desktopLaunchToken.get() != launchToken) return@runCatching null val launch = ProotDesktopLaunchBuilder.create( context = this, - runtime = ProotRuntimeInstaller.install(this), + runtime = prootRuntime, rootfs = rootfs, - x11SocketDirectory = socketDirectory, + x11Endpoint = x11Endpoint, environment = request.environment, configuration = request.configuration, audioEndpoint = audioController.endpoint(), @@ -666,6 +790,7 @@ class RuntimeSupervisorService : Service() { mapOf( "rootfs" to owned.rootfsName, "display" to DISPLAY_NUMBER, + "launch_ms" to SystemClock.elapsedRealtime() - desktopRequestedAtMs, ), ) monitorDesktop(owned) @@ -973,12 +1098,12 @@ class RuntimeSupervisorService : Service() { fields = mapOf("exception" to error.javaClass.name), ) } - val x11SocketDirectory = x11Controller.start(rootfs, bootId) + val x11Endpoint = x11Controller.start(rootfs, bootId) ProotTerminalLaunchBuilder.create( context = this, runtime = runtime, rootfs = rootfs, - x11SocketDirectory = x11SocketDirectory, + x11Endpoint = x11Endpoint, audioEndpoint = audioController.endpoint(), ) }.mapCatching { launch -> diff --git a/app/src/main/java/org/randomcoder/udroid/x11/GuestX11TransportProbe.kt b/app/src/main/java/org/randomcoder/udroid/x11/GuestX11TransportProbe.kt new file mode 100644 index 0000000..f15f785 --- /dev/null +++ b/app/src/main/java/org/randomcoder/udroid/x11/GuestX11TransportProbe.kt @@ -0,0 +1,408 @@ +package org.randomcoder.udroid.x11 + +import android.content.Context +import android.os.SystemClock +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.randomcoder.udroid.BuildConfig +import org.randomcoder.udroid.install.ProotRuntime +import org.randomcoder.udroid.runtime.ANDROID_PROOT_BIND_MOUNTS +import org.randomcoder.udroid.runtime.AndroidExecutableCommand +import org.randomcoder.udroid.runtime.NativeProbeInstaller +import org.randomcoder.udroid.runtime.ProotPathContract +import org.randomcoder.udroid.runtime.ProotTerminalLaunchBuilder +import java.io.File +import java.util.concurrent.TimeUnit + +private const val EACCES = 13 + +internal sealed interface GuestX11TransportResult { + data class Ready( + val protocolMajor: Int, + val protocolMinor: Int, + val addressBytes: Int? = null, + val elapsedMs: Long? = null, + ) : GuestX11TransportResult + + data class Failed( + val stage: String, + val errno: Int?, + val detail: String, + val addressBytes: Int? = null, + val elapsedMs: Long? = null, + ) : GuestX11TransportResult { + val userMessage: String + get() = + if (errno == EACCES) { + "X11 guest transport denied: $detail" + } else { + "X11 guest transport failed: $detail" + } + } +} + +internal data class GuestX11ProbeReport( + val abstractSocket: GuestX11TransportResult, + val filesystemSocket: GuestX11TransportResult, + val client: GuestX11ClientProbeResult, +) + +internal data class GuestX11ClientProbeResult( + val status: String, + val exitCode: Int?, + val elapsedMs: Long, + val fields: Map, + val output: String, +) + +internal enum class GuestX11SocketNamespace { + ABSTRACT, + FILESYSTEM, +} + +/** Verifies the display socket through the same PRoot alias used by Linux clients. */ +internal class GuestX11TransportProbe(private val context: Context) { + fun query( + runtime: ProotRuntime, + rootfs: File, + endpoint: X11DisplayEndpoint, + ): GuestX11ProbeReport { + val nativeProbe = NativeProbeInstaller.install(context) + val temporaryDirectory = + File(context.cacheDir, "proot").apply { + check(mkdirs() || isDirectory) { "Could not prepare PRoot temporary storage" } + } + val environment = + ProotTerminalLaunchBuilder + .buildEnvironment( + androidHome = context.filesDir.absolutePath, + loaderPath = runtime.loader.absolutePath, + temporaryDirectory = temporaryDirectory.absolutePath, + ).associate { value -> + val separator = value.indexOf('=') + value.substring(0, separator) to value.substring(separator + 1) + } + fun startProbe(namespace: GuestX11SocketNamespace): Process { + val arguments = + GuestX11ProbeCommand.buildArguments( + prootPath = runtime.executable.absolutePath, + rootfsPath = ProotPathContract.rootfsPath(context, rootfs), + socketDirectory = endpoint.socketDirectory.absolutePath, + bindSocket = endpoint.requiresGuestBind, + nativeProbe = nativeProbe.absolutePath, + forceDenied = + BuildConfig.X11_GUEST_PROBE_FAULT == "deny" && + namespace == GuestX11SocketNamespace.FILESYSTEM, + socketNamespace = namespace, + ) + val command = + AndroidExecutableCommand.create( + runtime.executable, + *arguments.drop(1).toTypedArray(), + ) + return ProcessBuilder(command) + .directory(context.filesDir) + .redirectErrorStream(true) + .apply { + environment().clear() + environment().putAll(environment) + }.start() + } + fun awaitProbe( + process: Process, + namespace: GuestX11SocketNamespace, + ): GuestX11TransportResult { + if (!process.waitFor(PROBE_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + process.destroyForcibly() + return GuestX11TransportResult.Failed( + stage = "timeout", + errno = null, + detail = "guest handshake timed out", + ) + } + val output = process.inputStream.bufferedReader().use { it.readText() } + return GuestX11ProbeOutput.parse(output, namespace) + } + + // Start both at once so a blocked abstract connect cannot delay or mask the + // filesystem transport that actually gates desktop startup. + val abstractProcess = startProbe(GuestX11SocketNamespace.ABSTRACT) + val filesystemProcess = startProbe(GuestX11SocketNamespace.FILESYSTEM) + return GuestX11ProbeReport( + abstractSocket = + awaitProbe( + abstractProcess, + GuestX11SocketNamespace.ABSTRACT, + ), + filesystemSocket = + awaitProbe( + filesystemProcess, + GuestX11SocketNamespace.FILESYSTEM, + ), + client = queryGuestClient(runtime, rootfs, endpoint, environment), + ) + } + + private fun queryGuestClient( + runtime: ProotRuntime, + rootfs: File, + endpoint: X11DisplayEndpoint, + hostEnvironment: Map, + ): GuestX11ClientProbeResult { + val guestHome = if (File(rootfs, "root").isDirectory) "/root" else "/" + val arguments = + GuestX11ClientProbeCommand.buildArguments( + prootPath = runtime.executable.absolutePath, + rootfsPath = ProotPathContract.rootfsPath(context, rootfs), + socketDirectory = endpoint.socketDirectory.absolutePath, + bindSocket = endpoint.requiresGuestBind, + guestHome = guestHome, + ) + val command = + AndroidExecutableCommand.create( + runtime.executable, + *arguments.drop(1).toTypedArray(), + ) + val startedMs = SystemClock.elapsedRealtime() + val process = + ProcessBuilder(command) + .directory(context.filesDir) + .redirectErrorStream(true) + .apply { + environment().clear() + environment().putAll(hostEnvironment) + }.start() + if (!process.waitFor(CLIENT_PROBE_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + process.destroyForcibly() + return GuestX11ClientProbeResult( + status = "timeout", + exitCode = null, + elapsedMs = SystemClock.elapsedRealtime() - startedMs, + fields = emptyMap(), + output = "guest xrdb probe timed out", + ) + } + val output = process.inputStream.bufferedReader().use { it.readText() } + val fields = GuestX11ClientProbeOutput.parseFields(output) + val boundedOutput = + fields["xrdb_output"] + ?: output + .lineSequence() + .joinToString(" ") + .trim() + .take(CLIENT_PROBE_OUTPUT_CHARS) + return GuestX11ClientProbeResult( + status = fields["xrdb_status"] ?: "probe_output_missing", + exitCode = process.exitValue(), + elapsedMs = SystemClock.elapsedRealtime() - startedMs, + fields = fields, + output = boundedOutput, + ) + } + + private companion object { + const val PROBE_TIMEOUT_SECONDS = 3L + const val CLIENT_PROBE_TIMEOUT_SECONDS = 8L + const val CLIENT_PROBE_OUTPUT_CHARS = 1_024 + } +} + +internal object GuestX11ClientProbeCommand { + private const val RECORD_PREFIX = "UDROID_X11|" + + fun buildArguments( + prootPath: String, + rootfsPath: String, + socketDirectory: String, + bindSocket: Boolean, + guestHome: String, + androidBindMounts: List = ANDROID_PROOT_BIND_MOUNTS, + ): List = + buildList { + add(prootPath) + add("--link2symlink") + add("--kill-on-exit") + add("--root-id") + add("--rootfs=$rootfsPath") + androidBindMounts.forEach { path -> + add("-b") + add(path) + } + if (bindSocket) { + add("-b") + add("$socketDirectory:/tmp/.X11-unix") + } + add("--cwd=/") + add("/usr/bin/env") + add("-i") + add("HOME=$guestHome") + add("USER=root") + add("LOGNAME=root") + add("SHELL=/bin/sh") + add("LANG=C.UTF-8") + add("PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin") + add("DISPLAY=:0") + add("/bin/sh") + add("-c") + add(probeScript()) + } + + private fun probeScript(): String = + """ + out=/tmp/.udroid-xrdb-probe.${'$'}${'$'} + trap 'rm -f "${'$'}out" "${'$'}out".*' EXIT HUP INT TERM + record() { printf '${RECORD_PREFIX}%s=%s\n' "${'$'}1" "${'$'}2"; } + record client_display "${'$'}DISPLAY" + record selinux_context "${'$'}(cat /proc/self/attr/current 2>/dev/null | tr '\n\r' ' ' | head -c 192)" + record selinux_enforcing "${'$'}(cat /sys/fs/selinux/enforce 2>/dev/null | head -c 8)" + record mount_namespace "${'$'}(readlink /proc/self/ns/mnt 2>/dev/null | head -c 96)" + record process_security "${'$'}(grep -E '^(NoNewPrivs|Seccomp):' /proc/self/status 2>/dev/null | tr '\n\r' ',,' | head -c 96)" + record socket_stat "${'$'}(stat -Lc '%F mode=%a uid=%u gid=%g size=%s' /tmp/.X11-unix/X0 2>&1 | tr '\n\r' ' ' | head -c 256)" + record socket_directory_stat "${'$'}(stat -Lc '%F mode=%a uid=%u gid=%g' /tmp/.X11-unix 2>&1 | tr '\n\r' ' ' | head -c 256)" + record xrdb_path "${'$'}(command -v xrdb 2>/dev/null | head -c 256)" + if command -v dpkg-query >/dev/null 2>&1; then + record libxcb_package "${'$'}(dpkg-query -W libxcb1 2>&1 | tr '\n\r\t' ' ' | head -c 256)" + elif command -v apk >/dev/null 2>&1; then + record libxcb_package "${'$'}(apk info -v libxcb 2>&1 | tr '\n\r' ' ' | head -c 256)" + else + record libxcb_package unavailable + fi + if command -v xrdb >/dev/null 2>&1; then + run_xrdb() { + label="${'$'}1" + target="${'$'}2" + probe_out="${'$'}out.${'$'}label" + if command -v timeout >/dev/null 2>&1; then + timeout 2 xrdb -display "${'$'}target" -query >"${'$'}probe_out" 2>&1 + else + xrdb -display "${'$'}target" -query >"${'$'}probe_out" 2>&1 + fi + rc=${'$'}? + record "${'$'}{label}_display" "${'$'}target" + record "${'$'}{label}_exit" "${'$'}rc" + if [ "${'$'}rc" -eq 0 ]; then + record "${'$'}{label}_status" ready + record "${'$'}{label}_output" '' + else + record "${'$'}{label}_status" failed + record "${'$'}{label}_output" "${'$'}(tr '\n\r' ' ' <"${'$'}probe_out" | head -c 512)" + fi + } + run_xrdb xrdb ':0' + run_xrdb xrdb_unix 'unix/:0' + run_xrdb xrdb_path '/tmp/.X11-unix/X0' + else + record xrdb_exit 127 + record xrdb_status unavailable + record xrdb_output 'xrdb is not installed' + fi + """.trimIndent() +} + +internal object GuestX11ClientProbeOutput { + private const val RECORD_PREFIX = "UDROID_X11|" + + fun parseFields(output: String): Map = + output + .lineSequence() + .filter { it.startsWith(RECORD_PREFIX) } + .mapNotNull { line -> + val value = line.removePrefix(RECORD_PREFIX) + val separator = value.indexOf('=') + if (separator <= 0) null else value.substring(0, separator) to value.substring(separator + 1) + }.toMap() +} + +internal object GuestX11ProbeCommand { + const val GUEST_SOCKET = "/tmp/.X11-unix/X0" + const val GUEST_PROBE = "/tmp/.udroid-x11-probe" + + fun buildArguments( + prootPath: String, + rootfsPath: String, + socketDirectory: String, + bindSocket: Boolean, + nativeProbe: String, + forceDenied: Boolean, + socketNamespace: GuestX11SocketNamespace = GuestX11SocketNamespace.FILESYSTEM, + androidBindMounts: List = ANDROID_PROOT_BIND_MOUNTS, + systemLinkerPath: String = AndroidExecutableCommand.systemLinkerPath(), + ): List = + buildList { + add(prootPath) + add("--link2symlink") + add("--kill-on-exit") + add("--root-id") + add("--rootfs=$rootfsPath") + androidBindMounts.forEach { path -> + add("-b") + add(path) + } + if (bindSocket) { + add("-b") + add("$socketDirectory:/tmp/.X11-unix") + } + add("-b") + add("$nativeProbe:$GUEST_PROBE") + add("--cwd=/") + add(systemLinkerPath) + add(GUEST_PROBE) + add( + when { + forceDenied -> "--x11-deny" + socketNamespace == GuestX11SocketNamespace.ABSTRACT -> "--x11-abstract" + else -> "--x11" + }, + ) + add(GUEST_SOCKET) + } +} + +internal object GuestX11ProbeOutput { + fun parse( + output: String, + namespace: GuestX11SocketNamespace, + ): GuestX11TransportResult { + val namespaceValue = namespace.name.lowercase() + val record = + output + .lineSequence() + .map(String::trim) + .filter(String::isNotEmpty) + .mapNotNull { line -> + runCatching { Json.parseToJsonElement(line).jsonObject }.getOrNull() + }.firstOrNull { value -> + value["event"]?.jsonPrimitive?.content == "x11_guest_probe" && + value["socket_namespace"]?.jsonPrimitive?.content == namespaceValue + } + val fallbackDetail = output.trim().takeIf(String::isNotEmpty) ?: "probe returned no result" + if (record == null) { + return GuestX11TransportResult.Failed( + stage = "probe_output", + errno = null, + detail = "$namespaceValue probe returned no result: $fallbackDetail", + ) + } + return parseRecord(record) + } + + private fun parseRecord(record: kotlinx.serialization.json.JsonObject): GuestX11TransportResult { + val status = record["status"]?.jsonPrimitive?.content.orEmpty() + if (status == "ready") { + return GuestX11TransportResult.Ready( + protocolMajor = record["protocol_major"]?.jsonPrimitive?.content?.toIntOrNull() ?: 0, + protocolMinor = record["protocol_minor"]?.jsonPrimitive?.content?.toIntOrNull() ?: 0, + addressBytes = record["address_bytes"]?.jsonPrimitive?.content?.toIntOrNull(), + elapsedMs = record["elapsed_ms"]?.jsonPrimitive?.content?.toLongOrNull(), + ) + } + return GuestX11TransportResult.Failed( + stage = status.ifBlank { "unknown" }, + errno = record["errno"]?.jsonPrimitive?.content?.toIntOrNull(), + detail = record["detail"]?.jsonPrimitive?.content ?: status.ifBlank { "unknown failure" }, + addressBytes = record["address_bytes"]?.jsonPrimitive?.content?.toIntOrNull(), + elapsedMs = record["elapsed_ms"]?.jsonPrimitive?.content?.toLongOrNull(), + ) + } +} diff --git a/app/src/main/java/org/randomcoder/udroid/x11/X11DisplayEndpoint.kt b/app/src/main/java/org/randomcoder/udroid/x11/X11DisplayEndpoint.kt new file mode 100644 index 0000000..7d322f9 --- /dev/null +++ b/app/src/main/java/org/randomcoder/udroid/x11/X11DisplayEndpoint.kt @@ -0,0 +1,17 @@ +package org.randomcoder.udroid.x11 + +import java.io.File + +enum class X11GuestTransport(val journalValue: String) { + DIRECT_ROOTFS("direct_rootfs"), + PRIVATE_BIND("private_bind"), +} + +data class X11DisplayEndpoint( + val runtimeDirectory: File, + val socketDirectory: File, + val transport: X11GuestTransport, +) { + val requiresGuestBind: Boolean + get() = transport == X11GuestTransport.PRIVATE_BIND +} diff --git a/app/src/main/java/org/randomcoder/udroid/x11/X11ServerController.kt b/app/src/main/java/org/randomcoder/udroid/x11/X11ServerController.kt index 4fbb394..689728d 100644 --- a/app/src/main/java/org/randomcoder/udroid/x11/X11ServerController.kt +++ b/app/src/main/java/org/randomcoder/udroid/x11/X11ServerController.kt @@ -11,6 +11,7 @@ import android.os.Looper import android.os.Message import android.os.Messenger import android.os.ParcelFileDescriptor +import android.system.Os import org.randomcoder.udroid.runtime.EventJournal import java.io.File @@ -33,7 +34,7 @@ class X11ServerController( private var bound = false private var pendingStart: StartRequest? = null private var rendererCallback: ((ParcelFileDescriptor?) -> Unit)? = null - private val readyCallbacks = mutableListOf<(File?) -> Unit>() + private val readyCallbacks = mutableListOf<(X11DisplayEndpoint?) -> Unit>() private var rendererRequestInFlight = false private var ready = false @@ -71,14 +72,10 @@ class X11ServerController( fun start( rootfs: File, bootId: String, - ): File { - val runtimeDirectory = X11RuntimePaths.runtimeDirectory(context) - val socketDirectory = X11RuntimePaths.socketDirectory(context) - check(socketDirectory.mkdirs() || socketDirectory.isDirectory) { - "Could not prepare the private X11 socket directory" - } + ): X11DisplayEndpoint { + val endpoint = selectEndpoint(rootfs, bootId) val xkbRoot = File(rootfs, "usr/share/X11/xkb") - val request = StartRequest(runtimeDirectory, xkbRoot, bootId) + val request = StartRequest(endpoint, xkbRoot, bootId) pendingStart = request journal.append( @@ -90,7 +87,8 @@ class X11ServerController( fields = mapOf( "protocol_version" to X11ServerProtocol.VERSION, - "socket" to X11RuntimePaths.displaySocket(context).absolutePath, + "socket" to File(endpoint.socketDirectory, "X0").absolutePath, + "transport" to endpoint.transport.journalValue, ), ) @@ -105,7 +103,7 @@ class X11ServerController( ) check(bound) { "Android rejected the embedded X11 service binding" } } - return socketDirectory + return endpoint } fun stop(bootId: String?) { @@ -141,16 +139,16 @@ class X11ServerController( if (ready && !rendererRequestInFlight) sendRendererRequest() } - fun activeSocketDirectory(): File? = + fun activeEndpoint(): X11DisplayEndpoint? = pendingStart ?.takeIf { ready } - ?.let { X11RuntimePaths.socketDirectory(context) } - ?.takeIf(File::isDirectory) + ?.endpoint + ?.takeIf { it.socketDirectory.isDirectory } - fun whenReady(callback: (File?) -> Unit) { - val socketDirectory = activeSocketDirectory() - if (socketDirectory != null) { - callback(socketDirectory) + fun whenReady(callback: (X11DisplayEndpoint?) -> Unit) { + val endpoint = activeEndpoint() + if (endpoint != null) { + callback(endpoint) } else { readyCallbacks += callback } @@ -167,7 +165,7 @@ class X11ServerController( Bundle().apply { putString( X11ServerProtocol.KEY_RUNTIME_DIRECTORY, - request.runtimeDirectory.absolutePath, + request.endpoint.runtimeDirectory.absolutePath, ) putString( X11ServerProtocol.KEY_XKB_ROOT, @@ -197,6 +195,9 @@ class X11ServerController( buildMap { put("protocol_version", X11ServerProtocol.VERSION) put("server_pid", data.getInt(X11ServerProtocol.KEY_PID)) + pendingStart?.endpoint?.transport?.journalValue?.let { + put("transport", it) + } data.getString(X11ServerProtocol.KEY_SOCKET_PATH)?.let { put("socket", it) } @@ -217,9 +218,9 @@ class X11ServerController( ) if (state == X11ServerProtocol.STATE_READY) { ready = true - val socketDirectory = activeSocketDirectory() + val endpoint = activeEndpoint() readyCallbacks.toList().also(readyCallbacks::removeAll).forEach { - it(socketDirectory) + it(endpoint) } if (rendererCallback != null && !rendererRequestInFlight) { sendRendererRequest() @@ -284,9 +285,71 @@ class X11ServerController( ) } + private fun selectEndpoint( + rootfs: File, + bootId: String, + ): X11DisplayEndpoint = + runCatching { directRootfsEndpoint(rootfs) } + .getOrElse { error -> + journal.append( + component = "x11", + severity = "warning", + event = "direct_transport_unavailable", + message = error.message ?: "Could not prepare direct rootfs X11 transport", + bootId = bootId, + fields = mapOf("fallback" to X11GuestTransport.PRIVATE_BIND.journalValue), + ) + privateBindEndpoint() + } + + private fun directRootfsEndpoint(rootfs: File): X11DisplayEndpoint { + val root = rootfs.canonicalFile + val runtime = File(root, "tmp") + check(runtime.mkdirs() || runtime.isDirectory) { + "Could not prepare the rootfs X11 runtime directory" + } + val canonicalRuntime = runtime.canonicalFile + check(canonicalRuntime.toPath().startsWith(root.toPath())) { + "The rootfs X11 runtime directory escaped the installed image" + } + Os.chmod(canonicalRuntime.absolutePath, ROOTFS_TMP_MODE) + val socketDirectory = File(canonicalRuntime, ".X11-unix") + check(socketDirectory.mkdirs() || socketDirectory.isDirectory) { + "Could not prepare the rootfs X11 socket directory" + } + val canonicalSocketDirectory = socketDirectory.canonicalFile + check(canonicalSocketDirectory.toPath().startsWith(root.toPath())) { + "The rootfs X11 socket directory escaped the installed image" + } + // PRoot can leave a mode-000 bind target behind after unmounting it. + Os.chmod(canonicalSocketDirectory.absolutePath, ROOTFS_TMP_MODE) + return X11DisplayEndpoint( + runtimeDirectory = canonicalRuntime, + socketDirectory = canonicalSocketDirectory, + transport = X11GuestTransport.DIRECT_ROOTFS, + ) + } + + private fun privateBindEndpoint(): X11DisplayEndpoint { + val runtime = X11RuntimePaths.runtimeDirectory(context) + val socketDirectory = X11RuntimePaths.socketDirectory(context) + check(socketDirectory.mkdirs() || socketDirectory.isDirectory) { + "Could not prepare the private X11 socket directory" + } + return X11DisplayEndpoint( + runtimeDirectory = runtime, + socketDirectory = socketDirectory, + transport = X11GuestTransport.PRIVATE_BIND, + ) + } + private data class StartRequest( - val runtimeDirectory: File, + val endpoint: X11DisplayEndpoint, val xkbRoot: File, val bootId: String, ) + + private companion object { + const val ROOTFS_TMP_MODE = 0x3ff + } } diff --git a/app/src/main/java/org/randomcoder/udroid/x11/X11ServerService.kt b/app/src/main/java/org/randomcoder/udroid/x11/X11ServerService.kt index a692938..36f184e 100644 --- a/app/src/main/java/org/randomcoder/udroid/x11/X11ServerService.kt +++ b/app/src/main/java/org/randomcoder/udroid/x11/X11ServerService.kt @@ -22,6 +22,8 @@ class X11ServerService : Service() { } private val startAccepted = AtomicBoolean(false) private val serverReady = AtomicBoolean(false) + @Volatile + private var activeSocket: File? = null private val handler by lazy { Handler(mainLooper) { message -> when (message.what) { @@ -61,7 +63,7 @@ class X11ServerService : Service() { return } if (!startAccepted.compareAndSet(false, true)) { - val socket = X11RuntimePaths.displaySocket(this) + val socket = activeSocket ?: X11RuntimePaths.displaySocket(this) val probe = X11ProtocolProbe.query(socket) reply( client, @@ -88,6 +90,7 @@ class X11ServerService : Service() { }.let(::File) prepareRuntime(runtimeDirectory, xkbRoot) val socket = File(runtimeDirectory, ".X11-unix/X0") + activeSocket = socket reply( client, X11ServerProtocol.STATE_STARTING, @@ -132,7 +135,7 @@ class X11ServerService : Service() { } val socketDirectory = File(runtime, ".X11-unix") check(socketDirectory.mkdirs() || socketDirectory.isDirectory) { - "Could not create the private X11 socket directory" + "Could not create the X11 socket directory" } val staleSocket = File(socketDirectory, "X0") check(!staleSocket.exists() || staleSocket.delete()) { diff --git a/app/src/test/java/org/randomcoder/udroid/runtime/DesktopEnvironmentScannerTest.kt b/app/src/test/java/org/randomcoder/udroid/runtime/DesktopEnvironmentScannerTest.kt index 6e25bb4..445f20f 100644 --- a/app/src/test/java/org/randomcoder/udroid/runtime/DesktopEnvironmentScannerTest.kt +++ b/app/src/test/java/org/randomcoder/udroid/runtime/DesktopEnvironmentScannerTest.kt @@ -70,6 +70,7 @@ class DesktopEnvironmentScannerTest { prootPath = "/data/proot", rootfsPath = "/data/rootfs", x11SocketDirectory = "/data/x11", + bindX11Socket = false, guestHome = "/root", environment = desktop, configuration = @@ -87,6 +88,7 @@ class DesktopEnvironmentScannerTest { assertTrue(arguments.contains("PULSE_SERVER=tcp:127.0.0.1:4713")) assertTrue(arguments.contains("PULSE_COOKIE=/tmp/.udroid-pulse/cookie")) assertTrue(arguments.contains("GDK_SCALE=2")) + assertFalse(arguments.any { "/data/x11" in it }) val script = arguments[arguments.indexOf("-lc") + 1] assertTrue(script.contains("-s false")) assertTrue(script.contains("sleep 1")) diff --git a/app/src/test/java/org/randomcoder/udroid/runtime/ProotApplicationLaunchTest.kt b/app/src/test/java/org/randomcoder/udroid/runtime/ProotApplicationLaunchTest.kt index c2a53a8..797db3c 100644 --- a/app/src/test/java/org/randomcoder/udroid/runtime/ProotApplicationLaunchTest.kt +++ b/app/src/test/java/org/randomcoder/udroid/runtime/ProotApplicationLaunchTest.kt @@ -38,4 +38,22 @@ class ProotApplicationLaunchTest { assertFalse(arguments.contains("sh")) assertFalse(arguments.contains("-c")) } + + @Test + fun `direct rootfs display does not add an X11 bind alias`() { + val socketDirectory = "/data/rootfs/tmp/.X11-unix" + val arguments = + ProotApplicationLaunchBuilder.buildArguments( + prootPath = "/data/proot", + rootfsPath = "/data/rootfs", + x11SocketDirectory = socketDirectory, + bindX11Socket = false, + guestHome = "/root", + guestWorkingDirectory = "/root", + applicationArguments = listOf("/usr/bin/demo"), + ) + + assertTrue("DISPLAY=:0" in arguments) + assertTrue(arguments.none { socketDirectory in it }) + } } diff --git a/app/src/test/java/org/randomcoder/udroid/runtime/ProotTerminalLaunchTest.kt b/app/src/test/java/org/randomcoder/udroid/runtime/ProotTerminalLaunchTest.kt index a291d2c..e6b40a2 100644 --- a/app/src/test/java/org/randomcoder/udroid/runtime/ProotTerminalLaunchTest.kt +++ b/app/src/test/java/org/randomcoder/udroid/runtime/ProotTerminalLaunchTest.kt @@ -101,6 +101,24 @@ class ProotTerminalLaunchTest { assertTrue("DISPLAY=:0" in arguments) } + @Test + fun `direct rootfs X11 exports display without a bind alias`() { + val socketDirectory = "/data/rootfs/tmp/.X11-unix" + val arguments = + ProotTerminalLaunchBuilder.buildArguments( + linker = "linker64", + prootPath = "proot", + rootfsPath = "/data/rootfs", + guestHome = "/root", + guestShell = "/bin/sh", + x11SocketDirectory = socketDirectory, + bindX11Socket = false, + ) + + assertTrue("DISPLAY=:0" in arguments) + assertTrue(arguments.none { socketDirectory in it }) + } + @Test fun `authenticated loopback audio is exported to the guest`() { val arguments = diff --git a/app/src/test/java/org/randomcoder/udroid/x11/GuestX11TransportProbeTest.kt b/app/src/test/java/org/randomcoder/udroid/x11/GuestX11TransportProbeTest.kt new file mode 100644 index 0000000..8c79ad5 --- /dev/null +++ b/app/src/test/java/org/randomcoder/udroid/x11/GuestX11TransportProbeTest.kt @@ -0,0 +1,136 @@ +package org.randomcoder.udroid.x11 + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class GuestX11TransportProbeTest { + @Test + fun `ready output preserves negotiated protocol`() { + val result = + GuestX11ProbeOutput.parse( + """ + {"event":"x11_guest_probe","status":"ready","socket_namespace":"filesystem","protocol_major":11,"protocol_minor":0,"address_bytes":110,"elapsed_ms":7} + """.trimIndent(), + GuestX11SocketNamespace.FILESYSTEM, + ) + + assertEquals(GuestX11TransportResult.Ready(11, 0, 110, 7), result) + } + + @Test + fun `permission denial becomes a transport-specific error`() { + val result = + GuestX11ProbeOutput.parse( + """ + {"event":"x11_guest_probe","status":"connect_failed","socket_namespace":"abstract","errno":13,"detail":"Permission denied"} + """.trimIndent(), + GuestX11SocketNamespace.ABSTRACT, + ) as GuestX11TransportResult.Failed + + assertEquals("connect_failed", result.stage) + assertEquals(13, result.errno) + assertEquals("X11 guest transport denied: Permission denied", result.userMessage) + } + + @Test + fun `probe uses the exact guest socket bind alias`() { + val arguments = + GuestX11ProbeCommand.buildArguments( + prootPath = "/data/proot", + rootfsPath = "/data/rootfs", + socketDirectory = "/data/x11/.X11-unix", + bindSocket = true, + nativeProbe = "/data/runtime_probe", + forceDenied = false, + androidBindMounts = listOf("/system", "/proc"), + systemLinkerPath = "/system/bin/linker64", + ) + + assertTrue("/data/x11/.X11-unix:/tmp/.X11-unix" in arguments) + assertTrue("/data/runtime_probe:/tmp/.udroid-x11-probe" in arguments) + assertEquals( + listOf( + "/system/bin/linker64", + "/tmp/.udroid-x11-probe", + "--x11", + "/tmp/.X11-unix/X0", + ), + arguments.takeLast(4), + ) + } + + @Test + fun `abstract probe selects the Linux abstract namespace`() { + val arguments = + GuestX11ProbeCommand.buildArguments( + prootPath = "/data/proot", + rootfsPath = "/data/rootfs", + socketDirectory = "/data/x11/.X11-unix", + bindSocket = false, + nativeProbe = "/data/runtime_probe", + forceDenied = false, + socketNamespace = GuestX11SocketNamespace.ABSTRACT, + androidBindMounts = emptyList(), + systemLinkerPath = "/system/bin/linker64", + ) + + assertEquals("--x11-abstract", arguments[arguments.lastIndex - 1]) + } + + @Test + fun `fault build selects deterministic permission denial`() { + val arguments = + GuestX11ProbeCommand.buildArguments( + prootPath = "/data/proot", + rootfsPath = "/data/rootfs", + socketDirectory = "/data/x11/.X11-unix", + bindSocket = false, + nativeProbe = "/data/runtime_probe", + forceDenied = true, + androidBindMounts = emptyList(), + systemLinkerPath = "/system/bin/linker64", + ) + + assertEquals("--x11-deny", arguments[arguments.lastIndex - 1]) + assertTrue("/data/x11/.X11-unix:/tmp/.X11-unix" !in arguments) + } + + @Test + fun `guest client probe uses the desktop display and bound socket`() { + val arguments = + GuestX11ClientProbeCommand.buildArguments( + prootPath = "/data/proot", + rootfsPath = "/data/rootfs", + socketDirectory = "/data/x11/.X11-unix", + bindSocket = true, + guestHome = "/root", + androidBindMounts = listOf("/system"), + ) + + assertTrue("/data/x11/.X11-unix:/tmp/.X11-unix" in arguments) + assertTrue("DISPLAY=:0" in arguments) + assertTrue(arguments.last().contains("xrdb -display \"${'$'}target\" -query")) + assertTrue(arguments.last().contains("run_xrdb xrdb_unix 'unix/:0'")) + assertTrue(arguments.last().contains("run_xrdb xrdb_path '/tmp/.X11-unix/X0'")) + assertTrue(arguments.last().contains("/proc/self/attr/current")) + } + + @Test + fun `guest client output keeps only bounded diagnostic records`() { + val fields = + GuestX11ClientProbeOutput.parseFields( + """ + unrelated output + UDROID_X11|xrdb_status=failed + UDROID_X11|xrdb_exit=1 + UDROID_X11|xrdb_output=xrdb: Permission denied + """.trimIndent(), + ) + + assertEquals("failed", fields["xrdb_status"]) + assertEquals("1", fields["xrdb_exit"]) + assertEquals("xrdb: Permission denied", fields["xrdb_output"]) + assertEquals(3, fields.size) + } +} diff --git a/docs/LINUX_APPLICATION_LAUNCHER.md b/docs/LINUX_APPLICATION_LAUNCHER.md index 7d3fb65..755a2c1 100644 --- a/docs/LINUX_APPLICATION_LAUNCHER.md +++ b/docs/LINUX_APPLICATION_LAUNCHER.md @@ -63,7 +63,8 @@ Graphical applications receive an isolated environment containing: - `XDG_CURRENT_DESKTOP=UDROID` - `GDK_BACKEND=x11` - `QT_QPA_PLATFORM=xcb` -- the same private X11 socket bind used by the interactive distro +- the active X11 endpoint selected for the distro: normally the socket living + directly at `/tmp/.X11-unix/X0`, or the validated private-bind fallback Each application is a separate PRoot process owned by `RuntimeSupervisorService`. Output is drained off the process pipe into the diff --git a/docs/X11_RUNTIME_ARCHITECTURE.md b/docs/X11_RUNTIME_ARCHITECTURE.md index 609676a..bb51f0b 100644 --- a/docs/X11_RUNTIME_ARCHITECTURE.md +++ b/docs/X11_RUNTIME_ARCHITECTURE.md @@ -1,6 +1,6 @@ # Embedded X11 runtime architecture -Status: accepted integration boundary, 2026-07-24 +Status: accepted integration boundary, updated 2026-08-13 ## Decision @@ -47,9 +47,9 @@ supervised X server and PRoot guest continue running. flowchart LR UI["uDroid desktop Activity"] -->|"attach Surface and IME"| View["Lorie display view"] Supervisor["Runtime supervisor"] -->|"versioned Binder contract"| Server["X11 server process"] - Server --> Socket["Private X socket"] + Server --> Socket["X0 inside active rootfs /tmp"] Supervisor --> PRoot["PRoot distro"] - Socket -->|"bind-mounted as /tmp/.X11-unix/X0"| PRoot + Socket -->|"visible directly as /tmp/.X11-unix/X0"| PRoot PRoot -->|"DISPLAY=:0"| Apps["Linux X11 apps"] Server -->|"buffer and event FD transport"| View View -->|"EGL/GLES present"| Surface["Android Surface"] @@ -62,8 +62,13 @@ flowchart LR desktop Activity and its `Surface`. - The desktop Activity owns only the visible Android surface and input/IME adapters. Closing or rotating it must not terminate Linux or the X server. -- The X socket and control socket live in a per-generation private runtime - directory. That directory is bind-mounted into PRoot as `/tmp`. +- The X socket lives directly in the active rootfs at + `/tmp/.X11-unix/X0`. This avoids relying on PRoot to translate an + app-private Unix-socket directory on OEM Android builds. The old private + directory transport remains a preparation-time fallback and must pass the + same guest handshake before a desktop is launched. +- The Binder control and renderer channels remain app-private and independent + from the guest filesystem. - The control protocol uses Binder and file descriptors. It is never exposed on TCP and never accepts an unauthenticated broadcast. @@ -136,18 +141,20 @@ Desktop environments are not the first health test. Each gate records latency, RSS/PSS, CPU time, frame/present counts, and failure reason. 1. `server-start`: the native process reaches the Xorg ready state. -2. `socket-ready`: the private `X0` socket exists and accepts a connection. -3. `x-query`: a tiny client reads the root window geometry and required +2. `socket-ready`: the host-side `X0` path completes an X11 setup handshake. +3. `guest-socket-ready`: the same packaged native probe runs through the exact + PRoot namespace and completes setup against `/tmp/.X11-unix/X0`. +4. `x-query`: a tiny client reads the root window geometry and required extensions. -4. `test-pattern`: the server draws a deterministic pattern with a known +5. `test-pattern`: the server draws a deterministic pattern with a known checksum and the Android surface receives frames. -5. `surface-cycle`: attach, resize, detach, and reattach without restarting +6. `surface-cycle`: attach, resize, detach, and reattach without restarting the server. -6. `input-loop`: injected pointer and key events are observed by a tiny X +7. `input-loop`: injected pointer and key events are observed by a tiny X client. -7. `present-soak`: bounded frame pacing and buffer lifetime test. -8. `xterm`: first real guest application. -9. `lightweight-session`: first desktop, initially without composition. +8. `present-soak`: bounded frame pacing and buffer lifetime test. +9. `xterm`: first real guest application. +10. `lightweight-session`: first desktop, initially without composition. GNOME, KDE, browsers, and games remain later macro probes. @@ -159,12 +166,12 @@ visible application presentation, motion, and surface-cycle checks: | Probe | Result | | --- | --- | | Process isolation | `org.randomcoder.udroid:x11` owned the X server | -| Server socket | app-private `.X11-unix/X0`, mode `srwxrwxrwx` | +| Server socket (historical run) | app-private `.X11-unix/X0`, mode `srwxrwxrwx` | | Protocol setup | X11 `11.0` connection setup completed | | Cold Binder/process setup | 708 ms | | Native Xorg to protocol-ready | 117 ms | | Total request to protocol-ready | 825 ms | -| Guest bridge | same socket bind-mounted at `/tmp/.X11-unix`, `DISPLAY=:0` | +| Guest bridge | historical private socket bind at `/tmp/.X11-unix`, `DISPLAY=:0` | | Renderer transport | one Binder-delivered renderer FD per Desktop attach | | Android display target | 1080×2142 below expanded controls; near-full height with the 32 dp strip | | Live X11 geometry | raw probe observed 1280×720 in Fixed mode and 1080×2142 after returning to Native | @@ -193,6 +200,30 @@ parses the eight-byte setup response. Merely connecting to the Unix socket is not considered ready; that weaker test initially hid a probe-side `LocalSocket` ordering bug. +## Guest transport compatibility checkpoint + +Issue 23 showed that host readiness is insufficient: the Android process can +connect to its socket while the PRoot-visible bind alias fails with `EACCES`. +uDroid now runs the packaged protocol probe inside the exact PRoot namespace +before launching a desktop. A failure records its stage and errno, leaves the +display unclaimed, and never emits `desktop_started`. + +The primary route creates `X0` directly under the active rootfs. Startup also +repairs `/tmp` and `/tmp/.X11-unix` to mode `01777`, because an earlier PRoot +bind may leave a mode-000 mount-point placeholder behind. Terminal, desktop, +and graphical-application launchers omit the X11 bind for this route. + +The API 34 AVD regression test proved both directions: + +- injected guest `EACCES`: `guest_transport_failed`, errno 13, no desktop + process; +- normal direct-rootfs route: X11 11.0 guest handshake followed by + `desktop_started`, with no X11 directory bind in either PRoot command. + +The exact Huawei policy or PRoot behavior still requires confirmation on the +reporter's Android 12 device. The diagnostic contract no longer confuses that +transport failure with XFCE, D-Bus, compositing, or GPU startup. + The presentation investigation used two independent observations. An XWD dump of the root window contained the expected gears while Android was still black, which isolated the failure to the Android surface rather than GLX or Xorg. The