diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 7a2a8286..fc62a38a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -4,6 +4,12 @@ title: "[BUG]: " labels: [bug, needs-triage] body: + - type: markdown + attributes: + value: | + ## 🌐 ENGLISH ONLY + **All issues MUST be written in English. Non-English reports will be closed without response.** + - type: markdown attributes: value: | diff --git a/.github/ISSUE_TEMPLATE/feature_request_CN.md b/.github/ISSUE_TEMPLATE/feature_request_CN.md deleted file mode 100644 index 9727060c..00000000 --- a/.github/ISSUE_TEMPLATE/feature_request_CN.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -name: 功能请求 -about: 为此项目提议新功能 -title: "[功能请求]" -labels: enhancement -assignees: '' ---- - -**你的功能请求是否与某个问题相关?请描述。** -清晰简洁地描述问题是什么。例如:当 [...] 的时候我总是感到困扰。 - -**描述你想要的解决方案** -清晰简洁地描述你希望发生的事情。 - -**描述你考虑过的替代方案** -清晰简洁地描述你考虑过的任何替代解决方案或功能。 - -**补充信息** -在此处添加关于此功能请求的任何其他背景信息或截图。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0abec2f..6ccefc7b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,4 @@ name: Droidspaces CI -run-name: ${{ github.event.head_commit.message || format('Release {0}', github.event.inputs.tag_name) || github.workflow }} on: push: @@ -69,8 +68,10 @@ jobs: echo "[+] Toolchains ready." tree -L 3 $HOME/toolchains - - name: Build All Tarball & Sync to Android - run: make all-tarball + - name: Build All Tarball, Debian Packages & Sync to Android + run: | + make all-tarball + scripts/mkdeb.sh - name: Decode Keystore env: @@ -103,28 +104,41 @@ jobs: - name: Stage & Rename Artifacts run: | - TAG="${{ github.event.inputs.tag_name || 'test' }}" - DATE=$(date +%Y-%m-%d) - APK_NAME="Droidspaces-universal-${TAG}-${DATE}.apk" + TAG="${{ github.event.inputs.tag_name || 'CI' }}" + COMMIT_HASH=$(git log -1 --pretty=%h) + APK_NAME="Droidspaces-universal-${TAG}-${COMMIT_HASH}.apk" TAR_NAME=$(ls droidspaces-v*-*.tar.gz) mkdir -p dist mv Android/app/build/outputs/apk/release/app-release.apk dist/${APK_NAME} mv ${TAR_NAME} dist/ + mv droidspaces_*.deb dist/ echo "APK_FILE=dist/${APK_NAME}" >> $GITHUB_ENV echo "TAR_FILE=dist/${TAR_NAME}" >> $GITHUB_ENV - name: Generate Telegram Message if: github.event_name != 'pull_request' run: | - EVENT_TYPE="${{ github.event_name == 'workflow_dispatch' && github.event.inputs.create_release == 'true' && 'Official Release' || 'Git Push' }}" + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + if [ "${{ github.event.inputs.create_release }}" = "true" ]; then + EVENT_TYPE="Official Release" + else + EVENT_TYPE="Manual Trigger" + fi + else + EVENT_TYPE="Git Push" + fi COMMIT_MSG=$(git log -1 --pretty=format:%s) + COMMIT_SHA=$(git log -1 --pretty=format:%h) + COMMIT_AUTHOR=$(git log -1 --pretty=format:%an) { echo "TELEGRAM_MESSAGE<> "$GITHUB_ENV" - - name: Upload Final Artifacts (APK & Tarball) + - name: Upload Final Artifacts (APK, Tarball & Debian Packages) uses: actions/upload-artifact@v4 with: name: droidspaces-artifacts @@ -211,3 +225,4 @@ jobs: files: | release-assets/*.apk release-assets/*.tar.gz + release-assets/*.deb diff --git a/.gitignore b/.gitignore index ee048bf1..dbfe53da 100644 --- a/.gitignore +++ b/.gitignore @@ -1,22 +1,25 @@ *.o *.tar.gz -droidspaces-* -output -.cursor -old_code_with_android-app/ -logs/ +*.deb +output/ *.img -*.txt *.patch *.diff *.rej *.orig *.config +.cache +.claude +Android/app/src/main/assets/binaries/ +Android/app/src/main/assets/supported_locales.txt # Keystores *.keystore local.properties +# IDE / tooling +compile_commands.json + # Nix stuff /result *.qcow2 diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..112d4013 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,11 @@ +[submodule "external/busybox"] + path = external/busybox + url = https://github.com/Droidspaces/busybox-droidspaces.git +[submodule "external/www"] + path = external/www + url = https://github.com/Droidspaces/webui.git + branch = main +[submodule "external/socketd"] + path = external/socketd + url = https://github.com/Droidspaces/socketd.git + branch = main diff --git a/Android/app/build.gradle.kts b/Android/app/build.gradle.kts index e343b795..dd768519 100644 --- a/Android/app/build.gradle.kts +++ b/Android/app/build.gradle.kts @@ -18,7 +18,7 @@ if (keystorePropertiesFile.exists()) { // Helper to extract version from C header fun getDSVersion(): String { - val headerFile = file("../../src/droidspace.h") + val headerFile = file("../../src/include/droidspace.h") if (!headerFile.exists()) return "1.0.0" val pattern = Regex("#define\\s+DS_VERSION\\s+\"([^\"]+)\"") @@ -319,6 +319,10 @@ dependencies { // Navigation implementation("androidx.navigation:navigation-compose:2.7.7") + // Drag-and-drop reordering for Compose lists (Auto boot priority screen) + // 2.1.x targets Compose 1.6.x, matching the BOM above. + implementation("sh.calvin.reorderable:reorderable:2.1.1") + // ViewModel implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0") @@ -332,8 +336,10 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") // Terminal emulator (ported from LXC-Manager / termux-app) - implementation("com.github.termux.termux-app:terminal-view:0.118.1") - implementation("com.github.termux.termux-app:terminal-emulator:0.118.1") + // 0.118.3 consumes unsupported CSI private-parameter sequences (ESC [ = / ESC [ <) + // instead of leaking them to the screen — fish 4.x probes with ESC[=5u. + implementation("com.github.termux.termux-app:terminal-view:0.118.3") + implementation("com.github.termux.termux-app:terminal-emulator:0.118.3") // Debug debugImplementation("androidx.compose.ui:ui-tooling") diff --git a/Android/app/src/main/AndroidManifest.xml b/Android/app/src/main/AndroidManifest.xml index bb4a8abe..ac14c8a5 100644 --- a/Android/app/src/main/AndroidManifest.xml +++ b/Android/app/src/main/AndroidManifest.xml @@ -6,15 +6,16 @@ - + + + + - - - - - - - - - diff --git a/Android/app/src/main/assets/boot-module/module.prop b/Android/app/src/main/assets/boot-module/module.prop index ea24a2f1..90ac2beb 100644 --- a/Android/app/src/main/assets/boot-module/module.prop +++ b/Android/app/src/main/assets/boot-module/module.prop @@ -1,6 +1,6 @@ id=droidspaces name=Droidspaces: Daemon & Init -version=v6.2.5 -versionCode=6250 +version=v6.4.5 +versionCode=6450 author=ravindu644 description=Automatically starts the Droidspaces daemon if "Daemon Mode" is enabled, and starts containers during boot if "Run-at-Boot" is active. diff --git a/Android/app/src/main/assets/boot-module/sepolicy.rule b/Android/app/src/main/assets/boot-module/sepolicy.rule index a335b653..efdcbcc2 100644 --- a/Android/app/src/main/assets/boot-module/sepolicy.rule +++ b/Android/app/src/main/assets/boot-module/sepolicy.rule @@ -63,10 +63,14 @@ allow system_server droidspacesd fd use allow system_server droidspacesd fifo_file write # for Termux:X11 and DRI3 denials in daemon mode -allow untrusted_app droidspacesd fd use -allow untrusted_app droidspacesd fifo_file write allow untrusted_app_27 droidspacesd fd use +allow untrusted_app_27 droidspacesd fifo_file write # for pulseAudio allow audioserver droidspacesd binder { call transfer } +allow audioserver droidspacesd fd use allow mediaserver droidspacesd binder { call transfer } +allow mediaserver droidspacesd fd use + +# fix permission denied when read/write mounted storage (#213) +allow mediaprovider_app droidspacesd fd use diff --git a/Android/app/src/main/assets/boot-module/service.sh b/Android/app/src/main/assets/boot-module/service.sh index 742ca1ac..bb936308 100644 --- a/Android/app/src/main/assets/boot-module/service.sh +++ b/Android/app/src/main/assets/boot-module/service.sh @@ -145,39 +145,118 @@ else log "Daemon mode disabled, skipping" fi -# Auto-boot containers +# Auto-boot containers (priority-ordered, gateway-aware) +# +# Containers boot from the smallest run_at_boot_priority to the largest; missing +# or invalid priorities sort last (best-effort), then alphabetically. A +# net_mode=gateway client is skipped if its gateway_container did not come up, +# so we never boot a router-less client. Independent containers always boot, and +# a single failure never blocks the rest. log "Scanning for containers with run_at_boot=1..." success=0 failed=0 +skipped=0 +TAB=$(${BUSYBOX_BINARY} printf '\t') +BOOT_TABLE="${DROIDSPACE_DIR}/.autoboot_table" +SORTED_FILE="${DROIDSPACE_DIR}/.autoboot_sorted" +STARTED_OK="${DROIDSPACE_DIR}/.autoboot_started" +: > "${BOOT_TABLE}" +: > "${STARTED_OK}" + +# Read a single key=value from a config file +get_cfg_val() { + ${BUSYBOX_BINARY} grep "^$1=" "$2" 2>/dev/null | \ + ${BUSYBOX_BINARY} head -1 | ${BUSYBOX_BINARY} sed 's/^[^=]*=//' | \ + ${BUSYBOX_BINARY} tr -d '\r\n' +} + +# Find the container.config whose name= matches $1 (echoes path, empty if none) +cfg_for_name() { + for c in $(${BUSYBOX_BINARY} find "${CONTAINERS_DIR}" -name "container.config" 2>/dev/null); do + n=$(get_cfg_val name "$c") + [ -n "$n" ] || n=$(${BUSYBOX_BINARY} basename "$(${BUSYBOX_BINARY} dirname "$c")") + if [ "$n" = "$1" ]; then + echo "$c" + return 0 + fi + done + return 1 +} + +# A gateway is available if it started during this run, or is already running +gateway_available() { + if ${BUSYBOX_BINARY} grep -qxF "$1" "${STARTED_OK}" 2>/dev/null; then + return 0 + fi + gcfg=$(cfg_for_name "$1") + [ -n "${gcfg}" ] || return 1 + gpid=$("${DROIDSPACE_BINARY}" --config "${gcfg}" pid 2>/dev/null) + [ "${gpid}" != "NONE" ] && [ -n "${gpid}" ] +} + +# Start one container; returns 0 if it ends up with a live PID +boot_container() { + "${DROIDSPACE_BINARY}" --config "$1" start 2>&1 | \ + ${BUSYBOX_BINARY} sed "s/$(${BUSYBOX_BINARY} printf '\033')\[[0-9;]*[mK]//g" + bpid=$("${DROIDSPACE_BINARY}" --config "$1" pid 2>/dev/null) + [ "${bpid}" != "NONE" ] && [ -n "${bpid}" ] +} + +# Build the boot table: PRIORITY \t NAME \t NET_MODE \t GATEWAY \t CFG for cfg in $(${BUSYBOX_BINARY} find "${CONTAINERS_DIR}" -name "container.config" 2>/dev/null); do [ -f "${cfg}" ] || continue + [ "$(get_cfg_val run_at_boot "${cfg}")" = "1" ] || continue - run_at_boot=$(${BUSYBOX_BINARY} grep "^run_at_boot=" "${cfg}" 2>/dev/null | \ - ${BUSYBOX_BINARY} head -1 | ${BUSYBOX_BINARY} sed 's/^[^=]*=//' | \ - ${BUSYBOX_BINARY} tr -d '\r\n') + name=$(get_cfg_val name "${cfg}") + [ -n "${name}" ] || name=$(${BUSYBOX_BINARY} basename "$(${BUSYBOX_BINARY} dirname "${cfg}")") - [ "${run_at_boot}" = "1" ] || continue + prio=$(get_cfg_val run_at_boot_priority "${cfg}") + case "${prio}" in + ''|*[!0-9]*) prio=99999999 ;; + esac - name=$(${BUSYBOX_BINARY} grep "^name=" "${cfg}" 2>/dev/null | \ - ${BUSYBOX_BINARY} head -1 | ${BUSYBOX_BINARY} sed 's/^[^=]*=//' | \ - ${BUSYBOX_BINARY} tr -d '\r\n') - display="${name:-$(basename "$(dirname "${cfg}")")}" + # '-' placeholder for empty fields: IFS= collapses adjacent tabs + # (tab is IFS whitespace), so empty middle fields would shift the columns. + netmode=$(get_cfg_val net_mode "${cfg}") + [ -n "${netmode}" ] || netmode="-" + gw=$(get_cfg_val gateway_container "${cfg}") + [ -n "${gw}" ] || gw="-" + + ${BUSYBOX_BINARY} printf '%s\t%s\t%s\t%s\t%s\n' "${prio}" "${name}" "${netmode}" "${gw}" "${cfg}" >> "${BOOT_TABLE}" +done - log "Starting container: ${display}" - "${DROIDSPACE_BINARY}" --config "${cfg}" start 2>&1 | \ - ${BUSYBOX_BINARY} sed "s/$(printf '\033')\[[0-9;]*[mK]//g" +# Smallest priority first, then name. Gaps/holes and large sentinels sort cleanly. +${BUSYBOX_BINARY} sort -t"${TAB}" -k1,1n -k2,2 "${BOOT_TABLE}" > "${SORTED_FILE}" - PID=$("${DROIDSPACE_BINARY}" --config "${cfg}" pid 2>/dev/null) - if [ "${PID}" != "NONE" ] && [ -n "${PID}" ]; then - log "SUCCESS: ${display} (PID: ${PID})" +# Reading from a file (not a pipe) keeps the loop in this shell so the counters +# survive each iteration. +while IFS="${TAB}" read -r prio name netmode gw cfg; do + [ -n "${cfg}" ] || continue + [ "${gw}" = "-" ] && gw="" + + if [ "${netmode}" = "gateway" ] && [ -n "${gw}" ]; then + if ! gateway_available "${gw}"; then + log "SKIP: ${name} (gateway '${gw}' not running)" + skipped=$((skipped + 1)) + continue + fi + fi + + log "Starting container: ${name}" + if boot_container "${cfg}"; then + PID=$("${DROIDSPACE_BINARY}" --config "${cfg}" pid 2>/dev/null) + log "SUCCESS: ${name} (PID: ${PID})" + ${BUSYBOX_BINARY} printf '%s\n' "${name}" >> "${STARTED_OK}" success=$((success + 1)) else - log "FAILED: ${display}" + log "FAILED: ${name}" failed=$((failed + 1)) fi -done +done < "${SORTED_FILE}" + +${BUSYBOX_BINARY} rm -f "${BOOT_TABLE}" "${SORTED_FILE}" "${STARTED_OK}" 2>/dev/null stop_dmesg_logger -log "Boot Summary: Daemon: ${DAEMON_STATUS} | ${success} started | ${failed} failed" -update_prop "Daemon: ${DAEMON_STATUS} | Containers: ${success} started, ${failed} failed" +log "Boot Summary: Daemon: ${DAEMON_STATUS} | ${success} started | ${failed} failed | ${skipped} skipped" +update_prop "Daemon: ${DAEMON_STATUS} | Containers: ${success} started, ${failed} failed, ${skipped} skipped" diff --git a/Android/app/src/main/assets/bugreport.sh b/Android/app/src/main/assets/bugreport.sh index 5739d3be..2c8b8c26 100755 --- a/Android/app/src/main/assets/bugreport.sh +++ b/Android/app/src/main/assets/bugreport.sh @@ -12,7 +12,14 @@ BUSYBOX="${DROIDSPACE_DIR}/bin/busybox" LOGS_DIR="${DROIDSPACE_DIR}/Logs" DATE_TIME="$("${BUSYBOX}" date +"%Y-%m-%d_%H-%M-%S")" BUGREPORT_DIR="${LOGS_DIR}/bugreport_${DATE_TIME}" -OUTPUT_TARBALL="/sdcard/Droidspaces-bugreport_${DATE_TIME}.tar.gz" +TARBALL="Droidspaces-bugreport_${DATE_TIME}.tar.gz" + +# Check if /sdcard is writable +if [ -w /sdcard ]; then + OUTPUT_TARBALL="/sdcard/$TARBALL" +else + OUTPUT_TARBALL="$DROIDSPACE_DIR/$TARBALL" +fi # We need busybox for reliable cross-device tools (tar, sort, grep, etc.) if [ ! -f "${BUSYBOX}" ] || [ ! -x "${BUSYBOX}" ]; then diff --git a/Android/app/src/main/assets/chkopenrc.sh b/Android/app/src/main/assets/chkopenrc.sh index 98cc8371..5512fe59 100644 --- a/Android/app/src/main/assets/chkopenrc.sh +++ b/Android/app/src/main/assets/chkopenrc.sh @@ -4,6 +4,10 @@ RUNLEVELS_DIR="/etc/runlevels" INITD_DIR="/etc/init.d" +_all_running() { + rc-status -f ini 2>/dev/null | awk -F'=' '/=/ {gsub(/^[ \t]+|[ \t]+$/, "", $1); print $1}' +} + _all_services() { ls "$INITD_DIR" 2>/dev/null | grep -v '^\.' | sort } @@ -22,13 +26,24 @@ _get_desc() { case "$1" in --running) - # Output: name|Description (matches chksystemd.sh --running) - for svc in $(_all_services); do - if rc-service "$svc" status >/dev/null 2>&1; then + # Output: name|Description (matches chksystemd.sh --running) + + if running=$(_all_running 2>/dev/null); then + # Preferred method: use rc-status output directly. + echo "$running" | while IFS= read -r svc; do + [ -n "$svc" ] || continue desc=$(_get_desc "$svc") echo "${svc}|${desc}" - fi - done + done + else + # Fallback: query each service individually. + for svc in $(_all_services); do + if rc-service "$svc" status >/dev/null 2>&1; then + desc=$(_get_desc "$svc") + echo "${svc}|${desc}" + fi + done + fi ;; --enabled) # Output: name diff --git a/Android/app/src/main/assets/chkprocd.sh b/Android/app/src/main/assets/chkprocd.sh new file mode 100644 index 00000000..1a71a784 --- /dev/null +++ b/Android/app/src/main/assets/chkprocd.sh @@ -0,0 +1,86 @@ +#!/bin/sh +# chkprocd.sh - OpenWrt/procd service query tool +# Output format for --all: +# name|description|enabled|running +# where enabled is enabled/disabled and running is running/stopped/unknown. + +INITD_DIR="/etc/init.d" + +_is_safe_name() { + echo "$1" | grep -Eq '^[A-Za-z0-9_.+@-]+$' +} + +_all_services() { + [ -d "$INITD_DIR" ] || return 0 + for path in "$INITD_DIR"/*; do + [ -f "$path" ] || continue + [ -x "$path" ] || continue + name="${path##*/}" + _is_safe_name "$name" || continue + case "$name" in + .*) continue ;; + esac + echo "$name" + done | sort -u +} + +_get_desc() { + svc="$1" + script="$INITD_DIR/$svc" + + # Some init scripts define DESCRIPTION/description. Most OpenWrt scripts do + # not, so empty description is a valid result. + # Keep this parser deliberately conservative to avoid shell-quoting + # complexity on BusyBox ash. Descriptions are optional in the UI. + grep -m1 -E '^(DESCRIPTION|description)=' "$script" 2>/dev/null \ + | sed 's/^[^=]*=//;s/^"//;s/"$//' \ + | head -n 1 +} + +_is_enabled() { + "$INITD_DIR/$1" enabled >/dev/null 2>&1 +} + +_running_state() { + svc="$1" + + if "$INITD_DIR/$svc" running >/dev/null 2>&1; then + echo running + return 0 + fi + + # procd init scripts generally implement status; one-shot/boot scripts may + # not. Treat unsupported status as unknown rather than pretending stopped. + output=$("$INITD_DIR/$svc" status 2>&1) + rc=$? + if [ $rc -eq 0 ]; then + echo running + return 0 + fi + + echo "$output" | grep -Eiq 'inactive|not running|stopped|disabled|dead' && { + echo stopped + return 0 + } + + echo unknown +} + +case "$1" in + --all) + for svc in $(_all_services); do + desc=$(_get_desc "$svc") + if _is_enabled "$svc"; then + enabled="enabled" + else + enabled="disabled" + fi + running=$(_running_state "$svc") + echo "${svc}|${desc}|${enabled}|${running}" + done + ;; + *) + echo "usage: $0 --all" >&2 + exit 2 + ;; +esac diff --git a/Android/app/src/main/assets/post_extract_fixes.sh b/Android/app/src/main/assets/post_extract_fixes.sh index 77731e25..2d9f65d0 100755 --- a/Android/app/src/main/assets/post_extract_fixes.sh +++ b/Android/app/src/main/assets/post_extract_fixes.sh @@ -46,13 +46,17 @@ if $TEST ! -d "$ROOTFS_PATH"; then exit 1 fi -log "Starting post-extraction fixes for: $ROOTFS_PATH" +# Helper to execute a command inside the chroot environment +run_in_chroot() { + local command="$*" + local common_exports="export PATH='/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/libexec:/opt/bin'; export TMPDIR='/tmp';" -# Check if fixes were already applied -if $TEST -f "$ROOTFS_PATH/etc/droidspaces"; then - log "Post-extraction fixes already applied, skipping..." - exit 0 -fi + # We use busybox chroot to run commands inside the rootfs + # Note: This assumes /bin/sh exists in the rootfs + $CHROOT "$ROOTFS_PATH" /bin/sh -c "$common_exports $command" +} + +log "Starting post-extraction fixes for: $ROOTFS_PATH" # Detect NixOS if $TEST -d "$ROOTFS_PATH/nix"; then @@ -62,15 +66,17 @@ if $TEST -d "$ROOTFS_PATH/nix"; then exit 0 fi -# Helper to execute a command inside the chroot environment -run_in_chroot() { - local command="$*" - local common_exports="export PATH='/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/libexec:/opt/bin'; export TMPDIR='/tmp';" +# Pre-commit machine-id to prevent first-boot deadlock in Fedora 44 +log "Generating machine-id..." +$CAT /proc/sys/kernel/random/uuid | $BB tr -d '-' | $BB tr -d '\n' > "$ROOTFS_PATH/etc/machine-id" +$ECHO "" >> "$ROOTFS_PATH/etc/machine-id" +$CHMOD 444 "$ROOTFS_PATH/etc/machine-id" - # We use busybox chroot to run commands inside the rootfs - # Note: This assumes /bin/sh exists in the rootfs - $CHROOT "$ROOTFS_PATH" /bin/sh -c "$common_exports $command" -} +# Check if fixes were already applied +if $TEST -f "$ROOTFS_PATH/etc/droidspaces"; then + log "Post-extraction fixes already applied, skipping..." + exit 0 +fi # --- 1. General Fixes (Init-independent) --- @@ -105,12 +111,6 @@ if $TEST -f "$ROOTFS_PATH/usr/bin/systemctl" || $TEST -f "$ROOTFS_PATH/bin/syste log "Systemd detected (at $GUEST_SYSTEMD_PATH), applying fixes..." - # 00. Pre-commit machine-id to prevent first-boot deadlock in Fedora 44 - log "Generating machine-id..." - $CAT /proc/sys/kernel/random/uuid | $BB tr -d '-' | $BB tr -d '\n' > "$ROOTFS_PATH/etc/machine-id" - $ECHO "" >> "$ROOTFS_PATH/etc/machine-id" - $CHMOD 444 "$ROOTFS_PATH/etc/machine-id" - # 01. Mask problematic services for Android kernels log "Masking problematic systemd services..." # Mask systemd-networkd-wait-online.service @@ -184,16 +184,18 @@ EOF $PRINTF "[Unit]\nConditionPathIsReadWrite=\n" > "$ROOTFS_PATH/etc/systemd/system/${unit}.d/99-readonly-fix.conf" done - # 06. Limit specific network services to only start in NAT mode - # Prevents cellular network breakage when running in host network mode - log "Applying NAT mode guards to network services..." + # 06. Limit specific network services to NAT and gateway modes only + # Both need an in-container DHCP client (NAT: lease from Droidspaces; gateway: + # lease from the gateway container, e.g. OpenWRT). Host/none modes still skip + # them to prevent cellular network breakage. + log "Applying NAT/gateway mode guards to network services..." for unit in NetworkManager.service dhcpcd.service systemd-resolved.service systemd-networkd.service; do if $TEST -f "$ROOTFS_PATH/$GUEST_SYSTEMD_PATH/$unit" || $TEST -f "$ROOTFS_PATH/etc/systemd/system/multi-user.target.wants/$unit"; then $MKDIR -p "$ROOTFS_PATH/etc/systemd/system/${unit}.d" $CAT > "$ROOTFS_PATH/etc/systemd/system/${unit}.d/99-netmode-limit.conf" << 'EOF' [Service] ExecCondition= -ExecCondition=/bin/sh -c "grep -q 'net_mode=nat' /run/droidspaces/container.config" +ExecCondition=/bin/sh -c "grep -qE 'net_mode=(nat|gateway)' /run/droidspaces/container.config" EOF fi done @@ -221,12 +223,13 @@ fi # --- 3. Alpine/OpenRC & dhcpcd-Specific Fixes --- -# Replace dhcpcd init script to only start in NAT network mode +# Replace dhcpcd init script to only start in NAT or gateway network mode # This is the OpenRC equivalent of systemd's ExecCondition - if the container # is running in host network mode, dhcpcd is cleanly skipped at boot to prevent -# cellular network breakage and kernel panics on Android interfaces. +# cellular network breakage and kernel panics on Android interfaces. Gateway +# mode needs it too: the DHCP lease comes from the gateway container. if $TEST -f "$ROOTFS_PATH/etc/init.d/dhcpcd"; then - log "Alpine/OpenRC dhcpcd service detected, applying NAT mode limitation..." + log "Alpine/OpenRC dhcpcd service detected, applying NAT/gateway mode limitation..." $CAT > "$ROOTFS_PATH/etc/init.d/dhcpcd" << 'INITEOF' #!/sbin/openrc-run @@ -246,9 +249,9 @@ depend() { } start_pre() { - # Only start in NAT mode - prevents cellular network breakage in host network mode - if ! grep -q 'net_mode=nat' /run/droidspaces/container.config 2>/dev/null; then - einfo "Skipping dhcpcd: not in NAT network mode" + # Only start in NAT or gateway mode - prevents cellular network breakage in host network mode + if ! grep -qE 'net_mode=(nat|gateway)' /run/droidspaces/container.config 2>/dev/null; then + einfo "Skipping dhcpcd: not in NAT or gateway network mode" return 1 fi checkpath -d /run/dhcpcd diff --git a/Android/app/src/main/assets/validate_rootfs.sh b/Android/app/src/main/assets/validate_rootfs.sh new file mode 100644 index 00000000..050075bc --- /dev/null +++ b/Android/app/src/main/assets/validate_rootfs.sh @@ -0,0 +1,75 @@ +#!/system/bin/sh +# Rootfs Tarball Validator for Droidspaces +# Copyright (c) 2026 ravindu644 +# Inspects a container tarball (WITHOUT extracting) to confirm it actually +# contains a Linux rootfs, so arbitrary archives cannot be installed. + +# Parameters +TARBALL="$1" +BUSYBOX_PATH="${BUSYBOX_PATH:-/data/local/Droidspaces/bin/busybox}" +BB="$BUSYBOX_PATH" + +log() { $BB echo "$1" >&2; } + +# Check BusyBox +if [ ! -x "$BB" ]; then + log "BusyBox not found or not executable at $BB" + exit 1 +fi + +# Check tarball +if [ -z "$TARBALL" ] || [ ! -f "$TARBALL" ]; then + log "Tarball not found: $TARBALL" + exit 1 +fi + +# Pick a decompressor from the extension (mirrors the extractor). +case "$TARBALL" in + *.xz) DECOMP="$BB xzcat" ;; + *) DECOMP="$BB zcat" ;; +esac + +# Stream entry names and confirm the core top-level directories every Linux +# rootfs has (bin, sbin, etc, usr). awk bails out the moment all four are +# found, so a valid rootfs is confirmed without decompressing the whole +# archive. Merged-usr distros still list bin/sbin as symlink entries, so this +# stays compatible. A tarball wrapped in a single top-level dir (e.g. +# rootfs/bin/...) is intentionally rejected - it would extract to the wrong +# layout anyway. +RESULT=$($DECOMP "$TARBALL" 2>/dev/null | $BB tar -tf - 2>/dev/null | $BB awk ' + { + name = $0 + sub(/^\.\//, "", name) + sub(/^\//, "", name) + split(name, parts, "/") + top = parts[1] + if (top == "bin") b = 1 + if (top == "sbin") s = 1 + if (top == "etc") e = 1 + if (top == "usr") u = 1 + if (b && s && e && u) { print "OK"; exit } + } + END { + if (!(b && s && e && u)) { + miss = "" + if (!b) miss = miss " bin" + if (!s) miss = miss " sbin" + if (!e) miss = miss " etc" + if (!u) miss = miss " usr" + print "MISSING" miss + } + }') + +case "$RESULT" in + OK) + exit 0 + ;; + MISSING*) + log "Not a Linux rootfs - missing top-level dirs:${RESULT#MISSING}" + exit 2 + ;; + *) + log "Could not read archive (corrupt or not a .tar.gz/.tar.xz?)" + exit 3 + ;; +esac diff --git a/Android/app/src/main/java/com/droidspaces/app/DroidspacesApplication.kt b/Android/app/src/main/java/com/droidspaces/app/DroidspacesApplication.kt index 4cc21b17..6a6efd7e 100644 --- a/Android/app/src/main/java/com/droidspaces/app/DroidspacesApplication.kt +++ b/Android/app/src/main/java/com/droidspaces/app/DroidspacesApplication.kt @@ -10,8 +10,9 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch class DroidspacesApplication : Application() { - // Use SupervisorJob to prevent child coroutine failures from cancelling the scope - val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + // Use SupervisorJob to prevent child coroutine failures from cancelling the scope. + // Private: only this class schedules onto it (avoids an app-wide unscoped launcher). + private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) override fun onCreate() { super.onCreate() diff --git a/Android/app/src/main/java/com/droidspaces/app/MainActivity.kt b/Android/app/src/main/java/com/droidspaces/app/MainActivity.kt index cb22b145..f84fc704 100644 --- a/Android/app/src/main/java/com/droidspaces/app/MainActivity.kt +++ b/Android/app/src/main/java/com/droidspaces/app/MainActivity.kt @@ -24,8 +24,18 @@ import com.droidspaces.app.ui.theme.rememberThemeState class MainActivity : AppCompatActivity() { + companion object { + const val ACTION_SHORTCUT_CONTAINERS = "com.droidspaces.app.action.SHORTCUT_CONTAINERS" + const val ACTION_SHORTCUT_PANEL = "com.droidspaces.app.action.SHORTCUT_PANEL" + const val ACTION_SHORTCUT_SETTINGS = "com.droidspaces.app.action.SHORTCUT_SETTINGS" + } + private var isLoading by mutableStateOf(false) + // Deep-link target from a launcher long-press shortcut ("containers" / "panel" + // / "settings"), consumed once by DroidspacesNavigation then reset to null. + private var pendingShortcut by mutableStateOf(null) + // ── POST_NOTIFICATIONS permission (Android 13+ / API 33+) ───────────────── // Samsung's SecFgsManagerController suppresses any FGS notification from apps // that don't hold this permission, which immediately strips the ONGOING flag @@ -107,6 +117,12 @@ class MainActivity : AppCompatActivity() { // suppressed by Samsung's notification manager before it starts. requestNotificationPermission() + // Skip shortcut handling when the activity is relaunched from Recents, + // otherwise the stale shortcut intent would re-fire the deep link. + if ((intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) == 0) { + handleIntent(intent) + } + // Render UI immediately - no blocking operations setContent { ThemeWrapper { @@ -116,12 +132,29 @@ class MainActivity : AppCompatActivity() { color = MaterialTheme.colorScheme.background ) { DroidspacesNavigation( - onContentReady = { isLoading = false } + onContentReady = { isLoading = false }, + pendingShortcut = pendingShortcut, + onShortcutHandled = { pendingShortcut = null } ) } } } } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + handleIntent(intent) + } + + private fun handleIntent(intent: Intent) { + pendingShortcut = when (intent.action) { + ACTION_SHORTCUT_CONTAINERS -> "containers" + ACTION_SHORTCUT_PANEL -> "panel" + ACTION_SHORTCUT_SETTINGS -> "settings" + else -> pendingShortcut + } + } } @Composable diff --git a/Android/app/src/main/java/com/droidspaces/app/receiver/BootReceiver.kt b/Android/app/src/main/java/com/droidspaces/app/receiver/BootReceiver.kt deleted file mode 100644 index 1fb95b3f..00000000 --- a/Android/app/src/main/java/com/droidspaces/app/receiver/BootReceiver.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.droidspaces.app.receiver - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent - -/** - * Handles BOOT_COMPLETED broadcast. - * - * Declared in AndroidManifest.xml to satisfy the structural requirement of pairing - * RECEIVE_BOOT_COMPLETED permission with an actual receiver. Without this declaration, - * static analysis engines flag the orphaned permission as a dropper/persistence pattern. - * - * Currently a no-op: Droidspaces containers are user-initiated and do not auto-start on boot. - * This receiver exists purely to make the manifest structurally correct. - */ -class BootReceiver : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - // No auto-start on boot. Containers require explicit user action. - // This receiver is intentionally empty. - } -} diff --git a/Android/app/src/main/java/com/droidspaces/app/service/TerminalSessionService.kt b/Android/app/src/main/java/com/droidspaces/app/service/TerminalSessionService.kt index 7bd252d4..b2dc5c5a 100644 --- a/Android/app/src/main/java/com/droidspaces/app/service/TerminalSessionService.kt +++ b/Android/app/src/main/java/com/droidspaces/app/service/TerminalSessionService.kt @@ -3,16 +3,19 @@ package com.droidspaces.app.service import android.app.* import android.content.Context import android.content.Intent +import android.net.Uri import android.content.pm.ServiceInfo import android.os.Binder import android.os.Build import android.os.IBinder import android.os.PowerManager +import android.provider.Settings import androidx.compose.runtime.mutableStateMapOf import androidx.core.app.NotificationCompat import com.droidspaces.app.MainActivity import com.droidspaces.app.R import com.droidspaces.app.ui.terminal.DroidspacesTerminalSession +import com.droidspaces.app.ui.terminal.NoOpTerminalSessionClient import com.termux.terminal.TerminalSession import com.termux.terminal.TerminalSessionClient @@ -53,6 +56,16 @@ class TerminalSessionService : Service() { fun getSession(id: String): TerminalSession? = sessions[id] + /** + * Swap every session's client to the app-scoped no-op so a disposed terminal + * screen (its TerminalBackEnd -> Activity/TerminalView) is not retained by this + * long-lived service. Sessions keep running; the UI re-attaches its own client + * on re-entry. See FINDINGS_APP_VULN V16. + */ + fun detachAllClients() { + sessions.values.forEach { it.updateTerminalSessionClient(NoOpTerminalSessionClient) } + } + fun terminateSession(id: String) { runCatching { // Send Ctrl+D (EOF) first so bash exits cleanly and su unwinds. @@ -116,6 +129,35 @@ class TerminalSessionService : Service() { PowerManager.PARTIAL_WAKE_LOCK, "droidspaces:terminal" ).also { it.acquire() } + // Request battery optimization exemption so Android doesn't + // kill the service while the wakelock is held. + // API 23+ (Android 6+); safe no-op below that. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val pm2 = getSystemService(POWER_SERVICE) as PowerManager + if (!pm2.isIgnoringBatteryOptimizations(packageName)) { + try { + // Direct per-app exemption dialog (requires the manifest permission). + startActivity( + Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { + data = Uri.parse("package:$packageName") + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + ) + } catch (e: Exception) { + // Fallback: open the general battery optimization settings screen. + // Covers OEMs that block the direct intent (some Xiaomi/MIUI builds). + try { + startActivity( + Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + ) + } catch (e2: Exception) { + e2.printStackTrace() + } + } + } + } } } else { wakeLock?.release() diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/AccentColorPicker.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/AccentColorPicker.kt index 62e90968..c5f30518 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/AccentColorPicker.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/AccentColorPicker.kt @@ -9,8 +9,11 @@ import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import com.droidspaces.app.R import com.droidspaces.app.ui.theme.ThemePalette /** @@ -27,8 +30,10 @@ fun AccentColorPicker( modifier: Modifier = Modifier ) { Column(modifier = modifier.fillMaxWidth()) { - // Section header + // Section header — transparent container so it matches the surrounding + // Settings rows (Language / SwitchItem) instead of the darker default surface. ListItem( + colors = ListItemDefaults.colors(containerColor = Color.Transparent), leadingContent = { Icon( imageVector = Icons.Default.Palette, @@ -37,7 +42,7 @@ fun AccentColorPicker( }, headlineContent = { Text( - text = "Accent Color", + text = stringResource(R.string.accent_color), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold ) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerCard.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerCard.kt index bec8a867..ba426c3c 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerCard.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerCard.kt @@ -37,25 +37,44 @@ import com.droidspaces.app.util.ContainerOSInfoManager import com.droidspaces.app.util.ContainerStatus import com.droidspaces.app.util.IconUtils +/** + * Per-item callbacks for [ContainerCard], grouped so the call site passes one + * stable object instead of 11 separate lambdas. + */ +data class ContainerCardActions( + val onStart: () -> Unit = {}, + val onStop: () -> Unit = {}, + val onRestart: () -> Unit = {}, + val onEdit: () -> Unit = {}, + val onEnter: () -> Unit = {}, + val onUninstall: () -> Unit = {}, + val onMigrate: () -> Unit = {}, + val onResize: () -> Unit = {}, + val onExport: () -> Unit = {}, + val onToggleExpand: () -> Unit = {}, + val onShowLogs: () -> Unit = {}, +) + @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun ContainerCard( container: ContainerInfo, - onStart: () -> Unit = {}, - onStop: () -> Unit = {}, - onRestart: () -> Unit = {}, - onEdit: () -> Unit = {}, - onEnter: () -> Unit = {}, - onUninstall: () -> Unit = {}, - onMigrate: () -> Unit = {}, - onResize: () -> Unit = {}, - onExport: () -> Unit = {}, + actions: ContainerCardActions = ContainerCardActions(), isOperationRunning: Boolean = false, isExpanded: Boolean = false, - onToggleExpand: () -> Unit = {}, - onShowLogs: () -> Unit = {}, modifier: Modifier = Modifier ) { + val onStart = actions.onStart + val onStop = actions.onStop + val onRestart = actions.onRestart + val onEdit = actions.onEdit + val onEnter = actions.onEnter + val onUninstall = actions.onUninstall + val onMigrate = actions.onMigrate + val onResize = actions.onResize + val onExport = actions.onExport + val onToggleExpand = actions.onToggleExpand + val onShowLogs = actions.onShowLogs val context = LocalContext.current val cardShape = RoundedCornerShape(20.dp) @@ -126,26 +145,7 @@ fun ContainerCard( ContainerStatus.STOPPED -> context.getString(R.string.status_stopped) to MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) } - Surface( - color = statusColor.copy(alpha = 0.1f), - shape = RoundedCornerShape(8.dp), - border = BorderStroke(1.dp, statusColor.copy(alpha = 0.2f)) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) - ) { - Surface(modifier = Modifier.size(6.dp), shape = CircleShape, color = statusColor) {} - Text( - text = statusText.uppercase(), - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Black, - letterSpacing = 0.5.sp, - color = statusColor - ) - } - } + StatusPill(label = statusText.uppercase(), color = statusColor) } } @@ -158,7 +158,7 @@ fun ContainerCard( // Info Rows val displayHostname = container.hostname.takeIf { it.isNotEmpty() } ?: container.name val hasSparseImage = container.useSparseImage && container.sparseImageSizeGB != null - val netModeLabel = when (container.netMode) { "nat" -> context.getString(R.string.network_mode_nat_short); "none" -> context.getString(R.string.network_mode_none_short); else -> context.getString(R.string.network_mode_host_short) } + val netModeLabel = when (container.netMode) { "nat" -> context.getString(R.string.network_mode_nat_short); "none" -> context.getString(R.string.network_mode_none_short); "gateway" -> context.getString(R.string.network_mode_gateway_short); else -> context.getString(R.string.network_mode_host_short) } Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { Icon(Icons.Default.Computer, null, modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)) Text(context.getString(R.string.hostname_label, displayHostname), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)) @@ -172,22 +172,26 @@ fun ContainerCard( } } - // Options Row - val options = mutableListOf() - if (container.disableIPv6) options.add(context.getString(R.string.ipv6_option)) - if (container.enableAndroidStorage) options.add(context.getString(R.string.storage_option)) - if (container.enableHwAccess) options.add(context.getString(R.string.hw_option)) - if (container.enableGpuMode || container.enableHwAccess) options.add(context.getString(R.string.gpu_option)) - if (container.enableTermuxX11) options.add(context.getString(R.string.x11_option)) - if (container.enableVirgl) options.add(context.getString(R.string.virgl_option)) - if (container.enablePulseaudio) options.add(context.getString(R.string.pulseaudio_option)) - if (container.selinuxPermissive) options.add(context.getString(R.string.selinux_permissive_option)) - if (container.volatileMode) options.add(context.getString(R.string.volatile_option)) - if (container.forceCgroupv1) options.add(context.getString(R.string.cgroup_v1_option)) - if (container.blockNestedNs) options.add(context.getString(R.string.deadlock_shield_option)) - if (container.privileged.isNotEmpty()) options.add(context.getString(R.string.privileged_option)) - if (container.customInit.isNotEmpty()) options.add(context.getString(R.string.custom_init_option)) - if (container.runAtBoot) options.add(context.getString(R.string.run_at_boot)) + // Options Row — depends only on stable container fields, so build once per container. + val options = remember(container) { + buildList { + if (container.disableIPv6) add(context.getString(R.string.ipv6_option)) + if (container.enableAndroidStorage) add(context.getString(R.string.storage_option)) + if (container.enableHwAccess) add(context.getString(R.string.hw_option)) + if (container.enableGpuMode || container.enableHwAccess) add(context.getString(R.string.gpu_option)) + if (container.enableTermuxX11) add(context.getString(R.string.x11_option)) + if (container.enableVirgl) add(context.getString(R.string.virgl_option)) + if (container.enablePulseaudio) add(context.getString(R.string.pulseaudio_option)) + if (container.selinuxPermissive) add(context.getString(R.string.selinux_permissive_option)) + if (container.allowUserns) add(context.getString(R.string.userns_option)) + if (container.volatileMode) add(context.getString(R.string.volatile_option)) + if (container.forceCgroupv1) add(context.getString(R.string.cgroup_v1_option)) + if (container.blockNestedNs) add(context.getString(R.string.deadlock_shield_option)) + if (container.privileged.isNotEmpty()) add(context.getString(R.string.privileged_option)) + if (container.customInit.isNotEmpty()) add(context.getString(R.string.custom_init_option)) + if (container.runAtBoot) add(context.getString(R.string.run_at_boot)) + } + } if (options.isNotEmpty()) { Text(context.getString(R.string.options_label, options.joinToString(", ")), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)) } @@ -198,7 +202,7 @@ fun ContainerCard( .fillMaxWidth() .padding(horizontal = 4.dp), color = MaterialTheme.colorScheme.surfaceContainerHigh, // Depth for secondary actions - shape = RoundedCornerShape(12.dp), + shape = RoundedCornerShape(20.dp), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)) ) { Row(modifier = Modifier.fillMaxWidth().padding(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) { diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerConfigForm.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerConfigForm.kt new file mode 100644 index 00000000..07f0c6ad --- /dev/null +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/ContainerConfigForm.kt @@ -0,0 +1,744 @@ +package com.droidspaces.app.ui.component + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.VolumeUp +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.AutoDelete +import androidx.compose.material.icons.filled.Code +import androidx.compose.material.icons.filled.Computer +import androidx.compose.material.icons.filled.Cyclone +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Devices +import androidx.compose.material.icons.filled.Dns +import androidx.compose.material.icons.filled.GppBad +import androidx.compose.material.icons.filled.GppMaybe +import androidx.compose.material.icons.filled.Groups +import androidx.compose.material.icons.filled.Layers +import androidx.compose.material.icons.filled.Memory +import androidx.compose.material.icons.filled.NetworkCheck +import androidx.compose.material.icons.filled.PowerSettingsNew +import androidx.compose.material.icons.filled.Public +import androidx.compose.material.icons.filled.Security +import androidx.compose.material.icons.filled.Storage +import androidx.compose.material.icons.filled.Terminal +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.droidspaces.app.R +import com.droidspaces.app.ui.util.rememberClearFocus +import com.droidspaces.app.util.BindMount +import com.droidspaces.app.util.Constants +import com.droidspaces.app.util.ContainerConfigState +import com.droidspaces.app.util.ContainerInfo +import com.droidspaces.app.util.GatewayErrors +import com.droidspaces.app.util.ValidationUtils + +/** + * The single, shared container-configuration form used by both the Create wizard + * ([com.droidspaces.app.ui.screen.ContainerConfigScreen]) and the Edit screen + * ([com.droidspaces.app.ui.screen.EditContainerScreen]). + * + * State is fully hoisted: the caller owns a [ContainerConfigState] and receives + * every edit via [onStateChange]. Transient UI (dialog visibility, NAT octet + * text) stays local. [gatewayErrors]/[collisionContainer] are computed by the + * caller (which also needs them to gate its action button) and passed in for + * display. [leadingContent] renders caller-specific header rows (e.g. the Edit + * screen's hostname field) at the top of the scrolling column. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ContainerConfigForm( + state: ContainerConfigState, + onStateChange: (ContainerConfigState) -> Unit, + installedContainers: List, + selfName: String, + gatewayErrors: GatewayErrors, + collisionContainer: ContainerInfo?, + modifier: Modifier = Modifier, + leadingContent: @Composable ColumnScope.() -> Unit = {}, +) { + val context = LocalContext.current + val clearFocus = rememberClearFocus() + + var showFilePicker by remember { mutableStateOf(false) } + var showDestDialog by remember { mutableStateOf(false) } + var tempSrcPath by remember { mutableStateOf("") } + var showEnvDialog by remember { mutableStateOf(false) } + var showPrivilegedDialog by remember { mutableStateOf(false) } + var showHwAccessDialog by remember { mutableStateOf(false) } + + val modernFieldShape = RoundedCornerShape(16.dp) + val modernFieldColors = DsTextFieldDefaults.colors() + + if (showFilePicker) { + FilePickerDialog( + onDismiss = { showFilePicker = false }, + onConfirm = { path -> + tempSrcPath = path + showFilePicker = false + showDestDialog = true + } + ) + } + + if (showDestDialog) { + var destPath by remember { mutableStateOf("") } + var roEnabled by remember { mutableStateOf(false) } + Dialog( + onDismissRequest = { showDestDialog = false }, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .imePadding(), + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surfaceContainer, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), + tonalElevation = 0.dp + ) { + Column(modifier = Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text(context.getString(R.string.enter_container_path), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + OutlinedTextField( + value = destPath, + onValueChange = { destPath = it }, + label = { Text(context.getString(R.string.container_path_placeholder)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + shape = modernFieldShape, + colors = modernFieldColors + ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(context.getString(R.string.read_only), style = MaterialTheme.typography.bodyMedium) + Switch(checked = roEnabled, onCheckedChange = { roEnabled = it }) + } + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Surface( + modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable(onClick = { clearFocus(); showDestDialog = false }), + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), + tonalElevation = 0.dp + ) { + Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { + Text(context.getString(R.string.cancel), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold) + } + } + Surface( + modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable( + enabled = destPath.startsWith("/"), + onClick = { + clearFocus() + if (destPath.isNotBlank()) { + onStateChange(state.copy(bindMounts = state.bindMounts + BindMount(tempSrcPath, destPath, roEnabled))) + showDestDialog = false + } + } + ), + shape = RoundedCornerShape(14.dp), + color = if (destPath.startsWith("/")) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), + tonalElevation = 0.dp + ) { + Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { + Text( + context.getString(R.string.ok), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = if (destPath.startsWith("/")) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + ) + } + } + } + } + } + } + } + + if (showPrivilegedDialog) { + PrivilegedModeDialog( + initialPrivileged = state.privileged, + onConfirm = { tags -> + onStateChange(state.copy(privileged = tags)) + showPrivilegedDialog = false + }, + onDismiss = { showPrivilegedDialog = false } + ) + } + + if (showHwAccessDialog) { + HardwareAccessDialog( + onConfirm = { + onStateChange(state.copy(enableHwAccess = true)) + showHwAccessDialog = false + }, + onDismiss = { showHwAccessDialog = false } + ) + } + + if (showEnvDialog) { + EnvironmentVariablesDialog( + initialContent = state.envFileContent, + onConfirm = { newContent -> + onStateChange(state.copy(envFileContent = newContent)) + showEnvDialog = false + }, + onDismiss = { showEnvDialog = false } + ) + } + + Column( + modifier = modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp) + .padding(top = 8.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + leadingContent() + + Text( + text = context.getString(R.string.cat_networking), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 8.dp) + ) + + DsDropdown( + label = context.getString(R.string.network_mode), + selected = state.netMode, + options = listOf("nat", "host", "none", "gateway"), + displayName = { context.getString(when (it) { "nat" -> R.string.network_mode_nat; "none" -> R.string.network_mode_none; "gateway" -> R.string.network_mode_gateway; else -> R.string.network_mode_host }) }, + onSelect = { mode -> + clearFocus() + onStateChange(state.copy(netMode = mode, disableIPv6 = if (mode != "host") false else state.disableIPv6)) + }, + leadingIcon = Icons.Default.Public + ) + + GatewaySettingsSection( + visible = state.netMode == "gateway", + config = GatewayConfig(state.gatewayContainer, state.gatewayNet, state.gatewayIface, state.gatewayBridge), + onConfigChange = { c -> + // Preserve original behavior: clear focus only on gateway-container + // selection (a dropdown pick), not while typing net/iface/bridge. + if (c.container != state.gatewayContainer) clearFocus() + onStateChange(state.copy(gatewayContainer = c.container, gatewayNet = c.net, gatewayIface = c.iface, gatewayBridge = c.bridge)) + }, + selfName = selfName, + installedContainers = installedContainers, + errors = gatewayErrors + ) + + if (state.netMode == "nat") { + Column( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = context.getString(R.string.nat_settings), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary + ) + + Text( + text = context.getString(R.string.static_ip_address), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 16.dp) + ) + Text( + text = context.getString(R.string.static_ip_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier.padding(bottom = 8.dp) + ) + + val octets = remember(state.staticNatIp) { + val parts = state.staticNatIp.split(".") + if (parts.size == 4) Pair(parts[2], parts[3]) else Pair("", "") + } + var octet3 by remember(octets) { mutableStateOf(octets.first) } + var octet4 by remember(octets) { mutableStateOf(octets.second) } + + val updateIp = { o3: String, o4: String -> + onStateChange( + state.copy( + staticNatIp = if (o3.isBlank() && o4.isBlank()) "" else "${Constants.NAT_IP_PREFIX}.$o3.$o4" + ) + ) + } + + val isOctet3Valid = remember(octet3) { + octet3.isEmpty() || (octet3.toIntOrNull()?.let { it in Constants.NAT_OCTET_MIN..Constants.NAT_OCTET_MAX } ?: false) + } + val isOctet4Valid = remember(octet4) { + octet4.isEmpty() || (octet4.toIntOrNull()?.let { it in Constants.NAT_OCTET_MIN..Constants.NAT_OCTET_MAX } ?: false) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "${Constants.NAT_IP_PREFIX}.", + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(top = 8.dp) + ) + OutlinedTextField( + value = octet3, + onValueChange = { + if (it.length <= 3 && it.all { c -> c.isDigit() }) { + octet3 = it + updateIp(it, octet4) + } + }, + label = { Text(context.getString(R.string.octet_label, 3)) }, + modifier = Modifier.weight(1f), + singleLine = true, + shape = modernFieldShape, + colors = modernFieldColors, + isError = !isOctet3Valid, + supportingText = { if (!isOctet3Valid) Text(context.getString(R.string.error_octet_range)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) + ) + Text( + text = ".", + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(top = 8.dp) + ) + OutlinedTextField( + value = octet4, + onValueChange = { + if (it.length <= 3 && it.all { c -> c.isDigit() }) { + octet4 = it + updateIp(octet3, it) + } + }, + label = { Text(context.getString(R.string.octet_label, 4)) }, + modifier = Modifier.weight(1f), + singleLine = true, + shape = modernFieldShape, + colors = modernFieldColors, + isError = !isOctet4Valid, + supportingText = { if (!isOctet4Valid) Text(context.getString(R.string.error_octet_range)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) + ) + } + + if (collisionContainer != null) { + Text( + text = context.getString(R.string.error_ip_collision, collisionContainer.name), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 4.dp) + ) + } + + Text( + text = context.getString(R.string.upstream_interface_title), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = context.getString(R.string.upstream_interface_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + UpstreamInterfaceList( + upstreamInterfaces = state.upstreamInterfaces, + onInterfacesChange = { onStateChange(state.copy(upstreamInterfaces = it)) } + ) + + Text( + text = context.getString(R.string.port_forwarding), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 16.dp) + ) + PortForwardingList( + portForwards = state.portForwards, + onPortForwardsChange = { onStateChange(state.copy(portForwards = it)) } + ) + } + } + + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.25f), + thickness = 1.dp + ) + + val isDnsError = remember(state.dnsServers) { + state.dnsServers.isNotEmpty() && !state.dnsServers.all { it.isDigit() || it == '.' || it == ':' || it == ',' } + } + OutlinedTextField( + value = state.dnsServers, + onValueChange = { onStateChange(state.copy(dnsServers = it)) }, + label = { Text(context.getString(R.string.dns_servers_label)) }, + supportingText = { if (isDnsError) Text(context.getString(R.string.dns_servers_hint)) }, + isError = isDnsError, + placeholder = { Text(context.getString(R.string.dns_servers_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = modernFieldShape, + colors = modernFieldColors, + leadingIcon = { Icon(Icons.Default.Dns, contentDescription = null) } + ) + + val ipv6IsForced = state.netMode != "host" + ToggleCard( + icon = Icons.Default.NetworkCheck, + title = context.getString(R.string.disable_ipv6), + description = if (ipv6IsForced) context.getString(R.string.disable_ipv6_nat_forced) else context.getString(R.string.disable_ipv6_description), + checked = if (ipv6IsForced) true else state.disableIPv6, + onCheckedChange = { clearFocus(); onStateChange(state.copy(disableIPv6 = it)) }, + enabled = !ipv6IsForced + ) + + Text( + text = context.getString(R.string.cat_integration), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 16.dp) + ) + + ToggleCard( + icon = Icons.Default.Storage, + title = context.getString(R.string.android_storage), + description = context.getString(R.string.android_storage_description), + checked = state.enableAndroidStorage, + onCheckedChange = { clearFocus(); onStateChange(state.copy(enableAndroidStorage = it)) } + ) + + ToggleCard( + icon = Icons.Default.Devices, + title = context.getString(R.string.hardware_access), + description = context.getString(R.string.hardware_access_description), + checked = state.enableHwAccess, + onCheckedChange = { newValue -> + clearFocus() + if (newValue) showHwAccessDialog = true else onStateChange(state.copy(enableHwAccess = false)) + } + ) + + ToggleCard( + icon = Icons.Default.Memory, + title = context.getString(R.string.gpu_access), + description = context.getString(R.string.gpu_access_description), + checked = if (state.enableHwAccess) true else state.enableGpuMode, + onCheckedChange = { if (!state.enableHwAccess) { clearFocus(); onStateChange(state.copy(enableGpuMode = it)) } }, + enabled = !state.enableHwAccess + ) + + ToggleCard( + painter = painterResource(R.drawable.ic_x11), + title = context.getString(R.string.termux_x11), + description = context.getString(R.string.termux_x11_description), + checked = state.enableTermuxX11, + onCheckedChange = { onStateChange(state.copy(enableTermuxX11 = it)) }, + enabled = true + ) + + ToggleCard( + icon = Icons.Default.Layers, + title = context.getString(R.string.enable_virgl), + description = context.getString(R.string.enable_virgl_description), + checked = state.enableVirgl, + onCheckedChange = { clearFocus(); onStateChange(state.copy(enableVirgl = it)) }, + enabled = true + ) + + ToggleCard( + icon = Icons.AutoMirrored.Filled.VolumeUp, + title = context.getString(R.string.enable_pulseaudio), + description = context.getString(R.string.enable_pulseaudio_description), + checked = state.enablePulseaudio, + onCheckedChange = { clearFocus(); onStateChange(state.copy(enablePulseaudio = it)) }, + enabled = true + ) + + Text( + text = context.getString(R.string.cat_security), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 16.dp) + ) + + ToggleCard( + icon = Icons.Default.Security, + title = context.getString(R.string.selinux_permissive), + description = context.getString(R.string.selinux_permissive_description), + checked = state.selinuxPermissive, + onCheckedChange = { clearFocus(); onStateChange(state.copy(selinuxPermissive = it)) } + ) + + val isSeccompDisabled = state.privileged.contains("noseccomp") || state.privileged.contains("full") + // /proc/self/setgroups only exists when CONFIG_USER_NS is enabled. + val usernsSupported = remember { java.io.File("/proc/self/setgroups").exists() } + + LaunchedEffect(isSeccompDisabled, usernsSupported) { + var s = state + if (isSeccompDisabled) s = s.copy(blockNestedNs = false) + if (isSeccompDisabled && usernsSupported) s = s.copy(allowUserns = true) + if (!usernsSupported) s = s.copy(allowUserns = false) + if (s != state) onStateChange(s) + } + + ToggleCard( + icon = Icons.Default.Groups, + title = context.getString(R.string.allow_userns), + description = if (usernsSupported) context.getString(R.string.allow_userns_description) else context.getString(R.string.allow_userns_description_not_supported), + checked = state.allowUserns, + onCheckedChange = { clearFocus(); onStateChange(state.copy(allowUserns = it)) }, + enabled = !isSeccompDisabled && usernsSupported + ) + + ToggleCard( + icon = Icons.Default.AutoDelete, + title = context.getString(R.string.volatile_mode), + description = context.getString(R.string.volatile_mode_description), + checked = state.volatileMode, + onCheckedChange = { clearFocus(); onStateChange(state.copy(volatileMode = it)) } + ) + + ToggleCard( + icon = Icons.Default.Cyclone, + title = context.getString(R.string.force_cgroupv1), + description = context.getString(R.string.force_cgroupv1_description), + checked = state.forceCgroupv1, + onCheckedChange = { clearFocus(); onStateChange(state.copy(forceCgroupv1 = it)) } + ) + + ToggleCard( + icon = Icons.Default.GppBad, + title = context.getString(R.string.manual_deadlock_shield), + description = context.getString(R.string.manual_deadlock_shield_description), + checked = if (isSeccompDisabled) false else state.blockNestedNs, + onCheckedChange = { clearFocus(); onStateChange(state.copy(blockNestedNs = it)) }, + enabled = !isSeccompDisabled + ) + + SettingsRowCard( + title = context.getString(R.string.privileged_mode), + subtitle = if (state.privileged.isEmpty()) context.getString(R.string.not_configured) else state.privileged, + description = context.getString(R.string.privileged_mode_description), + icon = Icons.Default.GppMaybe, + onClick = { clearFocus(); showPrivilegedDialog = true } + ) + + ToggleCard( + icon = Icons.Default.PowerSettingsNew, + title = context.getString(R.string.run_at_boot), + description = context.getString(R.string.run_at_boot_description), + checked = state.runAtBoot, + onCheckedChange = { clearFocus(); onStateChange(state.copy(runAtBoot = it)) } + ) + + Text( + text = context.getString(R.string.cat_advanced), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 16.dp) + ) + + val envCount = ValidationUtils.countEnvVars(state.envFileContent) + val envSubtitle = if (envCount > 0) { + context.getString(R.string.environment_variables_configured, envCount) + } else { + context.getString(R.string.not_configured) + } + SettingsRowCard( + title = context.getString(R.string.environment_variables), + subtitle = envSubtitle, + icon = Icons.Default.Code, + onClick = { clearFocus(); showEnvDialog = true } + ) + + if (state.customInit.isNotEmpty()) { + Surface( + color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.15f), + shape = RoundedCornerShape(16.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.3f)), + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon(imageVector = Icons.Default.Warning, contentDescription = null, tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(20.dp)) + Text(text = context.getString(R.string.custom_init_warning), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + } + } + } + + OutlinedTextField( + value = state.customInit, + onValueChange = { newValue -> onStateChange(state.copy(customInit = newValue.filter { !it.isWhitespace() })) }, + label = { Text(context.getString(R.string.custom_init_label)) }, + placeholder = { Text(context.getString(R.string.custom_init_placeholder)) }, + supportingText = { + if (state.customInit.isNotEmpty() && !state.customInit.startsWith("/")) { + Text(context.getString(R.string.custom_init_error_absolute), color = MaterialTheme.colorScheme.error) + } else { + Text(context.getString(R.string.custom_init_hint)) + } + }, + isError = state.customInit.isNotEmpty() && !state.customInit.startsWith("/"), + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = modernFieldShape, + colors = modernFieldColors, + leadingIcon = { Icon(Icons.Default.Terminal, contentDescription = null) } + ) + + AnimatedVisibility( + visible = state.enableTermuxX11, + enter = expandVertically(animationSpec = tween(durationMillis = 300)) + fadeIn(animationSpec = tween(durationMillis = 300)), + exit = shrinkVertically(animationSpec = tween(durationMillis = 300)) + fadeOut(animationSpec = tween(durationMillis = 300)) + ) { + OutlinedTextField( + value = state.tx11ExtraFlags, + onValueChange = { onStateChange(state.copy(tx11ExtraFlags = it)) }, + label = { Text(context.getString(R.string.tx11_extra_flags_label)) }, + placeholder = { Text(context.getString(R.string.tx11_extra_flags_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = modernFieldShape, + colors = modernFieldColors, + leadingIcon = { Icon(painter = painterResource(R.drawable.ic_x11), contentDescription = null, modifier = Modifier.size(15.dp)) } + ) + } + + AnimatedVisibility( + visible = state.enableVirgl, + enter = expandVertically(animationSpec = tween(durationMillis = 300)) + fadeIn(animationSpec = tween(durationMillis = 300)), + exit = shrinkVertically(animationSpec = tween(durationMillis = 300)) + fadeOut(animationSpec = tween(durationMillis = 300)) + ) { + OutlinedTextField( + value = state.virglExtraFlags, + onValueChange = { onStateChange(state.copy(virglExtraFlags = it)) }, + label = { Text(context.getString(R.string.virgl_extra_flags_label)) }, + placeholder = { Text(context.getString(R.string.virgl_extra_flags_placeholder)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = modernFieldShape, + colors = modernFieldColors, + leadingIcon = { Icon(Icons.Default.Layers, contentDescription = null) } + ) + } + + Row( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(text = context.getString(R.string.bind_mounts), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + } + + state.bindMounts.forEach { mount -> + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + ) { + Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text(text = context.getString(R.string.host_path, mount.src), style = MaterialTheme.typography.bodyMedium, overflow = TextOverflow.Ellipsis, maxLines = 1) + Text(text = context.getString(R.string.container_path, mount.dest), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary, overflow = TextOverflow.Ellipsis, maxLines = 1) + if (mount.ro) { + Surface(shape = RoundedCornerShape(6.dp), color = MaterialTheme.colorScheme.secondaryContainer, modifier = Modifier.padding(top = 4.dp)) { + Text(text = context.getString(R.string.read_only), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSecondaryContainer, modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)) + } + } + } + IconButton(onClick = { onStateChange(state.copy(bindMounts = state.bindMounts - mount)) }) { + Icon(Icons.Default.Delete, contentDescription = null, tint = MaterialTheme.colorScheme.error) + } + } + } + } + + val addBindBtnShape = RoundedCornerShape(16.dp) + Surface( + modifier = Modifier.fillMaxWidth().clip(addBindBtnShape).clickable( + onClick = { showFilePicker = true }, + indication = rememberRipple(bounded = true), + interactionSource = remember { MutableInteractionSource() } + ), + shape = addBindBtnShape, + color = MaterialTheme.colorScheme.surfaceContainerLow, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), + tonalElevation = 0.dp + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = context.getString(R.string.add_bind_mount), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } +} diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/DangerousActionConfirm.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/DangerousActionConfirm.kt new file mode 100644 index 00000000..55fe52da --- /dev/null +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/DangerousActionConfirm.kt @@ -0,0 +1,83 @@ +package com.droidspaces.app.ui.component + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.droidspaces.app.R + +/** + * The red "you are about to do something dangerous" disclaimer card shared by + * PrivilegedModeDialog and HardwareAccessDialog. + */ +@Composable +fun DangerousWarningCard(title: String, text: String, modifier: Modifier = Modifier) { + Surface( + color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f), + shape = RoundedCornerShape(16.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.2f)), + modifier = modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } +} + +/** + * The "type the confirmation phrase to proceed" gate shared by the dangerous + * dialogs: an instruction line plus a validated text field. [isError] highlights + * a non-empty, not-yet-matching entry. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConfirmPhraseField( + value: String, + onValueChange: (String) -> Unit, + isError: Boolean, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = context.getString(R.string.privileged_confirm_instruction), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold + ) + OutlinedTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text(context.getString(R.string.i_understand_caps)) }, + singleLine = true, + isError = isError, + shape = RoundedCornerShape(14.dp), + colors = DsTextFieldDefaults.surfaceColors() + ) + } +} diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/DialogFooterRow.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/DialogFooterRow.kt new file mode 100644 index 00000000..901564a9 --- /dev/null +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/DialogFooterRow.kt @@ -0,0 +1,70 @@ +package com.droidspaces.app.ui.component + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +/** + * Shared "Cancel / Confirm" dialog footer: two equal-weight rounded [Surface] + * buttons. Replaces the identical two-Surface footer that was copy-pasted across + * the app's dialogs (DT-5). The confirm button dims and disables via + * [confirmEnabled]; [cancelBorderAlpha] and [textFontWeight] keep the small + * per-dialog cosmetic differences. + */ +@Composable +fun DialogFooterRow( + dismissLabel: String, + confirmLabel: String, + onDismiss: () -> Unit, + onConfirm: () -> Unit, + modifier: Modifier = Modifier, + confirmEnabled: Boolean = true, + cancelBorderAlpha: Float = 0.4f, + textFontWeight: FontWeight = FontWeight.SemiBold, + confirmColor: Color = MaterialTheme.colorScheme.primary, + confirmContentColor: Color = MaterialTheme.colorScheme.onPrimary, +) { + Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Surface( + modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable(onClick = onDismiss), + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = cancelBorderAlpha)), + tonalElevation = 0.dp + ) { + Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { + Text(dismissLabel, style = MaterialTheme.typography.labelLarge, fontWeight = textFontWeight) + } + } + Surface( + modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable(enabled = confirmEnabled, onClick = onConfirm), + shape = RoundedCornerShape(14.dp), + color = if (confirmEnabled) confirmColor else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), + tonalElevation = 0.dp + ) { + Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { + Text( + confirmLabel, + style = MaterialTheme.typography.labelLarge, + fontWeight = textFontWeight, + color = if (confirmEnabled) confirmContentColor else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + ) + } + } + } +} diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/DroidspacesStatusCard.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/DroidspacesStatusCard.kt index 442ac8ca..d388d57c 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/DroidspacesStatusCard.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/DroidspacesStatusCard.kt @@ -115,26 +115,7 @@ fun DroidspacesStatusCard( // Right side: mode pill if (isWorking && backendMode != null) { - Surface( - color = accentColor.copy(alpha = 0.1f), - shape = RoundedCornerShape(8.dp), - border = BorderStroke(1.dp, accentColor.copy(alpha = 0.2f)) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) - ) { - Surface(modifier = Modifier.size(6.dp), shape = CircleShape, color = accentColor) {} - Text( - text = backendMode!!.uppercase(), - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Black, - letterSpacing = 0.5.sp, - color = accentColor - ) - } - } + StatusPill(label = backendMode!!.uppercase(), color = accentColor) } } diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/DsDropdown.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/DsDropdown.kt index ba850e43..bde05567 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/DsDropdown.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/DsDropdown.kt @@ -1,5 +1,7 @@ package com.droidspaces.app.ui.component +import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape @@ -8,6 +10,9 @@ import androidx.compose.material.icons.filled.Check import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.font.FontWeight @@ -22,22 +27,26 @@ fun DsDropdown( displayName: (T) -> String, onSelect: (T) -> Unit, modifier: Modifier = Modifier, - leadingIcon: ImageVector? = null + leadingIcon: ImageVector? = null, + isError: Boolean = false, + supportingText: String? = null, + enabled: Boolean = true ) { var expanded by remember { mutableStateOf(false) } val focusManager = LocalFocusManager.current - val fieldShape = RoundedCornerShape(16.dp) + val fieldShape = RoundedCornerShape(20.dp) val fieldColors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f), focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) ) ExposedDropdownMenuBox( - expanded = expanded, - onExpandedChange = { + expanded = expanded && enabled, + onExpandedChange = { + if (!enabled) return@ExposedDropdownMenuBox expanded = it if (!it) focusManager.clearFocus() }, @@ -47,7 +56,10 @@ fun DsDropdown( value = displayName(selected), onValueChange = {}, readOnly = true, + enabled = enabled, + isError = isError, label = { Text(label) }, + supportingText = supportingText?.let { { Text(it) } }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, leadingIcon = leadingIcon?.let { icon -> { Icon(icon, contentDescription = null) } }, shape = fieldShape, @@ -56,32 +68,52 @@ fun DsDropdown( .menuAnchor() .fillMaxWidth() ) - ExposedDropdownMenu( - expanded = expanded, - onDismissRequest = { - expanded = false - focusManager.clearFocus() - } + val isDark = MaterialTheme.colorScheme.background.luminance() < 0.5f + val dropdownColor = if (isDark) MaterialTheme.colorScheme.surfaceContainerHigh else MaterialTheme.colorScheme.surfaceContainer + + MaterialTheme( + colorScheme = MaterialTheme.colorScheme.copy( + surface = dropdownColor, + surfaceContainer = dropdownColor, + surfaceTint = Color.Transparent + ), + shapes = MaterialTheme.shapes.copy( + extraSmall = RoundedCornerShape(20.dp) + ) ) { - options.forEach { option -> - DropdownMenuItem( - text = { Text(displayName(option), fontWeight = FontWeight.Medium) }, - onClick = { - onSelect(option) - expanded = false - focusManager.clearFocus() - }, - leadingIcon = if (option == selected) { - { - Icon( - Icons.Default.Check, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.primary - ) - } - } else null - ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { + expanded = false + focusManager.clearFocus() + }, + modifier = Modifier + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f), + shape = RoundedCornerShape(20.dp) + ) + ) { + options.forEach { option -> + DropdownMenuItem( + text = { Text(displayName(option), fontWeight = FontWeight.Medium) }, + onClick = { + onSelect(option) + expanded = false + focusManager.clearFocus() + }, + leadingIcon = if (option == selected) { + { + Icon( + Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary + ) + } + } else null + ) + } } } } diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/DsTextFieldDefaults.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/DsTextFieldDefaults.kt new file mode 100644 index 00000000..beff820d --- /dev/null +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/DsTextFieldDefaults.kt @@ -0,0 +1,34 @@ +package com.droidspaces.app.ui.component + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.TextFieldColors +import androidx.compose.runtime.Composable + +/** + * Shared OutlinedTextField color sets. These exact color blocks were previously + * copy-pasted across a dozen call sites. Two visual variants exist in the app — + * both are preserved verbatim so nothing changes on screen: + * - [colors] container tint = surfaceContainerLow / surfaceVariant (forms, screens, dropdowns) + * - [surfaceColors] container tint = translucent surface (dialogs) + */ +object DsTextFieldDefaults { + @OptIn(ExperimentalMaterial3Api::class) + @Composable + fun colors(): TextFieldColors = OutlinedTextFieldDefaults.colors( + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, + focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ) + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + fun surfaceColors(): TextFieldColors = OutlinedTextFieldDefaults.colors( + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), + unfocusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), + focusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f) + ) +} diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/EnvironmentVariablesDialog.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/EnvironmentVariablesDialog.kt index f39c234f..abe5e58f 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/EnvironmentVariablesDialog.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/EnvironmentVariablesDialog.kt @@ -64,12 +64,7 @@ fun EnvironmentVariablesDialog( } val fieldShape = RoundedCornerShape(14.dp) - val fieldColors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), - unfocusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), - focusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f) - ) + val fieldColors = DsTextFieldDefaults.surfaceColors() Dialog( onDismissRequest = onDismiss, @@ -214,36 +209,13 @@ fun EnvironmentVariablesDialog( Spacer(modifier = Modifier.height(16.dp)) // Action buttons - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Surface( - modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable(onClick = onDismiss), - shape = RoundedCornerShape(14.dp), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)), - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { - Text(context.getString(R.string.cancel), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold) - } - } - Surface( - modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable( - onClick = { onConfirm(serializeEnvVars(vars)) } - ), - shape = RoundedCornerShape(14.dp), - color = MaterialTheme.colorScheme.primary, - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { - Text( - confirmLabel ?: context.getString(R.string.ok), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onPrimary - ) - } - } - } + DialogFooterRow( + dismissLabel = context.getString(R.string.cancel), + confirmLabel = confirmLabel ?: context.getString(R.string.ok), + onDismiss = onDismiss, + onConfirm = { onConfirm(serializeEnvVars(vars)) }, + cancelBorderAlpha = 0.35f + ) } } } diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/FilePickerDialog.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/FilePickerDialog.kt index e0099ea3..fec89fcf 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/FilePickerDialog.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/FilePickerDialog.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput +import com.droidspaces.app.util.ContainerCommandBuilder import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.draw.clip @@ -127,7 +128,7 @@ fun FilePickerDialog( } if (targetDir != currentPath && targetDir.isNotEmpty()) { val exists = withContext(Dispatchers.IO) { - val result = Shell.cmd("[ -d \"$targetDir\" ] && echo yes").exec() + val result = Shell.cmd("[ -d ${ContainerCommandBuilder.quote(targetDir)} ] && echo yes").exec() result.isSuccess && result.out.firstOrNull() == "yes" } if (exists) { @@ -216,12 +217,7 @@ fun FilePickerDialog( fontFamily = JetBrainsMono ), shape = RoundedCornerShape(14.dp), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - unfocusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), - focusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f) - ) + colors = DsTextFieldDefaults.surfaceColors() ) Spacer(modifier = Modifier.height(12.dp)) @@ -297,32 +293,13 @@ fun FilePickerDialog( Spacer(modifier = Modifier.height(16.dp)) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Surface( - modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable(onClick = onDismiss), - shape = RoundedCornerShape(14.dp), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f), - border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { - Text(context.getString(R.string.cancel), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold) - } - } - Surface( - modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable(onClick = { - clearFocus() - onConfirm(currentPath) - }), - shape = RoundedCornerShape(14.dp), - color = MaterialTheme.colorScheme.primary, - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { - Text(context.getString(R.string.select_folder), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onPrimary) - } - } - } + DialogFooterRow( + dismissLabel = context.getString(R.string.cancel), + confirmLabel = context.getString(R.string.select_folder), + onDismiss = onDismiss, + onConfirm = { clearFocus(); onConfirm(currentPath) }, + textFontWeight = FontWeight.Bold + ) } } } @@ -373,7 +350,7 @@ private fun FileItemRow( } private suspend fun fetchItems(path: String, showFiles: Boolean): List = withContext(Dispatchers.IO) { - val result = Shell.cmd("ls -F \"$path\" 2>/dev/null").exec() + val result = Shell.cmd("ls -F ${ContainerCommandBuilder.quote(path)} 2>/dev/null").exec() if (!result.isSuccess) return@withContext emptyList() result.out.mapNotNull { line -> diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/GatewaySettingsSection.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/GatewaySettingsSection.kt new file mode 100644 index 00000000..ea2b98c1 --- /dev/null +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/GatewaySettingsSection.kt @@ -0,0 +1,246 @@ +package com.droidspaces.app.ui.component + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Router +import androidx.compose.material.icons.filled.Tune +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.droidspaces.app.R +import com.droidspaces.app.util.ContainerInfo +import com.droidspaces.app.util.GatewayErrors +import com.droidspaces.app.util.ValidationUtils + +/** + * Gateway-mode settings block, shared by the installer and edit-container screens. + * Shows the required gateway-container dropdown plus a "Configure Gateway" button that + * opens a dialog for the optional interface / LAN-name / bridge overrides. All + * validation errors come from [errors]; the caller blocks Save/Next on `errors.isValid`. + */ +/** Grouped gateway-mode overrides (replaces 4 separate value/onChange param pairs). */ +data class GatewayConfig( + val container: String = "", + val net: String = "", + val iface: String = "", + val bridge: String = "", +) + +@Composable +fun GatewaySettingsSection( + visible: Boolean, + config: GatewayConfig, + onConfigChange: (GatewayConfig) -> Unit, + selfName: String, + installedContainers: List, + errors: GatewayErrors +) { + val context = LocalContext.current + var showDialog by remember { mutableStateOf(false) } + val candidates = remember(installedContainers, selfName) { + installedContainers.map { it.name }.filter { it != selfName } + } + // Error from any of the three advanced (dialog) fields, surfaced under the button. + val advancedError = errors.iface ?: errors.bridge ?: errors.net + + // Instant show/hide (no expand/shrink) so switching network modes stays smooth + // instead of fighting the NAT section's animation in the opposite direction. + if (visible) { + Column( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = context.getString(R.string.gateway_settings), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = context.getString(R.string.gateway_settings_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.secondary + ) + + // Required: which running container is the router. + val noCandidates = candidates.isEmpty() + DsDropdown( + label = context.getString(R.string.gateway_container), + selected = config.container, + options = candidates, + displayName = { it }, + onSelect = { onConfigChange(config.copy(container = it)) }, + leadingIcon = Icons.Default.Router, + isError = errors.container != null, + supportingText = if (noCandidates) + context.getString(R.string.error_no_gateway_candidates) + else + errors.container, + enabled = !noCandidates + ) + + // "Configure Gateway" entry — same row-card aesthetics as Privileged Mode, + // showing only title + description. + SettingsRowCard( + title = context.getString(R.string.gateway_configure), + subtitle = "", + description = context.getString(R.string.gateway_configure_intro), + icon = Icons.Default.Tune, + onClick = { showDialog = true } + ) + if (advancedError != null) { + Text( + text = advancedError, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(start = 4.dp) + ) + } + } + } + + if (showDialog) { + GatewayConfigureDialog( + selfName = selfName, + gatewayContainer = config.container, + installed = installedContainers, + initialNet = config.net, + initialIface = config.iface, + initialBridge = config.bridge, + onConfirm = { net, iface, bridge -> + onConfigChange(config.copy(net = net, iface = iface, bridge = bridge)) + showDialog = false + }, + onDismiss = { showDialog = false } + ) + } +} + +@Composable +private fun GatewayConfigureDialog( + selfName: String, + gatewayContainer: String, + installed: List, + initialNet: String, + initialIface: String, + initialBridge: String, + onConfirm: (net: String, iface: String, bridge: String) -> Unit, + onDismiss: () -> Unit +) { + val context = LocalContext.current + var net by remember { mutableStateOf(initialNet) } + var iface by remember { mutableStateOf(initialIface) } + var bridge by remember { mutableStateOf(initialBridge) } + + // Live-validate the three fields against every other container so collisions show + // before the user closes the dialog. + val errs = ValidationUtils.validateGatewayConfig( + selfName, gatewayContainer, net, iface, bridge, installed, context + ) + val advancedValid = errs.iface == null && errs.net == null && errs.bridge == null + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .imePadding(), + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surfaceContainer, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), + tonalElevation = 0.dp + ) { + Column( + modifier = Modifier + .padding(24.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + context.getString(R.string.gateway_configure_title), + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleLarge + ) + + // 1 — Interface in Gateway (most important). + ExplainedField( + title = context.getString(R.string.gateway_iface), + explanation = context.getString(R.string.gateway_iface_explain), + value = iface, + onChange = { iface = it }, + hint = context.getString(R.string.gateway_iface_hint), + error = errs.iface + ) + // 2 — LAN Name. + ExplainedField( + title = context.getString(R.string.gateway_net), + explanation = context.getString(R.string.gateway_net_explain), + value = net, + onChange = { net = it }, + hint = context.getString(R.string.gateway_net_hint), + error = errs.net + ) + // 3 — Host Bridge. + ExplainedField( + title = context.getString(R.string.gateway_bridge), + explanation = context.getString(R.string.gateway_bridge_explain), + value = bridge, + onChange = { bridge = it }, + hint = context.getString(R.string.gateway_bridge_hint), + error = errs.bridge + ) + + DialogFooterRow( + dismissLabel = context.getString(R.string.cancel), + confirmLabel = context.getString(R.string.ok), + onDismiss = onDismiss, + onConfirm = { onConfirm(net, iface, bridge) }, + confirmEnabled = advancedValid + ) + } + } + } +} + +@Composable +private fun ExplainedField( + title: String, + explanation: String, + value: String, + onChange: (String) -> Unit, + hint: String, + error: String? +) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold) + Text(explanation, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + OutlinedTextField( + value = value, + // Only interface-name characters are valid; filter the rest as the user types. + onValueChange = { input -> + onChange(input.filter { it.isLetterOrDigit() || it == '_' || it == '-' }) + }, + placeholder = { Text(hint) }, + isError = error != null, + supportingText = error?.let { { Text(it) } }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = DsTextFieldDefaults.colors() + ) + } +} diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/HardwareAccessDialog.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/HardwareAccessDialog.kt index 0b060738..74d0ce9a 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/HardwareAccessDialog.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/HardwareAccessDialog.kt @@ -53,97 +53,26 @@ fun HardwareAccessDialog( fontWeight = FontWeight.Bold ) - // Warning Card - Surface( - color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f), - shape = RoundedCornerShape(16.dp), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.2f)), - modifier = Modifier.fillMaxWidth() - ) { - Column(modifier = Modifier.padding(16.dp)) { - Text( - text = context.getString(R.string.privileged_warning_title), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.error - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = context.getString(R.string.hw_access_disclaimer), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onErrorContainer - ) - } - } + DangerousWarningCard( + title = context.getString(R.string.privileged_warning_title), + text = context.getString(R.string.hw_access_disclaimer) + ) - // Confirmation Gate - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - text = context.getString(R.string.privileged_confirm_instruction), - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.Bold - ) - OutlinedTextField( - value = confirmText, - onValueChange = { confirmText = it }, - modifier = Modifier.fillMaxWidth(), - placeholder = { Text(context.getString(R.string.i_understand_caps)) }, - singleLine = true, - isError = confirmText.isNotEmpty() && !isConfirmed, - shape = RoundedCornerShape(14.dp), - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), - unfocusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), - focusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f) - ) - ) - } + ConfirmPhraseField( + value = confirmText, + onValueChange = { confirmText = it }, + isError = confirmText.isNotEmpty() && !isConfirmed + ) - // Buttons - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Surface( - modifier = Modifier - .weight(1f) - .clip(RoundedCornerShape(14.dp)) - .clickable(onClick = onDismiss), - shape = RoundedCornerShape(14.dp), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { - Text( - context.getString(R.string.cancel), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold - ) - } - } - Surface( - modifier = Modifier - .weight(1f) - .clip(RoundedCornerShape(14.dp)) - .clickable( - enabled = isConfirmed, - onClick = onConfirm - ), - shape = RoundedCornerShape(14.dp), - color = if (isConfirmed) MaterialTheme.colorScheme.error - else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { - Text( - context.getString(R.string.ok), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - color = if (isConfirmed) MaterialTheme.colorScheme.onError - else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - ) - } - } - } + DialogFooterRow( + dismissLabel = context.getString(R.string.cancel), + confirmLabel = context.getString(R.string.ok), + onDismiss = onDismiss, + onConfirm = onConfirm, + confirmEnabled = isConfirmed, + confirmColor = MaterialTheme.colorScheme.error, + confirmContentColor = MaterialTheme.colorScheme.onError + ) } } } diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/HelpCard.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/HelpCard.kt index ac53ca4f..ebcbb466 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/HelpCard.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/HelpCard.kt @@ -2,14 +2,10 @@ package com.droidspaces.app.ui.component import android.content.Intent import android.net.Uri -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.* import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -26,20 +22,12 @@ fun HelpCard( val url = context.getString(R.string.help_card_url) val cardShape = RoundedCornerShape(20.dp) - val interactionSource = remember { MutableInteractionSource() } - Surface( - modifier = modifier - .fillMaxWidth() - .clip(cardShape) - .clickable( - interactionSource = interactionSource, - indication = rememberRipple(bounded = true), - onClick = { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) - context.startActivity(intent) - } - ), + onClick = { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + context.startActivity(intent) + }, + modifier = modifier.fillMaxWidth(), shape = cardShape, color = MaterialTheme.colorScheme.surfaceContainer, border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/PortForwardingList.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/PortForwardingList.kt index b5b123b9..d043589f 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/PortForwardingList.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/PortForwardingList.kt @@ -214,12 +214,7 @@ private fun AddPortForwardDialog( isError = hostError != null || widthError != null || overlapError != null, supportingText = { Text(hostError ?: widthError ?: overlapError ?: "") }, shape = RoundedCornerShape(16.dp), - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), - unfocusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), - focusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f) - ) + colors = DsTextFieldDefaults.surfaceColors() ) OutlinedTextField( @@ -232,12 +227,7 @@ private fun AddPortForwardDialog( isError = containerError != null || widthError != null || overlapError != null, supportingText = { Text(containerError ?: widthError ?: overlapError ?: context.getString(R.string.optional_symmetric_hint)) }, shape = RoundedCornerShape(16.dp), - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), - unfocusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), - focusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f) - ) + colors = DsTextFieldDefaults.surfaceColors() ) DsDropdown( @@ -249,41 +239,14 @@ private fun AddPortForwardDialog( ) } - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Surface( - modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable(onClick = onDismiss), - shape = RoundedCornerShape(14.dp), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)), - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { - Text(context.getString(R.string.cancel), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold) - } - } - Surface( - modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable( - enabled = isFormValid, - onClick = { - if (isFormValid) { - onConfirm(PortForward(hostPort.trim(), if (containerPort.isBlank()) null else containerPort.trim(), proto)) - } - } - ), - shape = RoundedCornerShape(14.dp), - color = if (isFormValid) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { - Text( - context.getString(R.string.add), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - color = if (isFormValid) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - ) - } - } - } + DialogFooterRow( + dismissLabel = context.getString(R.string.cancel), + confirmLabel = context.getString(R.string.add), + onDismiss = onDismiss, + onConfirm = { onConfirm(PortForward(hostPort.trim(), if (containerPort.isBlank()) null else containerPort.trim(), proto)) }, + confirmEnabled = isFormValid, + cancelBorderAlpha = 0.35f + ) } } } diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/PrimaryActionBottomBar.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/PrimaryActionBottomBar.kt new file mode 100644 index 00000000..9a0d8cf7 --- /dev/null +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/PrimaryActionBottomBar.kt @@ -0,0 +1,107 @@ +package com.droidspaces.app.ui.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp + +/** + * Shared "primary call-to-action" bottom bar: a full-width [Surface] with a top + * divider and a rounded primary action button (icon + label). Replaces the same + * Surface -> Column -> HorizontalDivider -> clickable Surface -> Box -> Row block + * that was copy-pasted across the wizard/detail screens (DT-4). + * + * The varying bits are parameters so each screen keeps its exact look: + * [enabled] toggles the disabled colors, [secondaryAction] renders an extra row + * (e.g. RootCheck's Skip), and [containerColor]/[disabledContainerColor] let a + * screen keep the button a constant color regardless of [enabled]. + */ +@Composable +fun PrimaryActionBottomBar( + label: String, + icon: ImageVector, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + containerColor: Color = MaterialTheme.colorScheme.primary, + contentColor: Color = MaterialTheme.colorScheme.onPrimary, + disabledContainerColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), + disabledContentColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), + barColor: Color = MaterialTheme.colorScheme.surfaceContainer, + dividerAlpha: Float = 0.25f, + horizontalPadding: Dp = 24.dp, + labelFontSize: TextUnit = TextUnit.Unspecified, + secondaryAction: (@Composable ColumnScope.() -> Unit)? = null, +) { + val btnShape = RoundedCornerShape(20.dp) + val bg = if (enabled) containerColor else disabledContainerColor + val fg = if (enabled) contentColor else disabledContentColor + Surface( + modifier = modifier.fillMaxWidth(), + color = barColor, + tonalElevation = 0.dp + ) { + Column(modifier = Modifier.fillMaxWidth()) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = dividerAlpha), + thickness = 1.dp + ) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontalPadding) + .navigationBarsPadding(), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(btnShape) + .clickable(enabled = enabled, onClick = onClick), + shape = btnShape, + color = bg, + tonalElevation = 0.dp + ) { + Box(modifier = Modifier.padding(vertical = 16.dp), contentAlignment = Alignment.Center) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp), tint = fg) + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + fontSize = labelFontSize, + color = fg + ) + } + } + } + secondaryAction?.invoke(this) + } + } + } +} diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/PrivilegedModeDialog.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/PrivilegedModeDialog.kt index 92bc321b..d40cc94b 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/PrivilegedModeDialog.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/PrivilegedModeDialog.kt @@ -95,28 +95,10 @@ fun PrivilegedModeDialog( fontWeight = FontWeight.Bold ) - // Disclaimer Card - Surface( - color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f), - shape = RoundedCornerShape(16.dp), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.2f)), - modifier = Modifier.fillMaxWidth() - ) { - Column(modifier = Modifier.padding(16.dp)) { - Text( - text = context.getString(R.string.privileged_warning_title), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.error - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = context.getString(R.string.privileged_disclaimer), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onErrorContainer - ) - } - } + DangerousWarningCard( + title = context.getString(R.string.privileged_warning_title), + text = context.getString(R.string.privileged_disclaimer) + ) // Granular Toggles using modern look Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { @@ -170,84 +152,38 @@ fun PrivilegedModeDialog( ) } - // Confirmation Gate + // Confirmation Gate (not needed when clearing all flags) if (!allOff) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - text = context.getString(R.string.privileged_confirm_instruction), - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.Bold - ) - OutlinedTextField( - value = confirmText, - onValueChange = { confirmText = it }, - modifier = Modifier.fillMaxWidth(), - placeholder = { Text(context.getString(R.string.i_understand_caps)) }, - singleLine = true, - isError = confirmText.isNotEmpty() && !isConfirmed, - shape = RoundedCornerShape(14.dp), - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), - unfocusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), - focusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f) - ) - ) - } + ConfirmPhraseField( + value = confirmText, + onValueChange = { confirmText = it }, + isError = confirmText.isNotEmpty() && !isConfirmed + ) } - // Buttons - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Surface( - modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable(onClick = onDismiss), - shape = RoundedCornerShape(14.dp), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { - Text(context.getString(R.string.cancel), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold) - } - } - Surface( - modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable( - enabled = isConfirmed || allOff, - onClick = { - val tags = mutableListOf() - if (full) { - tags.add("full") - } else { - if (nomask) tags.add("nomask") - if (nocaps) tags.add("nocaps") - if (noseccomp) tags.add("noseccomp") - if (shared) tags.add("shared") - if (unfiltered) tags.add("unfiltered-dev") - } - onConfirm(tags.joinToString(",")) - } - ), - shape = RoundedCornerShape(14.dp), - color = if (isConfirmed || allOff) { - if (allOff) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error + DialogFooterRow( + dismissLabel = context.getString(R.string.cancel), + confirmLabel = context.getString(R.string.ok), + onDismiss = onDismiss, + onConfirm = { + val tags = mutableListOf() + if (full) { + tags.add("full") } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f) - }, - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { - Text( - context.getString(R.string.ok), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - color = if (isConfirmed || allOff) { - if (allOff) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onError - } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - } - ) + if (nomask) tags.add("nomask") + if (nocaps) tags.add("nocaps") + if (noseccomp) tags.add("noseccomp") + if (shared) tags.add("shared") + if (unfiltered) tags.add("unfiltered-dev") } - } - } + onConfirm(tags.joinToString(",")) + }, + // allOff means "clear privileged mode" — a safe action, so it is + // enabled without the confirm phrase and uses the primary color. + confirmEnabled = isConfirmed || allOff, + confirmColor = if (allOff) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, + confirmContentColor = if (allOff) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onError + ) } } } diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/RootfsRepoSheet.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/RootfsRepoSheet.kt index e95e3582..3b9f4d39 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/RootfsRepoSheet.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/RootfsRepoSheet.kt @@ -367,26 +367,7 @@ private fun RootfsAssetCard( } if (displayLabel.isNotEmpty()) { - Surface( - color = statusColor.copy(alpha = 0.1f), - shape = RoundedCornerShape(8.dp), - border = BorderStroke(1.dp, statusColor.copy(alpha = 0.2f)) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) - ) { - Surface(modifier = Modifier.size(6.dp), shape = CircleShape, color = statusColor) {} - Text( - text = displayLabel, - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Black, - letterSpacing = 0.5.sp, - color = statusColor - ) - } - } + StatusPill(label = displayLabel, color = statusColor) } } @@ -668,12 +649,7 @@ private fun RepoManagerDialog( var urlError by remember { mutableStateOf("") } val fieldShape = RoundedCornerShape(14.dp) - val fieldColors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), - unfocusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), - focusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f) - ) + val fieldColors = DsTextFieldDefaults.surfaceColors() fun tryAdd() { val n = newName.trim(); val u = newUrl.trim() @@ -847,32 +823,18 @@ private fun RepoManagerDialog( Spacer(Modifier.height(16.dp)) // Single footer row: Close / Save - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Surface( - modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable(onClick = onDismiss), - shape = RoundedCornerShape(14.dp), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)) - ) { - Box(Modifier.padding(14.dp), contentAlignment = Alignment.Center) { - Text(context.getString(R.string.cancel), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold) - } - } - Surface( - modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable { - val currentUrls = repos.map { it.second }.toSet() - val toRemove = originalUrls.filter { it !in currentUrls } - val toAdd = repos.filter { it.second !in originalUrls } - onSave(toAdd, toRemove) - }, - shape = RoundedCornerShape(14.dp), - color = MaterialTheme.colorScheme.primary - ) { - Box(Modifier.padding(14.dp), contentAlignment = Alignment.Center) { - Text(context.getString(R.string.ok), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onPrimary) - } - } - } + DialogFooterRow( + dismissLabel = context.getString(R.string.cancel), + confirmLabel = context.getString(R.string.ok), + onDismiss = onDismiss, + onConfirm = { + val currentUrls = repos.map { it.second }.toSet() + val toRemove = originalUrls.filter { it !in currentUrls } + val toAdd = repos.filter { it.second !in originalUrls } + onSave(toAdd, toRemove) + }, + cancelBorderAlpha = 0.35f + ) } } } diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/RunningContainerCard.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/RunningContainerCard.kt index ea12423c..94655e3c 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/RunningContainerCard.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/RunningContainerCard.kt @@ -73,7 +73,7 @@ fun RunningContainerCard( modifier = Modifier.weight(1f) ) - val sessionCount by remember { + val sessionCount by remember(container.name) { derivedStateOf { TerminalSessionService.globalSessionList.values.count { it.containerName == container.name diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/SettingsCard.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/SettingsCard.kt new file mode 100644 index 00000000..0eddcb9d --- /dev/null +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/SettingsCard.kt @@ -0,0 +1,92 @@ +package com.droidspaces.app.ui.component + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +/** + * Shared settings-card shell: a rounded `surfaceContainerHigh` [Surface] with a + * leading icon, a title, an optional [subtitleContent] block and an optional + * [trailing] slot. [ToggleCard] and [SettingsRowCard] are thin wrappers over + * this (DT-9) — the two previously duplicated this whole layout. + */ +@Composable +fun SettingsCard( + title: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + icon: ImageVector? = null, + painter: Painter? = null, + subtitleContent: (@Composable ColumnScope.() -> Unit)? = null, + trailing: (@Composable () -> Unit)? = null, +) { + val alpha = if (enabled) 1f else 0.5f + Surface( + modifier = modifier.fillMaxWidth(), + onClick = onClick, + enabled = enabled, + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.weight(1f) + ) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary.copy(alpha = alpha) + ) + } else if (painter != null) { + Icon( + painter = painter, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary.copy(alpha = alpha) + ) + } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + subtitleContent?.invoke(this) + } + } + trailing?.invoke() + } + } +} diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/SettingsRowCard.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/SettingsRowCard.kt index 42bc476d..f6f8ba1a 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/SettingsRowCard.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/SettingsRowCard.kt @@ -1,19 +1,16 @@ package com.droidspaces.app.ui.component -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.* +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp /** - * Reusable settings row card component for non-toggle options. - * Provides consistent styling with ToggleCard but for clickable actions. + * Non-toggle settings row: a clickable [SettingsCard] with an optional + * [description] line and an accent [subtitle] line. */ @Composable fun SettingsRowCard( @@ -27,65 +24,29 @@ fun SettingsRowCard( description: String? = null ) { val alpha = if (enabled) 1f else 0.5f - Surface( - modifier = modifier.fillMaxWidth(), + SettingsCard( + title = title, onClick = onClick, + modifier = modifier, enabled = enabled, - shape = RoundedCornerShape(20.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh, - border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - modifier = Modifier.weight(1f) - ) { - if (icon != null) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.primary.copy(alpha = alpha) - ) - } else if (painter != null) { - Icon( - painter = painter, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.primary.copy(alpha = alpha) - ) - } - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold - ) - if (description != null) { - Text( - text = description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f * alpha) - ) - } - Text( - text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.9f * alpha), - fontWeight = FontWeight.Bold - ) - } + icon = icon, + painter = painter, + subtitleContent = { + if (description != null) { + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f * alpha) + ) + } + if (subtitle.isNotBlank()) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.9f * alpha), + fontWeight = FontWeight.Bold + ) } } - } + ) } diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/StatusBarStyle.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/StatusBarStyle.kt deleted file mode 100644 index fe927f33..00000000 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/StatusBarStyle.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.droidspaces.app.ui.component - -import androidx.activity.ComponentActivity -import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.LocalContext - -@Composable -fun StatusBarStyle( - darkMode: Boolean = isSystemInDarkTheme(), - statusBarScrim: Color = Color.Transparent, - navigationBarScrim: Color = Color.Transparent, -) { - val context = LocalContext.current - val activity = context as? ComponentActivity ?: return - - SideEffect { - activity.enableEdgeToEdge( - statusBarStyle = androidx.activity.SystemBarStyle.auto( - statusBarScrim.toArgb(), - statusBarScrim.toArgb(), - ) { darkMode }, - navigationBarStyle = when { - darkMode -> androidx.activity.SystemBarStyle.dark( - navigationBarScrim.toArgb(), - ) - else -> androidx.activity.SystemBarStyle.light( - navigationBarScrim.toArgb(), - navigationBarScrim.toArgb(), - ) - }, - ) - } -} - diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/StatusPill.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/StatusPill.kt new file mode 100644 index 00000000..7ba06420 --- /dev/null +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/StatusPill.kt @@ -0,0 +1,50 @@ +package com.droidspaces.app.ui.component + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * Small "status pill": a colored dot + bold label in a tinted rounded chip. + * Previously copy-pasted verbatim in ContainerCard, DroidspacesStatusCard and + * RootfsRepoSheet. The caller supplies the already-formatted [label] (some sites + * uppercase it, some don't) and the accent [color]. + */ +@Composable +fun StatusPill(label: String, color: Color, modifier: Modifier = Modifier) { + Surface( + modifier = modifier, + color = color.copy(alpha = 0.1f), + shape = RoundedCornerShape(8.dp), + border = BorderStroke(1.dp, color.copy(alpha = 0.2f)) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Surface(modifier = Modifier.size(6.dp), shape = CircleShape, color = color) {} + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Black, + letterSpacing = 0.5.sp, + color = color + ) + } + } +} diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/SwitchItem.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/SwitchItem.kt index b94134ff..f0948293 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/SwitchItem.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/SwitchItem.kt @@ -24,6 +24,7 @@ fun SwitchItem( val stateAlpha = Modifier.alpha(if (enabled) 1f else 0.5f) ListItem( + colors = ListItemDefaults.colors(containerColor = androidx.compose.ui.graphics.Color.Transparent), modifier = Modifier .toggleable( value = checked, @@ -52,7 +53,7 @@ fun SwitchItem( Switch( checked = checked, enabled = enabled, - onCheckedChange = onCheckedChange, + onCheckedChange = null, interactionSource = interactionSource, colors = SwitchDefaults.colors( checkedThumbColor = MaterialTheme.colorScheme.onPrimary, diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/ToggleCard.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/ToggleCard.kt index 8ba8a3c6..0aee8ff0 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/ToggleCard.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/ToggleCard.kt @@ -1,20 +1,17 @@ package com.droidspaces.app.ui.component -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material3.* +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp +import androidx.compose.ui.graphics.vector.ImageVector /** - * Reusable toggle card component that eliminates duplication in ContainerConfigScreen. - * Provides consistent styling and behavior for all toggle options. + * Toggle option row: a [SettingsCard] with a trailing [Switch]. Tapping the card + * toggles the switch. */ @Composable fun ToggleCard( @@ -28,60 +25,24 @@ fun ToggleCard( painter: Painter? = null ) { val alpha = if (enabled) 1f else 0.5f - Surface( - modifier = modifier.fillMaxWidth(), + SettingsCard( + title = title, onClick = { if (enabled) onCheckedChange(!checked) }, + modifier = modifier, enabled = enabled, - shape = RoundedCornerShape(20.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh, - border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - modifier = Modifier.weight(1f) - ) { - if (icon != null) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.primary.copy(alpha = alpha) - ) - } else if (painter != null) { - Icon( - painter = painter, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.primary.copy(alpha = alpha) - ) - } - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold - ) - Text( - text = description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f * alpha) - ) - } - } + icon = icon, + painter = painter, + subtitleContent = { + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f * alpha) + ) + }, + trailing = { Switch( checked = checked, - onCheckedChange = onCheckedChange, + onCheckedChange = null, enabled = enabled, colors = SwitchDefaults.colors( checkedThumbColor = MaterialTheme.colorScheme.onPrimary, @@ -92,6 +53,5 @@ fun ToggleCard( ) ) } - } + ) } - diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/component/UpstreamInterfaceList.kt b/Android/app/src/main/java/com/droidspaces/app/ui/component/UpstreamInterfaceList.kt index 60e8323f..143ce866 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/component/UpstreamInterfaceList.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/component/UpstreamInterfaceList.kt @@ -217,45 +217,17 @@ private fun AddUpstreamDialog( singleLine = true, modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ) + colors = DsTextFieldDefaults.colors() ) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Surface( - modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable(onClick = onDismiss), - shape = RoundedCornerShape(14.dp), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)), - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { - Text(context.getString(R.string.cancel), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold) - } - } - Surface( - modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable( - enabled = customIface.isNotBlank() && selectedInterfaces.size < 8, - onClick = { onAdd(customIface.trim()) } - ), - shape = RoundedCornerShape(14.dp), - color = if (customIface.isNotBlank()) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { - Text( - context.getString(R.string.add), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - color = if (customIface.isNotBlank()) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - ) - } - } - } + DialogFooterRow( + dismissLabel = context.getString(R.string.cancel), + confirmLabel = context.getString(R.string.add), + onDismiss = onDismiss, + onConfirm = { onAdd(customIface.trim()) }, + confirmEnabled = customIface.isNotBlank() && selectedInterfaces.size < 8, + cancelBorderAlpha = 0.35f + ) } } } diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/navigation/DroidspacesNavigation.kt b/Android/app/src/main/java/com/droidspaces/app/ui/navigation/DroidspacesNavigation.kt index 6602260a..e3d3bd63 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/navigation/DroidspacesNavigation.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/navigation/DroidspacesNavigation.kt @@ -21,9 +21,11 @@ import com.droidspaces.app.util.Constants import com.droidspaces.app.util.AnimationUtils import com.droidspaces.app.ui.screen.InstallationScreen import com.droidspaces.app.ui.screen.MainTabScreen +import com.droidspaces.app.ui.screen.TabItem import com.droidspaces.app.ui.screen.RootCheckScreen import com.droidspaces.app.ui.screen.SettingsScreen import com.droidspaces.app.ui.screen.RequirementsScreen +import com.droidspaces.app.ui.screen.AutoBootPriorityScreen import com.droidspaces.app.ui.screen.WelcomeScreen import com.droidspaces.app.ui.screen.ContainerNameScreen import com.droidspaces.app.ui.screen.SparseImageConfigScreen @@ -33,6 +35,7 @@ import com.droidspaces.app.ui.screen.InstallationProgressScreen import com.droidspaces.app.ui.screen.EditContainerScreen import com.droidspaces.app.ui.screen.ContainerDetailsScreen import com.droidspaces.app.ui.screen.SystemdScreen +import com.droidspaces.app.ui.screen.ProcdScreen import com.droidspaces.app.ui.screen.OpenRCScreen import com.droidspaces.app.ui.screen.ContainerTerminalScreen import com.droidspaces.app.ui.viewmodel.ContainerInstallationViewModel @@ -68,6 +71,7 @@ sealed class Screen(val route: String) { } data object Settings : Screen("settings") data object Requirements : Screen("requirements") + data object AutoBootPriority : Screen("auto_boot_priority") // Container installation wizard screens data object ContainerName : Screen("container_name/{tarballUri}") { @@ -90,6 +94,9 @@ sealed class Screen(val route: String) { data object Systemd : Screen("systemd/{containerName}") { fun createRoute(containerName: String) = "systemd/${Uri.encode(containerName)}" } + data object Procd : Screen("procd/{containerName}") { + fun createRoute(containerName: String) = "procd/${Uri.encode(containerName)}" + } data object OpenRC : Screen("openrc/{containerName}") { fun createRoute(containerName: String) = "openrc/${Uri.encode(containerName)}" } @@ -99,14 +106,36 @@ sealed class Screen(val route: String) { } } +/** + * Resolve the install-wizard [ContainerInstallationViewModel] scoped to the first + * wizard screen's back-stack entry (so all wizard steps share one instance), + * falling back to the current entry if that route isn't on the stack. + */ +@Composable +private fun wizardScopedViewModel( + navController: NavHostController, + backStackEntry: NavBackStackEntry +): ContainerInstallationViewModel = viewModel( + remember(backStackEntry) { + runCatching { navController.getBackStackEntry(Screen.ContainerName.route) } + .getOrElse { backStackEntry } + } +) + @Composable fun DroidspacesNavigation( navController: NavHostController = rememberNavController(), - onContentReady: () -> Unit = {} + onContentReady: () -> Unit = {}, + pendingShortcut: String? = null, + onShortcutHandled: () -> Unit = {} ) { val context = LocalContext.current val prefsManager = remember { PreferencesManager.getInstance(context) } + // Tab requested by a launcher shortcut (Containers / Panel). Fed into + // MainTabScreen, which scrolls the pager to it and clears the request. + var requestedTab by remember { mutableStateOf(null) } + // Shared ViewModels scoped to Activity to ensure state consistency across screens val activity = context as? ComponentActivity val sharedContainerViewModel: ContainerViewModel = if (activity != null) { @@ -138,6 +167,30 @@ fun DroidspacesNavigation( } } + // Route a launcher long-press shortcut to its destination. Containers/Panel + // select a tab on the (reused) Home screen; Settings is pushed on top of it. + // Ignored until onboarding is complete so we never deep-link past setup. + LaunchedEffect(pendingShortcut) { + val shortcut = pendingShortcut ?: return@LaunchedEffect + if (prefsManager.isSetupCompleted) { + when (shortcut) { + "settings" -> navController.navigate(Screen.Settings.route) { + popUpTo(Screen.Home.route) { inclusive = false } + launchSingleTop = true + } + "containers", "panel" -> { + requestedTab = + if (shortcut == "containers") TabItem.Containers else TabItem.ControlPanel + navController.navigate(Screen.Home.createRoute()) { + popUpTo(Screen.Home.route) { inclusive = false } + launchSingleTop = true + } + } + } + } + onShortcutHandled() + } + // Define animation specs - consistent 200ms for snappy feel val defaultEnterTransition: AnimatedContentTransitionScope.() -> EnterTransition = { fadeIn(animationSpec = AnimationUtils.fastSpec()) @@ -235,6 +288,8 @@ fun DroidspacesNavigation( containerViewModel = sharedContainerViewModel, // skipInitialRefresh=false when fromInstallation=true (invert the logic) skipInitialRefresh = !fromInstallation, + requestedTab = requestedTab, + onRequestedTabConsumed = { requestedTab = null }, onNavigateToSettings = { navController.navigate(Screen.Settings.route) }, @@ -292,39 +347,14 @@ fun DroidspacesNavigation( enterTransition = defaultEnterTransition, exitTransition = defaultExitTransition ) { backStackEntry -> - val viewModel: ContainerInstallationViewModel = viewModel( - remember(backStackEntry) { - runCatching { navController.getBackStackEntry(Screen.ContainerName.route) } - .getOrElse { backStackEntry } - } - ) + val viewModel = wizardScopedViewModel(navController, backStackEntry) ContainerConfigScreen( - initialNetMode = viewModel.netMode, - initialDisableIPv6 = viewModel.disableIPv6, - initialEnableAndroidStorage = viewModel.enableAndroidStorage, - initialEnableHwAccess = viewModel.enableHwAccess, - initialEnableGpuMode = viewModel.enableGpuMode, - initialEnableTermuxX11 = viewModel.enableTermuxX11, - initialTx11ExtraFlags = viewModel.tx11ExtraFlags, - initialEnableVirgl = viewModel.enableVirgl, - initialVirglExtraFlags = viewModel.virglExtraFlags, - initialEnablePulseaudio = viewModel.enablePulseaudio, - initialSelinuxPermissive = viewModel.selinuxPermissive, - initialVolatileMode = viewModel.volatileMode, - initialBindMounts = viewModel.bindMounts, - initialDnsServers = viewModel.dnsServers, - initialRunAtBoot = viewModel.runAtBoot, - initialCustomInit = viewModel.customInit, - initialStaticNatIp = viewModel.staticNatIp, - initialForceCgroupv1 = viewModel.forceCgroupv1, - initialBlockNestedNs = viewModel.blockNestedNs, - initialPrivileged = viewModel.privileged, - initialEnvFileContent = viewModel.envFileContent ?: "", - initialUpstreamInterfaces = viewModel.upstreamInterfaces, - initialPortForwards = viewModel.portForwards, - onNext = { netMode, disableIPv6, enableAndroidStorage, enableHwAccess, enableGpuMode, enableTermuxX11, tx11ExtraFlags, enableVirgl, virglExtraFlags, enablePulseaudio, selinuxPermissive, volatileMode, bindMounts, dnsServers, runAtBoot, customInit, staticNatIp, forceCgroupv1, blockNestedNs, privileged, envFileContent, upstreamInterfaces, portForwards -> - viewModel.setConfig(netMode, disableIPv6, enableAndroidStorage, enableHwAccess, enableGpuMode, enableTermuxX11, tx11ExtraFlags, enableVirgl, virglExtraFlags, enablePulseaudio, selinuxPermissive, volatileMode, bindMounts, dnsServers, runAtBoot, customInit, staticNatIp, envFileContent, upstreamInterfaces, portForwards, forceCgroupv1, blockNestedNs, privileged) + initialState = viewModel.configState, + containerName = viewModel.containerName, + installedContainers = sharedContainerViewModel.containerList, + onNext = { state -> + viewModel.setConfig(state) navController.navigate(Screen.SparseImageConfig.route) }, onBack = { @@ -338,12 +368,7 @@ fun DroidspacesNavigation( enterTransition = defaultEnterTransition, exitTransition = defaultExitTransition ) { backStackEntry -> - val viewModel: ContainerInstallationViewModel = viewModel( - remember(backStackEntry) { - runCatching { navController.getBackStackEntry(Screen.ContainerName.route) } - .getOrElse { backStackEntry } - } - ) + val viewModel = wizardScopedViewModel(navController, backStackEntry) SparseImageConfigScreen( initialUseSparseImage = viewModel.useSparseImage, @@ -363,12 +388,7 @@ fun DroidspacesNavigation( enterTransition = defaultEnterTransition, exitTransition = defaultExitTransition ) { backStackEntry -> - val viewModel: ContainerInstallationViewModel = viewModel( - remember(backStackEntry) { - runCatching { navController.getBackStackEntry(Screen.ContainerName.route) } - .getOrElse { backStackEntry } - } - ) + val viewModel = wizardScopedViewModel(navController, backStackEntry) val config = viewModel.buildConfig() val tarballUri = viewModel.tarballUri val ctx = LocalContext.current @@ -414,12 +434,7 @@ fun DroidspacesNavigation( enterTransition = defaultEnterTransition, exitTransition = defaultExitTransition ) { backStackEntry -> - val viewModel: ContainerInstallationViewModel = viewModel( - remember(backStackEntry) { - runCatching { navController.getBackStackEntry(Screen.ContainerName.route) } - .getOrElse { backStackEntry } - } - ) + val viewModel = wizardScopedViewModel(navController, backStackEntry) val config = viewModel.buildConfig() val tarballUri = viewModel.tarballUri @@ -507,6 +522,24 @@ fun DroidspacesNavigation( }, onNavigateToRequirements = { navController.navigate(Screen.Requirements.route) + }, + onNavigateToAutoBootPriority = { + navController.navigate(Screen.AutoBootPriority.route) + } + ) + } + + composable( + route = Screen.AutoBootPriority.route, + enterTransition = defaultEnterTransition, + exitTransition = defaultExitTransition, + popEnterTransition = defaultEnterTransition, + popExitTransition = defaultExitTransition + ) { + AutoBootPriorityScreen( + containerViewModel = sharedContainerViewModel, + onBack = { + navController.popBackStack() } ) } @@ -549,6 +582,8 @@ fun DroidspacesNavigation( when (initSystem) { com.droidspaces.app.ui.screen.InitSystem.SYSTEMD -> navController.navigate(Screen.Systemd.createRoute(containerName)) + com.droidspaces.app.ui.screen.InitSystem.PROCD -> + navController.navigate(Screen.Procd.createRoute(containerName)) com.droidspaces.app.ui.screen.InitSystem.OPENRC -> navController.navigate(Screen.OpenRC.createRoute(containerName)) } @@ -577,6 +612,21 @@ fun DroidspacesNavigation( ) } + composable( + route = Screen.Procd.route, + arguments = listOf( + navArgument("containerName") { type = NavType.StringType } + ), + enterTransition = defaultEnterTransition, + exitTransition = defaultExitTransition + ) { backStackEntry -> + val containerName = backStackEntry.arguments?.getString("containerName") ?: "" + ProcdScreen( + containerName = containerName, + onNavigateBack = { navController.popBackStack() } + ) + } + composable( route = Screen.OpenRC.route, arguments = listOf( diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/AutoBootPriorityScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/AutoBootPriorityScreen.kt new file mode 100644 index 00000000..9b8a4c9c --- /dev/null +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/AutoBootPriorityScreen.kt @@ -0,0 +1,365 @@ +package com.droidspaces.app.ui.screen + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.DragHandle +import androidx.compose.material.icons.filled.PowerSettingsNew +import androidx.compose.material.icons.filled.Save +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.droidspaces.app.R +import com.droidspaces.app.ui.util.LoadingIndicator +import com.droidspaces.app.ui.viewmodel.ContainerViewModel +import com.droidspaces.app.util.ContainerInfo +import com.droidspaces.app.util.ContainerManager +import com.droidspaces.app.util.ContainerOSInfoManager +import com.droidspaces.app.util.IconUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import sh.calvin.reorderable.ReorderableItem +import sh.calvin.reorderable.rememberReorderableLazyListState + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun AutoBootPriorityScreen( + containerViewModel: ContainerViewModel, + onBack: () -> Unit +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + // Snapshot of run-at-boot containers, ordered by saved priority (unset goes + // last, then alphabetical). Built once so user drags are not clobbered. + val initialOrder = remember { + containerViewModel.containerList + .filter { it.runAtBoot } + .sortedWith( + compareBy( + { if (it.runAtBootPriority > 0) it.runAtBootPriority else Int.MAX_VALUE }, + { it.name.lowercase() } + ) + ) + } + var items by remember { mutableStateOf(initialOrder) } + // Baseline order persisted so far; updated after a successful save so the + // button greys out again once there is nothing new to save. + var savedNames by remember { mutableStateOf(initialOrder.map { it.name }) } + val hasChanges by remember { derivedStateOf { items.map { it.name } != savedNames } } + var isSaving by remember { mutableStateOf(false) } + var isSaved by remember { mutableStateOf(false) } + + // Drop the "Saved" state as soon as the user reorders again. + LaunchedEffect(hasChanges) { + if (hasChanges && isSaved) isSaved = false + } + + val lazyListState = rememberLazyListState() + val reorderableState = rememberReorderableLazyListState(lazyListState) { from, to -> + items = items.toMutableList().apply { add(to.index, removeAt(from.index)) } + } + + fun saveChanges() { + scope.launch { + isSaving = true + isSaved = false + withContext(Dispatchers.IO) { + // Assign 1..N by current order -- collision-free by construction. + items.forEachIndexed { index, info -> + ContainerManager.updateContainerConfig( + context, + info.name, + info.copy(runAtBootPriority = index + 1) + ) + } + } + containerViewModel.refresh() + savedNames = items.map { it.name } + isSaving = false + isSaved = true + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(context.getString(R.string.auto_boot_priority)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = context.getString(R.string.back) + ) + } + } + ) + }, + bottomBar = { + if (items.isNotEmpty()) { + val btnShape = RoundedCornerShape(20.dp) + val isReadyToSave = !isSaving && !isSaved && hasChanges + val targetBtnColor = when { + isSaved -> MaterialTheme.colorScheme.primaryContainer + isSaving || isReadyToSave -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f) + } + val animatedBtnColor by animateColorAsState( + targetValue = targetBtnColor, + animationSpec = tween(durationMillis = 250), + label = "btn_color" + ) + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceContainer, + tonalElevation = 0.dp + ) { + Column(modifier = Modifier.fillMaxWidth()) { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.25f), + thickness = 1.dp + ) + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp) + .navigationBarsPadding() + .clip(btnShape) + .clickable( + enabled = isReadyToSave, + onClick = { saveChanges() }, + indication = rememberRipple(bounded = true), + interactionSource = remember { MutableInteractionSource() } + ), + shape = btnShape, + color = animatedBtnColor, + tonalElevation = 0.dp + ) { + Box(modifier = Modifier.padding(vertical = 16.dp).fillMaxWidth(), contentAlignment = Alignment.Center) { + when { + isSaved -> { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + Text( + text = context.getString(R.string.saved), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } + isSaving -> { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + LoadingIndicator( + modifier = Modifier.size(20.dp), + color = MaterialTheme.colorScheme.onPrimary + ) + Text( + text = context.getString(R.string.saving), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onPrimary + ) + } + } + else -> { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon( + imageVector = Icons.Default.Save, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = if (isReadyToSave) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + ) + Text( + text = context.getString(R.string.save_changes), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = if (isReadyToSave) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + ) + } + } + } + } + } + } + } + } + } + ) { innerPadding -> + if (items.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(horizontal = 32.dp), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + Icons.Default.PowerSettingsNew, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.outline + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + context.getString(R.string.auto_boot_priority_empty), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Medium + ) + } + } + } else { + // Hint lives OUTSIDE the LazyColumn so the reorderable item indices map + // 1:1 to `items` (a header item would offset from.index/to.index). + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .consumeWindowInsets(innerPadding) + .padding(horizontal = 16.dp) + ) { + Text( + text = context.getString(R.string.auto_boot_priority_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 12.dp) + ) + + LazyColumn( + state = lazyListState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(items, key = { it.name }) { container -> + ReorderableItem(reorderableState, key = container.name) { isDragging -> + BootPriorityRow( + index = items.indexOf(container) + 1, + container = container, + isDragging = isDragging, + dragHandle = { + IconButton( + modifier = Modifier.draggableHandle(), + onClick = {} + ) { + Icon( + Icons.Default.DragHandle, + contentDescription = context.getString(R.string.reorder_handle), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + ) + } + } + } + } + } + } +} + +@Composable +private fun BootPriorityRow( + index: Int, + container: ContainerInfo, + isDragging: Boolean, + dragHandle: @Composable () -> Unit +) { + val elevation = if (isDragging) 8.dp else 0.dp + Surface( + modifier = Modifier + .fillMaxWidth() + .shadow(elevation, RoundedCornerShape(16.dp), clip = false), + shape = RoundedCornerShape(16.dp), + color = if (isDragging) + MaterialTheme.colorScheme.surfaceVariant + else + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), + tonalElevation = 0.dp + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Priority position badge + Box( + modifier = Modifier + .size(28.dp) + .clip(RoundedCornerShape(8.dp)), + contentAlignment = Alignment.Center + ) { + Surface( + modifier = Modifier.fillMaxSize(), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f), + tonalElevation = 0.dp + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = index.toString(), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + } + } + } + + // Distro icon, sourced + tinted exactly like the containers tab. + val context = LocalContext.current + val cacheVersion by ContainerOSInfoManager.iconCacheVersion + val cachedOsInfo = remember(container.name, cacheVersion) { + ContainerOSInfoManager.getCachedOSInfo(container.name, context) + } + Icon( + painter = IconUtils.getDistroIcon(cachedOsInfo?.prettyName ?: cachedOsInfo?.name), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = if (container.isRunning) + MaterialTheme.colorScheme.primary + else + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + + Text( + text = container.name, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + + dragHandle() + } + } +} diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerConfigScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerConfigScreen.kt index 2623fc50..bd8c8888 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerConfigScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerConfigScreen.kt @@ -1,268 +1,78 @@ package com.droidspaces.app.ui.screen -import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowForward -import androidx.compose.material.icons.automirrored.filled.VolumeUp -import androidx.compose.material.icons.filled.* -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import com.droidspaces.app.ui.component.ToggleCard -import com.droidspaces.app.ui.component.DsDropdown -import androidx.compose.material.icons.filled.Public -import com.droidspaces.app.ui.component.UpstreamInterfaceList -import com.droidspaces.app.ui.component.PortForwardingList -import androidx.compose.ui.platform.LocalContext import com.droidspaces.app.R +import com.droidspaces.app.ui.component.ContainerConfigForm import com.droidspaces.app.ui.util.ClearFocusOnClickOutside +import com.droidspaces.app.util.ContainerConfigState +import com.droidspaces.app.util.ContainerInfo +import com.droidspaces.app.util.ValidationUtils -import androidx.compose.ui.text.style.TextOverflow -import com.droidspaces.app.util.BindMount -import com.droidspaces.app.util.PortForward -import com.droidspaces.app.util.ContainerManager -import kotlinx.coroutines.launch -import com.droidspaces.app.ui.component.FilePickerDialog -import com.droidspaces.app.ui.component.SettingsRowCard -import com.droidspaces.app.ui.component.EnvironmentVariablesDialog -import com.droidspaces.app.ui.component.PrivilegedModeDialog -import com.droidspaces.app.ui.component.HardwareAccessDialog -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.animation.core.LinearEasing -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.foundation.shape.RoundedCornerShape -import kotlinx.coroutines.delay -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.ExperimentalLayoutApi - -@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable fun ContainerConfigScreen( - initialNetMode: String = "nat", - initialDisableIPv6: Boolean = false, - initialEnableAndroidStorage: Boolean = false, - initialEnableHwAccess: Boolean = false, - initialEnableGpuMode: Boolean = false, - initialEnableTermuxX11: Boolean = false, - initialTx11ExtraFlags: String = "", - initialEnableVirgl: Boolean = false, - initialVirglExtraFlags: String = "", - initialEnablePulseaudio: Boolean = false, - initialSelinuxPermissive: Boolean = false, - initialVolatileMode: Boolean = false, - initialBindMounts: List = emptyList(), - initialDnsServers: String = "", - initialRunAtBoot: Boolean = false, - initialCustomInit: String = "", - initialStaticNatIp: String = "", - initialForceCgroupv1: Boolean = false, - initialBlockNestedNs: Boolean = false, - initialPrivileged: String = "", - initialEnvFileContent: String = "", - initialUpstreamInterfaces: List = emptyList(), - initialPortForwards: List = emptyList(), - onNext: ( - netMode: String, - disableIPv6: Boolean, - enableAndroidStorage: Boolean, - enableHwAccess: Boolean, - enableGpuMode: Boolean, - enableTermuxX11: Boolean, - tx11ExtraFlags: String, - enableVirgl: Boolean, - virglExtraFlags: String, - enablePulseaudio: Boolean, - selinuxPermissive: Boolean, - volatileMode: Boolean, - bindMounts: List, - dnsServers: String, - runAtBoot: Boolean, - customInit: String, - staticNatIp: String, - forceCgroupv1: Boolean, - blockNestedNs: Boolean, - privileged: String, - envFileContent: String?, - upstreamInterfaces: List, - portForwards: List - ) -> Unit, + initialState: ContainerConfigState = ContainerConfigState(), + containerName: String = "", + installedContainers: List = emptyList(), + onNext: (ContainerConfigState) -> Unit, onBack: () -> Unit ) { - var netMode by remember { mutableStateOf(initialNetMode) } - var disableIPv6 by remember { mutableStateOf(initialDisableIPv6) } - var enableAndroidStorage by remember { mutableStateOf(initialEnableAndroidStorage) } - var enableHwAccess by remember { mutableStateOf(initialEnableHwAccess) } - var enableGpuMode by remember { mutableStateOf(initialEnableGpuMode) } - var enableTermuxX11 by remember { mutableStateOf(initialEnableTermuxX11) } - var tx11ExtraFlags by remember { mutableStateOf(initialTx11ExtraFlags) } - var enableVirgl by remember { mutableStateOf(initialEnableVirgl) } - var virglExtraFlags by remember { mutableStateOf(initialVirglExtraFlags) } - var enablePulseaudio by remember { mutableStateOf(initialEnablePulseaudio) } - var selinuxPermissive by remember { mutableStateOf(initialSelinuxPermissive) } - var volatileMode by remember { mutableStateOf(initialVolatileMode) } - var bindMounts by remember { mutableStateOf(initialBindMounts) } - var dnsServers by remember { mutableStateOf(initialDnsServers) } - var runAtBoot by remember { mutableStateOf(initialRunAtBoot) } - var customInit by remember { mutableStateOf(initialCustomInit) } - var staticNatIp by remember { mutableStateOf(initialStaticNatIp) } - var forceCgroupv1 by remember { mutableStateOf(initialForceCgroupv1) } - var blockNestedNs by remember { mutableStateOf(initialBlockNestedNs) } - var envFileContent by remember { mutableStateOf(initialEnvFileContent) } - var upstreamInterfaces by remember { mutableStateOf(initialUpstreamInterfaces) } - var portForwards by remember { mutableStateOf(initialPortForwards) } - var privileged by remember { mutableStateOf(initialPrivileged) } val context = LocalContext.current - - // Internal UI States - var showFilePicker by remember { mutableStateOf(false) } - var showDestDialog by remember { mutableStateOf(false) } - var tempSrcPath by remember { mutableStateOf("") } - - if (showFilePicker) { - FilePickerDialog( - onDismiss = { showFilePicker = false }, - onConfirm = { path -> - tempSrcPath = path - showFilePicker = false - showDestDialog = true - } - ) - } - - if (showDestDialog) { - var destPath by remember { mutableStateOf("") } - var roEnabled by remember { mutableStateOf(false) } - val clearFocus = com.droidspaces.app.ui.util.rememberClearFocus() - Dialog( - onDismissRequest = { showDestDialog = false }, - properties = DialogProperties(usePlatformDefaultWidth = false) - ) { - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) - .imePadding(), - shape = RoundedCornerShape(24.dp), - color = MaterialTheme.colorScheme.surfaceContainer, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), - tonalElevation = 0.dp - ) { - Column(modifier = Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { - Text(context.getString(R.string.enter_container_path), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) - OutlinedTextField( - value = destPath, - onValueChange = { destPath = it }, - label = { Text(context.getString(R.string.container_path_placeholder)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ) - ) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text(context.getString(R.string.read_only), style = MaterialTheme.typography.bodyMedium) - Switch(checked = roEnabled, onCheckedChange = { roEnabled = it }) - } - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Surface( - modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable(onClick = { clearFocus(); showDestDialog = false }), - shape = RoundedCornerShape(14.dp), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { - Text(context.getString(R.string.cancel), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold) - } - } - Surface( - modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable( - enabled = destPath.startsWith("/"), - onClick = { - clearFocus() - if (destPath.isNotBlank()) { - bindMounts = bindMounts + BindMount(tempSrcPath, destPath, roEnabled) - showDestDialog = false - } - } - ), - shape = RoundedCornerShape(14.dp), - color = if (destPath.startsWith("/")) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { - Text( - context.getString(R.string.ok), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - color = if (destPath.startsWith("/")) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - ) - } - } - } - } - } - } + var state by remember { mutableStateOf(initialState) } + + val gatewayErrors = ValidationUtils.validateGatewayConfig( + selfName = containerName, + gatewayContainer = state.gatewayContainer, + net = state.gatewayNet, + iface = state.gatewayIface, + bridge = state.gatewayBridge, + installed = installedContainers, + context = context + ) + + val collisionContainer = remember(state.netMode, state.staticNatIp, installedContainers) { + if (state.netMode != "nat" || state.staticNatIp.isEmpty()) null + else installedContainers.find { it.name != containerName && it.staticNatIp == state.staticNatIp } } - var showEnvDialog by remember { mutableStateOf(false) } - var showPrivilegedDialog by remember { mutableStateOf(false) } - var showHwAccessDialog by remember { mutableStateOf(false) } - - if (showPrivilegedDialog) { - PrivilegedModeDialog( - initialPrivileged = privileged, - onConfirm = { tags -> - privileged = tags - showPrivilegedDialog = false - }, - onDismiss = { showPrivilegedDialog = false } - ) - } - - if (showHwAccessDialog) { - HardwareAccessDialog( - onConfirm = { - enableHwAccess = true - showHwAccessDialog = false - }, - onDismiss = { showHwAccessDialog = false } - ) - } - - if (showEnvDialog) { - EnvironmentVariablesDialog( - initialContent = envFileContent, - onConfirm = { newContent -> - envFileContent = newContent - showEnvDialog = false - }, - onDismiss = { showEnvDialog = false } - ) - } + val canProceed = (state.netMode != "gateway" || gatewayErrors.isValid) && collisionContainer == null Scaffold( topBar = { @@ -277,7 +87,6 @@ fun ContainerConfigScreen( }, bottomBar = { val btnShape = RoundedCornerShape(20.dp) - val isUpstreamValid = netMode != "nat" || upstreamInterfaces.isNotEmpty() Surface( modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.surfaceContainer, @@ -295,30 +104,31 @@ fun ContainerConfigScreen( .navigationBarsPadding() .clip(btnShape) .clickable( - enabled = isUpstreamValid, - onClick = { - onNext(netMode, disableIPv6, enableAndroidStorage, enableHwAccess, enableGpuMode, enableTermuxX11, tx11ExtraFlags, enableVirgl, virglExtraFlags, enablePulseaudio, selinuxPermissive, volatileMode, bindMounts, dnsServers, runAtBoot, customInit, staticNatIp, forceCgroupv1, blockNestedNs, privileged, if (envFileContent.isBlank()) null else envFileContent, upstreamInterfaces, portForwards) - }, - indication = androidx.compose.material.ripple.rememberRipple(bounded = true), - interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() } + enabled = canProceed, + onClick = { onNext(state) }, + indication = rememberRipple(bounded = true), + interactionSource = remember { MutableInteractionSource() } ), shape = btnShape, - color = if (isUpstreamValid) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), + color = if (canProceed) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), tonalElevation = 0.dp ) { Box(modifier = Modifier.padding(vertical = 16.dp), contentAlignment = Alignment.Center) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { Icon( Icons.AutoMirrored.Filled.ArrowForward, contentDescription = null, modifier = Modifier.size(18.dp), - tint = if (isUpstreamValid) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + tint = if (canProceed) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) ) Text( context.getString(R.string.next_storage), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold, - color = if (isUpstreamValid) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + color = if (canProceed) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) ) } } @@ -333,594 +143,22 @@ fun ContainerConfigScreen( .consumeWindowInsets(innerPadding) .imePadding() ) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 24.dp) - .padding(top = 24.dp) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text( - text = context.getString(R.string.container_options), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = context.getString(R.string.cat_networking), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(top = 8.dp) - ) - - DsDropdown( - label = context.getString(R.string.network_mode), - selected = netMode, - options = listOf("nat", "host", "none"), - displayName = { context.getString(when (it) { "nat" -> R.string.network_mode_nat; "none" -> R.string.network_mode_none; else -> R.string.network_mode_host }) }, - onSelect = { mode -> netMode = mode; if (mode != "host") disableIPv6 = false }, - leadingIcon = Icons.Default.Public - ) - - androidx.compose.animation.AnimatedVisibility( - visible = netMode == "nat", - enter = androidx.compose.animation.expandVertically( - animationSpec = tween(durationMillis = 300, easing = androidx.compose.animation.core.FastOutSlowInEasing), - expandFrom = Alignment.Top - ) + androidx.compose.animation.fadeIn(animationSpec = tween(durationMillis = 300)), - exit = androidx.compose.animation.shrinkVertically( - animationSpec = tween(durationMillis = 300, easing = androidx.compose.animation.core.FastOutSlowInEasing), - shrinkTowards = Alignment.Top - ) + androidx.compose.animation.fadeOut(animationSpec = tween(durationMillis = 300)) - ) { - Column( - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text( - text = context.getString(R.string.nat_settings), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary - ) - - // Static IP Address Configuration - Text( - text = context.getString(R.string.static_ip_address), - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(top = 16.dp) - ) - Text( - text = context.getString(R.string.static_ip_description), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.secondary, - modifier = Modifier.padding(bottom = 8.dp) - ) - - val octets = remember(staticNatIp) { - val parts = staticNatIp.split(".") - if (parts.size == 4) { - Pair(parts[2], parts[3]) - } else { - Pair("", "") - } - } - - var octet3 by remember(octets) { mutableStateOf(octets.first) } - var octet4 by remember(octets) { mutableStateOf(octets.second) } - - val updateIp = { o3: String, o4: String -> - staticNatIp = if (o3.isBlank() && o4.isBlank()) { - "" - } else { - "${com.droidspaces.app.util.Constants.NAT_IP_PREFIX}.$o3.$o4" - } - } - - val isOctet3Valid = remember(octet3) { - octet3.isEmpty() || (octet3.toIntOrNull()?.let { it in com.droidspaces.app.util.Constants.NAT_OCTET_MIN..com.droidspaces.app.util.Constants.NAT_OCTET_MAX } ?: false) - } - val isOctet4Valid = remember(octet4) { - octet4.isEmpty() || (octet4.toIntOrNull()?.let { it in com.droidspaces.app.util.Constants.NAT_OCTET_MIN..com.droidspaces.app.util.Constants.NAT_OCTET_MAX } ?: false) - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "${com.droidspaces.app.util.Constants.NAT_IP_PREFIX}.", - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(top = 8.dp) - ) - - OutlinedTextField( - value = octet3, - onValueChange = { - if (it.length <= 3 && it.all { c -> c.isDigit() }) { - octet3 = it - updateIp(it, octet4) - } - }, - label = { Text(context.getString(R.string.octet_label, 3)) }, - modifier = Modifier.weight(1f), - singleLine = true, - shape = RoundedCornerShape(16.dp), - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ), - isError = !isOctet3Valid, - supportingText = { if (!isOctet3Valid) Text(context.getString(R.string.error_octet_range)) }, - keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = androidx.compose.ui.text.input.KeyboardType.Number) - ) - - Text( - text = ".", - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(top = 8.dp) - ) - - OutlinedTextField( - value = octet4, - onValueChange = { - if (it.length <= 3 && it.all { c -> c.isDigit() }) { - octet4 = it - updateIp(octet3, it) - } - }, - label = { Text(context.getString(R.string.octet_label, 4)) }, - modifier = Modifier.weight(1f), - singleLine = true, - shape = RoundedCornerShape(16.dp), - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ), - isError = !isOctet4Valid, - supportingText = { if (!isOctet4Valid) Text(context.getString(R.string.error_octet_range)) }, - keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = androidx.compose.ui.text.input.KeyboardType.Number) - ) - } - - // Upstream Interfaces - val isUpstreamValid = upstreamInterfaces.isNotEmpty() + ContainerConfigForm( + state = state, + onStateChange = { state = it }, + installedContainers = installedContainers, + selfName = containerName, + gatewayErrors = gatewayErrors, + collisionContainer = collisionContainer, + modifier = Modifier.fillMaxSize(), + leadingContent = { Text( - text = context.getString(R.string.upstream_interfaces_mandatory), - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Bold, - color = if (!isUpstreamValid) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface + text = context.getString(R.string.container_options), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold ) - - if (!isUpstreamValid) { - Text( - text = context.getString(R.string.upstream_interfaces_required_error), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error - ) - } - - UpstreamInterfaceList( - upstreamInterfaces = upstreamInterfaces, - onInterfacesChange = { upstreamInterfaces = it } - ) - - // Port Forwards - Text( - text = context.getString(R.string.port_forwarding), - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(top = 16.dp) - ) - - PortForwardingList( - portForwards = portForwards, - onPortForwardsChange = { portForwards = it } - ) - } - } - - // DNS Servers input - val isDnsError = remember(dnsServers) { - dnsServers.isNotEmpty() && !dnsServers.all { it.isDigit() || it == '.' || it == ':' || it == ',' } - } - - OutlinedTextField( - value = dnsServers, - onValueChange = { dnsServers = it }, - label = { Text(context.getString(R.string.dns_servers_label)) }, - supportingText = { - if (isDnsError) { - Text(context.getString(R.string.dns_servers_hint)) - } - }, - isError = isDnsError, - placeholder = { Text(context.getString(R.string.dns_servers_placeholder)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - shape = RoundedCornerShape(16.dp), - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ), - leadingIcon = { - Icon(Icons.Default.Dns, contentDescription = null) - } - ) - - // In NAT/NONE mode, IPv6 is always disabled (forced). In host mode the user can opt in. - val ipv6IsForced = netMode != "host" - ToggleCard( - icon = Icons.Default.NetworkCheck, - title = context.getString(R.string.disable_ipv6), - description = if (ipv6IsForced) - context.getString(R.string.disable_ipv6_nat_forced) - else - context.getString(R.string.disable_ipv6_description), - checked = if (ipv6IsForced) true else disableIPv6, - onCheckedChange = { disableIPv6 = it }, - enabled = !ipv6IsForced - ) - - Text( - text = context.getString(R.string.cat_integration), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(top = 16.dp) - ) - - ToggleCard( - icon = Icons.Default.Storage, - title = context.getString(R.string.android_storage), - description = context.getString(R.string.android_storage_description), - checked = enableAndroidStorage, - onCheckedChange = { enableAndroidStorage = it } - ) - - ToggleCard( - icon = Icons.Default.Devices, - title = context.getString(R.string.hardware_access), - description = context.getString(R.string.hardware_access_description), - checked = enableHwAccess, - onCheckedChange = { newValue -> - if (newValue) { - showHwAccessDialog = true - } else { - enableHwAccess = false - } - } - ) - - ToggleCard( - icon = Icons.Default.Memory, - title = context.getString(R.string.gpu_access), - description = context.getString(R.string.gpu_access_description), - checked = if (enableHwAccess) true else enableGpuMode, - onCheckedChange = { if (!enableHwAccess) enableGpuMode = it }, - enabled = !enableHwAccess - ) - - ToggleCard( - painter = androidx.compose.ui.res.painterResource(R.drawable.ic_x11), - title = context.getString(R.string.termux_x11), - description = context.getString(R.string.termux_x11_description), - checked = enableTermuxX11, - onCheckedChange = { enableTermuxX11 = it }, - enabled = true - ) - - ToggleCard( - icon = Icons.Default.Layers, - title = context.getString(R.string.enable_virgl), - description = context.getString(R.string.enable_virgl_description), - checked = enableVirgl, - onCheckedChange = { enableVirgl = it }, - enabled = true - ) - - ToggleCard( - icon = Icons.AutoMirrored.Filled.VolumeUp, - title = context.getString(R.string.enable_pulseaudio), - description = context.getString(R.string.enable_pulseaudio_description), - checked = enablePulseaudio, - onCheckedChange = { enablePulseaudio = it }, - enabled = true - ) - - Text( - text = context.getString(R.string.cat_security), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(top = 16.dp) - ) - - ToggleCard( - icon = Icons.Default.Security, - title = context.getString(R.string.selinux_permissive), - description = context.getString(R.string.selinux_permissive_description), - checked = selinuxPermissive, - onCheckedChange = { selinuxPermissive = it } - ) - - ToggleCard( - icon = Icons.Default.AutoDelete, - title = context.getString(R.string.volatile_mode), - description = context.getString(R.string.volatile_mode_description), - checked = volatileMode, - onCheckedChange = { volatileMode = it } - ) - - ToggleCard( - icon = Icons.Default.Cyclone, - title = context.getString(R.string.force_cgroupv1), - description = context.getString(R.string.force_cgroupv1_description), - checked = forceCgroupv1, - onCheckedChange = { forceCgroupv1 = it } - ) - - val isSeccompDisabled = privileged.contains("noseccomp") || privileged.contains("full") - LaunchedEffect(isSeccompDisabled) { - if (isSeccompDisabled) blockNestedNs = false - } - - ToggleCard( - icon = Icons.Default.GppBad, - title = context.getString(R.string.manual_deadlock_shield), - description = context.getString(R.string.manual_deadlock_shield_description), - checked = if (isSeccompDisabled) false else blockNestedNs, - onCheckedChange = { blockNestedNs = it }, - enabled = !isSeccompDisabled - ) - - SettingsRowCard( - title = context.getString(R.string.privileged_mode), - subtitle = if (privileged.isEmpty()) context.getString(R.string.not_configured) else privileged, - description = context.getString(R.string.privileged_mode_description), - icon = Icons.Default.GppMaybe, - onClick = { - showPrivilegedDialog = true - } - ) - - ToggleCard( - icon = Icons.Default.PowerSettingsNew, - title = context.getString(R.string.run_at_boot), - description = context.getString(R.string.run_at_boot_description), - checked = runAtBoot, - onCheckedChange = { runAtBoot = it } - ) - - Text( - text = context.getString(R.string.cat_advanced), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(top = 16.dp) - ) - - // Environment Variables Row - fun countEnvVars(content: String): Int { - return content.lines() - .map { it.trim() } - .count { it.isNotEmpty() && !it.startsWith("#") && it.contains("=") } - } - - val envCount = countEnvVars(envFileContent) - val envSubtitle = if (envCount > 0) { - context.getString(R.string.environment_variables_configured, envCount) - } else { - context.getString(R.string.not_configured) - } - - SettingsRowCard( - title = context.getString(R.string.environment_variables), - subtitle = envSubtitle, - icon = Icons.Default.Code, - onClick = { - showEnvDialog = true } ) - - // Custom Init Binary - if (customInit.isNotEmpty()) { - Surface( - color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.15f), - shape = RoundedCornerShape(16.dp), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.3f)), - modifier = Modifier.fillMaxWidth() - ) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Icon( - imageVector = Icons.Default.Warning, - contentDescription = null, - tint = MaterialTheme.colorScheme.error, - modifier = Modifier.size(20.dp) - ) - Text( - text = context.getString(R.string.custom_init_warning), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error - ) - } - } - } - - OutlinedTextField( - value = customInit, - onValueChange = { customInit = it.filter { !it.isWhitespace() } }, - label = { Text(context.getString(R.string.custom_init_label)) }, - placeholder = { Text(context.getString(R.string.custom_init_placeholder)) }, - supportingText = { - if (customInit.isNotEmpty() && !customInit.startsWith("/")) { - Text(context.getString(R.string.custom_init_error_absolute), - color = MaterialTheme.colorScheme.error) - } else { - Text(context.getString(R.string.custom_init_hint)) - } - }, - isError = customInit.isNotEmpty() && !customInit.startsWith("/"), - modifier = Modifier.fillMaxWidth(), - singleLine = true, - shape = RoundedCornerShape(16.dp), - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ), - leadingIcon = { - Icon(Icons.Default.Terminal, contentDescription = null) - } - ) - - androidx.compose.animation.AnimatedVisibility( - visible = enableTermuxX11, - enter = androidx.compose.animation.expandVertically( - animationSpec = tween(durationMillis = 300) - ) + androidx.compose.animation.fadeIn(animationSpec = tween(durationMillis = 300)), - exit = androidx.compose.animation.shrinkVertically( - animationSpec = tween(durationMillis = 300) - ) + androidx.compose.animation.fadeOut(animationSpec = tween(durationMillis = 300)) - ) { - OutlinedTextField( - value = tx11ExtraFlags, - onValueChange = { tx11ExtraFlags = it }, - label = { Text(context.getString(R.string.tx11_extra_flags_label)) }, - placeholder = { Text(context.getString(R.string.tx11_extra_flags_placeholder)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - shape = RoundedCornerShape(16.dp), - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ), - leadingIcon = { - Icon( - painter = androidx.compose.ui.res.painterResource(R.drawable.ic_x11), - contentDescription = null, - modifier = Modifier.size(15.dp) - ) - } - ) - } - - androidx.compose.animation.AnimatedVisibility( - visible = enableVirgl, - enter = androidx.compose.animation.expandVertically( - animationSpec = tween(durationMillis = 300) - ) + androidx.compose.animation.fadeIn(animationSpec = tween(durationMillis = 300)), - exit = androidx.compose.animation.shrinkVertically( - animationSpec = tween(durationMillis = 300) - ) + androidx.compose.animation.fadeOut(animationSpec = tween(durationMillis = 300)) - ) { - OutlinedTextField( - value = virglExtraFlags, - onValueChange = { virglExtraFlags = it }, - label = { Text(context.getString(R.string.virgl_extra_flags_label)) }, - placeholder = { Text(context.getString(R.string.virgl_extra_flags_placeholder)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - shape = RoundedCornerShape(16.dp), - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ), - leadingIcon = { Icon(Icons.Default.Layers, contentDescription = null) } - ) - } - - - // Bind Mounts Section - Row( - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = context.getString(R.string.bind_mounts), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold - ) - } - - bindMounts.forEach { mount -> - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ) - ) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = context.getString(R.string.host_path, mount.src), - style = MaterialTheme.typography.bodyMedium, - overflow = TextOverflow.Ellipsis, - maxLines = 1 - ) - Text( - text = context.getString(R.string.container_path, mount.dest), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.secondary, - overflow = TextOverflow.Ellipsis, - maxLines = 1 - ) - if (mount.ro) { - Surface( - shape = RoundedCornerShape(6.dp), - color = MaterialTheme.colorScheme.secondaryContainer, - modifier = Modifier.padding(top = 4.dp) - ) { - Text( - text = context.getString(R.string.read_only), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSecondaryContainer, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) - ) - } - } - } - IconButton(onClick = { - bindMounts = bindMounts - mount - }) { - Icon(Icons.Default.Delete, contentDescription = null, tint = MaterialTheme.colorScheme.error) - } - } - } - } - - OutlinedButton( - onClick = { showFilePicker = true }, - modifier = Modifier.fillMaxWidth(), - border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) - ) { - Icon(Icons.Default.Add, contentDescription = null) - Spacer(modifier = Modifier.width(8.dp)) - Text(context.getString(R.string.add_bind_mount)) - } - - Spacer(modifier = Modifier.height(16.dp)) } } } -} diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerDetailsScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerDetailsScreen.kt index 7e62116d..378046be 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerDetailsScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerDetailsScreen.kt @@ -24,7 +24,9 @@ import com.droidspaces.app.ui.component.ContainerUsersCard import com.droidspaces.app.util.ContainerInfo import com.droidspaces.app.util.ContainerOSInfoManager import com.droidspaces.app.util.ContainerSystemdManager +import com.droidspaces.app.util.ContainerProcdManager import com.droidspaces.app.util.ContainerOpenRCManager +import com.droidspaces.app.util.ContainerDiskUsageManager import com.droidspaces.app.util.ContainerManager import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -72,13 +74,23 @@ fun ContainerDetailsScreen( mutableStateOf(ContainerOSInfoManager.getCachedOSInfo(container.name, context)) } - // Init system state - detect systemd first, then OpenRC + // Init system state - detect systemd first, then OpenWrt/procd, then OpenRC var initSystemState by remember { mutableStateOf(InitSystemCardState.Checking) } + // Disk usage of the sparse rootfs.img (only relevant for sparse-image containers). + // Seed from the in-memory cache so a re-opened screen paints instantly. + var diskUsage by remember { + mutableStateOf( + if (container.useSparseImage) ContainerDiskUsageManager.getCached(container.rootfsPath) else null + ) + } + LaunchedEffect(container.name) { initSystemState = when { ContainerSystemdManager.isSystemdAvailable(container.name) -> InitSystemCardState.Available(InitSystem.SYSTEMD) + ContainerProcdManager.isProcdAvailable(container.name) -> + InitSystemCardState.Available(InitSystem.PROCD) ContainerOpenRCManager.isOpenRCAvailable(container.name) -> InitSystemCardState.Available(InitSystem.OPENRC) else -> @@ -127,6 +139,15 @@ fun ContainerDetailsScreen( } } + // Disk-usage footprint - computed once when the screen opens (the sparse image's + // footprint changes slowly, so a snapshot is enough). Runs in its own effect so the + // first reading isn't blocked behind the heavier OS-info shell calls above. + if (container.useSparseImage) { + LaunchedEffect(container.name) { + ContainerDiskUsageManager.getUsage(container.rootfsPath)?.let { diskUsage = it } + } + } + Scaffold( topBar = { TopAppBar( @@ -188,8 +209,10 @@ fun ContainerDetailsScreen( } osInfo?.let { info -> - // Dynamic Height Synced Grid - measure all 6 and find max - val tokens = mutableListOf<@Composable () -> Unit>().apply { + // Dynamic Height Synced Grid - measure all 6 and find max. + // Built once per (osInfo, running) instead of every recomposition + // (this block re-fires on the 2s OS-info poll). + val tokens = remember(info, container.isRunning) { mutableListOf<@Composable () -> Unit>().apply { add { IdentityToken(context.getString(R.string.distribution), info.prettyName ?: info.name ?: "Linux", IconUtils.getDistroIcon(info.prettyName ?: info.name), MaterialTheme.colorScheme.primary) } add { IdentityToken(context.getString(R.string.hostname), info.hostname ?: "localhost", rememberVectorPainter(image = Icons.Default.Computer), MaterialTheme.colorScheme.secondary) } add { IdentityToken(context.getString(R.string.uptime), info.uptime ?: "0s", rememberVectorPainter(image = Icons.Default.Timer), MaterialTheme.colorScheme.tertiary) } @@ -198,7 +221,7 @@ fun ContainerDetailsScreen( add { IdentityToken(context.getString(R.string.cpu_usage_label), info.cpuUsage?.let { context.getString(R.string.cpu_percent_label, it) } ?: "---", rememberVectorPainter(image = Icons.Default.Speed), MaterialTheme.colorScheme.primary) } add { IdentityToken(context.getString(R.string.ram_usage_label), info.ramUsageMb?.let { context.getString(R.string.ram_percent_label, it, info.ramPercent ?: 0.0) } ?: "---", rememberVectorPainter(image = Icons.Default.Memory), MaterialTheme.colorScheme.secondary) } } - } + } } SyncedGrid(items = tokens) } ?: Box( @@ -215,6 +238,13 @@ fun ContainerDetailsScreen( } } + // Disk Usage Card - only for sparse-image containers (shows rootfs.img footprint) + if (container.useSparseImage) { + item(key = "diskusage_${container.name}") { + SparseDiskUsageCard(usage = diskUsage) + } + } + // Users Card - Stable key (don't include refreshTrigger to prevent recreation) item(key = "users_${container.name}") { ContainerUsersCard( @@ -244,7 +274,7 @@ fun ContainerDetailsScreen( /** * Which init system is available in the container. */ -enum class InitSystem { SYSTEMD, OPENRC } +enum class InitSystem { SYSTEMD, PROCD, OPENRC } /** * Init system card state - sealed for type safety and stability @@ -437,7 +467,107 @@ private fun TerminalCard( } /** - * PREMIUM INIT SYSTEM CARD - handles Systemd, OpenRC, and unavailable states. + * Disk Usage Card - shows how much real device storage a sparse rootfs.img occupies. + * Only rendered for sparse-image containers. [usage] is null until the first measurement + * lands, during which a lightweight placeholder is shown. + */ +@Composable +private fun SparseDiskUsageCard( + usage: ContainerDiskUsageManager.DiskUsage?, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + + var visible by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { visible = true } + + val alpha by animateFloatAsState( + targetValue = if (visible) 1f else 0f, + animationSpec = AnimationUtils.cardFadeSpec(), + label = "disk_usage_card_fade" + ) + + val fraction = usage?.let { + if (it.totalBytes > 0L) (it.usedBytes.toFloat() / it.totalBytes.toFloat()).coerceIn(0f, 1f) else 0f + } ?: 0f + val animatedFraction by animateFloatAsState( + targetValue = fraction, + animationSpec = AnimationUtils.cardFadeSpec(), + label = "disk_usage_progress" + ) + + Surface( + modifier = modifier + .fillMaxWidth() + .heightIn(min = 88.dp) + .alpha(alpha) + .graphicsLayer { this.alpha = alpha }, + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_disk), + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary + ) + Column { + Text( + text = context.getString(R.string.disk_usage), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + val subtitle = usage?.let { + context.getString( + R.string.disk_usage_used_of_total, + formatBytes(it.usedBytes), + formatBytes(it.totalBytes) + ) + } ?: context.getString(R.string.disk_usage_calculating) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } + } + + LinearProgressIndicator( + progress = { animatedFraction }, + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(RoundedCornerShape(4.dp)), + trackColor = MaterialTheme.colorScheme.surfaceVariant + ) + } + } +} + +/** + * Formats a byte count as a human-readable string (GiB with one decimal, or MiB below 1 GiB). + */ +private fun formatBytes(bytes: Long): String { + val gb = bytes.toDouble() / (1024.0 * 1024.0 * 1024.0) + if (gb >= 1.0) return String.format("%.1f GB", gb) + val mb = bytes.toDouble() / (1024.0 * 1024.0) + return String.format("%.0f MB", mb) +} + +/** + * PREMIUM INIT SYSTEM CARD - handles Systemd, OpenWrt/procd, OpenRC, and unavailable states. */ @Composable private fun PremiumInitSystemCard( @@ -458,6 +588,7 @@ private fun PremiumInitSystemCard( val cardTitle = when (state) { is InitSystemCardState.Available -> when (state.initSystem) { InitSystem.SYSTEMD -> context.getString(R.string.systemd) + InitSystem.PROCD -> context.getString(R.string.openwrt) InitSystem.OPENRC -> context.getString(R.string.openrc) } else -> context.getString(R.string.init_system) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerNameScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerNameScreen.kt index 213c9f7e..d098c164 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerNameScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerNameScreen.kt @@ -1,4 +1,6 @@ package com.droidspaces.app.ui.screen + +import com.droidspaces.app.ui.component.PrimaryActionBottomBar import androidx.compose.ui.graphics.Color import androidx.compose.foundation.layout.* @@ -59,18 +61,19 @@ fun ContainerNameScreen( // Check for duplicate name when container name changes (instant check using cached names) LaunchedEffect(containerName, existingContainerNames) { if (containerName.isNotBlank()) { - // Validate format first - val formatResult = ValidationUtils.validateContainerName(containerName, context) + // Validate format first (against the normalized name, which is what we store) + val normalizedName = ValidationUtils.normalizeContainerName(containerName) + val formatResult = ValidationUtils.validateContainerName(normalizedName, context) if (formatResult.isError) { nameError = formatResult.errorMessage } else { // Check for duplicate - instant check using cached names // Compare both exact names and sanitized names // This handles cases where user enters "My Container" and there's "My-Container" or vice versa - val sanitizedInput = ContainerManager.sanitizeContainerName(containerName) + val sanitizedInput = ContainerManager.sanitizeContainerName(normalizedName) val isDuplicate = existingContainerNames.any { existingName -> // Check exact match (case-insensitive) - existingName.equals(containerName, ignoreCase = true) || + existingName.equals(normalizedName, ignoreCase = true) || // Check sanitized match (handles spaces vs dashes) ContainerManager.sanitizeContainerName(existingName).equals(sanitizedInput, ignoreCase = true) } @@ -98,63 +101,24 @@ fun ContainerNameScreen( ) }, bottomBar = { - val btnShape = RoundedCornerShape(20.dp) val isNextValid = containerName.isNotBlank() && nameError == null && hostnameError == null - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surfaceContainer, - tonalElevation = 0.dp - ) { - Column(modifier = Modifier.fillMaxWidth()) { - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.25f), - thickness = 1.dp - ) - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp) - .navigationBarsPadding() - .clip(btnShape) - .clickable( - enabled = isNextValid, - onClick = { - clearFocus() - val nameResult = ValidationUtils.validateContainerName(containerName, context) - val hostnameResult = ValidationUtils.validateHostname(hostname.ifEmpty { ValidationUtils.sanitizeHostname(containerName) }, context) - if (!nameResult.isError && !hostnameResult.isError) { - onNext(containerName, hostname.ifEmpty { ValidationUtils.sanitizeHostname(containerName) }) - } else { - nameError = nameResult.errorMessage - hostnameError = hostnameResult.errorMessage - } - }, - indication = androidx.compose.material.ripple.rememberRipple(bounded = true), - interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() } - ), - shape = btnShape, - color = if (isNextValid) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(vertical = 16.dp), contentAlignment = Alignment.Center) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Icon( - Icons.AutoMirrored.Filled.ArrowForward, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = if (isNextValid) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - ) - Text( - context.getString(R.string.next_configuration), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - color = if (isNextValid) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - ) - } - } + PrimaryActionBottomBar( + label = context.getString(R.string.next_configuration), + icon = Icons.AutoMirrored.Filled.ArrowForward, + onClick = { + clearFocus() + val normalizedName = ValidationUtils.normalizeContainerName(containerName) + val nameResult = ValidationUtils.validateContainerName(normalizedName, context) + val hostnameResult = ValidationUtils.validateHostname(hostname.ifEmpty { ValidationUtils.sanitizeHostname(normalizedName) }, context) + if (!nameResult.isError && !hostnameResult.isError) { + onNext(normalizedName, hostname.ifEmpty { ValidationUtils.sanitizeHostname(normalizedName) }) + } else { + nameError = nameResult.errorMessage + hostnameError = hostnameResult.errorMessage } - } - } + }, + enabled = isNextValid + ) } ) { innerPadding -> Box( diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerTerminalScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerTerminalScreen.kt index 998ee878..ff996bb1 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerTerminalScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainerTerminalScreen.kt @@ -76,7 +76,13 @@ fun ContainerTerminalScreen( override fun onServiceDisconnected(name: ComponentName?) { binder = null } } context.bindService(Intent(context, TerminalSessionService::class.java), conn, Context.BIND_AUTO_CREATE) - onDispose { context.unbindService(conn) } + onDispose { + // Detach the UI client from any backgrounded sessions before unbinding so + // the service doesn't retain this Activity/TerminalView (VULN V16). The + // screen re-attaches its own client on re-entry. + binder?.detachAllClients() + context.unbindService(conn) + } } val availableUsers = remember(initialUsers) { diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainersScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainersScreen.kt index a9814a2f..3fdf6f2e 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainersScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ContainersScreen.kt @@ -1,10 +1,15 @@ package com.droidspaces.app.ui.screen +import com.droidspaces.app.ui.component.DsTextFieldDefaults + import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -17,7 +22,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.material3.SnackbarHost import com.droidspaces.app.ui.util.ProgressDialog @@ -32,46 +40,25 @@ import kotlinx.coroutines.launch import androidx.compose.runtime.rememberCoroutineScope import androidx.lifecycle.viewmodel.compose.viewModel import com.droidspaces.app.ui.viewmodel.SystemStatsViewModel -import com.topjohnwu.superuser.Shell -import com.droidspaces.app.util.ContainerManager import com.droidspaces.app.util.ContainerInfo -import com.droidspaces.app.util.ContainerCommandBuilder -import com.droidspaces.app.util.ContainerOSInfoManager -import com.droidspaces.app.util.ContainerOperationExecutor -import com.droidspaces.app.util.ContainerLogger -import com.droidspaces.app.util.ViewModelLogger -import com.droidspaces.app.util.SystemInfoManager import com.droidspaces.app.util.PreferencesManager import com.droidspaces.app.util.FilePickerUtils import com.droidspaces.app.ui.component.ContainerCard +import com.droidspaces.app.ui.component.ContainerCardActions import com.droidspaces.app.ui.component.TerminalDialog import com.droidspaces.app.ui.component.EmptyState import com.droidspaces.app.ui.component.ErrorState import com.droidspaces.app.ui.component.RootUnavailableState import com.droidspaces.app.ui.component.RootfsRepoSheet import com.droidspaces.app.ui.viewmodel.ContainerViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.io.File -import java.io.OutputStream -import android.util.Log +import com.droidspaces.app.ui.viewmodel.ContainerOperationsViewModel +import com.droidspaces.app.ui.viewmodel.UninstallState +import com.droidspaces.app.ui.viewmodel.SparseOperation import androidx.compose.foundation.clickable import androidx.compose.ui.draw.clip import com.droidspaces.app.R import androidx.compose.ui.window.Dialog -// Uninstall state (similar to SystemdScreen ActionState) -private sealed class UninstallState { - data object Idle : UninstallState() - data class InProgress(val containerName: String, val message: String) : UninstallState() -} - -// Sparse operation state -private sealed class SparseOperation { - data class Migrate(val container: ContainerInfo) : SparseOperation() - data class Resize(val container: ContainerInfo) : SparseOperation() -} - @OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) @Composable fun ContainersScreen( @@ -82,7 +69,8 @@ fun ContainersScreen( onNavigateToContainerDetails: (String) -> Unit = {}, containerViewModel: ContainerViewModel, expandedContainerName: String?, - onExpandedContainerNameChange: (String?) -> Unit + onExpandedContainerNameChange: (String?) -> Unit, + emptyStateBottomInset: Dp = 0.dp ) { val scope = rememberCoroutineScope() val context = LocalContext.current @@ -91,122 +79,15 @@ fun ContainersScreen( val snackbarHostState = remember { SnackbarHostState() } - // Track running operations and logs per container - // Use mutableStateListOf directly like installation screen for optimal performance - var runningOperationContainer by remember { mutableStateOf(null) } - var containerLogs by remember { mutableStateOf>>>(emptyMap()) } - var showLogViewerFor by remember { mutableStateOf(null) } - var lastErrorContainer by remember { mutableStateOf(null) } + // Container lifecycle/maintenance operations + their state live in the ViewModel. + val opsViewModel: ContainerOperationsViewModel = viewModel() - // Uninstall/Operation state management + // UI-only state (dialog triggers / pending pickers). var showUninstallConfirmation by remember { mutableStateOf(null) } - var uninstallState by remember { mutableStateOf(UninstallState.Idle) } - var uninstallLogsDialog by remember { mutableStateOf?>(null) } - - // Sparse operation state management var pendingSparseOperation by remember { mutableStateOf(null) } - - // Export state - tracks which container is pending export (waiting for file picker) var pendingExportContainer by remember { mutableStateOf(null) } - - // Repo sheet visibility var showRepoSheet by remember { mutableStateOf(false) } - // Execute container export - writes archive to user-picked URI via temp file - suspend fun executeExport(container: ContainerInfo, outputUri: Uri) { - runningOperationContainer = container.name - val logs = if (!containerLogs.containsKey(container.name)) { - val newLogs = androidx.compose.runtime.mutableStateListOf>() - containerLogs = containerLogs.toMutableMap().apply { put(container.name, newLogs) } - newLogs - } else { - containerLogs[container.name]!! - } - logs.clear() - prefsManager.clearContainerLogs(container.name) - // Defer showLogViewerFor until after stopping logic - - val logger = ViewModelLogger { level, message -> - logs.add(level to message) - }.apply { verbose = true } - - var scriptFile: File? = null - var tempArchive: File? = null - try { - // Check if container is running first - val isRunning = ContainerManager.checkContainerStatus(container.name).first - if (isRunning) { - // Show progress dialog - stopping (using UninstallState for consistent UI) - uninstallState = UninstallState.InProgress(container.name, context.getString(R.string.stopping_container)) - - val stopCommand = ContainerCommandBuilder.buildStopCommand(container) - val stopResult = ContainerOperationExecutor.executeCommand(stopCommand, "stop", logger) - - // Dismiss progress dialog - uninstallState = UninstallState.Idle - - if (!stopResult) { - logger.e(context.getString(R.string.failed_to_stop_container, container.name)) - scope.showError(snackbarHostState, context.getString(R.string.failed_to_stop_container, container.name)) - return - } - } - - // Clear stop-logs so terminal starts fresh for the actual export - logs.clear() - - // Now show the log viewer for the actual operation - showLogViewerFor = container.name - logger.i(context.getString(R.string.starting_export)) - - // Deploy export_container.sh from assets - val deployed = File("${context.cacheDir}/export_container.sh") - scriptFile = deployed - context.assets.open("export_container.sh").use { input -> - deployed.outputStream().use { out: java.io.OutputStream -> input.copyTo(out) } - } - Shell.cmd("chmod 755 \"${deployed.absolutePath}\"").exec() - - // Write archive to a temp file first, then copy to the URI - tempArchive = File("${context.cacheDir}/${container.name}_export_tmp.tar.gz") - tempArchive.delete() - - val cmd = "\"${deployed.absolutePath}\" \"${container.name}\" \"${tempArchive.absolutePath}\"" - val success = ContainerOperationExecutor.executeCommand( - command = cmd, - operation = "export", - logger = logger, - skipHeader = true, - operationCompletedMessage = context.getString(R.string.operation_completed_success) - ) - - if (success && tempArchive.exists() && tempArchive.length() > 0) { - // Copy temp file → user-picked URI - logger.i("Writing archive to destination...") - withContext(Dispatchers.IO) { - context.contentResolver.openOutputStream(outputUri)?.use { out -> - tempArchive.inputStream().use { it.copyTo(out) } - } - } - logger.i("Done! Archive written successfully.") - } else if (!success) { - logger.e(context.getString(R.string.export_container_failed, container.name)) - scope.showError(snackbarHostState, context.getString(R.string.export_container_failed, container.name)) - } - } catch (e: Exception) { - logger.e("Export error: ${e.message}") - logger.e(e.stackTraceToString()) - scope.showError(snackbarHostState, context.getString(R.string.export_container_failed, container.name)) - } finally { - scriptFile?.delete() - tempArchive?.delete() - prefsManager.saveContainerLogs(container.name, logs.toList()) - kotlinx.coroutines.delay(500) - runningOperationContainer = null - } - } - - // File picker launcher - CreateDocument for saving the export archive val exportFileLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.CreateDocument("application/gzip") @@ -215,7 +96,7 @@ fun ContainersScreen( pendingExportContainer = null if (uri != null && container != null) { scope.launch { - executeExport(container, uri) + opsViewModel.executeExport(container, uri, onError = { msg -> scope.showError(snackbarHostState, msg) }) } } } @@ -228,14 +109,11 @@ fun ContainersScreen( contract = ActivityResultContracts.GetContent() ) { uri: Uri? -> if (uri != null) { - // Use FilePickerUtils to properly get filename from any URI type (including recent files) - // and validate file extension scope.launch { val (isValid, fileName) = FilePickerUtils.isValidTarball(context, uri) if (isValid && fileName != null) { onNavigateToInstallation(uri) } else { - // Show error snackbar with actual filename or helpful message val errorMessage = if (fileName != null) { context.getString(R.string.file_picker_error, fileName) } else { @@ -247,365 +125,24 @@ fun ContainersScreen( } } - // Get containers from ViewModel - single source of truth (KernelSU pattern) val containers = containerViewModel.containerList - // Execute container uninstallation - using same pattern as SystemdScreen - suspend fun executeUninstall(container: ContainerInfo) { - // Collect logs during uninstallation - val collectedLogs = mutableListOf() - - // Create logger to collect logs - val logger = ViewModelLogger { _, message -> - // Collect logs for potential error dialog - collectedLogs.add(message) - }.apply { - verbose = true - } - - try { - // Check if container is running first - val isRunning = ContainerManager.checkContainerStatus(container.name).first - - if (isRunning) { - // Show progress dialog - stopping - uninstallState = UninstallState.InProgress(container.name, context.getString(R.string.stopping_container)) - - // Stop the container first - val stopCommand = ContainerCommandBuilder.buildStopCommand(container) - val stopResult = ContainerOperationExecutor.executeCommand(stopCommand, "stop", logger) - - if (!stopResult) { - // Failed to stop - dismiss progress and show error - uninstallState = UninstallState.Idle - if (collectedLogs.isNotEmpty()) { - uninstallLogsDialog = collectedLogs - } else { - scope.showError(snackbarHostState, context.getString(R.string.failed_to_stop_container, container.name)) - } - containerViewModel.refresh() - return - } - } - - // Show progress dialog - uninstalling - uninstallState = UninstallState.InProgress(container.name, context.getString(R.string.uninstalling_container)) - - // Execute uninstallation using ContainerManager - val result = ContainerManager.uninstallContainer(container, logger) - - // Dismiss progress dialog - uninstallState = UninstallState.Idle - - if (result.isFailure) { - // Failed - show logs dialog if there are logs, otherwise snackbar - if (collectedLogs.isNotEmpty()) { - uninstallLogsDialog = collectedLogs - } else { - scope.showError(snackbarHostState, context.getString(R.string.failed_to_uninstall_container, container.name)) - } - - // Refresh container status immediately on failure - containerViewModel.refresh() - } else { - // Success - clear cached OS info for this container - ContainerOSInfoManager.clearCache(container.name, context) - - // Show snackbar - scope.showSuccess(snackbarHostState, context.getString(R.string.container_uninstalled_success, container.name)) - - // Refresh container status immediately after successful uninstallation - containerViewModel.refresh() - } - } catch (e: Exception) { - // Dismiss progress dialog - uninstallState = UninstallState.Idle - - // Add exception to logs - collectedLogs.add("Exception: ${e.message}") - collectedLogs.add(e.stackTraceToString()) - - // Show logs dialog - if (collectedLogs.isNotEmpty()) { - uninstallLogsDialog = collectedLogs - } else { - scope.showError(snackbarHostState, context.getString(R.string.failed_to_uninstall_container, container.name)) - } - - // Refresh container status immediately even on exception - containerViewModel.refresh() - } - } - - // Execute container operation (start/stop/restart) - using same pattern as installation - suspend fun executeOperation(container: ContainerInfo, operation: String) { - // Show terminal icon immediately - prepare console first - runningOperationContainer = container.name - - // Initialize logs list if needed - use mutableStateListOf for optimal performance - val logs = if (!containerLogs.containsKey(container.name)) { - val newLogs = androidx.compose.runtime.mutableStateListOf>() - containerLogs = containerLogs.toMutableMap().apply { - put(container.name, newLogs) - } - newLogs - } else { - containerLogs[container.name]!! - } - - // Auto-clear previous logs when starting new action (only store 1 action) - logs.clear() - prefsManager.clearContainerLogs(container.name) - - // Immediately show blocking log viewer - showLogViewerFor = container.name - - // Create logger - directly add to mutableStateListOf (no map recreation needed!) - // This matches installation screen pattern exactly for smooth performance - // Logger uses suspend functions and ensures Main thread for UI updates - val logger = ViewModelLogger { level, message -> - // Direct add to SnapshotStateList - Compose will handle efficient recomposition - // No need to recreate map or trigger manual recomposition - // This callback is already on Main thread (ensured by ViewModelLogger) - logs.add(level to message) - }.apply { - verbose = true - } - - try { - // Kill terminal sessions + clear usage cache before stop/restart - if (operation == "stop" || operation == "restart") { - context.startService( - android.content.Intent(context, com.droidspaces.app.service.TerminalSessionService::class.java).apply { - action = com.droidspaces.app.service.TerminalSessionService.ACTION_STOP_CONTAINER_SESSIONS - putExtra(com.droidspaces.app.service.TerminalSessionService.EXTRA_CONTAINER_NAME, container.name) - } - ) - systemStatsViewModel.clearContainerUsage(container.name) - } - - // Build command - val command = when (operation) { - "start" -> ContainerCommandBuilder.buildStartCommand(container) - "stop" -> ContainerCommandBuilder.buildStopCommand(container) - "restart" -> ContainerCommandBuilder.buildRestartCommand(container) - else -> { - runningOperationContainer = null - return - } - } - - // Execute command using logger callback pattern (same as installation). - // Pass operationCompletedMessage so the success line is logged inside executeCommand - // in guaranteed order on Main.immediate - before this coroutine resumes. - // This eliminates the race where logger.i() calls posted from here could - // interleave with the exit-code line logged inside executeCommand. - val success = ContainerOperationExecutor.executeCommand( - command = command, - operation = operation, - logger = logger, - operationCompletedMessage = context.getString(R.string.operation_completed_success) - ) - - if (!success) { - lastErrorContainer = container.name - logger.e("") - logger.e(context.getString(R.string.operation_failed)) - - // Operation failed - console stays open, user must close manually - - // Show snackbar - scope.launch { - snackbarHostState.showSnackbar( - message = context.getString(R.string.failure_in_operation, operation, container.name), - duration = SnackbarDuration.Long - ) - } - - // Refresh container status and SELinux immediately on failure (KernelSU pattern) - containerViewModel.refresh() - SystemInfoManager.refreshSELinuxStatus() - } else { - lastErrorContainer = null - - // Operation succeeded - console stays open, user must close manually - - // Refresh container status and SELinux immediately after successful operation (KernelSU pattern) - containerViewModel.refresh() - SystemInfoManager.refreshSELinuxStatus() - } - } catch (e: Exception) { - logger.e("Error: ${e.message}") - logger.e(e.stackTraceToString()) - lastErrorContainer = container.name - - // Operation failed with exception - console stays open, user must close manually - - // Show snackbar - scope.launch { - snackbarHostState.showSnackbar( - message = context.getString(R.string.failure_in_operation, operation, container.name), - duration = SnackbarDuration.Long - ) - } - - // Refresh container status immediately even on exception (KernelSU pattern) - containerViewModel.refresh() - } finally { - // Save logs to cache when operation completes (only last action) - prefsManager.saveContainerLogs(container.name, logs.toList()) - - // Add a generous delay before clearing status to allow TerminalConsole - // to finish its final scroll animation for the "Success" message. - kotlinx.coroutines.delay(500) - - // Clear running operation state (but keep console open) - runningOperationContainer = null - } - } - - // Execute sparse image operation (migrate/resize) - suspend fun executeSparseOperation(operation: SparseOperation, sizeGb: Int) { - val container = when (operation) { - is SparseOperation.Migrate -> operation.container - is SparseOperation.Resize -> operation.container - } - - // 1. Prepare terminal and logs - runningOperationContainer = container.name - val logs = if (!containerLogs.containsKey(container.name)) { - val newLogs = androidx.compose.runtime.mutableStateListOf>() - containerLogs = containerLogs.toMutableMap().apply { put(container.name, newLogs) } - newLogs - } else { - containerLogs[container.name]!! - } - logs.clear() - prefsManager.clearContainerLogs(container.name) - // Defer showLogViewerFor until after stopping logic - - val logger = ViewModelLogger { level, message -> - logs.add(level to message) - }.apply { verbose = true } - - var scriptFile: File? = null - try { - // Check if container is running first - val isRunning = ContainerManager.checkContainerStatus(container.name).first - if (isRunning) { - // Show progress dialog - stopping (using UninstallState for consistent UI) - uninstallState = UninstallState.InProgress(container.name, context.getString(R.string.stopping_container)) - - val stopCommand = ContainerCommandBuilder.buildStopCommand(container) - val stopResult = ContainerOperationExecutor.executeCommand(stopCommand, "stop", logger) - - // Dismiss progress dialog - uninstallState = UninstallState.Idle - - if (!stopResult) { - logger.e(context.getString(R.string.failed_to_stop_container, container.name)) - return - } - // Small delay to ensure cleanup - kotlinx.coroutines.delay(1000) - } - - // Clear stop-logs so terminal starts fresh for the actual operation - logs.clear() - - // Now show the log viewer for the actual operation - showLogViewerFor = container.name - - // 3. Deploy sparsemgr.sh from assets - val deployedFile = File("${context.cacheDir}/sparsemgr.sh") - scriptFile = deployedFile - context.assets.open("sparsemgr.sh").use { input -> - deployedFile.outputStream().use { output: OutputStream -> - input.copyTo(output) - } - } - Shell.cmd("chmod 755 \"${deployedFile.absolutePath}\"").exec() - - // 4. Build and execute command - val baseDir = ContainerManager.getContainerDirectory(container.name) - val cmd = when (operation) { - is SparseOperation.Migrate -> { - logger.i(context.getString(R.string.starting_migration)) - "\"${deployedFile.absolutePath}\" -d \"$baseDir\" migrate $sizeGb" - } - is SparseOperation.Resize -> { - logger.i(context.getString(R.string.starting_resizing)) - val imgPath = ContainerManager.getSparseImagePath(container.name) - "\"${deployedFile.absolutePath}\" -i \"$imgPath\" resize $sizeGb --yes" - } - } - - val success = ContainerOperationExecutor.executeCommand( - command = cmd, - operation = "sparse_op", - logger = logger, - skipHeader = true, - operationCompletedMessage = context.getString(R.string.operation_completed_success) - ) - - if (success) { - // 5. Update container config on success - logger.i("Updating container configuration...") - val updatedConfig = if (operation is SparseOperation.Migrate) { - container.copy( - useSparseImage = true, - sparseImageSizeGB = sizeGb, - rootfsPath = if (container.rootfsPath.endsWith(".img")) container.rootfsPath else "${container.rootfsPath}.img" - ) - } else { - container.copy( - sparseImageSizeGB = sizeGb - ) - } - val configResult = withContext(Dispatchers.IO) { - ContainerManager.updateContainerConfig(context, container.name, updatedConfig) - } - - if (configResult.isSuccess) { - logger.i("Configuration updated successfully") - containerViewModel.refresh() - } else { - logger.w("Warning: Failed to update container.config: ${configResult.exceptionOrNull()?.message}") - } - } else { - logger.e(context.getString(R.string.operation_failed)) - } - - } catch (e: Exception) { - logger.e("Error during sparse operation: ${e.message}") - logger.e(e.stackTraceToString()) - } finally { - scriptFile?.delete() - prefsManager.saveContainerLogs(container.name, logs.toList()) - // Add a generous delay before clearing status to allow TerminalConsole - // to finish its final scroll animation for the "Update Config" message. - kotlinx.coroutines.delay(500) - runningOperationContainer = null - } - } - - Box( modifier = Modifier.fillMaxSize() ) { // Show root unavailable state first, then backend unavailable, then content when { !isRootAvailable -> { - RootUnavailableState() + RootUnavailableState(modifier = Modifier.padding(bottom = emptyStateBottomInset)) } !isBackendAvailable -> { - ErrorState() + ErrorState(modifier = Modifier.padding(bottom = emptyStateBottomInset)) } containers.isEmpty() -> { if (containerViewModel.isRefreshing) { Box( - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize().padding(bottom = emptyStateBottomInset), contentAlignment = Alignment.Center ) { LoadingIndicator(size = LoadingSize.Large) @@ -614,13 +151,16 @@ fun ContainersScreen( EmptyState( icon = Icons.Default.Storage, title = context.getString(R.string.no_containers_installed), - description = context.getString(R.string.install_container_description) + description = context.getString(R.string.install_container_description), + // Reserve the floating tab bar's space so the centered + // content sits in the visible region, not behind the bar. + modifier = Modifier.padding(bottom = emptyStateBottomInset) ) } } else -> { // Show container cards - Column( + LazyColumn( modifier = Modifier .fillMaxSize() .combinedClickable( @@ -628,56 +168,76 @@ fun ContainersScreen( indication = null, onClick = { onExpandedContainerNameChange(null) } ) - .verticalScroll(rememberScrollState()) - .padding(horizontal = 16.dp) - .padding(top = 8.dp, bottom = 120.dp), // Clear floating tab bar + .padding(horizontal = 16.dp), + contentPadding = PaddingValues(top = 8.dp, bottom = 120.dp), // Clear floating tab bar verticalArrangement = Arrangement.spacedBy(16.dp) ) { - containers.forEach { container -> + items(containers, key = { it.name }) { container -> // Console button is always visible - logs persist for each container - val isRunning = runningOperationContainer == container.name + val isRunning = opsViewModel.runningOperationContainer == container.name ContainerCard( container = container, isOperationRunning = isRunning, isExpanded = expandedContainerName == container.name, + actions = ContainerCardActions( onToggleExpand = { onExpandedContainerNameChange(if (expandedContainerName == container.name) null else container.name) }, onShowLogs = { - showLogViewerFor = container.name + opsViewModel.showLogViewerFor = container.name }, onStart = { scope.launch { - executeOperation(container, "start") + opsViewModel.executeOperation( + container, "start", + onRefresh = { containerViewModel.refresh() }, + onClearUsage = { systemStatsViewModel.clearContainerUsage(it) }, + onFailureSnackbar = { msg -> scope.launch { snackbarHostState.showSnackbar(msg, duration = SnackbarDuration.Long) } } + ) } }, onStop = { scope.launch { - executeOperation(container, "stop") + opsViewModel.executeOperation( + container, "stop", + onRefresh = { containerViewModel.refresh() }, + onClearUsage = { systemStatsViewModel.clearContainerUsage(it) }, + onFailureSnackbar = { msg -> scope.launch { snackbarHostState.showSnackbar(msg, duration = SnackbarDuration.Long) } } + ) } }, onRestart = { scope.launch { - executeOperation(container, "restart") + opsViewModel.executeOperation( + container, "restart", + onRefresh = { containerViewModel.refresh() }, + onClearUsage = { systemStatsViewModel.clearContainerUsage(it) }, + onFailureSnackbar = { msg -> scope.launch { snackbarHostState.showSnackbar(msg, duration = SnackbarDuration.Long) } } + ) } }, onEdit = { + onExpandedContainerNameChange(null) onNavigateToEditContainer(container.name) }, onEnter = { onNavigateToContainerDetails(container.name) }, onUninstall = { + onExpandedContainerNameChange(null) showUninstallConfirmation = container }, onMigrate = { + onExpandedContainerNameChange(null) pendingSparseOperation = SparseOperation.Migrate(container) }, onResize = { + onExpandedContainerNameChange(null) pendingSparseOperation = SparseOperation.Resize(container) }, onExport = { + onExpandedContainerNameChange(null) // Generate filename: _yyyyMMdd_HHmmss.tar.gz val timestamp = java.text.SimpleDateFormat( "yyyyMMdd_HHmmss", @@ -687,6 +247,7 @@ fun ContainersScreen( pendingExportContainer = container exportFileLauncher.launch(fileName) } + ) ) } } @@ -760,36 +321,26 @@ fun ContainersScreen( ) // Log viewer dialog - console stays open, user must close manually - showLogViewerFor?.let { containerName -> + opsViewModel.showLogViewerFor?.let { containerName -> // Load logs from memory first, fallback to cache if empty - val memoryLogs = containerLogs[containerName]?.toList() ?: emptyList() + val memoryLogs = opsViewModel.containerLogs[containerName]?.toList() ?: emptyList() val cachedLogs = if (memoryLogs.isEmpty()) { prefsManager.loadContainerLogs(containerName) } else { emptyList() } val logs = memoryLogs.ifEmpty { cachedLogs } - val isBlocking = runningOperationContainer == containerName // Blocking when operation is running + val isBlocking = opsViewModel.runningOperationContainer == containerName // Blocking when operation is running TerminalDialog( title = context.getString(R.string.logs_title, containerName), logs = logs, onDismiss = { - showLogViewerFor = null + opsViewModel.showLogViewerFor = null // Refresh container status when console is closed (KernelSU pattern) containerViewModel.refresh() }, onClear = { - // Force-clear memory list (initialize if null) to ensure UI reacts immediately - val buffer = containerLogs[containerName] ?: androidx.compose.runtime.mutableStateListOf>().also { - containerLogs = containerLogs.toMutableMap().apply { put(containerName, it) } - } - buffer.clear() - - // Clear persistent cache synchronously - prefsManager.clearContainerLogs(containerName) - - // Trigger map recomposition for safety - containerLogs = containerLogs.toMutableMap() + opsViewModel.clearLogsBuffer(containerName) }, isBlocking = isBlocking // Block dismissal when operation is running ) @@ -802,7 +353,12 @@ fun ContainersScreen( onConfirm = { showUninstallConfirmation = null scope.launch { - executeUninstall(container) + opsViewModel.executeUninstall( + container, + onError = { msg -> scope.showError(snackbarHostState, msg) }, + onSuccess = { msg -> scope.showSuccess(snackbarHostState, msg) }, + onRefresh = { containerViewModel.refresh() } + ) } }, onDismiss = { @@ -812,17 +368,17 @@ fun ContainersScreen( } // Uninstall progress dialog - (uninstallState as? UninstallState.InProgress)?.let { state -> + (opsViewModel.uninstallState as? UninstallState.InProgress)?.let { state -> ProgressDialog( message = state.message ) } // Uninstall logs dialog (only on failure) - uninstallLogsDialog?.let { logs -> + opsViewModel.uninstallLogsDialog?.let { logs -> ErrorLogsDialog( logs = logs, - onDismiss = { uninstallLogsDialog = null } + onDismiss = { opsViewModel.dismissUninstallLogs() } ) } @@ -847,7 +403,7 @@ fun ContainersScreen( onConfirm = { size -> pendingSparseOperation = null scope.launch { - executeSparseOperation(op, size) + opsViewModel.executeSparseOperation(op, size, onRefresh = { containerViewModel.refresh() }) } }, onDismiss = { pendingSparseOperation = null } @@ -896,12 +452,7 @@ private fun SparseSizeDialog( modifier = Modifier.fillMaxWidth(), singleLine = true, shape = RoundedCornerShape(16.dp), - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ), + colors = DsTextFieldDefaults.colors(), isError = !isValid && sizeText.isNotEmpty(), supportingText = { if (!isValid && sizeText.isNotEmpty()) Text(context.getString(R.string.enter_size_between_4_512_gb)) @@ -955,6 +506,8 @@ private fun UninstallConfirmationDialog( ) { val context = LocalContext.current val dialogShape = RoundedCornerShape(24.dp) + var confirmText by remember { mutableStateOf("") } + val isConfirmed = confirmText == containerName Dialog( onDismissRequest = onDismiss, @@ -985,10 +538,38 @@ private fun UninstallConfirmationDialog( ) } Text( - text = context.getString(R.string.uninstall_container_message, containerName), + text = buildAnnotatedString { + val template = context.getString(R.string.uninstall_container_message) + val parts = template.split("%1\$s") + append(parts.getOrElse(0) { "" }) + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(containerName) } + append(parts.getOrElse(1) { "" }) + }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = context.getString(R.string.type_container_name_to_confirm), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold + ) + OutlinedTextField( + value = confirmText, + onValueChange = { confirmText = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text(containerName) }, + singleLine = true, + isError = confirmText.isNotEmpty() && !isConfirmed, + shape = RoundedCornerShape(14.dp), + colors = OutlinedTextFieldDefaults.colors( + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + focusedBorderColor = MaterialTheme.colorScheme.error.copy(alpha = 0.8f), + unfocusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), + focusedContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f) + ) + ) + } Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp) @@ -1005,9 +586,9 @@ private fun UninstallConfirmationDialog( } } Surface( - modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable(onClick = onConfirm), + modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable(enabled = isConfirmed, onClick = onConfirm), shape = RoundedCornerShape(14.dp), - color = MaterialTheme.colorScheme.error, + color = if (isConfirmed) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), tonalElevation = 0.dp ) { Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { @@ -1015,7 +596,7 @@ private fun UninstallConfirmationDialog( context.getString(R.string.uninstall), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onError + color = if (isConfirmed) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) ) } } diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ControlPanelScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ControlPanelScreen.kt index 2526681c..38dd32e0 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ControlPanelScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ControlPanelScreen.kt @@ -10,8 +10,12 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.repeatOnLifecycle import com.droidspaces.app.ui.component.EmptyState import com.droidspaces.app.ui.component.ErrorState import com.droidspaces.app.ui.component.RootUnavailableState @@ -36,6 +40,7 @@ fun ControlPanelScreen( containerViewModel: ContainerViewModel, onNavigateToContainerDetails: (String) -> Unit = {}, onNavigateToTerminal: (String) -> Unit = {}, + emptyStateBottomInset: Dp = 0.dp, ) { val context = LocalContext.current val snackbarHostState = remember { SnackbarHostState() } @@ -44,18 +49,17 @@ fun ControlPanelScreen( // Get running containers - derived from ViewModel state val runningContainers = containerViewModel.containerList.filter { it.isRunning } - // Start system stats monitoring (only once per screen lifetime) - // Synchronized with DisposableEffect to ensure polling stops when navigating away - DisposableEffect(Unit) { - systemStatsViewModel.startMonitoring() - onDispose { - systemStatsViewModel.stopMonitoring() - } - } + val lifecycleOwner = LocalLifecycleOwner.current - // Start container monitoring loop; restarts if running container list changes + // Poll only while this screen is composed (i.e. the Panel tab is selected) + // AND the app is in the foreground (Lifecycle STARTED). repeatOnLifecycle + // cancels the loop on background / tab-away and restarts it on return, so + // re-opening the app on the Panel tab resumes polling without a tab switch. + // Restarts whenever the running container set changes. LaunchedEffect(runningContainers) { - systemStatsViewModel.startContainerMonitoring(runningContainers) + lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + systemStatsViewModel.monitorContainers(runningContainers) + } } val containerUsageMap = systemStatsViewModel.containerUsageMap @@ -65,10 +69,10 @@ fun ControlPanelScreen( // Using when instead of early return to prevent UI glitches during recomposition when { !isRootAvailable -> { - RootUnavailableState() + RootUnavailableState(modifier = Modifier.padding(bottom = emptyStateBottomInset)) } !isBackendAvailable -> { - ErrorState() + ErrorState(modifier = Modifier.padding(bottom = emptyStateBottomInset)) } else -> { if (runningContainers.isEmpty()) { @@ -76,7 +80,9 @@ fun ControlPanelScreen( icon = Icons.Default.Dashboard, title = context.getString(R.string.no_containers_running), description = context.getString(R.string.start_container_first), - modifier = Modifier.padding(bottom = 120.dp) // Clear floating tab bar + // Reserve the floating tab bar's space so the centered + // content sits in the visible region, not behind the bar. + modifier = Modifier.padding(bottom = emptyStateBottomInset) ) } else { Column( diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/EditContainerScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/EditContainerScreen.kt index a7c9925f..13774042 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/EditContainerScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/EditContainerScreen.kt @@ -1,74 +1,74 @@ package com.droidspaces.app.ui.screen -import androidx.compose.ui.graphics.Color -import androidx.compose.foundation.ExperimentalFoundationApi +import com.droidspaces.app.ui.component.DsTextFieldDefaults + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.automirrored.filled.VolumeUp -import androidx.compose.material.icons.filled.* -import androidx.compose.material3.* -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Computer +import androidx.compose.material.icons.filled.Save +import androidx.compose.material.icons.filled.Warning import androidx.compose.material.ripple.rememberRipple -import androidx.compose.runtime.* -import com.droidspaces.app.ui.util.LoadingIndicator -import com.droidspaces.app.ui.util.LoadingSize +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import com.droidspaces.app.ui.util.rememberClearFocus +import com.droidspaces.app.R +import com.droidspaces.app.ui.component.ContainerConfigForm import com.droidspaces.app.ui.util.ClearFocusOnClickOutside -import com.droidspaces.app.ui.util.FocusUtils -import androidx.compose.foundation.clickable -import com.droidspaces.app.ui.component.ToggleCard +import com.droidspaces.app.ui.util.LoadingIndicator +import com.droidspaces.app.ui.util.rememberClearFocus +import com.droidspaces.app.ui.viewmodel.ContainerViewModel import com.droidspaces.app.util.ContainerInfo import com.droidspaces.app.util.ContainerManager import com.droidspaces.app.util.SystemInfoManager -import com.droidspaces.app.util.Constants import com.droidspaces.app.util.ValidationUtils -import com.droidspaces.app.ui.viewmodel.ContainerViewModel +import com.droidspaces.app.util.toConfigState +import com.droidspaces.app.util.withConfig import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import com.droidspaces.app.R - -import com.droidspaces.app.ui.component.FilePickerDialog -import com.droidspaces.app.util.BindMount -import androidx.compose.ui.text.style.TextOverflow -import com.droidspaces.app.ui.component.SettingsRowCard -import com.droidspaces.app.ui.component.EnvironmentVariablesDialog -import com.droidspaces.app.util.PortForward -import com.droidspaces.app.ui.component.PrivilegedModeDialog -import com.droidspaces.app.ui.component.HardwareAccessDialog -import com.droidspaces.app.ui.component.DsDropdown -import androidx.compose.material.icons.filled.Public -import com.droidspaces.app.ui.component.UpstreamInterfaceList -import com.droidspaces.app.ui.component.PortForwardingList -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.animation.core.LinearEasing -import androidx.compose.ui.graphics.graphicsLayer -import kotlinx.coroutines.delay -import androidx.compose.animation.animateColorAsState -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.ExperimentalLayoutApi -@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, ExperimentalLayoutApi::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable fun EditContainerScreen( container: ContainerInfo, @@ -79,8 +79,13 @@ fun EditContainerScreen( val scope = rememberCoroutineScope() val clearFocus = rememberClearFocus() - // State for editable fields + // Editable config + baseline for change detection. + var state by remember { mutableStateOf(container.toConfigState()) } + var savedState by remember { mutableStateOf(container.toConfigState()) } + + // Hostname is edited here (the create wizard collects it on a separate screen). var hostname by remember { mutableStateOf(container.hostname) } + var savedHostname by remember { mutableStateOf(container.hostname) } var hostnameError by remember { mutableStateOf(null) } LaunchedEffect(hostname) { @@ -90,101 +95,29 @@ fun EditContainerScreen( ).errorMessage } - var netMode by remember { mutableStateOf(container.netMode) } - var disableIPv6 by remember { mutableStateOf(container.disableIPv6) } - var enableAndroidStorage by remember { mutableStateOf(container.enableAndroidStorage) } - var enableHwAccess by remember { mutableStateOf(container.enableHwAccess) } - var enableGpuMode by remember { mutableStateOf(container.enableGpuMode) } - var enableTermuxX11 by remember { mutableStateOf(container.enableTermuxX11) } - var tx11ExtraFlags by remember { mutableStateOf(container.tx11ExtraFlags) } - var enableVirgl by remember { mutableStateOf(container.enableVirgl) } - var virglExtraFlags by remember { mutableStateOf(container.virglExtraFlags) } - var enablePulseaudio by remember { mutableStateOf(container.enablePulseaudio) } - var selinuxPermissive by remember { mutableStateOf(container.selinuxPermissive) } - var volatileMode by remember { mutableStateOf(container.volatileMode) } - var bindMounts by remember { mutableStateOf(container.bindMounts) } - var dnsServers by remember { mutableStateOf(container.dnsServers) } - var runAtBoot by remember { mutableStateOf(container.runAtBoot) } - var envFileContent by remember { mutableStateOf(container.envFileContent ?: "") } - var upstreamInterfaces by remember { mutableStateOf(container.upstreamInterfaces) } - var portForwards by remember { mutableStateOf(container.portForwards) } - var forceCgroupv1 by remember { mutableStateOf(container.forceCgroupv1) } - var blockNestedNs by remember { mutableStateOf(container.blockNestedNs) } - var staticNatIp by remember { mutableStateOf(container.staticNatIp) } - var privileged by remember { mutableStateOf(container.privileged) } - var customInit by remember { mutableStateOf(container.customInit) } - - // Track the "saved" baseline values - updated after each successful save - var savedHostname by remember { mutableStateOf(container.hostname) } - var savedNetMode by remember { mutableStateOf(container.netMode) } - var savedDisableIPv6 by remember { mutableStateOf(container.disableIPv6) } - var savedEnableAndroidStorage by remember { mutableStateOf(container.enableAndroidStorage) } - var savedEnableHwAccess by remember { mutableStateOf(container.enableHwAccess) } - var savedEnableGpuMode by remember { mutableStateOf(container.enableGpuMode) } - var savedEnableTermuxX11 by remember { mutableStateOf(container.enableTermuxX11) } - var savedTx11ExtraFlags by remember { mutableStateOf(container.tx11ExtraFlags) } - var savedEnableVirgl by remember { mutableStateOf(container.enableVirgl) } - var savedVirglExtraFlags by remember { mutableStateOf(container.virglExtraFlags) } - var savedEnablePulseaudio by remember { mutableStateOf(container.enablePulseaudio) } - var savedSelinuxPermissive by remember { mutableStateOf(container.selinuxPermissive) } - var savedVolatileMode by remember { mutableStateOf(container.volatileMode) } - var savedBindMounts by remember { mutableStateOf(container.bindMounts) } - var savedDnsServers by remember { mutableStateOf(container.dnsServers) } - var savedRunAtBoot by remember { mutableStateOf(container.runAtBoot) } - var savedEnvFileContent by remember { mutableStateOf(container.envFileContent ?: "") } - var savedUpstreamInterfaces by remember { mutableStateOf(container.upstreamInterfaces) } - var savedPortForwards by remember { mutableStateOf(container.portForwards) } - var savedForceCgroupv1 by remember { mutableStateOf(container.forceCgroupv1) } - var savedBlockNestedNs by remember { mutableStateOf(container.blockNestedNs) } - var savedStaticNatIp by remember { mutableStateOf(container.staticNatIp) } - var savedPrivileged by remember { mutableStateOf(container.privileged) } - var savedCustomInit by remember { mutableStateOf(container.customInit) } + val gatewayErrors = ValidationUtils.validateGatewayConfig( + selfName = container.name, + gatewayContainer = state.gatewayContainer, + net = state.gatewayNet, + iface = state.gatewayIface, + bridge = state.gatewayBridge, + installed = containerViewModel.containerList, + context = context + ) + + val collisionContainer = remember(state.netMode, state.staticNatIp, containerViewModel.containerList) { + if (state.netMode != "nat" || state.staticNatIp.isEmpty()) null + else containerViewModel.containerList.find { it.name != container.name && it.staticNatIp == state.staticNatIp } + } - // Navigation and internal UI states - var showFilePicker by remember { mutableStateOf(false) } - var showDestDialog by remember { mutableStateOf(false) } - var tempSrcPath by remember { mutableStateOf("") } + val hasChanges by remember { derivedStateOf { state != savedState || hostname != savedHostname } } - // Loading and error states var isSaving by remember { mutableStateOf(false) } var isSaved by remember { mutableStateOf(false) } var errorMessage by remember { mutableStateOf(null) } - // Track if any field has changed from SAVED values (not original) - val hasChanges by remember { - derivedStateOf { - hostname != savedHostname || - netMode != savedNetMode || - disableIPv6 != savedDisableIPv6 || - enableAndroidStorage != savedEnableAndroidStorage || - enableHwAccess != savedEnableHwAccess || - enableGpuMode != savedEnableGpuMode || - enableTermuxX11 != savedEnableTermuxX11 || - tx11ExtraFlags != savedTx11ExtraFlags || - enableVirgl != savedEnableVirgl || - virglExtraFlags != savedVirglExtraFlags || - enablePulseaudio != savedEnablePulseaudio || - selinuxPermissive != savedSelinuxPermissive || - volatileMode != savedVolatileMode || - bindMounts != savedBindMounts || - dnsServers != savedDnsServers || - runAtBoot != savedRunAtBoot || - envFileContent != savedEnvFileContent || - upstreamInterfaces != savedUpstreamInterfaces || - portForwards != savedPortForwards || - forceCgroupv1 != savedForceCgroupv1 || - blockNestedNs != savedBlockNestedNs || - staticNatIp != savedStaticNatIp || - privileged != savedPrivileged || - customInit != savedCustomInit - } - } - - // Reset saved state when user makes changes LaunchedEffect(hasChanges) { - if (hasChanges && isSaved) { - isSaved = false - } + if (hasChanges && isSaved) isSaved = false } fun saveChanges() { @@ -192,75 +125,19 @@ fun EditContainerScreen( isSaving = true isSaved = false errorMessage = null - try { val finalHostname = hostname.ifEmpty { ValidationUtils.sanitizeHostname(container.name) } - // Create updated ContainerInfo with new values - val updatedConfig = container.copy( - hostname = finalHostname, - netMode = netMode, - disableIPv6 = disableIPv6, - enableAndroidStorage = enableAndroidStorage, - enableHwAccess = enableHwAccess, - enableGpuMode = enableGpuMode, - enableTermuxX11 = enableTermuxX11, - tx11ExtraFlags = tx11ExtraFlags, - enableVirgl = enableVirgl, - virglExtraFlags = virglExtraFlags, - enablePulseaudio = enablePulseaudio, - selinuxPermissive = selinuxPermissive, - volatileMode = volatileMode, - bindMounts = bindMounts, - dnsServers = dnsServers, - runAtBoot = runAtBoot, - envFileContent = if (envFileContent.isBlank()) null else envFileContent, - upstreamInterfaces = upstreamInterfaces, - portForwards = portForwards, - forceCgroupv1 = forceCgroupv1, - blockNestedNs = blockNestedNs, - staticNatIp = staticNatIp, - privileged = privileged, - customInit = customInit - ) - - // Update config file + val updatedConfig = container.withConfig(state).copy(hostname = finalHostname) val result = withContext(Dispatchers.IO) { ContainerManager.updateContainerConfig(context, container.name, updatedConfig) } - result.fold( onSuccess = { - // Success - update saved baseline values to current values hostname = finalHostname savedHostname = finalHostname - savedNetMode = netMode - savedDisableIPv6 = disableIPv6 - savedEnableAndroidStorage = enableAndroidStorage - savedEnableHwAccess = enableHwAccess - savedEnableGpuMode = enableGpuMode - savedEnableTermuxX11 = enableTermuxX11 - savedTx11ExtraFlags = tx11ExtraFlags - savedEnableVirgl = enableVirgl - savedVirglExtraFlags = virglExtraFlags - savedEnablePulseaudio = enablePulseaudio - savedSelinuxPermissive = selinuxPermissive - savedVolatileMode = volatileMode - savedBindMounts = bindMounts - savedDnsServers = dnsServers - savedRunAtBoot = runAtBoot - savedEnvFileContent = envFileContent - savedUpstreamInterfaces = upstreamInterfaces - savedPortForwards = portForwards - savedForceCgroupv1 = forceCgroupv1 - savedBlockNestedNs = blockNestedNs - savedStaticNatIp = staticNatIp - savedPrivileged = privileged - savedCustomInit = customInit - - // Refresh container list and SELinux status using ViewModel + savedState = state containerViewModel.refresh() SystemInfoManager.refreshSELinuxStatus() - isSaving = false isSaved = true }, @@ -278,149 +155,13 @@ fun EditContainerScreen( } } - if (showFilePicker) { - FilePickerDialog( - onDismiss = { showFilePicker = false }, - onConfirm = { path -> - tempSrcPath = path - showFilePicker = false - showDestDialog = true - } - ) - } - - if (showDestDialog) { - var destPath by remember { mutableStateOf("") } - var roEnabled by remember { mutableStateOf(false) } - Dialog( - onDismissRequest = { showDestDialog = false }, - properties = DialogProperties(usePlatformDefaultWidth = false) - ) { - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) - .imePadding(), - shape = RoundedCornerShape(24.dp), - color = MaterialTheme.colorScheme.surfaceContainer, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), - tonalElevation = 0.dp - ) { - Column(modifier = Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { - Text(context.getString(R.string.enter_container_path), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) - OutlinedTextField( - value = destPath, - onValueChange = { destPath = it }, - label = { Text(context.getString(R.string.container_path_placeholder)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ) - ) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text(context.getString(R.string.read_only), style = MaterialTheme.typography.bodyMedium) - Switch(checked = roEnabled, onCheckedChange = { roEnabled = it }) - } - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Surface( - modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable(onClick = { clearFocus(); showDestDialog = false }), - shape = RoundedCornerShape(14.dp), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { - Text(context.getString(R.string.cancel), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold) - } - } - Surface( - modifier = Modifier.weight(1f).clip(RoundedCornerShape(14.dp)).clickable( - enabled = destPath.startsWith("/"), - onClick = { - clearFocus() - if (destPath.isNotBlank()) { - bindMounts = bindMounts + BindMount(tempSrcPath, destPath, roEnabled) - showDestDialog = false - } - } - ), - shape = RoundedCornerShape(14.dp), - color = if (destPath.startsWith("/")) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(14.dp), contentAlignment = Alignment.Center) { - Text( - context.getString(R.string.ok), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - color = if (destPath.startsWith("/")) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - ) - } - } - } - } - } - } - } - - var showEnvDialog by remember { mutableStateOf(false) } - var showPrivilegedDialog by remember { mutableStateOf(false) } - var showHwAccessDialog by remember { mutableStateOf(false) } - - if (showPrivilegedDialog) { - PrivilegedModeDialog( - initialPrivileged = privileged, - onConfirm = { tags -> - privileged = tags - showPrivilegedDialog = false - }, - onDismiss = { showPrivilegedDialog = false } - ) - } - - if (showHwAccessDialog) { - HardwareAccessDialog( - onConfirm = { - enableHwAccess = true - showHwAccessDialog = false - }, - onDismiss = { showHwAccessDialog = false } - ) - } - - if (showEnvDialog) { - EnvironmentVariablesDialog( - initialContent = envFileContent, - onConfirm = { newContent -> - envFileContent = newContent - showEnvDialog = false - }, - onDismiss = { showEnvDialog = false }, - confirmLabel = context.getString(R.string.save_changes) - ) - } - Scaffold( containerColor = Color.Transparent, topBar = { TopAppBar( - title = { - Text(context.getString(R.string.edit_container_title, container.name)) - }, + title = { Text(context.getString(R.string.edit_container_title, container.name)) }, navigationIcon = { - IconButton(onClick = { - clearFocus() - onBack() - }) { + IconButton(onClick = { clearFocus(); onBack() }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = context.getString(R.string.back)) } } @@ -428,7 +169,8 @@ fun EditContainerScreen( }, bottomBar = { val btnShape = RoundedCornerShape(20.dp) - val isReadyToSave = !isSaving && !isSaved && hasChanges && (netMode != "nat" || upstreamInterfaces.isNotEmpty()) && hostnameError == null + val isReadyToSave = !isSaving && !isSaved && hasChanges && hostnameError == null && + (state.netMode != "gateway" || gatewayErrors.isValid) && collisionContainer == null val targetBtnColor = when { isSaved -> MaterialTheme.colorScheme.primaryContainer isSaving || isReadyToSave -> MaterialTheme.colorScheme.primary @@ -457,12 +199,9 @@ fun EditContainerScreen( .clip(btnShape) .clickable( enabled = isReadyToSave, - onClick = { - clearFocus() - saveChanges() - }, - indication = androidx.compose.material.ripple.rememberRipple(bounded = true), - interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() } + onClick = { clearFocus(); saveChanges() }, + indication = rememberRipple(bounded = true), + interactionSource = remember { MutableInteractionSource() } ), shape = btnShape, color = animatedBtnColor, @@ -472,48 +211,20 @@ fun EditContainerScreen( when { isSaved -> { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onPrimaryContainer - ) - Text( - text = context.getString(R.string.saved), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) + Icon(imageVector = Icons.Default.Check, contentDescription = null, modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onPrimaryContainer) + Text(text = context.getString(R.string.saved), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onPrimaryContainer) } } isSaving -> { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - LoadingIndicator( - modifier = Modifier.size(20.dp), - color = MaterialTheme.colorScheme.onPrimary - ) - Text( - text = context.getString(R.string.saving), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onPrimary - ) + LoadingIndicator(modifier = Modifier.size(20.dp), color = MaterialTheme.colorScheme.onPrimary) + Text(text = context.getString(R.string.saving), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onPrimary) } } else -> { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Icon( - imageVector = Icons.Default.Save, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = if (isReadyToSave) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - ) - Text( - text = context.getString(R.string.save_changes), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - color = if (isReadyToSave) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - ) + Icon(imageVector = Icons.Default.Save, contentDescription = null, modifier = Modifier.size(20.dp), tint = if (isReadyToSave) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)) + Text(text = context.getString(R.string.save_changes), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold, color = if (isReadyToSave) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)) } } } @@ -529,714 +240,65 @@ fun EditContainerScreen( .consumeWindowInsets(innerPadding) .imePadding() ) { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 24.dp) - .padding(top = 8.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - // Warning if container is running - if (container.isRunning) { - Surface( - color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.2f), - shape = RoundedCornerShape(20.dp), - border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.3f)), - modifier = Modifier.fillMaxWidth() - ) { - Row( - modifier = Modifier.padding(20.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp) - ) { - Icon( - imageVector = Icons.Default.Warning, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.error - ) - Column { - Text( - text = context.getString(R.string.container_is_running), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.error - ) + ContainerConfigForm( + state = state, + onStateChange = { state = it }, + installedContainers = containerViewModel.containerList, + selfName = container.name, + gatewayErrors = gatewayErrors, + collisionContainer = collisionContainer, + modifier = Modifier.fillMaxSize(), + leadingContent = { + errorMessage?.let { error -> + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(20.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), + modifier = Modifier.fillMaxWidth().clickable { clearFocus() } + ) { Text( - text = context.getString(R.string.changes_take_effect_after_restart), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + text = error, + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer ) } } - } - } - - // Hostname input - val modernFieldShape = RoundedCornerShape(16.dp) - val modernFieldColors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ) - OutlinedTextField( - value = hostname, - onValueChange = { hostname = it }, - label = { Text(context.getString(R.string.hostname)) }, - placeholder = { Text(ValidationUtils.sanitizeHostname(container.name)) }, - isError = hostnameError != null, - supportingText = hostnameError?.let { { Text(it) } } ?: { - Text(context.getString(R.string.hostname_hint)) - }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - shape = modernFieldShape, - colors = modernFieldColors, - leadingIcon = { - Icon(Icons.Default.Computer, contentDescription = null) - } - ) - - Text( - text = context.getString(R.string.cat_networking), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(top = 8.dp) - ) - - DsDropdown( - label = context.getString(R.string.network_mode), - selected = netMode, - options = listOf("nat", "host", "none"), - displayName = { context.getString(when (it) { "nat" -> R.string.network_mode_nat; "none" -> R.string.network_mode_none; else -> R.string.network_mode_host }) }, - onSelect = { mode -> clearFocus(); netMode = mode; if (mode != "host") disableIPv6 = false }, - leadingIcon = Icons.Default.Public - ) - - androidx.compose.animation.AnimatedVisibility( - visible = netMode == "nat", - enter = androidx.compose.animation.expandVertically( - animationSpec = tween(durationMillis = 300, easing = androidx.compose.animation.core.FastOutSlowInEasing), - expandFrom = Alignment.Top - ) + androidx.compose.animation.fadeIn(animationSpec = tween(durationMillis = 300)), - exit = androidx.compose.animation.shrinkVertically( - animationSpec = tween(durationMillis = 300, easing = androidx.compose.animation.core.FastOutSlowInEasing), - shrinkTowards = Alignment.Top - ) + androidx.compose.animation.fadeOut(animationSpec = tween(durationMillis = 300)) - ) { - Column( - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text( - text = context.getString(R.string.nat_settings), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary - ) - - // Static IP Address Configuration - Text( - text = context.getString(R.string.static_ip_address), - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(top = 16.dp) - ) - Text( - text = context.getString(R.string.static_ip_description), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.secondary, - modifier = Modifier.padding(bottom = 8.dp) - ) - - val octets = remember(staticNatIp) { - val parts = staticNatIp.split(".") - if (parts.size == 4) { - Pair(parts[2], parts[3]) - } else { - Pair("", "") - } - } - - var octet3 by remember(octets) { mutableStateOf(octets.first) } - var octet4 by remember(octets) { mutableStateOf(octets.second) } - - val updateIp = { o3: String, o4: String -> - staticNatIp = if (o3.isBlank() && o4.isBlank()) { - "" - } else { - "${Constants.NAT_IP_PREFIX}.$o3.$o4" - } - } - - val isOctet3Valid = remember(octet3) { - octet3.isEmpty() || (octet3.toIntOrNull()?.let { it in Constants.NAT_OCTET_MIN..Constants.NAT_OCTET_MAX } ?: false) - } - val isOctet4Valid = remember(octet4) { - octet4.isEmpty() || (octet4.toIntOrNull()?.let { it in Constants.NAT_OCTET_MIN..Constants.NAT_OCTET_MAX } ?: false) - } - - val collisionContainer = remember(staticNatIp) { - if (staticNatIp.isEmpty()) null - else containerViewModel.containerList.find { it.name != container.name && it.staticNatIp == staticNatIp } - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "${Constants.NAT_IP_PREFIX}.", - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(top = 8.dp) - ) - - OutlinedTextField( - value = octet3, - onValueChange = { - if (it.length <= 3 && it.all { c -> c.isDigit() }) { - octet3 = it - updateIp(it, octet4) - } - }, - label = { Text(context.getString(R.string.octet_label, 3)) }, - modifier = Modifier.weight(1f), - singleLine = true, - shape = modernFieldShape, - colors = modernFieldColors, - isError = !isOctet3Valid, - supportingText = { if (!isOctet3Valid) Text(context.getString(R.string.error_octet_range)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) - ) - - Text( - text = ".", - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(top = 8.dp) - ) - - OutlinedTextField( - value = octet4, - onValueChange = { - if (it.length <= 3 && it.all { c -> c.isDigit() }) { - octet4 = it - updateIp(octet3, it) - } - }, - label = { Text(context.getString(R.string.octet_label, 4)) }, - modifier = Modifier.weight(1f), - singleLine = true, - shape = modernFieldShape, - colors = modernFieldColors, - isError = !isOctet4Valid, - supportingText = { if (!isOctet4Valid) Text(context.getString(R.string.error_octet_range)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) - ) - } - if (collisionContainer != null) { - Text( - text = context.getString(R.string.error_ip_collision, collisionContainer.name), - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(top = 4.dp) - ) - } - - // Upstream Interfaces - val isUpstreamValid = upstreamInterfaces.isNotEmpty() - Text( - text = context.getString(R.string.upstream_interfaces_mandatory), - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Bold, - color = if (!isUpstreamValid) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface - ) - - if (!isUpstreamValid) { - Text( - text = context.getString(R.string.upstream_interfaces_required_error), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error - ) - } - - UpstreamInterfaceList( - upstreamInterfaces = upstreamInterfaces, - onInterfacesChange = { upstreamInterfaces = it } - ) - - // Port Forwards - Text( - text = context.getString(R.string.port_forwarding), - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(top = 16.dp) - ) - - PortForwardingList( - portForwards = portForwards, - onPortForwardsChange = { portForwards = it } - ) - } - } - - // DNS Servers input - val isDnsError = remember(dnsServers) { - dnsServers.isNotEmpty() && !dnsServers.all { it.isDigit() || it == '.' || it == ':' || it == ',' } - } - - OutlinedTextField( - value = dnsServers, - onValueChange = { dnsServers = it }, - label = { Text(context.getString(R.string.dns_servers_label)) }, - supportingText = { - if (isDnsError) Text(context.getString(R.string.dns_servers_hint)) - }, - isError = isDnsError, - placeholder = { Text(context.getString(R.string.dns_servers_placeholder)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - shape = modernFieldShape, - colors = modernFieldColors, - leadingIcon = { - Icon(Icons.Default.Dns, contentDescription = null) - } - ) - - // In NAT/NONE mode, IPv6 is always disabled (forced). In host mode the user can opt in. - val ipv6IsForced = netMode != "host" - ToggleCard( - icon = Icons.Default.NetworkCheck, - title = context.getString(R.string.disable_ipv6), - description = if (ipv6IsForced) - context.getString(R.string.disable_ipv6_nat_forced) - else - context.getString(R.string.disable_ipv6_description), - checked = if (ipv6IsForced) true else disableIPv6, - onCheckedChange = { - clearFocus() - disableIPv6 = it - }, - enabled = !ipv6IsForced - ) - - Text( - text = context.getString(R.string.cat_integration), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(top = 16.dp) - ) - - ToggleCard( - icon = Icons.Default.Storage, - title = context.getString(R.string.android_storage), - description = context.getString(R.string.android_storage_description), - checked = enableAndroidStorage, - onCheckedChange = { - clearFocus() - enableAndroidStorage = it - } - ) - - ToggleCard( - icon = Icons.Default.Devices, - title = context.getString(R.string.hardware_access), - description = context.getString(R.string.hardware_access_description), - checked = enableHwAccess, - onCheckedChange = { newValue -> - clearFocus() - if (newValue) { - showHwAccessDialog = true - } else { - enableHwAccess = false - } - } - ) - - ToggleCard( - icon = Icons.Default.Memory, - title = context.getString(R.string.gpu_access), - description = context.getString(R.string.gpu_access_description), - checked = if (enableHwAccess) true else enableGpuMode, - onCheckedChange = { - if (!enableHwAccess) { - clearFocus() - enableGpuMode = it - } - }, - enabled = !enableHwAccess - ) - - ToggleCard( - painter = androidx.compose.ui.res.painterResource(R.drawable.ic_x11), - title = context.getString(R.string.termux_x11), - description = context.getString(R.string.termux_x11_description), - checked = enableTermuxX11, - onCheckedChange = { enableTermuxX11 = it }, - enabled = true - ) - - ToggleCard( - icon = Icons.Default.Layers, - title = context.getString(R.string.enable_virgl), - description = context.getString(R.string.enable_virgl_description), - checked = enableVirgl, - onCheckedChange = { - clearFocus() - enableVirgl = it - }, - enabled = true - ) - - ToggleCard( - icon = Icons.AutoMirrored.Filled.VolumeUp, - title = context.getString(R.string.enable_pulseaudio), - description = context.getString(R.string.enable_pulseaudio_description), - checked = enablePulseaudio, - onCheckedChange = { - clearFocus() - enablePulseaudio = it - }, - enabled = true - ) - - Text( - text = context.getString(R.string.cat_security), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(top = 16.dp) - ) - - ToggleCard( - icon = Icons.Default.Security, - title = context.getString(R.string.selinux_permissive), - description = context.getString(R.string.selinux_permissive_description), - checked = selinuxPermissive, - onCheckedChange = { - clearFocus() - selinuxPermissive = it - } - ) - - ToggleCard( - icon = Icons.Default.AutoDelete, - title = context.getString(R.string.volatile_mode), - description = context.getString(R.string.volatile_mode_description), - checked = volatileMode, - onCheckedChange = { - clearFocus() - volatileMode = it - } - ) - - ToggleCard( - icon = Icons.Default.Cyclone, - title = context.getString(R.string.force_cgroupv1), - description = context.getString(R.string.force_cgroupv1_description), - checked = forceCgroupv1, - onCheckedChange = { - clearFocus() - forceCgroupv1 = it - } - ) - - val isSeccompDisabled = privileged.contains("noseccomp") || privileged.contains("full") - LaunchedEffect(isSeccompDisabled) { - if (isSeccompDisabled) blockNestedNs = false - } - - ToggleCard( - icon = Icons.Default.GppBad, - title = context.getString(R.string.manual_deadlock_shield), - description = context.getString(R.string.manual_deadlock_shield_description), - checked = if (isSeccompDisabled) false else blockNestedNs, - onCheckedChange = { - clearFocus() - blockNestedNs = it - }, - enabled = !isSeccompDisabled - ) - - SettingsRowCard( - title = context.getString(R.string.privileged_mode), - subtitle = if (privileged.isEmpty()) context.getString(R.string.not_configured) else privileged, - description = context.getString(R.string.privileged_mode_description), - icon = Icons.Default.GppMaybe, - onClick = { - clearFocus() - showPrivilegedDialog = true - } - ) - - ToggleCard( - icon = Icons.Default.PowerSettingsNew, - title = context.getString(R.string.run_at_boot), - description = context.getString(R.string.run_at_boot_description), - checked = runAtBoot, - onCheckedChange = { - clearFocus() - runAtBoot = it - } - ) - - Text( - text = context.getString(R.string.cat_advanced), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(top = 16.dp) - ) - - // Environment Variables Row - fun countEnvVars(content: String): Int { - return content.lines() - .map { it.trim() } - .count { it.isNotEmpty() && !it.startsWith("#") && it.contains("=") } - } - - val envCount = countEnvVars(envFileContent) - val envSubtitle = if (envCount > 0) { - context.getString(R.string.environment_variables_configured, envCount) - } else { - context.getString(R.string.not_configured) - } - - SettingsRowCard( - title = context.getString(R.string.environment_variables), - subtitle = envSubtitle, - icon = Icons.Default.Code, - onClick = { - clearFocus() - showEnvDialog = true - } - ) - - // Custom Init Binary - if (customInit.isNotEmpty()) { - Surface( - color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.15f), - shape = RoundedCornerShape(16.dp), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.3f)), - modifier = Modifier.fillMaxWidth() - ) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Icon( - imageVector = Icons.Default.Warning, - contentDescription = null, - tint = MaterialTheme.colorScheme.error, - modifier = Modifier.size(20.dp) - ) - Text( - text = context.getString(R.string.custom_init_warning), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error - ) - } - } - } - - OutlinedTextField( - value = customInit, - onValueChange = { customInit = it.filter { !it.isWhitespace() } }, - label = { Text(context.getString(R.string.custom_init_label)) }, - placeholder = { Text(context.getString(R.string.custom_init_placeholder)) }, - supportingText = { - if (customInit.isNotEmpty() && !customInit.startsWith("/")) { - Text(context.getString(R.string.custom_init_error_absolute), - color = MaterialTheme.colorScheme.error) - } else { - Text(context.getString(R.string.custom_init_hint)) - } - }, - isError = customInit.isNotEmpty() && !customInit.startsWith("/"), - modifier = Modifier.fillMaxWidth(), - singleLine = true, - shape = modernFieldShape, - colors = modernFieldColors, - leadingIcon = { - Icon(Icons.Default.Terminal, contentDescription = null) - } - ) - - androidx.compose.animation.AnimatedVisibility( - visible = enableTermuxX11, - enter = androidx.compose.animation.expandVertically( - animationSpec = tween(durationMillis = 300) - ) + androidx.compose.animation.fadeIn(animationSpec = tween(durationMillis = 300)), - exit = androidx.compose.animation.shrinkVertically( - animationSpec = tween(durationMillis = 300) - ) + androidx.compose.animation.fadeOut(animationSpec = tween(durationMillis = 300)) - ) { - OutlinedTextField( - value = tx11ExtraFlags, - onValueChange = { tx11ExtraFlags = it }, - label = { Text(context.getString(R.string.tx11_extra_flags_label)) }, - placeholder = { Text(context.getString(R.string.tx11_extra_flags_placeholder)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - shape = RoundedCornerShape(16.dp), - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ), - leadingIcon = { - Icon( - painter = androidx.compose.ui.res.painterResource(R.drawable.ic_x11), - contentDescription = null, - modifier = Modifier.size(15.dp) - ) - } - ) - } - - androidx.compose.animation.AnimatedVisibility( - visible = enableVirgl, - enter = androidx.compose.animation.expandVertically( - animationSpec = tween(durationMillis = 300) - ) + androidx.compose.animation.fadeIn(animationSpec = tween(durationMillis = 300)), - exit = androidx.compose.animation.shrinkVertically( - animationSpec = tween(durationMillis = 300) - ) + androidx.compose.animation.fadeOut(animationSpec = tween(durationMillis = 300)) - ) { - OutlinedTextField( - value = virglExtraFlags, - onValueChange = { virglExtraFlags = it }, - label = { Text(context.getString(R.string.virgl_extra_flags_label)) }, - placeholder = { Text(context.getString(R.string.virgl_extra_flags_placeholder)) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - shape = RoundedCornerShape(16.dp), - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ), - leadingIcon = { Icon(Icons.Default.Layers, contentDescription = null) } - ) - } - - // Bind Mounts Section - Row( - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = context.getString(R.string.bind_mounts), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold - ) - - } - - bindMounts.forEach { mount -> - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(20.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh, - border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) - ) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = context.getString(R.string.host_path, mount.src), - style = MaterialTheme.typography.bodyMedium, - overflow = TextOverflow.Ellipsis, - maxLines = 1 - ) - Text( - text = context.getString(R.string.container_path, mount.dest), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.secondary, - overflow = TextOverflow.Ellipsis, - maxLines = 1 - ) - if (mount.ro) { - Surface( - shape = RoundedCornerShape(6.dp), - color = MaterialTheme.colorScheme.secondaryContainer, - modifier = Modifier.padding(top = 4.dp) - ) { - Text( - text = context.getString(R.string.read_only), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSecondaryContainer, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) - ) + if (container.isRunning) { + Surface( + color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.2f), + shape = RoundedCornerShape(20.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.3f)), + modifier = Modifier.fillMaxWidth() + ) { + Row(modifier = Modifier.padding(20.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Icon(imageVector = Icons.Default.Warning, contentDescription = null, modifier = Modifier.size(24.dp), tint = MaterialTheme.colorScheme.error) + Column { + Text(text = context.getString(R.string.container_is_running), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.error) + Text(text = context.getString(R.string.changes_take_effect_after_restart), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)) } } } - IconButton(onClick = { - bindMounts = bindMounts - mount - }) { - Icon(Icons.Default.Delete, contentDescription = null, tint = MaterialTheme.colorScheme.error) - } } - } - } - val addBindBtnShape = RoundedCornerShape(16.dp) - Surface( - modifier = Modifier.fillMaxWidth().clip(addBindBtnShape).clickable( - onClick = { showFilePicker = true }, - indication = rememberRipple(bounded = true), - interactionSource = remember { MutableInteractionSource() } - ), - shape = addBindBtnShape, - color = MaterialTheme.colorScheme.surfaceContainerLow, - border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), - tonalElevation = 0.dp - ) { - Row( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = context.getString(R.string.add_bind_mount), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - // Error message - errorMessage?.let { error -> - Surface( - color = MaterialTheme.colorScheme.surfaceContainerHigh, - shape = RoundedCornerShape(20.dp), - border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 16.dp) - .clickable { clearFocus() } - ) { - Text( - text = error, - modifier = Modifier.padding(16.dp), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onErrorContainer + val hostnameFieldShape = RoundedCornerShape(16.dp) + val hostnameFieldColors = DsTextFieldDefaults.colors() + OutlinedTextField( + value = hostname, + onValueChange = { hostname = it }, + label = { Text(context.getString(R.string.hostname)) }, + placeholder = { Text(ValidationUtils.sanitizeHostname(container.name)) }, + isError = hostnameError != null, + supportingText = hostnameError?.let { { Text(it) } } ?: { Text(context.getString(R.string.hostname_hint)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = hostnameFieldShape, + colors = hostnameFieldColors, + leadingIcon = { Icon(Icons.Default.Computer, contentDescription = null) } ) } - } - - Spacer(modifier = Modifier.height(16.dp)) + ) } } } -} diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/InitServiceScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/InitServiceScreen.kt new file mode 100644 index 00000000..fd9645c3 --- /dev/null +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/InitServiceScreen.kt @@ -0,0 +1,575 @@ +package com.droidspaces.app.ui.screen + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Block +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.LockOpen +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.SearchOff +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.droidspaces.app.R +import com.droidspaces.app.ui.util.* +import com.droidspaces.app.util.AnimationUtils +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +// ── UI-level model ─────────────────────────────────────────────────────────── +// Manager-agnostic view of an init-system service, so a single screen can drive +// systemd, OpenRC and procd. Each screen maps its manager's ServiceInfo -> this. + +/** Superset of the per-init-system statuses (color + label are derived from it). */ +enum class InitServiceUiStatus { + ENABLED_RUNNING, ENABLED_STOPPED, DISABLED_STOPPED, ABNORMAL, STATIC, MASKED, UNKNOWN +} + +/** Manager-agnostic command result. */ +data class InitCommandResult(val isSuccess: Boolean, val output: List, val error: List) + +/** An overflow-menu action (restart / mask / reload / …). */ +data class InitServiceMenuAction( + val labelRes: Int, + val icon: ImageVector, + val run: suspend () -> InitCommandResult, +) + +/** One service row plus the actions applicable to it. */ +data class InitServiceRow( + val name: String, + val description: String, + val isRunning: Boolean, + val isEnabled: Boolean, + val isMasked: Boolean, + val isStatic: Boolean, + val status: InitServiceUiStatus, + val startStop: suspend () -> InitCommandResult, + val enableDisable: suspend () -> InitCommandResult, + /** Non-null only when the service is masked (systemd). */ + val unmask: (suspend () -> InitCommandResult)?, + val menu: List, +) + +/** + * A filter chip. [predicate] is used both to filter the list and to compute the + * chip's count, so counts and filtering can never drift. [dotColor] null = "All". + */ +data class InitServiceFilterChip( + val id: String, + val labelRes: Int, + val dotColor: Color?, + val emptyRes: Int, + val predicate: (InitServiceRow) -> Boolean, +) + +private val JetBrainsMono = FontFamily( + Font(R.font.jetbrains_mono_regular, FontWeight.Normal), + Font(R.font.jetbrains_mono_bold, FontWeight.Bold) +) + +private sealed class InitScreenState { + data object Loading : InitScreenState() + data object NotAvailable : InitScreenState() + data class Ready(val rows: List) : InitScreenState() +} + +private sealed class InitActionState { + data object Idle : InitActionState() + data class InProgress(val serviceName: String, val actionName: String) : InitActionState() +} + +/** + * Generic init-system service management screen. Behaviour is identical across + * systemd / OpenRC / procd; the differences (title, availability probe, service + * fetch/mapping, and the filter set) are supplied by the caller. + */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun InitServiceScreen( + containerName: String, + titleRes: Int, + onNavigateBack: () -> Unit, + isAvailable: suspend (String) -> Boolean, + fetchRows: suspend (String) -> List, + filters: List, + defaultFilterId: String, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + + var screenState by remember { mutableStateOf(InitScreenState.Loading) } + var actionState by remember { mutableStateOf(InitActionState.Idle) } + var selectedFilterId by remember { mutableStateOf(defaultFilterId) } + var logsDialogContent by remember { mutableStateOf?>(null) } + var searchQuery by remember { mutableStateOf("") } + + var fetchJob by remember { mutableStateOf(null) } + var actionJob by remember { mutableStateOf(null) } + + fun fetchServices() { + fetchJob?.cancel() + screenState = InitScreenState.Loading + fetchJob = scope.launch { + try { + if (!isAvailable(containerName)) { + screenState = InitScreenState.NotAvailable + return@launch + } + screenState = InitScreenState.Ready(fetchRows(containerName)) + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + screenState = InitScreenState.NotAvailable + } + } + } + + fun executeAction(serviceName: String, actionName: String, action: suspend () -> InitCommandResult) { + actionJob?.cancel() + fetchJob?.cancel() + actionState = InitActionState.InProgress(serviceName, actionName) + actionJob = scope.launch { + try { + val result = action() + actionState = InitActionState.Idle + if (result.isSuccess) { + screenState = InitScreenState.Loading + scope.showSuccess(snackbarHostState, context.getString(R.string.action_successful, actionName, serviceName)) + fetchServices() + } else { + val allLogs = result.output + result.error + if (allLogs.isNotEmpty()) logsDialogContent = allLogs + else scope.showError(snackbarHostState, context.getString(R.string.failed_to_action, actionName, serviceName)) + } + } catch (e: Exception) { + actionState = InitActionState.Idle + if (e is kotlinx.coroutines.CancellationException) throw e + scope.showError(snackbarHostState, context.getString(R.string.error_unknown, e.message ?: context.getString(R.string.unknown))) + } + } + } + + LaunchedEffect(containerName) { fetchServices() } + + val allRows = (screenState as? InitScreenState.Ready)?.rows ?: emptyList() + val selectedFilter = filters.firstOrNull { it.id == selectedFilterId } ?: filters.first() + val counts = remember(allRows) { + filters.associate { chip -> chip.id to allRows.count(chip.predicate) } + } + + val clearFocus = rememberClearFocus() + + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = { Text(context.getString(titleRes), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) }, + navigationIcon = { IconButton(onClick = onNavigateBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, context.getString(R.string.back)) } }, + actions = { IconButton(onClick = { fetchServices() }, enabled = screenState !is InitScreenState.Loading && actionState is InitActionState.Idle) { Icon(Icons.Default.Refresh, context.getString(R.string.refresh)) } }, + colors = TopAppBarDefaults.centerAlignedTopAppBarColors(containerColor = Color.Transparent, scrolledContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f)) + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + containerColor = Color.Transparent + ) { padding -> + ClearFocusOnClickOutside(modifier = Modifier.padding(padding).fillMaxSize()) { + Box(modifier = Modifier.fillMaxSize()) { + when (screenState) { + is InitScreenState.Loading -> FullScreenLoading(message = context.getString(R.string.fetching_services)) + is InitScreenState.NotAvailable -> InitServiceNotAvailable() + is InitScreenState.Ready -> { + val pagerState = rememberPagerState( + initialPage = filters.indexOfFirst { it.id == selectedFilterId }.coerceAtLeast(0), + pageCount = { filters.size } + ) + // Swiping between filter pages keeps the chip row selection in sync. + LaunchedEffect(pagerState.currentPage) { + selectedFilterId = filters[pagerState.currentPage].id + } + Column(modifier = Modifier.fillMaxSize()) { + InitServiceSearchBar(query = searchQuery, onQueryChange = { searchQuery = it }) + InitServiceFilterChipsRow( + filters = filters, + counts = counts, + selectedFilterId = selectedFilterId, + onFilterSelected = { id -> + clearFocus() + val idx = filters.indexOfFirst { it.id == id } + if (idx >= 0) scope.launch { + // Animate only for adjacent hops; jump directly for distant + // ones so we don't flip through every page in between. + if (kotlin.math.abs(idx - pagerState.currentPage) <= 1) { + pagerState.animateScrollToPage(idx) + } else { + pagerState.scrollToPage(idx) + } + } + } + ) + if (searchQuery.isNotBlank()) { + // Search overrides the filter pages: one results list across all services. + val results = allRows.filter { it.name.contains(searchQuery, ignoreCase = true) } + if (results.isEmpty()) { + InitServiceEmptyState(emptyRes = selectedFilter.emptyRes, modifier = Modifier.weight(1f)) + } else { + LazyColumn(modifier = Modifier.weight(1f), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + items(results, key = { it.name }) { row -> + InitServiceCard(row = row, onAction = { actionName, act -> executeAction(row.name, actionName, act) }) + } + } + } + } else { + HorizontalPager(state = pagerState, modifier = Modifier.weight(1f)) { page -> + val pageFilter = filters[page] + val rows = allRows.filter(pageFilter.predicate) + .sortedWith(compareByDescending { it.isRunning }.thenBy { it.name }) + if (rows.isEmpty()) { + InitServiceEmptyState(emptyRes = pageFilter.emptyRes, modifier = Modifier.fillMaxSize()) + } else { + LazyColumn(modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + items(rows, key = { it.name }) { row -> + InitServiceCard(row = row, onAction = { actionName, act -> executeAction(row.name, actionName, act) }) + } + } + } + } + } + } + } + } + } + } + } + } + + (actionState as? InitActionState.InProgress)?.let { state -> ProgressDialog(message = context.getString(R.string.actioning_service, state.actionName, state.serviceName)) } + logsDialogContent?.let { logs -> ErrorLogsDialog(logs = logs, onDismiss = { logsDialogContent = null }) } +} + +@Composable +private fun InitServiceSearchBar(query: String, onQueryChange: (String) -> Unit) { + val context = LocalContext.current + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + val borderColor by animateColorAsState( + targetValue = if (isFocused) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f), + animationSpec = AnimationUtils.fastSpec() + ) + + Surface( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + color = MaterialTheme.colorScheme.surfaceContainer, + shape = RoundedCornerShape(16.dp), + border = BorderStroke(1.dp, borderColor) + ) { + TextField( + value = query, + onValueChange = onQueryChange, + modifier = Modifier.fillMaxWidth(), + interactionSource = interactionSource, + placeholder = { Text(context.getString(R.string.search_services), color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)) }, + leadingIcon = { Icon(Icons.Default.Search, null, tint = if (isFocused) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)) }, + trailingIcon = { if (query.isNotEmpty()) { IconButton(onClick = { onQueryChange("") }) { Icon(Icons.Default.Clear, null) } } }, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + cursorColor = MaterialTheme.colorScheme.primary + ), + singleLine = true, + textStyle = MaterialTheme.typography.bodyLarge, + keyboardOptions = FocusUtils.searchKeyboardOptions, + keyboardActions = FocusUtils.clearFocusKeyboardActions() + ) + } +} + +@Composable +private fun InitServiceFilterChipsRow( + filters: List, + counts: Map, + selectedFilterId: String, + onFilterSelected: (String) -> Unit +) { + val context = LocalContext.current + val listState = rememberLazyListState() + // Keep the highlighted chip on-screen when the filter changes (swipe or tap). + LaunchedEffect(selectedFilterId) { + val idx = filters.indexOfFirst { it.id == selectedFilterId } + if (idx >= 0) listState.animateScrollToItem(idx) + } + LazyRow( + state = listState, + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + items(filters) { chip -> + val count = counts[chip.id] ?: 0 + val isSelected = selectedFilterId == chip.id + FilterChip( + selected = isSelected, + onClick = { onFilterSelected(chip.id) }, + label = { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + if (chip.dotColor != null) { + Surface(modifier = Modifier.size(6.dp), shape = CircleShape, color = chip.dotColor) {} + } + Text("${context.getString(chip.labelRes)} ($count)", style = MaterialTheme.typography.labelLarge) + } + }, + shape = RoundedCornerShape(12.dp), + colors = FilterChipDefaults.filterChipColors(selectedContainerColor = MaterialTheme.colorScheme.primaryContainer), + border = FilterChipDefaults.filterChipBorder(selected = isSelected, enabled = true, + borderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), + selectedBorderColor = MaterialTheme.colorScheme.primary) + ) + } + } +} + +@Composable +private fun statusColorFor(status: InitServiceUiStatus): Color = when (status) { + InitServiceUiStatus.ENABLED_RUNNING -> Color(0xFF4CAF50) + InitServiceUiStatus.ENABLED_STOPPED -> Color(0xFFFFCA28) + InitServiceUiStatus.DISABLED_STOPPED -> Color(0xFFEF5350) + InitServiceUiStatus.ABNORMAL -> Color(0xFFFF7043) + InitServiceUiStatus.STATIC -> Color(0xFF607D8B) + InitServiceUiStatus.MASKED -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + InitServiceUiStatus.UNKNOWN -> Color(0xFF90A4AE) +} + +private fun statusLabelRes(status: InitServiceUiStatus): Int = when (status) { + InitServiceUiStatus.ENABLED_RUNNING -> R.string.running + InitServiceUiStatus.ENABLED_STOPPED -> R.string.enabled_legend + InitServiceUiStatus.DISABLED_STOPPED -> R.string.disabled_legend + InitServiceUiStatus.ABNORMAL -> R.string.abnormal_legend + InitServiceUiStatus.STATIC -> R.string.static_legend + InitServiceUiStatus.MASKED -> R.string.masked_legend + InitServiceUiStatus.UNKNOWN -> R.string.unknown_legend +} + +@Composable +private fun InitServiceCard( + row: InitServiceRow, + onAction: (String, suspend () -> InitCommandResult) -> Unit +) { + val context = LocalContext.current + var showMenu by remember { mutableStateOf(false) } + val statusColor = statusColorFor(row.status) + + Surface( + modifier = Modifier.fillMaxWidth().animateContentSize(AnimationUtils.mediumSpec()), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainer, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)) + ) { + Column { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp).height(32.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = row.name, + style = MaterialTheme.typography.titleMedium.copy( + fontFamily = JetBrainsMono, + fontSize = if (row.name.length > 25) 13.sp else 16.sp + ), + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Surface( + color = statusColor.copy(alpha = 0.1f), + shape = RoundedCornerShape(8.dp), + border = BorderStroke(1.dp, statusColor.copy(alpha = 0.2f)) + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Surface(modifier = Modifier.size(6.dp), shape = CircleShape, color = statusColor) {} + Text( + text = context.getString(statusLabelRes(row.status)).uppercase(), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Black, + letterSpacing = 0.5.sp, + color = statusColor + ) + } + } + } + + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) + + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + if (row.description.isNotEmpty()) { + Text( + text = row.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f) + ) + } + + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(12.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)) + ) { + Row(modifier = Modifier.fillMaxWidth().padding(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) { + val unmask = row.unmask + if (row.isMasked && unmask != null) { + Surface( + onClick = { onAction(context.getString(R.string.unmask), unmask) }, + modifier = Modifier.weight(1f).height(48.dp), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)) + ) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Default.LockOpen, null, Modifier.size(18.dp), tint = MaterialTheme.colorScheme.primary) + Text(context.getString(R.string.unmask), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary) + } + } + } + } else { + val btnColor = if (row.isRunning) MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.4f) else MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f) + val accentColor = if (row.isRunning) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary + Surface( + onClick = { onAction(if (row.isRunning) context.getString(R.string.stop) else context.getString(R.string.start), row.startStop) }, + modifier = Modifier.weight(1f).height(48.dp), + shape = RoundedCornerShape(16.dp), + color = btnColor, + border = BorderStroke(1.dp, accentColor.copy(alpha = 0.2f)) + ) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(if (row.isRunning) Icons.Default.Stop else Icons.Default.PlayArrow, null, Modifier.size(18.dp), tint = accentColor) + Text(if (row.isRunning) context.getString(R.string.stop) else context.getString(R.string.start), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, color = accentColor) + } + } + } + if (!row.isStatic) { + Surface( + onClick = { onAction(if (row.isEnabled) context.getString(R.string.disable_service) else context.getString(R.string.enable_service), row.enableDisable) }, + modifier = Modifier.weight(1f).height(48.dp), + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.05f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)) + ) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(if (row.isEnabled) Icons.Default.Block else Icons.Default.CheckCircle, null, Modifier.size(18.dp)) + Text(if (row.isEnabled) context.getString(R.string.disable_service) else context.getString(R.string.enable_service), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold) + } + } + } + } + if (row.menu.isNotEmpty()) { + Box { + Surface(onClick = { showMenu = true }, modifier = Modifier.size(48.dp), shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.05f), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f))) { + Box(contentAlignment = Alignment.Center) { Icon(Icons.Default.MoreVert, null, tint = MaterialTheme.colorScheme.onSurfaceVariant) } + } + // Dark-mode dropdown theming: force an opaque surface + rounded corners. + val isDark = MaterialTheme.colorScheme.background.luminance() < 0.5f + val dropdownColor = if (isDark) MaterialTheme.colorScheme.surfaceContainerHigh else MaterialTheme.colorScheme.surfaceContainer + MaterialTheme( + colorScheme = MaterialTheme.colorScheme.copy( + surface = dropdownColor, + surfaceContainer = dropdownColor, + surfaceTint = Color.Transparent + ), + shapes = MaterialTheme.shapes.copy(extraSmall = RoundedCornerShape(20.dp)) + ) { + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + modifier = Modifier.border(width = 1.dp, color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f), shape = RoundedCornerShape(20.dp)) + ) { + row.menu.forEach { item -> + DropdownMenuItem( + text = { Text(context.getString(item.labelRes)) }, + leadingIcon = { Icon(item.icon, null) }, + onClick = { showMenu = false; onAction(context.getString(item.labelRes), item.run) } + ) + } + } + } + } + } + } + } + } + } + } + } +} + +@Composable +private fun InitServiceNotAvailable() { + val context = LocalContext.current + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(24.dp), modifier = Modifier.padding(32.dp)) { + Surface(shape = CircleShape, color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.2f), modifier = Modifier.size(120.dp), border = BorderStroke(2.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.2f))) { + Box(contentAlignment = Alignment.Center) { Icon(Icons.Default.Warning, null, modifier = Modifier.size(56.dp), tint = MaterialTheme.colorScheme.error) } + } + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(context.getString(R.string.init_system_not_available), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center) + Text(context.getString(R.string.init_system_not_available_desc), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center) + } + } + } +} + +@Composable +private fun InitServiceEmptyState(emptyRes: Int, modifier: Modifier = Modifier) { + val context = LocalContext.current + Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) { + Icon(Icons.Default.SearchOff, null, modifier = Modifier.size(64.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)) + Text(text = context.getString(emptyRes), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)) + } + } +} diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationScreen.kt index 4790a02d..e4143735 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationScreen.kt @@ -1,6 +1,9 @@ package com.droidspaces.app.ui.screen + +import com.droidspaces.app.ui.component.PrimaryActionBottomBar import androidx.compose.ui.graphics.Color +import androidx.activity.compose.BackHandler import androidx.compose.animation.core.* import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -8,6 +11,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* @@ -19,18 +23,9 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import com.droidspaces.app.util.BinaryInstaller import com.droidspaces.app.util.InstallationStep -import com.droidspaces.app.util.ModuleInstaller import com.droidspaces.app.util.ModuleInstallationStep -import com.droidspaces.app.util.DroidspacesChecker -import com.droidspaces.app.util.DroidspacesBackendStatus -import com.droidspaces.app.util.PreferencesManager -import com.topjohnwu.superuser.Shell -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext import com.droidspaces.app.R -import com.droidspaces.app.util.Constants import com.droidspaces.app.ui.viewmodel.AppStateViewModel @@ -41,167 +36,40 @@ fun InstallationScreen( ) { val context = LocalContext.current - var currentStep by remember { mutableStateOf(null) } - var currentModuleStep by remember { mutableStateOf(null) } - var isInstalling by remember { mutableStateOf(false) } - var isSuccess by remember { mutableStateOf(false) } - var errorMessage by remember { mutableStateOf(null) } - var isInstallingModule by remember { mutableStateOf(false) } - var rebootRecommended by remember { mutableStateOf(false) } - - // Check backend status and determine what to install - LaunchedEffect(Unit) { - if (!isInstalling && !isSuccess) { - val backendStatus = withContext(Dispatchers.IO) { - DroidspacesChecker.checkBackendStatus() - } - - isInstalling = true - - val whichBackendMode = withContext(Dispatchers.IO) { - com.droidspaces.app.util.SystemInfoManager.getBackendMode(context) - } - val wasDaemon = whichBackendMode == "DAEMON" - - val isAtomicUpdate = backendStatus is DroidspacesBackendStatus.UpdateAvailable - - isInstallingModule = false - - // Capture symlink state before any module directory removal - val wasSymlinkEnabled = withContext(Dispatchers.IO) { - com.droidspaces.app.util.SymlinkInstaller.isSymlinkEnabled() - } - - // Check if SELinux policy exists BEFORE we start nuking things - val sepolicyExists = withContext(Dispatchers.IO) { - Shell.cmd("test -f ${Constants.MAGISK_MODULE_PATH}/sepolicy.rule").exec().isSuccess - } - if (!sepolicyExists) { - rebootRecommended = true - } - - if (!isAtomicUpdate) { - // Clean Slate: Remove the old module, but NEVER the bin directory - // (the daemon's g_self_path fix means the old binary stays valid - // until the daemon is restarted, and the new binary is already - // atomically in place at the canonical path). - currentStep = InstallationStep.CreatingDirectories("Nuking existing module...") - Shell.cmd("rm -rf '/data/adb/modules/droidspaces' 2>&1").exec() - } - - - // Step 2: Install binaries (atomic mv to canonical path - safe even while daemon is running) - val binaryResult = BinaryInstaller.install(context) { step -> - currentStep = step - } - binaryResult.fold( - onSuccess = { - // Signal the running daemon (if any) that the binary was swapped - if (wasDaemon) { - BinaryInstaller.signalDaemon() - } - // Step 3: Install module - isInstallingModule = true - val moduleResult = ModuleInstaller.install(context) { step -> - currentModuleStep = step - } - moduleResult.fold( - onSuccess = { - // On a truly fresh install, .daemon_mode won't exist yet. - // Force-enable daemon mode so new users get sane defaults. - // On reinstalls/updates the file already exists (value 0 or 1), - // meaning the user has an established preference — leave it alone. - val daemonFileExists = withContext(Dispatchers.IO) { - Shell.cmd("test -f '${Constants.DAEMON_MODE_FILE}'").exec().isSuccess - } - if (!daemonFileExists) { - PreferencesManager.getInstance(context).isDaemonModeEnabled = true - } - // Restore symlink if it was enabled before the update - if (wasSymlinkEnabled) { - withContext(Dispatchers.IO) { - com.droidspaces.app.util.SymlinkInstaller.enable() - } - } - isSuccess = true - isInstalling = false - isInstallingModule = false - // Proactive refresh to update UI state before user navigates back - appStateViewModel.resetForPostInstallation() - appStateViewModel.forceRefresh() - }, - onFailure = { error -> - errorMessage = error.message ?: context.getString(R.string.module_installation_failed) - isInstalling = false - isInstallingModule = false - // Refresh even on failure to update error status - appStateViewModel.resetForPostInstallation() - appStateViewModel.forceRefresh() - } - ) - }, - onFailure = { error -> - errorMessage = error.message ?: context.getString(R.string.binary_installation_failed) - isInstalling = false - // Refresh even on failure - appStateViewModel.resetForPostInstallation() - appStateViewModel.forceRefresh() - } - ) + // Install orchestration + state live in AppStateViewModel (DT-6). Read as locals + // so the UI below is unchanged; these are Compose state reads and recompose. + val currentStep = appStateViewModel.installCurrentStep + val currentModuleStep = appStateViewModel.installCurrentModuleStep + val isSuccess = appStateViewModel.isInstallSuccess + val errorMessage = appStateViewModel.installErrorMessage + val isInstallingModule = appStateViewModel.isInstallingModule + val rebootRecommended = appStateViewModel.installRebootRecommended + // Completely block the back gesture in every state. This screen must be + // left only via the Continue button, whose handler decides the next + // destination and triggers the post-install refresh. A raw back-stack pop + // would skip that and strand the user on a stale screen (e.g. the + // "update available" card still showing after the update finished). + BackHandler(enabled = true) { + // Intentionally no-op while installing, on success and on error. + } - } + // Run the install orchestration (idempotent inside the ViewModel). + LaunchedEffect(Unit) { + appStateViewModel.performInstallation() } Scaffold( containerColor = Color.Transparent, bottomBar = { - if (isSuccess) { - val btnShape = RoundedCornerShape(20.dp) - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surfaceContainer, - tonalElevation = 0.dp - ) { - Column(modifier = Modifier.fillMaxWidth()) { - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.25f), - thickness = 1.dp - ) - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp) - .navigationBarsPadding() - .clip(btnShape) - .clickable( - onClick = onInstallationComplete, - indication = androidx.compose.material.ripple.rememberRipple(bounded = true), - interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() } - ), - shape = btnShape, - color = MaterialTheme.colorScheme.primary, - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(vertical = 16.dp), contentAlignment = Alignment.Center) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.onPrimary - ) - Text( - text = context.getString(R.string.continue_button), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onPrimary - ) - } - } - } - } - } + // Show the Continue button once the work is finished, whether it + // succeeded or failed - it is the only accepted way off this screen. + if (isSuccess || errorMessage != null) { + PrimaryActionBottomBar( + label = context.getString(R.string.continue_button), + icon = if (isSuccess) Icons.Default.Check else Icons.AutoMirrored.Filled.ArrowForward, + onClick = onInstallationComplete + ) } } ) { innerPadding -> @@ -266,7 +134,7 @@ fun InstallationScreen( } errorMessage != null -> { Text( - text = errorMessage ?: context.getString(R.string.unknown_error), + text = errorMessage, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.error, textAlign = TextAlign.Center diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationSummaryScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationSummaryScreen.kt index d3937ff0..317d7b01 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationSummaryScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationSummaryScreen.kt @@ -1,4 +1,6 @@ package com.droidspaces.app.ui.screen + +import com.droidspaces.app.ui.component.PrimaryActionBottomBar import androidx.compose.ui.graphics.Color import androidx.compose.foundation.BorderStroke @@ -35,8 +37,6 @@ fun InstallationSummaryScreen( onInstall: () -> Unit, onBack: () -> Unit ) { - val btnShape = RoundedCornerShape(20.dp) - Scaffold( containerColor = Color.Transparent, topBar = { @@ -50,56 +50,12 @@ fun InstallationSummaryScreen( ) }, bottomBar = { - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.98f), - tonalElevation = 0.dp - ) { - Column(modifier = Modifier.fillMaxWidth()) { - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.25f), - thickness = 1.dp - ) - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp) - .navigationBarsPadding() - .clip(btnShape) - .clickable( - onClick = onInstall, - indication = rememberRipple(bounded = true), - interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() } - ), - shape = btnShape, - color = MaterialTheme.colorScheme.primary, - tonalElevation = 0.dp - ) { - Box( - modifier = Modifier.padding(vertical = 16.dp), - contentAlignment = Alignment.Center - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Icon( - Icons.Default.InstallMobile, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.onPrimary - ) - Text( - stringResource(R.string.install_container), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onPrimary - ) - } - } - } - } - } + PrimaryActionBottomBar( + label = stringResource(R.string.install_container), + icon = Icons.Default.InstallMobile, + onClick = onInstall, + barColor = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.98f) + ) } ) { innerPadding -> Column( @@ -171,20 +127,14 @@ fun InstallationSummaryScreen( if (config.enableVirgl) SummaryItem(stringResource(R.string.enable_virgl), stringResource(R.string.enabled_legend), Icons.Default.Layers) if (config.enablePulseaudio) SummaryItem(stringResource(R.string.enable_pulseaudio), stringResource(R.string.enabled_legend), Icons.AutoMirrored.Filled.VolumeUp) if (config.selinuxPermissive) SummaryItem(stringResource(R.string.selinux_permissive), stringResource(R.string.enabled_legend), Icons.Default.Security) + if (config.allowUserns) SummaryItem(stringResource(R.string.allow_userns), stringResource(R.string.enabled_legend), Icons.Default.Groups) if (config.volatileMode) SummaryItem(stringResource(R.string.volatile_mode), stringResource(R.string.enabled_legend), Icons.Default.AutoDelete) if (config.runAtBoot) SummaryItem(stringResource(R.string.run_at_boot), stringResource(R.string.enabled_legend), Icons.Default.PowerSettingsNew) if (config.forceCgroupv1) SummaryItem(stringResource(R.string.force_cgroupv1), stringResource(R.string.enabled_legend), Icons.Default.Layers) if (config.blockNestedNs) SummaryItem(stringResource(R.string.manual_deadlock_shield), stringResource(R.string.enabled_legend), Icons.Default.GppBad) if (config.privileged.isNotEmpty()) SummaryItem(stringResource(R.string.privileged_mode), config.privileged, Icons.Default.GppMaybe) - fun countEnvVars(content: String?): Int { - if (content.isNullOrBlank()) return 0 - return content.lines() - .map { it.trim() } - .count { it.isNotEmpty() && !it.startsWith("#") && it.contains("=") } - } - - val envCount = countEnvVars(config.envFileContent) + val envCount = com.droidspaces.app.util.ValidationUtils.countEnvVars(config.envFileContent) if (envCount > 0) { SummaryItem(stringResource(R.string.environment_variables), stringResource(R.string.environment_variables_configured, envCount), Icons.Default.Code) } @@ -196,7 +146,7 @@ fun InstallationSummaryScreen( } if (config.upstreamInterfaces.isNotEmpty()) { - SummaryItem(stringResource(R.string.upstream_interfaces_mandatory), config.upstreamInterfaces.joinToString(", "), Icons.Default.Public) + SummaryItem(stringResource(R.string.upstream_interface_title), config.upstreamInterfaces.joinToString(", "), Icons.Default.Public) } if (config.portForwards.isNotEmpty()) { @@ -207,7 +157,7 @@ fun InstallationSummaryScreen( if (!config.enableAndroidStorage && !config.enableHwAccess && !config.enableGpuMode && !config.selinuxPermissive && - !config.volatileMode && config.bindMounts.isEmpty() && + !config.allowUserns && !config.volatileMode && config.bindMounts.isEmpty() && !config.runAtBoot && !config.disableIPv6 && !config.enableTermuxX11 && !config.enableVirgl && !config.enablePulseaudio && !config.forceCgroupv1 && !config.blockNestedNs && @@ -227,11 +177,33 @@ fun InstallationSummaryScreen( } } +@Composable +private fun SummaryItem(label: String, value: String, icon: androidx.compose.ui.graphics.vector.ImageVector) = + SummaryItem(label, value) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f) + ) + } + +@Composable +private fun SummaryItem(label: String, value: String, icon: androidx.compose.ui.graphics.painter.Painter) = + SummaryItem(label, value) { + Icon( + painter = icon, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f) + ) + } + @Composable private fun SummaryItem( label: String, value: String, - icon: Any + icon: @Composable () -> Unit ) { Row( modifier = Modifier @@ -240,24 +212,7 @@ private fun SummaryItem( horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.CenterVertically ) { - when (icon) { - is androidx.compose.ui.graphics.vector.ImageVector -> { - Icon( - imageVector = icon, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f) - ) - } - is androidx.compose.ui.graphics.painter.Painter -> { - Icon( - painter = icon, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f) - ) - } - } + icon() Column( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/MainTabScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/MainTabScreen.kt index c0dd9069..a33159b2 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/MainTabScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/MainTabScreen.kt @@ -7,6 +7,9 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material3.* @@ -15,8 +18,11 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.activity.compose.BackHandler import androidx.lifecycle.viewmodel.compose.viewModel @@ -42,25 +48,18 @@ enum class TabItem(val titleResId: Int, val icon: androidx.compose.ui.graphics.v } /** - * Main tab screen with optimized state management. - * - * Key improvements: - * 1. Uses AppStateViewModel for backend status (persists across navigation) - * 2. No redundant state variables (refreshCounter, shouldTriggerAnimatedRefresh removed) - * 3. Single coroutine scope for all operations - * 4. Proper recomposition boundaries - * - * This fixes the "settings back button glitch" by NOT re-checking backend on navigation. * Backend is only checked on: * 1. Cold app start * 2. Pull-to-refresh * 3. Post-installation (when returning from installation flow) */ -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun MainTabScreen( containerViewModel: ContainerViewModel, skipInitialRefresh: Boolean = false, + requestedTab: TabItem? = null, + onRequestedTabConsumed: () -> Unit = {}, onNavigateToSettings: () -> Unit = {}, onNavigateToInstallation: () -> Unit = {}, onNavigateToContainerInstallation: (android.net.Uri) -> Unit = {}, @@ -76,15 +75,28 @@ fun MainTabScreen( // containerViewModel is now passed as parameter to ensure sharing - // Tab selection - survives configuration changes - var selectedTab by rememberSaveable { mutableStateOf(TabItem.Home) } + val tabs = remember { TabItem.values() } + val pagerState = rememberPagerState(initialPage = 0, pageCount = { tabs.size }) + val selectedTab = tabs[pagerState.currentPage] + + // Jump to the tab requested by a launcher shortcut, then clear the request. + LaunchedEffect(requestedTab) { + val target = requestedTab ?: return@LaunchedEffect + val index = tabs.indexOf(target) + if (index >= 0) { + pagerState.scrollToPage(index) + } + onRequestedTabConsumed() + } // Track which container has its action drawer expanded (hoisted for global collapse) var expandedContainerName by rememberSaveable { mutableStateOf(null) } // Handle back press - return to Home tab if not already there BackHandler(enabled = selectedTab != TabItem.Home) { - selectedTab = TabItem.Home + scope.launch { + pagerState.scrollToPage(tabs.indexOf(TabItem.Home)) + } } // Track if we've already triggered initial load in this session @@ -273,20 +285,33 @@ fun MainTabScreen( }, contentWindowInsets = WindowInsets(0) ) { innerPadding -> + val density = LocalDensity.current + // Measured height of the floating bottom bar (incl. system nav inset). + // Reserved as bottom space for centered empty states so they sit in the + // visible region above the bar instead of behind it. + var bottomBarHeight by remember { mutableStateOf(0.dp) } Box(modifier = Modifier.fillMaxSize().padding(innerPadding)) { - Column( - modifier = Modifier - .fillMaxSize() - ) { - when (selectedTab) { + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize() + ) { page -> + when (tabs[page]) { TabItem.Home -> { HomeTabContent( droidspacesStatus = droidspacesStatus, isChecking = isChecking, isRootAvailable = appStateViewModel.isRootAvailable, onNavigateToInstallation = onNavigateToInstallation, - onNavigateToContainers = { selectedTab = TabItem.Containers }, - onNavigateToControlPanel = { selectedTab = TabItem.ControlPanel }, + onNavigateToContainers = { + scope.launch { + pagerState.scrollToPage(tabs.indexOf(TabItem.Containers)) + } + }, + onNavigateToControlPanel = { + scope.launch { + pagerState.scrollToPage(tabs.indexOf(TabItem.ControlPanel)) + } + }, containerCount = containerCount, runningCount = runningCount, onRefresh = { performRefresh(TabItem.Home) } @@ -303,7 +328,8 @@ fun MainTabScreen( containerViewModel = containerViewModel, onRefresh = { performRefresh(TabItem.Containers) }, expandedContainerName = expandedContainerName, - onExpandedContainerNameChange = { expandedContainerName = it } + onExpandedContainerNameChange = { expandedContainerName = it }, + emptyStateBottomInset = bottomBarHeight ) } @@ -314,7 +340,8 @@ fun MainTabScreen( containerViewModel = containerViewModel, onRefresh = { performRefresh(TabItem.ControlPanel) }, onNavigateToContainerDetails = onNavigateToContainerDetails, - onNavigateToTerminal = onNavigateToTerminal + onNavigateToTerminal = onNavigateToTerminal, + emptyStateBottomInset = bottomBarHeight ) } } @@ -325,11 +352,14 @@ fun MainTabScreen( modifier = Modifier .align(Alignment.BottomCenter) .fillMaxWidth() + .onSizeChanged { bottomBarHeight = with(density) { it.height.toDp() } } ) { MainBottomBar( selectedTab = selectedTab, - onTabSelected = { - selectedTab = it + onTabSelected = { tab -> + scope.launch { + pagerState.scrollToPage(tabs.indexOf(tab)) + } expandedContainerName = null } ) @@ -499,7 +529,8 @@ private fun ContainersTabContent( containerViewModel: ContainerViewModel, onRefresh: suspend () -> Unit, expandedContainerName: String?, - onExpandedContainerNameChange: (String?) -> Unit + onExpandedContainerNameChange: (String?) -> Unit, + emptyStateBottomInset: Dp = 0.dp ) { PullToRefreshWrapper(onRefresh = { onRefresh() }) { ContainersScreen( @@ -510,7 +541,8 @@ private fun ContainersTabContent( onNavigateToContainerDetails = onNavigateToContainerDetails, containerViewModel = containerViewModel, expandedContainerName = expandedContainerName, - onExpandedContainerNameChange = onExpandedContainerNameChange + onExpandedContainerNameChange = onExpandedContainerNameChange, + emptyStateBottomInset = emptyStateBottomInset ) } } @@ -523,6 +555,7 @@ private fun ControlPanelTabContent( onRefresh: suspend () -> Unit, onNavigateToContainerDetails: (String) -> Unit, onNavigateToTerminal: (String) -> Unit, + emptyStateBottomInset: Dp = 0.dp ) { var refreshTrigger by remember { mutableStateOf(0) } @@ -536,6 +569,7 @@ private fun ControlPanelTabContent( containerViewModel = containerViewModel, onNavigateToContainerDetails = onNavigateToContainerDetails, onNavigateToTerminal = onNavigateToTerminal, + emptyStateBottomInset = emptyStateBottomInset ) } } diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/OpenRCScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/OpenRCScreen.kt index aa856534..0a1de5aa 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/OpenRCScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/OpenRCScreen.kt @@ -1,53 +1,39 @@ package com.droidspaces.app.ui.screen -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.animateContentSize -import androidx.compose.foundation.* -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsFocusedAsState -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.* -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.Font -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.droidspaces.app.R -import com.droidspaces.app.ui.util.* -import com.droidspaces.app.util.AnimationUtils import com.droidspaces.app.util.ContainerOpenRCManager -import com.droidspaces.app.util.ContainerOpenRCManager.ServiceFilter -import com.droidspaces.app.util.ContainerOpenRCManager.ServiceInfo -import com.droidspaces.app.util.ContainerOpenRCManager.ServiceStatus -import kotlinx.coroutines.launch -private val JetBrainsMono = FontFamily( - Font(R.font.jetbrains_mono_regular, FontWeight.Normal), - Font(R.font.jetbrains_mono_bold, FontWeight.Bold) -) +private fun ContainerOpenRCManager.CommandResult.toInit() = InitCommandResult(isSuccess, output, error) -private sealed class OpenRCScreenState { - data object Loading : OpenRCScreenState() - data object OpenRCNotAvailable : OpenRCScreenState() - data class Ready(val services: List) : OpenRCScreenState() -} - -private sealed class OpenRCActionState { - data object Idle : OpenRCActionState() - data class InProgress(val serviceName: String, val actionName: String) : OpenRCActionState() +private fun ContainerOpenRCManager.ServiceInfo.toRow(containerName: String): InitServiceRow { + val uiStatus = when (status) { + ContainerOpenRCManager.ServiceStatus.ENABLED_RUNNING -> InitServiceUiStatus.ENABLED_RUNNING + ContainerOpenRCManager.ServiceStatus.ENABLED_STOPPED -> InitServiceUiStatus.ENABLED_STOPPED + ContainerOpenRCManager.ServiceStatus.DISABLED_STOPPED -> InitServiceUiStatus.DISABLED_STOPPED + ContainerOpenRCManager.ServiceStatus.ABNORMAL -> InitServiceUiStatus.ABNORMAL + } + return InitServiceRow( + name = name, + description = description, + isRunning = isRunning, + isEnabled = isEnabled, + isMasked = false, + isStatic = false, + status = uiStatus, + startStop = { (if (isRunning) ContainerOpenRCManager.stopService(containerName, name) else ContainerOpenRCManager.startService(containerName, name)).toInit() }, + enableDisable = { (if (isEnabled) ContainerOpenRCManager.disableService(containerName, name) else ContainerOpenRCManager.enableService(containerName, name)).toInit() }, + unmask = null, + menu = buildList { + if (isRunning) add(InitServiceMenuAction(R.string.restart_service, Icons.Default.Refresh) { ContainerOpenRCManager.restartService(containerName, name).toInit() }) + } + ) } @OptIn(ExperimentalMaterial3Api::class) @@ -57,399 +43,23 @@ fun OpenRCScreen( onNavigateBack: () -> Unit ) { val context = LocalContext.current - val scope = rememberCoroutineScope() - val snackbarHostState = remember { SnackbarHostState() } - LaunchedEffect(Unit) { ContainerOpenRCManager.initialize(context) } - var screenState by remember { mutableStateOf(OpenRCScreenState.Loading) } - var actionState by remember { mutableStateOf(OpenRCActionState.Idle) } - var selectedFilter by remember { mutableStateOf(ServiceFilter.RUNNING) } - var logsDialogContent by remember { mutableStateOf?>(null) } - var searchQuery by remember { mutableStateOf("") } - - var fetchJob by remember { mutableStateOf(null) } - var actionJob by remember { mutableStateOf(null) } - - fun fetchServices() { - fetchJob?.cancel() - screenState = OpenRCScreenState.Loading - fetchJob = scope.launch { - try { - val available = ContainerOpenRCManager.isOpenRCAvailable(containerName) - if (!available) { - screenState = OpenRCScreenState.OpenRCNotAvailable - return@launch - } - val services = ContainerOpenRCManager.getAllServices(containerName) - screenState = OpenRCScreenState.Ready(services) - } catch (e: Exception) { - if (e is kotlinx.coroutines.CancellationException) throw e - screenState = OpenRCScreenState.OpenRCNotAvailable - } - } - } - - fun executeAction(serviceName: String, actionName: String, action: suspend () -> ContainerOpenRCManager.CommandResult) { - actionJob?.cancel() - fetchJob?.cancel() - actionState = OpenRCActionState.InProgress(serviceName, actionName) - actionJob = scope.launch { - try { - val result = action() - actionState = OpenRCActionState.Idle - if (result.isSuccess) { - screenState = OpenRCScreenState.Loading - scope.showSuccess(snackbarHostState, context.getString(R.string.action_successful, actionName, serviceName)) - fetchServices() - } else { - val allLogs = result.output + result.error - if (allLogs.isNotEmpty()) logsDialogContent = allLogs - else scope.showError(snackbarHostState, context.getString(R.string.failed_to_action, actionName, serviceName)) - } - } catch (e: Exception) { - actionState = OpenRCActionState.Idle - if (e is kotlinx.coroutines.CancellationException) throw e - scope.showError(snackbarHostState, context.getString(R.string.error_unknown, e.message ?: context.getString(R.string.unknown))) - } - } - } - - LaunchedEffect(containerName) { fetchServices() } - - val allServices = (screenState as? OpenRCScreenState.Ready)?.services ?: emptyList() - val filteredServices = remember(allServices, selectedFilter, searchQuery) { - if (searchQuery.isBlank()) ContainerOpenRCManager.filterServices(allServices, selectedFilter) - else allServices.filter { it.name.contains(searchQuery, ignoreCase = true) } - } - - val serviceCounts = remember(allServices) { - mapOf( - ServiceFilter.RUNNING to allServices.count { it.isRunning && it.isEnabled }, - ServiceFilter.ENABLED to allServices.count { it.isEnabled && !it.isRunning }, - ServiceFilter.DISABLED to allServices.count { !it.isEnabled && !it.isRunning }, - ServiceFilter.ABNORMAL to allServices.count { it.isRunning && !it.isEnabled }, - ServiceFilter.ALL to allServices.size - ) - } - - val clearFocus = rememberClearFocus() - - Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { - Scaffold( - topBar = { - CenterAlignedTopAppBar( - title = { Text(context.getString(R.string.openrc_services), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) }, - navigationIcon = { IconButton(onClick = onNavigateBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, context.getString(R.string.back)) } }, - actions = { IconButton(onClick = { fetchServices() }, enabled = screenState !is OpenRCScreenState.Loading && actionState is OpenRCActionState.Idle) { Icon(Icons.Default.Refresh, context.getString(R.string.refresh)) } }, - colors = TopAppBarDefaults.centerAlignedTopAppBarColors(containerColor = Color.Transparent, scrolledContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f)) - ) - }, - snackbarHost = { SnackbarHost(snackbarHostState) }, - containerColor = Color.Transparent - ) { padding -> - ClearFocusOnClickOutside(modifier = Modifier.padding(padding).fillMaxSize()) { - Box(modifier = Modifier.fillMaxSize()) { - when (screenState) { - is OpenRCScreenState.Loading -> FullScreenLoading(message = context.getString(R.string.fetching_services)) - is OpenRCScreenState.OpenRCNotAvailable -> OpenRCNotAvailable() - is OpenRCScreenState.Ready -> { - Column(modifier = Modifier.fillMaxSize()) { - OpenRCSearchBar(query = searchQuery, onQueryChange = { searchQuery = it }) - OpenRCFilterChipsRow(selectedFilter = selectedFilter, serviceCounts = serviceCounts, onFilterSelected = { selectedFilter = it; clearFocus() }) - if (filteredServices.isEmpty()) { - OpenRCEmptyServicesState(filter = selectedFilter, modifier = Modifier.weight(1f)) - } else { - LazyColumn(modifier = Modifier.weight(1f), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { - items(filteredServices) { service -> - OpenRCServiceCard(service = service, containerName = containerName, onAction = { name, act -> executeAction(service.name, name, act) }) - } - } - } - } - } - } - } - } - } - } - - (actionState as? OpenRCActionState.InProgress)?.let { state -> ProgressDialog(message = context.getString(R.string.actioning_service, state.actionName, state.serviceName)) } - logsDialogContent?.let { logs -> ErrorLogsDialog(logs = logs, onDismiss = { logsDialogContent = null }) } -} - -@Composable -private fun OpenRCSearchBar(query: String, onQueryChange: (String) -> Unit) { - val context = LocalContext.current - val interactionSource = remember { MutableInteractionSource() } - val isFocused by interactionSource.collectIsFocusedAsState() - val borderColor by animateColorAsState( - targetValue = if (isFocused) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f), - animationSpec = AnimationUtils.fastSpec() - ) - - Surface( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), - color = MaterialTheme.colorScheme.surfaceContainer, - shape = RoundedCornerShape(16.dp), - border = BorderStroke(1.dp, borderColor) - ) { - TextField( - value = query, - onValueChange = onQueryChange, - modifier = Modifier.fillMaxWidth(), - interactionSource = interactionSource, - placeholder = { Text(context.getString(R.string.search_services), color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)) }, - leadingIcon = { Icon(Icons.Default.Search, null, tint = if (isFocused) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)) }, - trailingIcon = { if (query.isNotEmpty()) { IconButton(onClick = { onQueryChange("") }) { Icon(Icons.Default.Clear, null) } } }, - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - cursorColor = MaterialTheme.colorScheme.primary - ), - singleLine = true, - textStyle = MaterialTheme.typography.bodyLarge, - keyboardOptions = FocusUtils.searchKeyboardOptions, - keyboardActions = FocusUtils.clearFocusKeyboardActions() - ) - } -} - -@Composable -private fun OpenRCFilterChipsRow( - selectedFilter: ServiceFilter, - serviceCounts: Map, - onFilterSelected: (ServiceFilter) -> Unit -) { - val context = LocalContext.current - // OpenRC has no masked/static - simpler filter set val filters = listOf( - Triple(ServiceFilter.RUNNING, R.string.running, Color(0xFF4CAF50)), - Triple(ServiceFilter.ENABLED, R.string.enabled_legend, Color(0xFFFFCA28)), - Triple(ServiceFilter.DISABLED, R.string.disabled_legend, Color(0xFFEF5350)), - Triple(ServiceFilter.ABNORMAL, R.string.abnormal_legend, Color(0xFFFF7043)), - Triple(ServiceFilter.ALL, R.string.all_legend, null) + InitServiceFilterChip("RUNNING", R.string.running, Color(0xFF4CAF50), R.string.no_running_services) { it.isRunning && it.isEnabled }, + InitServiceFilterChip("ENABLED", R.string.enabled_legend, Color(0xFFFFCA28), R.string.no_enabled_services) { it.isEnabled && !it.isRunning }, + InitServiceFilterChip("DISABLED", R.string.disabled_legend, Color(0xFFEF5350), R.string.no_disabled_services) { !it.isEnabled && !it.isRunning }, + InitServiceFilterChip("ABNORMAL", R.string.abnormal_legend, Color(0xFFFF7043), R.string.no_abnormal_services) { it.isRunning && !it.isEnabled }, + InitServiceFilterChip("ALL", R.string.all_legend, null, R.string.no_services_found) { true }, ) - Row( - modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - filters.forEach { (filter, labelRes, dotColor) -> - val count = serviceCounts[filter] ?: 0 - val isSelected = selectedFilter == filter - - FilterChip( - selected = isSelected, - onClick = { onFilterSelected(filter) }, - label = { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { - if (dotColor != null) { - Surface(modifier = Modifier.size(6.dp), shape = CircleShape, color = dotColor) {} - } - Text("${context.getString(labelRes)} ($count)", style = MaterialTheme.typography.labelLarge) - } - }, - shape = RoundedCornerShape(12.dp), - colors = FilterChipDefaults.filterChipColors(selectedContainerColor = MaterialTheme.colorScheme.primaryContainer), - border = FilterChipDefaults.filterChipBorder(selected = isSelected, enabled = true, - borderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), - selectedBorderColor = MaterialTheme.colorScheme.primary) - ) - } - } -} - -@Composable -private fun OpenRCServiceCard( - service: ServiceInfo, - containerName: String, - onAction: (String, suspend () -> ContainerOpenRCManager.CommandResult) -> Unit -) { - val context = LocalContext.current - var showMenu by remember { mutableStateOf(false) } - - val statusColor = when (service.status) { - ServiceStatus.ENABLED_RUNNING -> Color(0xFF4CAF50) - ServiceStatus.ENABLED_STOPPED -> Color(0xFFFFCA28) - ServiceStatus.DISABLED_STOPPED -> Color(0xFFEF5350) - ServiceStatus.ABNORMAL -> Color(0xFFFF7043) - } - - Surface( - modifier = Modifier.fillMaxWidth().animateContentSize(AnimationUtils.mediumSpec()), - shape = RoundedCornerShape(20.dp), - color = MaterialTheme.colorScheme.surfaceContainer, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)) - ) { - Column { - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp).height(32.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = service.name, - style = MaterialTheme.typography.titleMedium.copy( - fontFamily = JetBrainsMono, - fontSize = if (service.name.length > 25) 13.sp else 16.sp - ), - fontWeight = FontWeight.Bold, - modifier = Modifier.weight(1f), - maxLines = 1, - overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis - ) - - Surface( - color = statusColor.copy(alpha = 0.1f), - shape = RoundedCornerShape(8.dp), - border = BorderStroke(1.dp, statusColor.copy(alpha = 0.2f)) - ) { - Row( - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - Surface(modifier = Modifier.size(6.dp), shape = CircleShape, color = statusColor) {} - Text( - text = when (service.status) { - ServiceStatus.ENABLED_RUNNING -> context.getString(R.string.running) - ServiceStatus.ENABLED_STOPPED -> context.getString(R.string.enabled_legend) - ServiceStatus.DISABLED_STOPPED -> context.getString(R.string.disabled_legend) - ServiceStatus.ABNORMAL -> context.getString(R.string.abnormal_legend) - }.uppercase(), - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Black, - letterSpacing = 0.5.sp, - color = statusColor - ) - } - } - } - - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) - - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { - if (service.description.isNotEmpty()) { - Text( - text = service.description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f) - ) - } - - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surfaceContainerHigh, - shape = RoundedCornerShape(12.dp), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)) - ) { - Row(modifier = Modifier.fillMaxWidth().padding(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) { - val btnColor = if (service.isRunning) MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.4f) else MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f) - val accentColor = if (service.isRunning) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary - - // Start / Stop - Surface( - onClick = { - if (service.isRunning) onAction(context.getString(R.string.stop)) { ContainerOpenRCManager.stopService(containerName, service.name) } - else onAction(context.getString(R.string.start)) { ContainerOpenRCManager.startService(containerName, service.name) } - }, - modifier = Modifier.weight(1f).height(48.dp), - shape = RoundedCornerShape(16.dp), - color = btnColor, - border = BorderStroke(1.dp, accentColor.copy(alpha = 0.2f)) - ) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Icon(if (service.isRunning) Icons.Default.Stop else Icons.Default.PlayArrow, null, Modifier.size(18.dp), tint = accentColor) - Text(if (service.isRunning) context.getString(R.string.stop) else context.getString(R.string.start), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, color = accentColor) - } - } - } - - // Enable / Disable - Surface( - onClick = { - if (service.isEnabled) onAction(context.getString(R.string.disable_service)) { ContainerOpenRCManager.disableService(containerName, service.name) } - else onAction(context.getString(R.string.enable_service)) { ContainerOpenRCManager.enableService(containerName, service.name) } - }, - modifier = Modifier.weight(1f).height(48.dp), - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.05f), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)) - ) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Icon(if (service.isEnabled) Icons.Default.Block else Icons.Default.CheckCircle, null, Modifier.size(18.dp)) - Text(if (service.isEnabled) context.getString(R.string.disable_service) else context.getString(R.string.enable_service), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold) - } - } - } - - // More (Restart) - if (service.isRunning) { - Box { - Surface( - onClick = { showMenu = true }, - modifier = Modifier.size(48.dp), - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.05f), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)) - ) { - Box(contentAlignment = Alignment.Center) { Icon(Icons.Default.MoreVert, null, tint = MaterialTheme.colorScheme.onSurfaceVariant) } - } - DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) { - DropdownMenuItem( - text = { Text(context.getString(R.string.restart_service)) }, - leadingIcon = { Icon(Icons.Default.Refresh, null) }, - onClick = { showMenu = false; onAction(context.getString(R.string.restart_service)) { ContainerOpenRCManager.restartService(containerName, service.name) } } - ) - } - } - } - } - } - } - } - } -} - -@Composable -private fun OpenRCNotAvailable() { - val context = LocalContext.current - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(24.dp), modifier = Modifier.padding(32.dp)) { - Surface(shape = CircleShape, color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.2f), modifier = Modifier.size(120.dp), border = BorderStroke(2.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.2f))) { - Box(contentAlignment = Alignment.Center) { Icon(Icons.Default.Warning, null, modifier = Modifier.size(56.dp), tint = MaterialTheme.colorScheme.error) } - } - Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text(context.getString(R.string.init_system_not_available), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center) - Text(context.getString(R.string.init_system_not_available_desc), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center) - } - } - } -} - -@Composable -private fun OpenRCEmptyServicesState(filter: ServiceFilter, modifier: Modifier = Modifier) { - val context = LocalContext.current - Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) { - Icon(Icons.Default.SearchOff, null, modifier = Modifier.size(64.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)) - Text( - text = when (filter) { - ServiceFilter.RUNNING -> context.getString(R.string.no_running_services) - ServiceFilter.ENABLED -> context.getString(R.string.no_enabled_services) - ServiceFilter.DISABLED -> context.getString(R.string.no_disabled_services) - ServiceFilter.ABNORMAL -> context.getString(R.string.no_abnormal_services) - ServiceFilter.ALL -> context.getString(R.string.no_services_found) - }, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) - ) - } - } + InitServiceScreen( + containerName = containerName, + titleRes = R.string.openrc_services, + onNavigateBack = onNavigateBack, + isAvailable = { cn -> ContainerOpenRCManager.isOpenRCAvailable(cn) }, + fetchRows = { cn -> ContainerOpenRCManager.getAllServices(cn).map { it.toRow(cn) } }, + filters = filters, + defaultFilterId = "RUNNING", + ) } diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/ProcdScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ProcdScreen.kt new file mode 100644 index 00000000..1b255e55 --- /dev/null +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/ProcdScreen.kt @@ -0,0 +1,68 @@ +package com.droidspaces.app.ui.screen + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import com.droidspaces.app.R +import com.droidspaces.app.util.ContainerProcdManager + +private fun ContainerProcdManager.CommandResult.toInit() = InitCommandResult(isSuccess, output, error) + +private fun ContainerProcdManager.ServiceInfo.toRow(containerName: String): InitServiceRow { + val uiStatus = when (status) { + ContainerProcdManager.ServiceStatus.ENABLED_RUNNING -> InitServiceUiStatus.ENABLED_RUNNING + ContainerProcdManager.ServiceStatus.ENABLED_STOPPED -> InitServiceUiStatus.ENABLED_STOPPED + ContainerProcdManager.ServiceStatus.DISABLED_STOPPED -> InitServiceUiStatus.DISABLED_STOPPED + ContainerProcdManager.ServiceStatus.ABNORMAL -> InitServiceUiStatus.ABNORMAL + ContainerProcdManager.ServiceStatus.UNKNOWN -> InitServiceUiStatus.UNKNOWN + } + return InitServiceRow( + name = name, + description = description, + isRunning = isRunning, + isEnabled = isEnabled, + isMasked = false, + isStatic = false, + status = uiStatus, + startStop = { (if (isRunning) ContainerProcdManager.stopService(containerName, name) else ContainerProcdManager.startService(containerName, name)).toInit() }, + enableDisable = { (if (isEnabled) ContainerProcdManager.disableService(containerName, name) else ContainerProcdManager.enableService(containerName, name)).toInit() }, + unmask = null, + menu = listOf( + InitServiceMenuAction(R.string.restart_service, Icons.Default.Refresh) { ContainerProcdManager.restartService(containerName, name).toInit() }, + InitServiceMenuAction(R.string.reload_service, Icons.Default.Refresh) { ContainerProcdManager.reloadService(containerName, name).toInit() }, + ) + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProcdScreen( + containerName: String, + onNavigateBack: () -> Unit +) { + val context = LocalContext.current + LaunchedEffect(Unit) { ContainerProcdManager.initialize(context) } + + val filters = listOf( + InitServiceFilterChip("RUNNING", R.string.running, Color(0xFF4CAF50), R.string.no_running_services) { it.isRunning && it.isEnabled }, + InitServiceFilterChip("ENABLED", R.string.enabled_legend, Color(0xFFFFCA28), R.string.no_enabled_services) { it.isEnabled && !it.isRunning && it.status != InitServiceUiStatus.UNKNOWN }, + InitServiceFilterChip("DISABLED", R.string.disabled_legend, Color(0xFFEF5350), R.string.no_disabled_services) { !it.isEnabled && !it.isRunning && it.status != InitServiceUiStatus.UNKNOWN }, + InitServiceFilterChip("ABNORMAL", R.string.abnormal_legend, Color(0xFFFF7043), R.string.no_abnormal_services) { it.isRunning && !it.isEnabled }, + InitServiceFilterChip("UNKNOWN", R.string.unknown_legend, Color(0xFF90A4AE), R.string.no_unknown_services) { it.status == InitServiceUiStatus.UNKNOWN }, + InitServiceFilterChip("ALL", R.string.all_legend, null, R.string.no_services_found) { true }, + ) + + InitServiceScreen( + containerName = containerName, + titleRes = R.string.openwrt_services, + onNavigateBack = onNavigateBack, + isAvailable = { cn -> ContainerProcdManager.isProcdAvailable(cn) }, + fetchRows = { cn -> ContainerProcdManager.getAllServices(cn).map { it.toRow(cn) } }, + filters = filters, + defaultFilterId = "RUNNING", + ) +} diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/RequirementsScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/RequirementsScreen.kt index d469abca..b3f427d6 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/RequirementsScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/RequirementsScreen.kt @@ -41,6 +41,7 @@ import com.droidspaces.app.R import com.droidspaces.app.ui.component.TerminalDialog import com.droidspaces.app.ui.viewmodel.AppStateViewModel import com.droidspaces.app.util.Constants +import com.droidspaces.app.util.DroidspacesBackendStatus import com.droidspaces.app.util.ContainerOperationExecutor import com.droidspaces.app.util.ViewModelLogger import kotlinx.coroutines.Dispatchers @@ -57,6 +58,13 @@ fun RequirementsScreen( val context = LocalContext.current val appStateViewModel: AppStateViewModel = viewModel() val isRootAvailable = appStateViewModel.isRootAvailable + // Re-check on entry so a missing/corrupted backend is reflected here even if it + // was nuked after the last check elsewhere. + LaunchedEffect(Unit) { appStateViewModel.checkBackendStatus(force = true) } + // The check runs the droidspaces binary, so it's only meaningful when the + // backend is actually usable — gate the button on that (see test plan §2). + val backendReady = appStateViewModel.backendStatus == DroidspacesBackendStatus.Available || + appStateViewModel.backendStatus == DroidspacesBackendStatus.UpdateAvailable val scope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } @@ -95,6 +103,14 @@ fun RequirementsScreen( .padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { + // Termux Requirements Section + ExpandableKernelRequirementsSection( + title = context.getString(R.string.termux_requirements), + code = "# Run this command inside Termux to install dependencies needed for Termux:X11, VirGL and PulseAudio\n\ncurl -fsSL https://github.com/ravindu644/Droidspaces-OSS/raw/refs/heads/dev/scripts/setup-termux.sh | bash", + guideUrl = null, + snackbarHostState = snackbarHostState + ) + // non-GKI Kernel Requirements Section ExpandableKernelRequirementsSection( title = context.getString(R.string.kernel_requirements_nongki), @@ -169,7 +185,10 @@ CONFIG_NF_NAT_IPV4=y CONFIG_IP_NF_NAT=y # Disable this on older kernels to make internet work -CONFIG_ANDROID_PARANOID_NETWORK=n""", +CONFIG_ANDROID_PARANOID_NETWORK=n + +# Fix for docker unsafe procfs error +CONFIG_USER_NS=y""", guideUrl = "https://github.com/ravindu644/Droidspaces-OSS/blob/main/Documentation/Kernel-Configuration.md#non-gki", snackbarHostState = snackbarHostState ) @@ -199,6 +218,9 @@ CONFIG_NETFILTER_XT_MATCH_ADDRTYPE=y # --- Below configs are optional but recommended --- +# Fix for docker unsafe procfs error +CONFIG_USER_NS=y + # UFW support CONFIG_NETFILTER_XT_TARGET_REJECT=y CONFIG_NETFILTER_XT_TARGET_LOG=y @@ -218,9 +240,9 @@ CONFIG_TMPFS_XATTR=y""", snackbarHostState = snackbarHostState ) - // Check Requirements Button + // Check Requirements Button — disabled without root or a usable backend. CheckRequirementsButton( - isRootAvailable = isRootAvailable, + enabled = isRootAvailable && backendReady, isRunning = isCheckRunning, onClick = { scope.launch { @@ -475,13 +497,13 @@ private fun CodeBox( */ @Composable private fun CheckRequirementsButton( - isRootAvailable: Boolean, + enabled: Boolean, isRunning: Boolean, onClick: () -> Unit ) { val context = LocalContext.current val shape = RoundedCornerShape(20.dp) - val alpha = if (isRootAvailable && !isRunning) 1f else 0.5f + val alpha = if (enabled && !isRunning) 1f else 0.5f Surface( modifier = Modifier @@ -489,9 +511,9 @@ private fun CheckRequirementsButton( .height(54.dp) .alpha(alpha) .clip(shape) - .clickable(enabled = isRootAvailable && !isRunning, onClick = onClick), + .clickable(enabled = enabled && !isRunning, onClick = onClick), shape = shape, - color = if (isRootAvailable && !isRunning) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceContainerHigh, + color = if (enabled && !isRunning) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceContainerHigh, tonalElevation = 0.dp ) { Row( @@ -510,7 +532,7 @@ private fun CheckRequirementsButton( imageVector = Icons.Default.Search, contentDescription = null, modifier = Modifier.size(20.dp), - tint = if (isRootAvailable) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + tint = if (enabled) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) ) } Spacer(modifier = Modifier.width(10.dp)) @@ -518,7 +540,7 @@ private fun CheckRequirementsButton( text = context.getString(R.string.check_requirements), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, - color = if (isRootAvailable && !isRunning) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + color = if (enabled && !isRunning) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) ) if (!isRunning) { Spacer(modifier = Modifier.width(6.dp)) @@ -526,10 +548,9 @@ private fun CheckRequirementsButton( imageVector = Icons.Default.ChevronRight, contentDescription = null, modifier = Modifier.size(18.dp), - tint = if (isRootAvailable) MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f) else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + tint = if (enabled) MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f) else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) ) } } } } - diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/RootCheckScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/RootCheckScreen.kt index 5348a3b6..b1d359be 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/RootCheckScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/RootCheckScreen.kt @@ -1,5 +1,7 @@ package com.droidspaces.app.ui.screen +import com.droidspaces.app.ui.component.PrimaryActionBottomBar + import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.animateFloatAsState import kotlinx.coroutines.delay @@ -72,90 +74,50 @@ fun RootCheckScreen( Scaffold( containerColor = Color.Transparent, bottomBar = { - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surfaceContainer, - tonalElevation = 0.dp - ) { - Column(modifier = Modifier.fillMaxWidth()) { - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.25f), thickness = 1.dp) - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp) - .navigationBarsPadding() - .animateContentSize(animationSpec = AnimationUtils.fastSpec()), - verticalArrangement = Arrangement.spacedBy(12.dp) + PrimaryActionBottomBar( + label = context.getString( + if (currentRootStatus == RootStatus.Granted) R.string.continue_button + else if (isChecking) R.string.checking_root + else R.string.check_root_access + ), + icon = if (currentRootStatus == RootStatus.Granted) Icons.Default.CheckCircle else Icons.Default.Shield, + onClick = { + if (currentRootStatus == RootStatus.Granted) onNavigateToInstallation() + else checkRoot() + }, + enabled = !isChecking && currentRootStatus != RootStatus.Checking, + disabledContainerColor = MaterialTheme.colorScheme.primary, + disabledContentColor = MaterialTheme.colorScheme.onPrimary, + horizontalPadding = 20.dp, + labelFontSize = 16.sp, + secondaryAction = { + androidx.compose.animation.AnimatedVisibility( + visible = currentRootStatus == RootStatus.Denied && hasCheckedRoot, + enter = androidx.compose.animation.expandVertically() + androidx.compose.animation.fadeIn(), + exit = androidx.compose.animation.shrinkVertically() + androidx.compose.animation.fadeOut() ) { - // Main Action Button (Check Root / Continue) Surface( modifier = Modifier .fillMaxWidth() .clip(btnShape) - .clickable( - enabled = !isChecking && currentRootStatus != RootStatus.Checking, - onClick = { - if (currentRootStatus == RootStatus.Granted) onNavigateToInstallation() - else checkRoot() - } - ), + .clickable(onClick = onSkip), shape = btnShape, - color = MaterialTheme.colorScheme.primary, + color = Color.Transparent, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), tonalElevation = 0.dp ) { Box(modifier = Modifier.padding(vertical = 16.dp), contentAlignment = Alignment.Center) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Icon( - imageVector = if (currentRootStatus == RootStatus.Granted) Icons.Default.CheckCircle else Icons.Default.Shield, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.onPrimary - ) - Text( - text = context.getString( - if (currentRootStatus == RootStatus.Granted) R.string.continue_button - else if (isChecking) R.string.checking_root - else R.string.check_root_access - ), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, - color = MaterialTheme.colorScheme.onPrimary - ) - } - } - } - - // Skip button - only if Denied and has checked - androidx.compose.animation.AnimatedVisibility( - visible = currentRootStatus == RootStatus.Denied && hasCheckedRoot, - enter = androidx.compose.animation.expandVertically() + androidx.compose.animation.fadeIn(), - exit = androidx.compose.animation.shrinkVertically() + androidx.compose.animation.fadeOut() - ) { - Surface( - modifier = Modifier - .fillMaxWidth() - .clip(btnShape) - .clickable(onClick = onSkip), - shape = btnShape, - color = Color.Transparent, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(vertical = 16.dp), contentAlignment = Alignment.Center) { - Text( - text = context.getString(R.string.skip), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + Text( + text = context.getString(R.string.skip), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } } } } - } + ) } ) { innerPadding -> Column( diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/SettingsScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/SettingsScreen.kt index 6b694572..3670248a 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/SettingsScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/SettingsScreen.kt @@ -1,7 +1,6 @@ package com.droidspaces.app.ui.screen import android.content.Context -import android.content.SharedPreferences import android.content.ClipData import android.content.ClipboardManager import android.content.pm.PackageManager @@ -38,6 +37,7 @@ import androidx.compose.ui.res.painterResource import android.content.Intent import android.net.Uri import com.droidspaces.app.R +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.droidspaces.app.ui.component.AccentColorPicker import com.droidspaces.app.ui.component.BugReportDialog @@ -66,7 +66,8 @@ import androidx.compose.runtime.rememberCoroutineScope fun SettingsScreen( onBack: () -> Unit, onNavigateToInstallation: () -> Unit = {}, - onNavigateToRequirements: () -> Unit = {} + onNavigateToRequirements: () -> Unit = {}, + onNavigateToAutoBootPriority: () -> Unit = {} ) { val context = LocalContext.current val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() @@ -96,25 +97,12 @@ fun SettingsScreen( var showLanguageDialog by remember { mutableStateOf(false) } var currentAppLocale by remember { mutableStateOf(LocaleHelper.getCurrentAppLocale(context)) } - // Daemon mode state - var isDaemonModeEnabled by remember { mutableStateOf(prefsManager.isDaemonModeEnabled) } - - // Symlink state - var isSymlinkEnabled by remember { mutableStateOf(prefsManager.isSymlinkEnabled) } - - // Register SharedPreferences listener for daemon mode - DisposableEffect(prefsManager) { - val listener = SharedPreferences.OnSharedPreferenceChangeListener { _: SharedPreferences, key: String? -> - when (key) { - PreferencesManager.KEY_DAEMON_MODE_ENABLED -> isDaemonModeEnabled = prefsManager.isDaemonModeEnabled - PreferencesManager.KEY_SYMLINK_ENABLED -> isSymlinkEnabled = prefsManager.isSymlinkEnabled - } - } - prefsManager.prefs.registerOnSharedPreferenceChangeListener(listener) - onDispose { - prefsManager.prefs.unregisterOnSharedPreferenceChangeListener(listener) - } - } + // Daemon mode + symlink state, collected reactively from prefs + // (replaces a hand-rolled SharedPreferences change listener). + val isDaemonModeEnabled by prefsManager.daemonModeFlow + .collectAsStateWithLifecycle(initialValue = prefsManager.isDaemonModeEnabled) + val isSymlinkEnabled by prefsManager.symlinkEnabledFlow + .collectAsStateWithLifecycle(initialValue = prefsManager.isSymlinkEnabled) // Listen for locale changes and sync daemon mode from disk LaunchedEffect(Unit) { @@ -162,13 +150,20 @@ fun SettingsScreen( // Backend Reinstallation Section Text( text = context.getString(R.string.backend_section), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(start = 24.dp, bottom = 8.dp, top = 8.dp), color = MaterialTheme.colorScheme.primary ) - ListItem( + Surface( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + shape = RoundedCornerShape(24.dp), + color = if (darkTheme) MaterialTheme.colorScheme.surfaceContainerHigh else MaterialTheme.colorScheme.surfaceContainer, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)) + ) { + Column { + ListItem(colors = ListItemDefaults.colors(containerColor = Color.Transparent), leadingContent = { Icon( imageVector = Icons.Default.Build, @@ -214,6 +209,8 @@ fun SettingsScreen( ) ) + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) + // Daemon Mode Toggle SwitchItem( icon = Icons.Default.SettingsBackupRestore, @@ -226,6 +223,8 @@ fun SettingsScreen( } ) + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) + val isBackendAvailable = appStateViewModel.isBackendAvailable SwitchItem( icon = Icons.Default.Link, @@ -245,23 +244,63 @@ fun SettingsScreen( } ) + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) + + // Auto Boot Priority - clickable to navigate to the boot-order screen + ListItem(colors = ListItemDefaults.colors(containerColor = Color.Transparent), + leadingContent = { + Icon( + imageVector = Icons.Default.LowPriority, + contentDescription = null + ) + }, + headlineContent = { + Text( + text = context.getString(R.string.auto_boot_priority), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + }, + supportingContent = { + Text(context.getString(R.string.auto_boot_priority_description)) + }, + trailingContent = { + Icon( + imageVector = Icons.Default.ChevronRight, + contentDescription = null + ) + }, + modifier = Modifier.clickable { onNavigateToAutoBootPriority() } + ) + + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) + // Requirements Card - clickable to navigate to requirements page RequirementsCard( onNavigateToRequirements = onNavigateToRequirements ) + } + } - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + Spacer(modifier = Modifier.height(8.dp)) // Theme Section Text( text = context.getString(R.string.appearance_section), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(start = 24.dp, bottom = 8.dp, top = 8.dp), color = MaterialTheme.colorScheme.primary ) - // Language Picker + Surface( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + shape = RoundedCornerShape(24.dp), + color = if (darkTheme) MaterialTheme.colorScheme.surfaceContainerHigh else MaterialTheme.colorScheme.surfaceContainer, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)) + ) { + Column { + // Language Picker val currentLanguageDisplay = remember(currentAppLocale) { val currentLanguageCode = LocaleHelper.getCurrentLanguageCode() if (currentLanguageCode == "system") { @@ -274,7 +313,7 @@ fun SettingsScreen( } } - ListItem( + ListItem(colors = ListItemDefaults.colors(containerColor = Color.Transparent), leadingContent = { Icon( imageVector = Icons.Default.Translate, @@ -296,6 +335,8 @@ fun SettingsScreen( } ) + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) + // Follow System Theme SwitchItem( icon = Icons.Default.BrightnessAuto, @@ -304,16 +345,12 @@ fun SettingsScreen( checked = followSystemTheme, onCheckedChange = { checked -> prefsManager.followSystemTheme = checked - // Disable AMOLED mode when enabling follow system theme - // Theme state updates automatically via SharedPreferences listener - if (checked && amoledMode) { - prefsManager.amoledMode = false - } } ) // Dark Theme (only shown when not following system) if (!followSystemTheme) { + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) SwitchItem( icon = Icons.Default.DarkMode, title = context.getString(R.string.dark_theme), @@ -321,32 +358,29 @@ fun SettingsScreen( checked = darkTheme, onCheckedChange = { checked -> prefsManager.darkTheme = checked - // Disable AMOLED if dark theme is disabled - // Theme state updates automatically via SharedPreferences listener - if (!checked && amoledMode) { - prefsManager.amoledMode = false - } } ) + } - // AMOLED Mode (only shown when dark theme is explicitly enabled, directly beneath Dark Theme) - if (darkTheme) { - SwitchItem( - icon = Icons.Default.RadioButtonUnchecked, - title = context.getString(R.string.amoled_mode), - summary = context.getString(R.string.amoled_mode_description), - checked = amoledMode, - enabled = true, - onCheckedChange = { checked -> - prefsManager.amoledMode = checked - // Theme state updates automatically via SharedPreferences listener - } - ) - } + // AMOLED Mode (shown when followSystemTheme is true OR manual darkTheme is true) + if (followSystemTheme || darkTheme) { + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) + SwitchItem( + icon = Icons.Default.RadioButtonUnchecked, + title = context.getString(R.string.amoled_mode), + summary = context.getString(R.string.amoled_mode_description), + checked = amoledMode, + enabled = true, + onCheckedChange = { checked -> + prefsManager.amoledMode = checked + // Theme state updates automatically via SharedPreferences listener + } + ) } // Use Dynamic Color (Monet theming) - Only show on Android 12+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) SwitchItem( icon = Icons.Default.ColorLens, title = context.getString(R.string.dynamic_color), @@ -362,6 +396,7 @@ fun SettingsScreen( // Accent Color Picker - show when dynamic color is off, // or always show on devices below Android 12 (no dynamic color support) if (!useDynamicColor || android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.S) { + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) AccentColorPicker( selectedPalette = themeState.themePalette, isDarkTheme = darkTheme, @@ -370,11 +405,28 @@ fun SettingsScreen( } ) } + } + } - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + Spacer(modifier = Modifier.height(8.dp)) - // Bug Report Section - ListItem( + // Debugging Section + Text( + text = context.getString(R.string.debugging_section), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(start = 24.dp, bottom = 8.dp, top = 8.dp), + color = MaterialTheme.colorScheme.primary + ) + + Surface( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + + shape = RoundedCornerShape(24.dp), + color = if (darkTheme) MaterialTheme.colorScheme.surfaceContainerHigh else MaterialTheme.colorScheme.surfaceContainer, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)) + ) { + ListItem(colors = ListItemDefaults.colors(containerColor = Color.Transparent), leadingContent = { Icon( imageVector = Icons.Default.BugReport, @@ -418,20 +470,27 @@ fun SettingsScreen( Modifier } ) - ) + ) + } - HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + Spacer(modifier = Modifier.height(8.dp)) // About Section Text( text = context.getString(R.string.about_section), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(start = 24.dp, bottom = 8.dp, top = 8.dp), color = MaterialTheme.colorScheme.primary ) - ListItem( + Surface( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + shape = RoundedCornerShape(24.dp), + color = if (darkTheme) MaterialTheme.colorScheme.surfaceContainerHigh else MaterialTheme.colorScheme.surfaceContainer, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)) + ) { + ListItem(colors = ListItemDefaults.colors(containerColor = Color.Transparent), leadingContent = { Icon( imageVector = Icons.Default.Info, @@ -451,9 +510,10 @@ fun SettingsScreen( modifier = Modifier.clickable { showAboutDialog = true } - ) + ) + } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(24.dp)) } } @@ -495,9 +555,9 @@ private fun AboutDialog(onDismiss: () -> Unit) { ) { Surface( modifier = Modifier - .fillMaxWidth(0.9f) + .fillMaxWidth() .fillMaxHeight(0.85f) - .padding(16.dp), + .padding(horizontal = 24.dp), shape = RoundedCornerShape(20.dp), color = MaterialTheme.colorScheme.surfaceContainer, border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)) @@ -505,7 +565,7 @@ private fun AboutDialog(onDismiss: () -> Unit) { Column( modifier = Modifier .fillMaxSize() - .padding(24.dp) + .padding(top = 24.dp, start = 24.dp, end = 24.dp, bottom = 8.dp) ) { // Title Text( @@ -527,120 +587,126 @@ private fun AboutDialog(onDismiss: () -> Unit) { style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) - Spacer(modifier = Modifier.height(8.dp)) - HorizontalDivider() - Spacer(modifier = Modifier.height(4.dp)) Text( text = context.getString(R.string.developers), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.primary ) - // Developer 1 - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/ravindu644")) - context.startActivity(intent) - }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)) ) { - Icon( - imageVector = Icons.Default.Person, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - Column(modifier = Modifier.weight(1f)) { - Text( - text = context.getString(R.string.developer_ravindu644), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium - ) - Text( - text = context.getString(R.string.developer_ravindu644_role), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + Column { + // Developer 1 + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/ravindu644")) + context.startActivity(intent) + } + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = Icons.Default.Person, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = context.getString(R.string.developer_ravindu644), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium + ) + Text( + text = context.getString(R.string.developer_ravindu644_role), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = context.getString(R.string.github), + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } + + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) + + // Telegram channel + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://t.me/Droidspaces")) + context.startActivity(intent) + } + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.ic_telegram), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary + ) + Text( + text = context.getString(R.string.telegram_channel), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f) + ) + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = context.getString(R.string.telegram_channel), + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } + + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) + + // Source Code row + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/ravindu644/Droidspaces-OSS")) + context.startActivity(intent) + } + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = Icons.Default.Code, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary + ) + Text( + text = context.getString(R.string.source_code), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f) + ) + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = context.getString(R.string.source_code), + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } } - Icon( - imageVector = Icons.AutoMirrored.Filled.OpenInNew, - contentDescription = context.getString(R.string.github), - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) - ) } - Spacer(modifier = Modifier.height(4.dp)) - HorizontalDivider() - Spacer(modifier = Modifier.height(4.dp)) - // Telegram channel - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://t.me/Droidspaces")) - context.startActivity(intent) - }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Icon( - painter = painterResource(id = R.drawable.ic_telegram), - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary - ) - Text( - text = context.getString(R.string.telegram_channel), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - modifier = Modifier.weight(1f) - ) - Icon( - imageVector = Icons.AutoMirrored.Filled.OpenInNew, - contentDescription = context.getString(R.string.telegram_channel), - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) - ) - } - Spacer(modifier = Modifier.height(4.dp)) - HorizontalDivider() - Spacer(modifier = Modifier.height(4.dp)) - // Source Code row - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/ravindu644/Droidspaces-OSS")) - context.startActivity(intent) - }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Icon( - imageVector = Icons.Default.Code, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary - ) - Text( - text = context.getString(R.string.source_code), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - modifier = Modifier.weight(1f) - ) - Icon( - imageVector = Icons.AutoMirrored.Filled.OpenInNew, - contentDescription = context.getString(R.string.source_code), - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) - ) - } - Spacer(modifier = Modifier.height(4.dp)) - HorizontalDivider() - Spacer(modifier = Modifier.height(4.dp)) // Contributors Text( @@ -650,19 +716,30 @@ private fun AboutDialog(onDismiss: () -> Unit) { color = MaterialTheme.colorScheme.primary ) val contributors = remember { ContributorManager.load(context) } - contributors.forEach { contributor -> - ContributorItem( - contributor = contributor, - onClick = { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(contributor.githubUrl)) - context.startActivity(intent) + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)) + ) { + Column { + contributors.forEachIndexed { index, contributor -> + ContributorItem( + contributor = contributor, + onClick = { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(contributor.githubUrl)) + context.startActivity(intent) + } + ) + if (index < contributors.size - 1) { + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) + } } - ) + } } } // OK Button - Spacer(modifier = Modifier.height(8.dp)) TextButton( onClick = onDismiss, modifier = Modifier.align(Alignment.End) @@ -684,7 +761,7 @@ private fun ContributorItem(contributor: Contributor, onClick: () -> Unit) { }.getOrNull() else null } Row( - modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { @@ -738,7 +815,7 @@ private fun RequirementsCard( ) { val context = LocalContext.current - ListItem( + ListItem(colors = ListItemDefaults.colors(containerColor = Color.Transparent), leadingContent = { Icon( imageVector = Icons.Default.Code, diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/SparseImageConfigScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/SparseImageConfigScreen.kt index a07b3c36..59075e31 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/SparseImageConfigScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/SparseImageConfigScreen.kt @@ -1,4 +1,8 @@ package com.droidspaces.app.ui.screen + +import com.droidspaces.app.ui.component.DsTextFieldDefaults + +import com.droidspaces.app.ui.component.PrimaryActionBottomBar import androidx.compose.ui.graphics.Color import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.consumeWindowInsets @@ -40,12 +44,7 @@ fun SparseImageConfigScreen( var sizeError by remember { mutableStateOf(null) } val fieldShape = RoundedCornerShape(16.dp) - val fieldColors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow, - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ) + val fieldColors = DsTextFieldDefaults.colors() val isNextEnabled = !useSparseImage || (sizeGB.toIntOrNull()?.let { it in 4..512 } == true) @@ -62,59 +61,19 @@ fun SparseImageConfigScreen( ) }, bottomBar = { - val btnShape = RoundedCornerShape(20.dp) - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surfaceContainer, - tonalElevation = 0.dp - ) { - Column(modifier = Modifier.fillMaxWidth()) { - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.25f), - thickness = 1.dp - ) - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp) - .navigationBarsPadding() - .clip(btnShape) - .clickable( - enabled = isNextEnabled, - onClick = { - if (useSparseImage) { - val s = sizeGB.toIntOrNull() - if (s != null && s in 4..512) onNext(true, s) - } else { - onNext(false, 8) - } - }, - indication = androidx.compose.material.ripple.rememberRipple(bounded = true), - interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() } - ), - shape = btnShape, - color = if (isNextEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(vertical = 16.dp), contentAlignment = Alignment.Center) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Icon( - Icons.AutoMirrored.Filled.ArrowForward, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = if (isNextEnabled) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - ) - Text( - context.getString(R.string.next_configuration), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - color = if (isNextEnabled) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - ) - } - } + PrimaryActionBottomBar( + label = context.getString(R.string.next_configuration), + icon = Icons.AutoMirrored.Filled.ArrowForward, + onClick = { + if (useSparseImage) { + val s = sizeGB.toIntOrNull() + if (s != null && s in 4..512) onNext(true, s) + } else { + onNext(false, 8) } - } - } + }, + enabled = isNextEnabled + ) } ) { innerPadding -> Column( diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/SystemdScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/SystemdScreen.kt index f37037f0..4cb7c6f9 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/SystemdScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/SystemdScreen.kt @@ -1,55 +1,44 @@ package com.droidspaces.app.ui.screen -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.animateContentSize -import androidx.compose.foundation.* -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsFocusedAsState -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.* -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.Font -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.droidspaces.app.R -import com.droidspaces.app.ui.util.* -import com.droidspaces.app.util.AnimationUtils import com.droidspaces.app.util.ContainerSystemdManager -import com.droidspaces.app.util.ContainerSystemdManager.ServiceFilter -import com.droidspaces.app.util.ContainerSystemdManager.ServiceInfo -import com.droidspaces.app.util.ContainerSystemdManager.ServiceStatus -import kotlinx.coroutines.launch -// Premium Font Stack -private val JetBrainsMono = FontFamily( - Font(R.font.jetbrains_mono_regular, FontWeight.Normal), - Font(R.font.jetbrains_mono_bold, FontWeight.Bold) -) +private fun ContainerSystemdManager.CommandResult.toInit() = InitCommandResult(isSuccess, output, error) -// UI States -private sealed class SystemdScreenState { - data object Loading : SystemdScreenState() - data object SystemdNotAvailable : SystemdScreenState() - data class Ready(val services: List) : SystemdScreenState() -} - -private sealed class SystemdActionState { - data object Idle : SystemdActionState() - data class InProgress(val serviceName: String, val actionName: String) : SystemdActionState() +private fun ContainerSystemdManager.ServiceInfo.toRow(containerName: String): InitServiceRow { + val uiStatus = when (status) { + ContainerSystemdManager.ServiceStatus.ENABLED_RUNNING -> InitServiceUiStatus.ENABLED_RUNNING + ContainerSystemdManager.ServiceStatus.ENABLED_STOPPED -> InitServiceUiStatus.ENABLED_STOPPED + ContainerSystemdManager.ServiceStatus.DISABLED_STOPPED -> InitServiceUiStatus.DISABLED_STOPPED + ContainerSystemdManager.ServiceStatus.STATIC -> InitServiceUiStatus.STATIC + ContainerSystemdManager.ServiceStatus.ABNORMAL -> InitServiceUiStatus.ABNORMAL + ContainerSystemdManager.ServiceStatus.MASKED -> InitServiceUiStatus.MASKED + } + return InitServiceRow( + name = name, + description = description, + isRunning = isRunning, + isEnabled = isEnabled, + isMasked = isMasked, + isStatic = isStatic, + status = uiStatus, + startStop = { (if (isRunning) ContainerSystemdManager.stopService(containerName, name) else ContainerSystemdManager.startService(containerName, name)).toInit() }, + enableDisable = { (if (isEnabled) ContainerSystemdManager.disableService(containerName, name) else ContainerSystemdManager.enableService(containerName, name)).toInit() }, + unmask = if (isMasked) { { ContainerSystemdManager.unmaskService(containerName, name).toInit() } } else null, + menu = buildList { + if (isRunning) add(InitServiceMenuAction(R.string.restart_service, Icons.Default.Refresh) { ContainerSystemdManager.restartService(containerName, name).toInit() }) + add(InitServiceMenuAction(R.string.mask_service, Icons.Default.Lock) { ContainerSystemdManager.maskService(containerName, name).toInit() }) + } + ) } @OptIn(ExperimentalMaterial3Api::class) @@ -59,366 +48,26 @@ fun SystemdScreen( onNavigateBack: () -> Unit ) { val context = LocalContext.current - val scope = rememberCoroutineScope() - val snackbarHostState = remember { SnackbarHostState() } - LaunchedEffect(Unit) { ContainerSystemdManager.initialize(context) } - var screenState by remember { mutableStateOf(SystemdScreenState.Loading) } - var actionState by remember { mutableStateOf(SystemdActionState.Idle) } - var selectedFilter by remember { mutableStateOf(ServiceFilter.RUNNING) } - var logsDialogContent by remember { mutableStateOf?>(null) } - var searchQuery by remember { mutableStateOf("") } - - var fetchJob by remember { mutableStateOf(null) } - var actionJob by remember { mutableStateOf(null) } - - fun fetchServices() { - fetchJob?.cancel() - screenState = SystemdScreenState.Loading - fetchJob = scope.launch { - try { - val available = ContainerSystemdManager.isSystemdAvailable(containerName) - if (!available) { - screenState = SystemdScreenState.SystemdNotAvailable - return@launch - } - val services = ContainerSystemdManager.getAllServices(containerName) - screenState = SystemdScreenState.Ready(services) - } catch (e: Exception) { - if (e is kotlinx.coroutines.CancellationException) throw e - screenState = SystemdScreenState.SystemdNotAvailable - } - } - } - - fun executeAction(serviceName: String, actionName: String, action: suspend () -> ContainerSystemdManager.CommandResult) { - actionJob?.cancel() - fetchJob?.cancel() - actionState = SystemdActionState.InProgress(serviceName, actionName) - actionJob = scope.launch { - try { - val result = action() - actionState = SystemdActionState.Idle - if (result.isSuccess) { - screenState = SystemdScreenState.Loading - scope.showSuccess(snackbarHostState, context.getString(R.string.action_successful, actionName, serviceName)) - fetchServices() - } else { - val allLogs = result.output + result.error - if (allLogs.isNotEmpty()) logsDialogContent = allLogs - else scope.showError(snackbarHostState, context.getString(R.string.failed_to_action, actionName, serviceName)) - } - } catch (e: Exception) { - actionState = SystemdActionState.Idle - if (e is kotlinx.coroutines.CancellationException) throw e - scope.showError(snackbarHostState, context.getString(R.string.error_unknown, e.message ?: context.getString(R.string.unknown))) - } - } - } - - LaunchedEffect(containerName) { fetchServices() } - - val allServices = (screenState as? SystemdScreenState.Ready)?.services ?: emptyList() - val filteredServices = remember(allServices, selectedFilter, searchQuery) { - if (searchQuery.isBlank()) ContainerSystemdManager.filterServices(allServices, selectedFilter) - else allServices.filter { it.name.contains(searchQuery, ignoreCase = true) } - } - - val serviceCounts = remember(allServices) { - mapOf( - ServiceFilter.RUNNING to allServices.count { it.isRunning && it.isEnabled && !it.isMasked }, - ServiceFilter.ENABLED to allServices.count { it.isEnabled && !it.isRunning && !it.isMasked }, - ServiceFilter.DISABLED to allServices.count { !it.isEnabled && !it.isRunning && !it.isMasked && !it.isStatic }, - ServiceFilter.ABNORMAL to allServices.count { it.isRunning && !it.isEnabled && !it.isStatic && !it.isMasked }, - ServiceFilter.STATIC to allServices.count { it.isStatic }, - ServiceFilter.MASKED to allServices.count { it.isMasked }, - ServiceFilter.ALL to allServices.size - ) - } - - val clearFocus = rememberClearFocus() - - Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { - Scaffold( - topBar = { - CenterAlignedTopAppBar( - title = { Text(context.getString(R.string.systemd_services), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) }, - navigationIcon = { IconButton(onClick = onNavigateBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, context.getString(R.string.back)) } }, - actions = { IconButton(onClick = { fetchServices() }, enabled = screenState !is SystemdScreenState.Loading && actionState is SystemdActionState.Idle) { Icon(Icons.Default.Refresh, context.getString(R.string.refresh)) } }, - colors = TopAppBarDefaults.centerAlignedTopAppBarColors(containerColor = Color.Transparent, scrolledContainerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f)) - ) - }, - snackbarHost = { SnackbarHost(snackbarHostState) }, - containerColor = Color.Transparent - ) { padding -> - ClearFocusOnClickOutside(modifier = Modifier.padding(padding).fillMaxSize()) { - Box(modifier = Modifier.fillMaxSize()) { - when (screenState) { - is SystemdScreenState.Loading -> FullScreenLoading(message = context.getString(R.string.fetching_services)) - is SystemdScreenState.SystemdNotAvailable -> SystemdNotAvailable() - is SystemdScreenState.Ready -> { - Column(modifier = Modifier.fillMaxSize()) { - SearchBar(query = searchQuery, onQueryChange = { searchQuery = it }) - FilterChipsRow(selectedFilter = selectedFilter, serviceCounts = serviceCounts, onFilterSelected = { selectedFilter = it; clearFocus() }) - if (filteredServices.isEmpty()) { - EmptyServicesState(filter = selectedFilter, modifier = Modifier.weight(1f)) - } else { - LazyColumn(modifier = Modifier.weight(1f), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { - items(filteredServices) { service -> - ServiceCard(service = service, containerName = containerName, onAction = { name, act -> executeAction(service.name, name, act) }) - } - } - } - } - } - } - } - } - } - } - - (actionState as? SystemdActionState.InProgress)?.let { state -> ProgressDialog(message = context.getString(R.string.actioning_service, state.actionName, state.serviceName)) } - logsDialogContent?.let { logs -> ErrorLogsDialog(logs = logs, onDismiss = { logsDialogContent = null }) } -} - -@Composable -private fun SearchBar(query: String, onQueryChange: (String) -> Unit) { - val context = LocalContext.current - val interactionSource = remember { MutableInteractionSource() } - val isFocused by interactionSource.collectIsFocusedAsState() - val borderColor by animateColorAsState( - targetValue = if (isFocused) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f), - animationSpec = AnimationUtils.fastSpec() - ) - - Surface( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), - color = MaterialTheme.colorScheme.surfaceContainer, - shape = RoundedCornerShape(16.dp), - border = BorderStroke(1.dp, borderColor) - ) { - TextField( - value = query, - onValueChange = onQueryChange, - modifier = Modifier.fillMaxWidth(), - interactionSource = interactionSource, - placeholder = { Text(context.getString(R.string.search_services), color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)) }, - leadingIcon = { Icon(Icons.Default.Search, null, tint = if (isFocused) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)) }, - trailingIcon = { if (query.isNotEmpty()) { IconButton(onClick = { onQueryChange("") }) { Icon(Icons.Default.Clear, null) } } }, - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - cursorColor = MaterialTheme.colorScheme.primary - ), - singleLine = true, - textStyle = MaterialTheme.typography.bodyLarge, - keyboardOptions = FocusUtils.searchKeyboardOptions, - keyboardActions = FocusUtils.clearFocusKeyboardActions() - ) - } -} - -@Composable -private fun FilterChipsRow( - selectedFilter: ServiceFilter, - serviceCounts: Map, - onFilterSelected: (ServiceFilter) -> Unit -) { - val context = LocalContext.current + val maskedColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) val filters = listOf( - Triple(ServiceFilter.RUNNING, R.string.running, Color(0xFF4CAF50)), - Triple(ServiceFilter.ENABLED, R.string.enabled_legend, Color(0xFFFFCA28)), - Triple(ServiceFilter.DISABLED, R.string.disabled_legend, Color(0xFFEF5350)), - Triple(ServiceFilter.ABNORMAL, R.string.abnormal_legend, Color(0xFFFF7043)), - Triple(ServiceFilter.STATIC, R.string.static_legend, Color(0xFF607D8B)), - Triple(ServiceFilter.MASKED, R.string.masked_legend, MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)), - Triple(ServiceFilter.ALL, R.string.all_legend, null) + InitServiceFilterChip("RUNNING", R.string.running, Color(0xFF4CAF50), R.string.no_running_services) { it.isRunning && it.isEnabled && !it.isMasked }, + InitServiceFilterChip("ENABLED", R.string.enabled_legend, Color(0xFFFFCA28), R.string.no_enabled_services) { it.isEnabled && !it.isRunning && !it.isMasked }, + InitServiceFilterChip("DISABLED", R.string.disabled_legend, Color(0xFFEF5350), R.string.no_disabled_services) { !it.isEnabled && !it.isRunning && !it.isMasked && !it.isStatic }, + InitServiceFilterChip("ABNORMAL", R.string.abnormal_legend, Color(0xFFFF7043), R.string.no_abnormal_services) { it.isRunning && !it.isEnabled && !it.isStatic && !it.isMasked }, + InitServiceFilterChip("STATIC", R.string.static_legend, Color(0xFF607D8B), R.string.no_static_services) { it.isStatic }, + InitServiceFilterChip("MASKED", R.string.masked_legend, maskedColor, R.string.no_masked_services) { it.isMasked }, + InitServiceFilterChip("ALL", R.string.all_legend, null, R.string.no_services_found) { true }, ) - Row( - modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - filters.forEach { (filter, labelRes, dotColor) -> - val count = serviceCounts[filter] ?: 0 - val isSelected = selectedFilter == filter - - FilterChip( - selected = isSelected, - onClick = { onFilterSelected(filter) }, - label = { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { - if (dotColor != null) { - Surface(modifier = Modifier.size(6.dp), shape = CircleShape, color = dotColor) {} - } - Text("${context.getString(labelRes)} ($count)", style = MaterialTheme.typography.labelLarge) - } - }, - shape = RoundedCornerShape(12.dp), - colors = FilterChipDefaults.filterChipColors(selectedContainerColor = MaterialTheme.colorScheme.primaryContainer), - border = FilterChipDefaults.filterChipBorder(selected = isSelected, enabled = true, - borderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), - selectedBorderColor = MaterialTheme.colorScheme.primary) - ) - } - } -} - -@Composable -private fun ServiceCard( - service: ServiceInfo, - containerName: String, - onAction: (String, suspend () -> ContainerSystemdManager.CommandResult) -> Unit -) { - val context = LocalContext.current - var showMenu by remember { mutableStateOf(false) } - - val statusColor = when (service.status) { - ServiceStatus.ENABLED_RUNNING -> Color(0xFF4CAF50) - ServiceStatus.ENABLED_STOPPED -> Color(0xFFFFCA28) - ServiceStatus.DISABLED_STOPPED -> Color(0xFFEF5350) - ServiceStatus.STATIC -> Color(0xFF607D8B) - ServiceStatus.ABNORMAL -> Color(0xFFFF7043) - ServiceStatus.MASKED -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) - } - - Surface( - modifier = Modifier.fillMaxWidth().animateContentSize(AnimationUtils.mediumSpec()), - shape = RoundedCornerShape(20.dp), - color = MaterialTheme.colorScheme.surfaceContainer, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)) - ) { - Column { - // Premium Header Row (Symmetric) - Fixed Height 32dp + Padding - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp).height(32.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = service.name, - style = MaterialTheme.typography.titleMedium.copy( - fontFamily = JetBrainsMono, - fontSize = if (service.name.length > 25) 13.sp else 16.sp - ), - fontWeight = FontWeight.Bold, - modifier = Modifier.weight(1f), - maxLines = 1, - overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis - ) - - Surface( - color = statusColor.copy(alpha = 0.1f), - shape = RoundedCornerShape(8.dp), - border = BorderStroke(1.dp, statusColor.copy(alpha = 0.2f)) - ) { - Row( - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - Surface(modifier = Modifier.size(6.dp), shape = CircleShape, color = statusColor) {} - Text( - text = when (service.status) { - ServiceStatus.ENABLED_RUNNING -> context.getString(R.string.running) - ServiceStatus.ENABLED_STOPPED -> context.getString(R.string.enabled_legend) - ServiceStatus.STATIC -> context.getString(R.string.static_legend) - ServiceStatus.ABNORMAL -> context.getString(R.string.abnormal_legend) - ServiceStatus.DISABLED_STOPPED -> context.getString(R.string.disabled_legend) - ServiceStatus.MASKED -> context.getString(R.string.masked_legend) - }.uppercase(), - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Black, - letterSpacing = 0.5.sp, - color = statusColor - ) - } - } - } - - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)) - - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { - if (service.description.isNotEmpty()) { - Text( - text = service.description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f) - ) - } - - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surfaceContainerHigh, - shape = RoundedCornerShape(12.dp), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f)) - ) { - Row(modifier = Modifier.fillMaxWidth().padding(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) { - if (service.isMasked) { - Surface(onClick = { onAction(context.getString(R.string.unmask)) { ContainerSystemdManager.unmaskService(containerName, service.name) } }, modifier = Modifier.weight(1f).height(48.dp), shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f), border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.2f))) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Default.LockOpen, null, Modifier.size(18.dp), tint = MaterialTheme.colorScheme.primary); Text(context.getString(R.string.unmask), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary) } - } - } - } else { - val btnColor = if (service.isRunning) MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.4f) else MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f) - val accentColor = if (service.isRunning) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary - Surface(onClick = { if (service.isRunning) onAction(context.getString(R.string.stop)) { ContainerSystemdManager.stopService(containerName, service.name) } else onAction(context.getString(R.string.start)) { ContainerSystemdManager.startService(containerName, service.name) } }, modifier = Modifier.weight(1f).height(48.dp), shape = RoundedCornerShape(16.dp), color = btnColor, border = BorderStroke(1.dp, accentColor.copy(alpha = 0.2f))) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(if (service.isRunning) Icons.Default.Stop else Icons.Default.PlayArrow, null, Modifier.size(18.dp), tint = accentColor); Text(if (service.isRunning) context.getString(R.string.stop) else context.getString(R.string.start), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, color = accentColor) } } - } - if (!service.isStatic) { - Surface(onClick = { if (service.isEnabled) onAction(context.getString(R.string.disable_service)) { ContainerSystemdManager.disableService(containerName, service.name) } else onAction(context.getString(R.string.enable_service)) { ContainerSystemdManager.enableService(containerName, service.name) } }, modifier = Modifier.weight(1f).height(48.dp), shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.05f), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f))) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(if (service.isEnabled) Icons.Default.Block else Icons.Default.CheckCircle, null, Modifier.size(18.dp)); Text(if (service.isEnabled) context.getString(R.string.disable_service) else context.getString(R.string.enable_service), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold) } } - } - } - Box { - Surface(onClick = { showMenu = true }, modifier = Modifier.size(48.dp), shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.05f), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.2f))) { Box(contentAlignment = Alignment.Center) { Icon(Icons.Default.MoreVert, null, tint = MaterialTheme.colorScheme.onSurfaceVariant) } } - DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) { - if (service.isRunning) { DropdownMenuItem(text = { Text(context.getString(R.string.restart_service)) }, leadingIcon = { Icon(Icons.Default.Refresh, null) }, onClick = { showMenu = false; onAction(context.getString(R.string.restart_service)) { ContainerSystemdManager.restartService(containerName, service.name) } }) } - DropdownMenuItem(text = { Text(context.getString(R.string.mask_service)) }, leadingIcon = { Icon(Icons.Default.Lock, null) }, onClick = { showMenu = false; onAction(context.getString(R.string.mask_service)) { ContainerSystemdManager.maskService(containerName, service.name) } }) - } - } - } - } - } - } - } - } -} - -@Composable -private fun SystemdNotAvailable() { - val context = LocalContext.current - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(24.dp), modifier = Modifier.padding(32.dp)) { - Surface(shape = CircleShape, color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.2f), modifier = Modifier.size(120.dp), border = BorderStroke(2.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.2f))) { Box(contentAlignment = Alignment.Center) { Icon(Icons.Default.Warning, null, modifier = Modifier.size(56.dp), tint = MaterialTheme.colorScheme.error) } } - Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text(context.getString(R.string.init_system_not_available), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center) - Text(context.getString(R.string.init_system_not_available_desc), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center) - } - } - } -} - -@Composable -private fun EmptyServicesState(filter: ServiceFilter, modifier: Modifier = Modifier) { - val context = LocalContext.current - Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp)) { - Icon(Icons.Default.SearchOff, null, modifier = Modifier.size(64.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)) - Text(text = when (filter) { - ServiceFilter.RUNNING -> context.getString(R.string.no_running_services) - ServiceFilter.ENABLED -> context.getString(R.string.no_enabled_services) - ServiceFilter.DISABLED -> context.getString(R.string.no_disabled_services) - ServiceFilter.STATIC -> context.getString(R.string.no_static_services) - ServiceFilter.ABNORMAL -> context.getString(R.string.no_abnormal_services) - ServiceFilter.MASKED -> context.getString(R.string.no_masked_services) - ServiceFilter.ALL -> context.getString(R.string.no_services_found) - }, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)) - } - } + InitServiceScreen( + containerName = containerName, + titleRes = R.string.systemd_services, + onNavigateBack = onNavigateBack, + isAvailable = { cn -> ContainerSystemdManager.isSystemdAvailable(cn) }, + fetchRows = { cn -> ContainerSystemdManager.getAllServices(cn).map { it.toRow(cn) } }, + filters = filters, + defaultFilterId = "RUNNING", + ) } diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/WelcomeScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/WelcomeScreen.kt index 9796f24b..64480dc4 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/WelcomeScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/WelcomeScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.droidspaces.app.R +import com.droidspaces.app.ui.component.PrimaryActionBottomBar import com.droidspaces.app.util.AnimationUtils import kotlinx.coroutines.delay @@ -58,44 +59,17 @@ fun WelcomeScreen(onNavigateToRootCheck: () -> Unit) { ShowcaseCard(Icons.Default.PowerSettingsNew, R.string.feat_autoboot_title, R.string.feat_autoboot_desc), ) - val btnShape = RoundedCornerShape(20.dp) - Scaffold( containerColor = Color.Transparent, bottomBar = { - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surfaceContainer, - tonalElevation = 0.dp - ) { - Column(modifier = Modifier.fillMaxWidth()) { - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), thickness = 1.dp) - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp) - .navigationBarsPadding() - .clip(btnShape) - .clickable(onClick = onNavigateToRootCheck), - shape = btnShape, - color = MaterialTheme.colorScheme.primary, - tonalElevation = 0.dp - ) { - Box(modifier = Modifier.padding(vertical = 16.dp), contentAlignment = Alignment.Center) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Icon(Icons.Default.RocketLaunch, contentDescription = null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onPrimary) - Text( - text = context.getString(R.string.get_started), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, - color = MaterialTheme.colorScheme.onPrimary - ) - } - } - } - } - } + PrimaryActionBottomBar( + label = context.getString(R.string.get_started), + icon = Icons.Default.RocketLaunch, + onClick = onNavigateToRootCheck, + dividerAlpha = 0.4f, + horizontalPadding = 20.dp, + labelFontSize = 16.sp + ) } ) { innerPadding -> Column( diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/terminal/DroidspacesTerminalSession.kt b/Android/app/src/main/java/com/droidspaces/app/ui/terminal/DroidspacesTerminalSession.kt index b59412fe..96d39980 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/terminal/DroidspacesTerminalSession.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/terminal/DroidspacesTerminalSession.kt @@ -32,15 +32,25 @@ import com.termux.terminal.TerminalSessionClient */ object DroidspacesTerminalSession { + // Container names allow letters, digits, spaces, '_', '.', '-' (mirrors + // ValidationUtils.validateContainerName) — none are shell metacharacters, so + // a validated name is safe inside the "..." payload. A username discovered + // from the container's /etc/passwd is NOT validated elsewhere, so it is + // checked here too. See FINDINGS_APP_VULN V7. + private val SAFE_NAME = Regex("^[a-zA-Z0-9_\\s.-]+$") + private val SAFE_USER = Regex("^[A-Za-z0-9_.@:+-]+$") + fun create( sessionClient: TerminalSessionClient, containerName: String?, containerUser: String? = null, ): TerminalSession { - return if (containerName != null) { - // Escape any literal " in the container name - it sits inside "..." in the su payload. - val escapedName = containerName.replace("\"", "\\\"") - val user = containerUser ?: "root" + val safeName = containerName?.takeIf { SAFE_NAME.matches(it) } + return if (safeName != null) { + // Reject any username carrying shell metacharacters; fall back to root. + val user = containerUser?.takeIf { SAFE_USER.matches(it) } ?: "root" + // safeName cannot contain " / ' / $ / ` — keep the escape as belt-and-suspenders. + val escapedName = safeName.replace("\"", "\\\"") // Equivalent to: sh -c "su -c 'droidspaces -n "Name" enter user'" val shArg = "su -c 'droidspaces -n \"$escapedName\" enter $user'" @@ -56,7 +66,8 @@ object DroidspacesTerminalSession { /* client = */ sessionClient, ) } else { - // Bare interactive root shell. + // No container, or a name carrying shell metacharacters: open a bare + // interactive root shell rather than build an injectable payload. TerminalSession( /* shell = */ "/system/bin/sh", /* workingDir = */ "/sdcard", diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/terminal/NoOpTerminalSessionClient.kt b/Android/app/src/main/java/com/droidspaces/app/ui/terminal/NoOpTerminalSessionClient.kt new file mode 100644 index 00000000..22d98789 --- /dev/null +++ b/Android/app/src/main/java/com/droidspaces/app/ui/terminal/NoOpTerminalSessionClient.kt @@ -0,0 +1,37 @@ +package com.droidspaces.app.ui.terminal + +import com.termux.terminal.TerminalEmulator +import com.termux.terminal.TerminalSession +import com.termux.terminal.TerminalSessionClient + +/** + * A stateless, application-scoped [TerminalSessionClient]. + * + * A running [TerminalSession] keeps a reference to its client. The UI's client is a + * [TerminalBackEnd], which strongly holds the Activity and its TerminalView — so a + * backgrounded session living in [com.droidspaces.app.service.TerminalSessionService] + * would otherwise pin the destroyed Activity/view tree in memory. + * + * When the terminal screen is disposed, each session's client is swapped to this + * no-op so nothing UI-scoped is retained; the screen re-attaches its own + * [TerminalBackEnd] via `updateTerminalSessionClient` on re-entry. See VULN V16. + */ +object NoOpTerminalSessionClient : TerminalSessionClient { + override fun onTextChanged(changedSession: TerminalSession) {} + override fun onTitleChanged(changedSession: TerminalSession) {} + override fun onSessionFinished(finishedSession: TerminalSession) {} + override fun onCopyTextToClipboard(session: TerminalSession, text: String) {} + override fun onPasteTextFromClipboard(session: TerminalSession) {} + override fun onBell(session: TerminalSession) {} + override fun onColorsChanged(session: TerminalSession) {} + override fun onTerminalCursorStateChange(state: Boolean) {} + override fun getTerminalCursorStyle(): Int = TerminalEmulator.DEFAULT_TERMINAL_CURSOR_STYLE + + override fun logError(tag: String?, message: String?) {} + override fun logWarn(tag: String?, message: String?) {} + override fun logInfo(tag: String?, message: String?) {} + override fun logDebug(tag: String?, message: String?) {} + override fun logVerbose(tag: String?, message: String?) {} + override fun logStackTraceWithMessage(tag: String?, message: String?, e: Exception?) {} + override fun logStackTrace(tag: String?, e: Exception?) {} +} diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/theme/ThemeStateHolder.kt b/Android/app/src/main/java/com/droidspaces/app/ui/theme/ThemeStateHolder.kt index 66850a72..7dcf0dc8 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/theme/ThemeStateHolder.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/theme/ThemeStateHolder.kt @@ -76,14 +76,13 @@ fun rememberThemeState(): ThemeState { val systemDark = isSystemInDarkTheme() val effectiveDarkTheme = if (followSystemTheme) systemDark else darkTheme - val effectiveAmoledMode = amoledMode && effectiveDarkTheme // AMOLED only works with dark theme // Return state that triggers recomposition when any theme preference changes return remember(followSystemTheme, darkTheme, amoledMode, useDynamicColor, systemDark, themePalette) { ThemeState( followSystemTheme = followSystemTheme, darkTheme = effectiveDarkTheme, - amoledMode = effectiveAmoledMode, + amoledMode = amoledMode, useDynamicColor = useDynamicColor, themePalette = themePalette ) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/AppStateViewModel.kt b/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/AppStateViewModel.kt index 2f0fdead..ec0fd440 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/AppStateViewModel.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/AppStateViewModel.kt @@ -6,12 +6,20 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import com.droidspaces.app.R +import com.droidspaces.app.util.BinaryInstaller +import com.droidspaces.app.util.Constants import com.droidspaces.app.util.DroidspacesBackendStatus import com.droidspaces.app.util.DroidspacesChecker +import com.droidspaces.app.util.InstallationStep +import com.droidspaces.app.util.ModuleInstaller +import com.droidspaces.app.util.ModuleInstallationStep import com.droidspaces.app.util.PreferencesManager import com.droidspaces.app.util.RootChecker import com.droidspaces.app.util.RootStatus +import com.droidspaces.app.util.SymlinkInstaller import com.droidspaces.app.util.SystemInfoManager +import com.topjohnwu.superuser.Shell import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch @@ -69,6 +77,22 @@ class AppStateViewModel(application: Application) : AndroidViewModel(application // Current refresh job for cancellation private var refreshJob: Job? = null + // ── Installation flow state (drives InstallationScreen) ───────────────────── + var installCurrentStep by mutableStateOf(null) + private set + var installCurrentModuleStep by mutableStateOf(null) + private set + var isInstalling by mutableStateOf(false) + private set + var isInstallSuccess by mutableStateOf(false) + private set + var installErrorMessage by mutableStateOf(null) + private set + var isInstallingModule by mutableStateOf(false) + private set + var installRebootRecommended by mutableStateOf(false) + private set + fun checkBackendStatus(force: Boolean = false) { if (hasCompletedInitialCheck && !force) return @@ -143,6 +167,100 @@ class AppStateViewModel(application: Application) : AndroidViewModel(application SystemInfoManager.resetCache() } + /** + * Run the backend install/update orchestration (moved out of InstallationScreen's + * LaunchedEffect — see FINDINGS_APP_DUCT_TAPES DT-6). Idempotent: no-op if an + * install is already running or has succeeded. Drives the install* state above. + */ + suspend fun performInstallation() { + if (isInstalling || isInstallSuccess) return + val context = getApplication() + + val backendStatus = withContext(Dispatchers.IO) { + DroidspacesChecker.checkBackendStatus() + } + + isInstalling = true + + val whichBackendMode = withContext(Dispatchers.IO) { + SystemInfoManager.getBackendMode(context) + } + val wasDaemon = whichBackendMode == "DAEMON" + + val isAtomicUpdate = backendStatus is DroidspacesBackendStatus.UpdateAvailable + + isInstallingModule = false + + // Capture symlink state before any module directory removal + val wasSymlinkEnabled = withContext(Dispatchers.IO) { + SymlinkInstaller.isSymlinkEnabled() + } + + // Check if SELinux policy exists BEFORE we start nuking things + val sepolicyExists = withContext(Dispatchers.IO) { + Shell.cmd("test -f ${Constants.MAGISK_MODULE_PATH}/sepolicy.rule").exec().isSuccess + } + if (!sepolicyExists) { + installRebootRecommended = true + } + + if (!isAtomicUpdate) { + // Clean Slate: Remove the old module, but NEVER the bin directory. + installCurrentStep = InstallationStep.CreatingDirectories("Nuking existing module...") + Shell.cmd("rm -rf '/data/adb/modules/droidspaces' 2>&1").exec() + } + + // Step 2: Install binaries (atomic mv to canonical path) + val binaryResult = BinaryInstaller.install(context) { step -> + installCurrentStep = step + } + binaryResult.fold( + onSuccess = { + if (wasDaemon) { + BinaryInstaller.signalDaemon() + } + // Step 3: Install module + isInstallingModule = true + val moduleResult = ModuleInstaller.install(context) { step -> + installCurrentModuleStep = step + } + moduleResult.fold( + onSuccess = { + val daemonFileExists = withContext(Dispatchers.IO) { + Shell.cmd("test -f '${Constants.DAEMON_MODE_FILE}'").exec().isSuccess + } + if (!daemonFileExists) { + PreferencesManager.getInstance(context).isDaemonModeEnabled = true + } + if (wasSymlinkEnabled) { + withContext(Dispatchers.IO) { + SymlinkInstaller.enable() + } + } + isInstallSuccess = true + isInstalling = false + isInstallingModule = false + resetForPostInstallation() + forceRefresh() + }, + onFailure = { error -> + installErrorMessage = error.message ?: context.getString(R.string.module_installation_failed) + isInstalling = false + isInstallingModule = false + resetForPostInstallation() + forceRefresh() + } + ) + }, + onFailure = { error -> + installErrorMessage = error.message ?: context.getString(R.string.binary_installation_failed) + isInstalling = false + resetForPostInstallation() + forceRefresh() + } + ) + } + /** * Check if backend is available (working or update available). */ diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/ContainerInstallationViewModel.kt b/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/ContainerInstallationViewModel.kt index 59a5dacc..e18a6ecf 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/ContainerInstallationViewModel.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/ContainerInstallationViewModel.kt @@ -10,9 +10,9 @@ import com.droidspaces.app.util.ContainerManager import com.droidspaces.app.util.ContainerStatus import com.droidspaces.app.util.Constants -import com.droidspaces.app.util.BindMount -import com.droidspaces.app.util.PortForward +import com.droidspaces.app.util.ContainerConfigState import com.droidspaces.app.util.ValidationUtils +import com.droidspaces.app.util.withConfig class ContainerInstallationViewModel : ViewModel() { var tarballUri: Uri? by mutableStateOf(null) @@ -24,79 +24,14 @@ class ContainerInstallationViewModel : ViewModel() { var hostname: String by mutableStateOf("") private set - var netMode: String by mutableStateOf("nat") - private set - - var disableIPv6: Boolean by mutableStateOf(false) - private set - - var enableAndroidStorage: Boolean by mutableStateOf(false) - private set - - var enableHwAccess: Boolean by mutableStateOf(false) - private set - - var enableGpuMode: Boolean by mutableStateOf(false) - private set - - var enableTermuxX11: Boolean by mutableStateOf(false) - private set - - var tx11ExtraFlags: String by mutableStateOf("") - private set - - var enableVirgl: Boolean by mutableStateOf(false) - private set - - var virglExtraFlags: String by mutableStateOf("") - private set - - var enablePulseaudio: Boolean by mutableStateOf(false) - private set - - var selinuxPermissive: Boolean by mutableStateOf(false) - private set - - var volatileMode: Boolean by mutableStateOf(false) - private set - - var bindMounts: List by mutableStateOf(emptyList()) - private set - - var dnsServers: String by mutableStateOf("") - private set - - var runAtBoot: Boolean by mutableStateOf(false) - private set - - var customInit: String by mutableStateOf("") - private set - - var staticNatIp: String by mutableStateOf("") - private set - - var envFileContent: String? by mutableStateOf(null) - private set - var useSparseImage: Boolean by mutableStateOf(true) private set var sparseImageSizeGB: Int by mutableStateOf(8) private set - var upstreamInterfaces: List by mutableStateOf(emptyList()) - private set - - var portForwards: List by mutableStateOf(emptyList()) - private set - - var forceCgroupv1: Boolean by mutableStateOf(false) - private set - - var blockNestedNs: Boolean by mutableStateOf(false) - private set - - var privileged: String by mutableStateOf("") + /** All editable networking/security/advanced config, hoisted as one value. */ + var configState: ContainerConfigState by mutableStateOf(ContainerConfigState()) private set fun setTarball(uri: Uri) { @@ -113,54 +48,8 @@ class ContainerInstallationViewModel : ViewModel() { this.sparseImageSizeGB = sizeGB } - fun setConfig( - netMode: String, - disableIPv6: Boolean, - enableAndroidStorage: Boolean, - enableHwAccess: Boolean, - enableGpuMode: Boolean, - enableTermuxX11: Boolean, - tx11ExtraFlags: String, - enableVirgl: Boolean, - virglExtraFlags: String, - enablePulseaudio: Boolean, - selinuxPermissive: Boolean, - volatileMode: Boolean, - bindMounts: List, - dnsServers: String, - runAtBoot: Boolean, - customInit: String, - staticNatIp: String, - envFileContent: String?, - upstreamInterfaces: List, - portForwards: List, - forceCgroupv1: Boolean, - blockNestedNs: Boolean, - privileged: String - ) { - this.netMode = netMode - this.disableIPv6 = disableIPv6 - this.enableAndroidStorage = enableAndroidStorage - this.enableHwAccess = enableHwAccess - this.enableGpuMode = enableGpuMode - this.enableTermuxX11 = enableTermuxX11 - this.tx11ExtraFlags = tx11ExtraFlags - this.enableVirgl = enableVirgl - this.virglExtraFlags = virglExtraFlags - this.enablePulseaudio = enablePulseaudio - this.selinuxPermissive = selinuxPermissive - this.volatileMode = volatileMode - this.bindMounts = bindMounts - this.dnsServers = dnsServers - this.runAtBoot = runAtBoot - this.customInit = customInit - this.staticNatIp = staticNatIp - this.envFileContent = envFileContent - this.upstreamInterfaces = upstreamInterfaces - this.portForwards = portForwards - this.forceCgroupv1 = forceCgroupv1 - this.blockNestedNs = blockNestedNs - this.privileged = privileged + fun setConfig(config: ContainerConfigState) { + this.configState = config } fun buildConfig(): ContainerInfo? { @@ -175,64 +64,19 @@ class ContainerInstallationViewModel : ViewModel() { } else { ContainerManager.getRootfsPath(containerName) }, - netMode = netMode, - disableIPv6 = disableIPv6, - enableAndroidStorage = enableAndroidStorage, - enableHwAccess = enableHwAccess, - enableGpuMode = enableGpuMode, - enableTermuxX11 = enableTermuxX11, - tx11ExtraFlags = tx11ExtraFlags, - enableVirgl = enableVirgl, - virglExtraFlags = virglExtraFlags, - enablePulseaudio = enablePulseaudio, - selinuxPermissive = selinuxPermissive, - volatileMode = volatileMode, - bindMounts = bindMounts, - dnsServers = dnsServers, - runAtBoot = runAtBoot, - customInit = customInit, - staticNatIp = staticNatIp, - envFileContent = envFileContent, status = ContainerStatus.STOPPED, // Default status for new container useSparseImage = useSparseImage, sparseImageSizeGB = if (useSparseImage) sparseImageSizeGB else null, - upstreamInterfaces = upstreamInterfaces, - portForwards = portForwards, - forceCgroupv1 = forceCgroupv1, - blockNestedNs = blockNestedNs, - privileged = privileged - ) + ).withConfig(configState) } fun reset() { tarballUri = null containerName = "" hostname = "" - netMode = "nat" - disableIPv6 = false - enableAndroidStorage = false - enableHwAccess = false - enableGpuMode = false - enableTermuxX11 = false - tx11ExtraFlags = "" - enableVirgl = false - virglExtraFlags = "" - enablePulseaudio = false - selinuxPermissive = false - volatileMode = false - bindMounts = emptyList() - dnsServers = "" - runAtBoot = false - customInit = "" - staticNatIp = "" - envFileContent = null useSparseImage = true sparseImageSizeGB = 8 - upstreamInterfaces = emptyList() - portForwards = emptyList() - forceCgroupv1 = false - blockNestedNs = false - privileged = "" + configState = ContainerConfigState() } } diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/ContainerOperationsViewModel.kt b/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/ContainerOperationsViewModel.kt new file mode 100644 index 00000000..97e322ab --- /dev/null +++ b/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/ContainerOperationsViewModel.kt @@ -0,0 +1,390 @@ +package com.droidspaces.app.ui.viewmodel + +import android.app.Application +import android.content.Intent +import android.net.Uri +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.lifecycle.AndroidViewModel +import com.droidspaces.app.R +import com.droidspaces.app.service.TerminalSessionService +import com.droidspaces.app.util.ContainerCommandBuilder +import com.droidspaces.app.util.ContainerInfo +import com.droidspaces.app.util.ContainerManager +import com.droidspaces.app.util.ContainerOSInfoManager +import com.droidspaces.app.util.ContainerOperationExecutor +import com.droidspaces.app.util.PreferencesManager +import com.droidspaces.app.util.SystemInfoManager +import com.droidspaces.app.util.ViewModelLogger +import com.topjohnwu.superuser.Shell +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import java.io.File +import java.io.OutputStream + +/** Progress state for the stop/uninstall flow (drives the ProgressDialog). */ +sealed class UninstallState { + data object Idle : UninstallState() + data class InProgress(val containerName: String, val message: String) : UninstallState() +} + +/** A pending sparse-image operation awaiting a size from the user. */ +sealed class SparseOperation { + data class Migrate(val container: ContainerInfo) : SparseOperation() + data class Resize(val container: ContainerInfo) : SparseOperation() +} + +/** + * Owns the container lifecycle/maintenance operations (start/stop/restart, + * export, uninstall, sparse migrate/resize) that used to live as suspend + * functions inside `ContainersScreen`. Root shell, asset deploy, command + * building and content-URI streaming now live here; the composable observes the + * state below and passes UI concerns (snackbar, list refresh, usage-cache clear) + * back as callbacks so behavior is identical. See FINDINGS_APP_DUCT_TAPES DT-6. + */ +class ContainerOperationsViewModel(app: Application) : AndroidViewModel(app) { + + private val appContext get() = getApplication() + private val prefsManager get() = PreferencesManager.getInstance(appContext) + + /** Container whose operation is currently running (shows the terminal icon / blocks the log dialog). */ + var runningOperationContainer by mutableStateOf(null) + private set + + /** Per-container streaming log buffers. */ + var containerLogs by mutableStateOf>>>(emptyMap()) + private set + + /** Container whose log viewer dialog is open (set from UI and from operations). */ + var showLogViewerFor by mutableStateOf(null) + + /** Last container whose operation failed (kept for parity with prior behavior). */ + var lastErrorContainer by mutableStateOf(null) + private set + + /** Stop/uninstall progress. */ + var uninstallState by mutableStateOf(UninstallState.Idle) + private set + + /** Non-null when an uninstall failed and its logs should be shown (dismissed from UI). */ + var uninstallLogsDialog by mutableStateOf?>(null) + + fun dismissUninstallLogs() { uninstallLogsDialog = null } + + /** Get-or-create the streaming log buffer for [name] (matches the previous inline pattern). */ + private fun logsFor(name: String): SnapshotStateList> { + containerLogs[name]?.let { return it } + val newLogs = mutableStateListOf>() + containerLogs = containerLogs.toMutableMap().apply { put(name, newLogs) } + return newLogs + } + + /** Clear the in-memory + cached logs for [name] (log dialog "clear" action). */ + fun clearLogsBuffer(name: String) { + val buffer = containerLogs[name] ?: mutableStateListOf>().also { + containerLogs = containerLogs.toMutableMap().apply { put(name, it) } + } + buffer.clear() + prefsManager.clearContainerLogs(name) + containerLogs = containerLogs.toMutableMap() + } + + private fun string(resId: Int, vararg args: Any): String = + if (args.isEmpty()) appContext.getString(resId) else appContext.getString(resId, *args) + + // ── Export ──────────────────────────────────────────────────────────────── + suspend fun executeExport( + container: ContainerInfo, + outputUri: Uri, + onError: (String) -> Unit, + ) { + runningOperationContainer = container.name + val logs = logsFor(container.name) + logs.clear() + prefsManager.clearContainerLogs(container.name) + + val logger = ViewModelLogger { level, message -> logs.add(level to message) }.apply { verbose = true } + + var scriptFile: File? = null + var tempArchive: File? = null + try { + val isRunning = ContainerManager.checkContainerStatus(container.name).first + if (isRunning) { + uninstallState = UninstallState.InProgress(container.name, string(R.string.stopping_container)) + val stopCommand = ContainerCommandBuilder.buildStopCommand(container) + val stopResult = ContainerOperationExecutor.executeCommand(stopCommand, "stop", logger) + uninstallState = UninstallState.Idle + if (!stopResult) { + logger.e(string(R.string.failed_to_stop_container, container.name)) + onError(string(R.string.failed_to_stop_container, container.name)) + return + } + } + + logs.clear() + showLogViewerFor = container.name + logger.i(string(R.string.starting_export)) + + val deployed = File("${appContext.cacheDir}/export_container.sh") + scriptFile = deployed + appContext.assets.open("export_container.sh").use { input -> + deployed.outputStream().use { out: OutputStream -> input.copyTo(out) } + } + Shell.cmd("chmod 755 \"${deployed.absolutePath}\"").exec() + + tempArchive = File("${appContext.cacheDir}/${container.name}_export_tmp.tar.gz") + tempArchive.delete() + + val cmd = "\"${deployed.absolutePath}\" \"${container.name}\" \"${tempArchive.absolutePath}\"" + val success = ContainerOperationExecutor.executeCommand( + command = cmd, + operation = "export", + logger = logger, + skipHeader = true, + operationCompletedMessage = string(R.string.operation_completed_success) + ) + + if (success && tempArchive.exists() && tempArchive.length() > 0) { + logger.i("Writing archive to destination...") + withContext(Dispatchers.IO) { + appContext.contentResolver.openOutputStream(outputUri)?.use { out -> + tempArchive.inputStream().use { it.copyTo(out) } + } + } + logger.i("Done! Archive written successfully.") + } else if (!success) { + logger.e(string(R.string.export_container_failed, container.name)) + onError(string(R.string.export_container_failed, container.name)) + } + } catch (e: Exception) { + logger.e("Export error: ${e.message}") + logger.e(e.stackTraceToString()) + onError(string(R.string.export_container_failed, container.name)) + } finally { + scriptFile?.delete() + tempArchive?.delete() + prefsManager.saveContainerLogs(container.name, logs.toList()) + delay(500) + runningOperationContainer = null + } + } + + // ── Uninstall ─────────────────────────────────────────────────────────────── + suspend fun executeUninstall( + container: ContainerInfo, + onError: (String) -> Unit, + onSuccess: (String) -> Unit, + onRefresh: () -> Unit, + ) { + val collectedLogs = mutableListOf() + val logger = ViewModelLogger { _, message -> collectedLogs.add(message) }.apply { verbose = true } + + try { + val isRunning = ContainerManager.checkContainerStatus(container.name).first + if (isRunning) { + uninstallState = UninstallState.InProgress(container.name, string(R.string.stopping_container)) + val stopCommand = ContainerCommandBuilder.buildStopCommand(container) + val stopResult = ContainerOperationExecutor.executeCommand(stopCommand, "stop", logger) + if (!stopResult) { + uninstallState = UninstallState.Idle + if (collectedLogs.isNotEmpty()) uninstallLogsDialog = collectedLogs + else onError(string(R.string.failed_to_stop_container, container.name)) + onRefresh() + return + } + } + + uninstallState = UninstallState.InProgress(container.name, string(R.string.uninstalling_container)) + val result = ContainerManager.uninstallContainer(container, logger) + uninstallState = UninstallState.Idle + + if (result.isFailure) { + if (collectedLogs.isNotEmpty()) uninstallLogsDialog = collectedLogs + else onError(string(R.string.failed_to_uninstall_container, container.name)) + onRefresh() + } else { + ContainerOSInfoManager.clearCache(container.name, appContext) + onSuccess(string(R.string.container_uninstalled_success, container.name)) + onRefresh() + } + } catch (e: Exception) { + uninstallState = UninstallState.Idle + collectedLogs.add("Exception: ${e.message}") + collectedLogs.add(e.stackTraceToString()) + if (collectedLogs.isNotEmpty()) uninstallLogsDialog = collectedLogs + else onError(string(R.string.failed_to_uninstall_container, container.name)) + onRefresh() + } + } + + // ── Start / Stop / Restart ──────────────────────────────────────────────── + suspend fun executeOperation( + container: ContainerInfo, + operation: String, + onRefresh: () -> Unit, + onClearUsage: (String) -> Unit, + onFailureSnackbar: (String) -> Unit, + ) { + runningOperationContainer = container.name + val logs = logsFor(container.name) + logs.clear() + prefsManager.clearContainerLogs(container.name) + showLogViewerFor = container.name + + val logger = ViewModelLogger { level, message -> logs.add(level to message) }.apply { verbose = true } + + try { + if (operation == "stop" || operation == "restart") { + appContext.startService( + Intent(appContext, TerminalSessionService::class.java).apply { + action = TerminalSessionService.ACTION_STOP_CONTAINER_SESSIONS + putExtra(TerminalSessionService.EXTRA_CONTAINER_NAME, container.name) + } + ) + onClearUsage(container.name) + } + + val command = when (operation) { + "start" -> ContainerCommandBuilder.buildStartCommand(container) + "stop" -> ContainerCommandBuilder.buildStopCommand(container) + "restart" -> ContainerCommandBuilder.buildRestartCommand(container) + else -> { + runningOperationContainer = null + return + } + } + + val success = ContainerOperationExecutor.executeCommand( + command = command, + operation = operation, + logger = logger, + operationCompletedMessage = string(R.string.operation_completed_success) + ) + + if (!success) { + lastErrorContainer = container.name + logger.e("") + logger.e(string(R.string.operation_failed)) + onFailureSnackbar(string(R.string.failure_in_operation, operation, container.name)) + onRefresh() + SystemInfoManager.refreshSELinuxStatus() + } else { + lastErrorContainer = null + onRefresh() + SystemInfoManager.refreshSELinuxStatus() + } + } catch (e: Exception) { + logger.e("Error: ${e.message}") + logger.e(e.stackTraceToString()) + lastErrorContainer = container.name + onFailureSnackbar(string(R.string.failure_in_operation, operation, container.name)) + onRefresh() + } finally { + prefsManager.saveContainerLogs(container.name, logs.toList()) + delay(500) + runningOperationContainer = null + } + } + + // ── Sparse migrate / resize ───────────────────────────────────────────────── + suspend fun executeSparseOperation( + operation: SparseOperation, + sizeGb: Int, + onRefresh: () -> Unit, + ) { + val container = when (operation) { + is SparseOperation.Migrate -> operation.container + is SparseOperation.Resize -> operation.container + } + + runningOperationContainer = container.name + val logs = logsFor(container.name) + logs.clear() + prefsManager.clearContainerLogs(container.name) + + val logger = ViewModelLogger { level, message -> logs.add(level to message) }.apply { verbose = true } + + var scriptFile: File? = null + try { + val isRunning = ContainerManager.checkContainerStatus(container.name).first + if (isRunning) { + uninstallState = UninstallState.InProgress(container.name, string(R.string.stopping_container)) + val stopCommand = ContainerCommandBuilder.buildStopCommand(container) + val stopResult = ContainerOperationExecutor.executeCommand(stopCommand, "stop", logger) + uninstallState = UninstallState.Idle + if (!stopResult) { + logger.e(string(R.string.failed_to_stop_container, container.name)) + return + } + delay(1000) + } + + logs.clear() + showLogViewerFor = container.name + + val deployedFile = File("${appContext.cacheDir}/sparsemgr.sh") + scriptFile = deployedFile + appContext.assets.open("sparsemgr.sh").use { input -> + deployedFile.outputStream().use { output: OutputStream -> input.copyTo(output) } + } + Shell.cmd("chmod 755 \"${deployedFile.absolutePath}\"").exec() + + val baseDir = ContainerManager.getContainerDirectory(container.name) + val cmd = when (operation) { + is SparseOperation.Migrate -> { + logger.i(string(R.string.starting_migration)) + "\"${deployedFile.absolutePath}\" -d \"$baseDir\" migrate $sizeGb" + } + is SparseOperation.Resize -> { + logger.i(string(R.string.starting_resizing)) + val imgPath = ContainerManager.getSparseImagePath(container.name) + "\"${deployedFile.absolutePath}\" -i \"$imgPath\" resize $sizeGb --yes" + } + } + + val success = ContainerOperationExecutor.executeCommand( + command = cmd, + operation = "sparse_op", + logger = logger, + skipHeader = true, + operationCompletedMessage = string(R.string.operation_completed_success) + ) + + if (success) { + logger.i("Updating container configuration...") + val updatedConfig = if (operation is SparseOperation.Migrate) { + container.copy( + useSparseImage = true, + sparseImageSizeGB = sizeGb, + rootfsPath = if (container.rootfsPath.endsWith(".img")) container.rootfsPath else "${container.rootfsPath}.img" + ) + } else { + container.copy(sparseImageSizeGB = sizeGb) + } + val configResult = withContext(Dispatchers.IO) { + ContainerManager.updateContainerConfig(appContext, container.name, updatedConfig) + } + if (configResult.isSuccess) { + logger.i("Configuration updated successfully") + onRefresh() + } else { + logger.w("Warning: Failed to update container.config: ${configResult.exceptionOrNull()?.message}") + } + } else { + logger.e(string(R.string.operation_failed)) + } + } catch (e: Exception) { + logger.e("Error during sparse operation: ${e.message}") + logger.e(e.stackTraceToString()) + } finally { + scriptFile?.delete() + prefsManager.saveContainerLogs(container.name, logs.toList()) + delay(500) + runningOperationContainer = null + } + } +} diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/ContainerUsageViewModel.kt b/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/ContainerUsageViewModel.kt index be72846f..f743a506 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/ContainerUsageViewModel.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/ContainerUsageViewModel.kt @@ -9,10 +9,14 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.droidspaces.app.util.ContainerUsageCollector +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext /** * ViewModel for tracking real-time container usage statistics. @@ -49,20 +53,27 @@ class ContainerUsageViewModel(application: Application) : AndroidViewModel(appli // Start new monitoring loop updateJob = viewModelScope.launch(Dispatchers.IO) { while (true) { - // Collect usage for all monitored containers in parallel - containerNames.forEach { containerName -> - launch { - try { - val usage = ContainerUsageCollector.collectUsage(containerName) - _usageStats[containerName] = usage - } catch (e: Exception) { - Log.e(TAG, "Failed to collect usage for $containerName", e) + // Collect usage for all monitored containers concurrently, but AWAIT the + // whole tick before sleeping — so slow collectors can't let successive + // ticks pile up an unbounded number of in-flight child coroutines. + val results = coroutineScope { + containerNames.map { containerName -> + async { + try { + containerName to ContainerUsageCollector.collectUsage(containerName) + } catch (e: Exception) { + Log.e(TAG, "Failed to collect usage for $containerName", e) + null + } } - } + }.awaitAll() } - // Remove stats for containers that are no longer monitored - _usageStats.keys.removeAll { it !in containerNames } + // Compose snapshot state must be mutated on the main thread. + withContext(Dispatchers.Main) { + results.filterNotNull().forEach { (name, usage) -> _usageStats[name] = usage } + _usageStats.keys.removeAll { it !in containerNames } + } // Wait before next update delay(UPDATE_INTERVAL_MS) diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/ContainerViewModel.kt b/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/ContainerViewModel.kt index 6040dc48..649612ee 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/ContainerViewModel.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/ContainerViewModel.kt @@ -12,7 +12,7 @@ import com.droidspaces.app.util.ContainerInfo import com.droidspaces.app.util.ContainerOSInfoManager import com.droidspaces.app.util.ContainerManager import com.droidspaces.app.util.PreferencesManager -import com.droidspaces.app.util.Constants +import com.droidspaces.app.util.ContainerRuntime import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch @@ -171,8 +171,7 @@ class ContainerViewModel(application: Application) : AndroidViewModel(applicatio try { withContext(Dispatchers.IO) { Log.i(TAG, "Executing manual scan...") - val command = "${Constants.getDroidspacesCommand()} scan" - com.topjohnwu.superuser.Shell.cmd(command).exec() + ContainerRuntime.scan() // Fetch new list after scan val result = ContainerManager.listContainers() @@ -195,8 +194,7 @@ class ContainerViewModel(application: Application) : AndroidViewModel(applicatio withContext(Dispatchers.IO) { try { Log.i(TAG, "Executing silent background scan...") - val command = "${Constants.getDroidspacesCommand()} scan" - com.topjohnwu.superuser.Shell.cmd(command).exec() + ContainerRuntime.scan() // Fetch new list and update state val result = ContainerManager.listContainers() diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/RootfsRepoViewModel.kt b/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/RootfsRepoViewModel.kt index a948a5a4..05842875 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/RootfsRepoViewModel.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/RootfsRepoViewModel.kt @@ -130,20 +130,30 @@ class RootfsRepoViewModel(application: Application) : AndroidViewModel(applicati fun startDownload(asset: RootfsAsset) { if (downloadStates[asset.downloadUrl] is AssetDownloadState.Downloading) return val ctx = getApplication() - downloadJobs[asset.downloadUrl]?.cancel() + val url = asset.downloadUrl + downloadJobs[url]?.cancel() val downloadId = RootfsDownloadManager.enqueue(ctx, asset) - downloadIds[asset.downloadUrl] = downloadId - downloadJobs[asset.downloadUrl] = viewModelScope.launch { + downloadIds[url] = downloadId + lateinit var job: Job + job = viewModelScope.launch { RootfsDownloadManager.pollFlow(ctx, asset, downloadId).collect { status -> downloadStates = downloadStates.toMutableMap().apply { - put(asset.downloadUrl, when (status) { + put(url, when (status) { is DownloadStatus.Progress -> AssetDownloadState.Downloading(status.percent) is DownloadStatus.Completed -> AssetDownloadState.Done(status.fileUri) is DownloadStatus.Failed -> AssetDownloadState.Failed(status.reason) }) } } + // Poll completed normally (terminal status reached, not cancelled): drop this + // job's bookkeeping so the maps don't grow unbounded across repeated downloads. + // Guard on identity so a concurrent restart's entry is never removed. + if (downloadJobs[url] === job) { + downloadJobs.remove(url) + downloadIds.remove(url) + } } + downloadJobs[url] = job } fun cancelDownload(asset: RootfsAsset) { @@ -172,4 +182,12 @@ class RootfsRepoViewModel(application: Application) : AndroidViewModel(applicati fun getCustomRepos(): List> = PreferencesManager.getInstance(getApplication()).getCustomRepos() + + override fun onCleared() { + super.onCleared() + // viewModelScope cancels the poll coroutines; drop the bookkeeping too. + downloadJobs.values.forEach { it.cancel() } + downloadJobs.clear() + downloadIds.clear() + } } diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/SystemStatsViewModel.kt b/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/SystemStatsViewModel.kt index 07730894..8ab04d8d 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/SystemStatsViewModel.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/viewmodel/SystemStatsViewModel.kt @@ -2,132 +2,79 @@ package com.droidspaces.app.ui.viewmodel import android.app.Application import android.util.Log -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue import androidx.compose.runtime.mutableStateMapOf import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.viewModelScope -import androidx.lifecycle.ProcessLifecycleOwner -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import com.droidspaces.app.util.AndroidSystemStatsCollector import com.droidspaces.app.util.ContainerInfo import com.droidspaces.app.util.ContainerOSInfoManager import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.delay -import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +/** + * Holds per-container stats and exposes the polling loop as a suspend function. + * The loop is NOT self-managed here: the screen drives it via + * repeatOnLifecycle(STARTED), so polling runs only while the Panel tab is + * actually on screen AND the app is in the foreground, and is cancelled + * structurally when either stops being true. This avoids the old + * ProcessLifecycleOwner observer, which stopped on background but never + * restarted on return (polling stayed dead until a tab switch re-composed it). + */ class SystemStatsViewModel(application: Application) : AndroidViewModel(application) { - private var isAppInForeground = true - private val lifecycleObserver = LifecycleEventObserver { _, event -> - when (event) { - Lifecycle.Event.ON_START -> { - isAppInForeground = true - } - Lifecycle.Event.ON_STOP -> { - isAppInForeground = false - stopMonitoring() // Shutdown polling when app backgrounded - } - else -> {} - } - } - - init { - // Observe app-level lifecycle to stop polling in background - ProcessLifecycleOwner.get().lifecycle.addObserver(lifecycleObserver) - } - companion object { private const val TAG = "SystemStatsViewModel" - private const val SYSTEM_INTERVAL_MS = 2000L private const val CONTAINER_INTERVAL_MS = 2000L } - var systemUsage by mutableStateOf(AndroidSystemStatsCollector.SystemUsage()) - private set - // Per-container OS info (containerName -> OSInfo) — single source of truth for all UI var containerUsageMap = mutableStateMapOf() private set - private var systemJob: Job? = null - private var containerJob: Job? = null - - fun startMonitoring() { - if (!isAppInForeground) return - systemJob?.cancel() - systemJob = viewModelScope.launch(Dispatchers.IO) { - while (true) { - try { - systemUsage = AndroidSystemStatsCollector.collectUsage() - } catch (e: Exception) { - Log.e(TAG, "Failed to collect system usage", e) - } - delay(SYSTEM_INTERVAL_MS) - } - } - } - - fun startContainerMonitoring(containers: List) { - if (!isAppInForeground) return - containerJob?.cancel() + /** + * Poll OS info for every running container until cancelled. Returns + * immediately when nothing is running; the caller re-invokes with a fresh + * list whenever the running set changes. + */ + suspend fun monitorContainers(containers: List) { val running = containers.filter { it.isRunning } if (running.isEmpty()) return - containerJob = viewModelScope.launch(Dispatchers.IO) { - while (true) { - running.forEach { container -> - try { - val isAlive = com.droidspaces.app.util.ContainerManager + while (true) { + running.forEach { container -> + try { + val isAlive = withContext(Dispatchers.IO) { + com.droidspaces.app.util.ContainerManager .checkContainerStatus(container.name).first - if (!isAlive) { - // Kill all terminal sessions for this container - val ctx = getApplication() - ctx.startService( - android.content.Intent(ctx, com.droidspaces.app.service.TerminalSessionService::class.java).apply { - action = com.droidspaces.app.service.TerminalSessionService.ACTION_STOP_CONTAINER_SESSIONS - putExtra(com.droidspaces.app.service.TerminalSessionService.EXTRA_CONTAINER_NAME, container.name) - } - ) - containerUsageMap.remove(container.name) - return@forEach - } - val osInfo = ContainerOSInfoManager.getOSInfo( + } + if (!isAlive) { + // Kill all terminal sessions for this container + val ctx = getApplication() + ctx.startService( + android.content.Intent(ctx, com.droidspaces.app.service.TerminalSessionService::class.java).apply { + action = com.droidspaces.app.service.TerminalSessionService.ACTION_STOP_CONTAINER_SESSIONS + putExtra(com.droidspaces.app.service.TerminalSessionService.EXTRA_CONTAINER_NAME, container.name) + } + ) + containerUsageMap.remove(container.name) + return@forEach + } + val osInfo = withContext(Dispatchers.IO) { + ContainerOSInfoManager.getOSInfo( containerName = container.name, useCache = false, appContext = getApplication() ) - containerUsageMap[container.name] = osInfo - } catch (e: Exception) { - Log.e(TAG, "Failed to collect info for ${container.name}", e) } + containerUsageMap[container.name] = osInfo + } catch (e: Exception) { + Log.e(TAG, "Failed to collect info for ${container.name}", e) } - delay(CONTAINER_INTERVAL_MS) } + delay(CONTAINER_INTERVAL_MS) } } - fun stopContainerMonitoring() { - containerJob?.cancel() - containerJob = null - } - fun clearContainerUsage(containerName: String) { containerUsageMap.remove(containerName) } - - fun stopMonitoring() { - systemJob?.cancel() - systemJob = null - stopContainerMonitoring() - } - - override fun onCleared() { - super.onCleared() - ProcessLifecycleOwner.get().lifecycle.removeObserver(lifecycleObserver) - stopMonitoring() - } } diff --git a/Android/app/src/main/java/com/droidspaces/app/util/AndroidSystemStatsCollector.kt b/Android/app/src/main/java/com/droidspaces/app/util/AndroidSystemStatsCollector.kt deleted file mode 100644 index 0acd638c..00000000 --- a/Android/app/src/main/java/com/droidspaces/app/util/AndroidSystemStatsCollector.kt +++ /dev/null @@ -1,101 +0,0 @@ -package com.droidspaces.app.util - -import android.util.Log -import com.topjohnwu.superuser.Shell -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -/** Collects real-time Android system statistics from /proc on the host. */ -object AndroidSystemStatsCollector { - private const val TAG = "AndroidSystemStatsCollector" - - data class SystemUsage( - val cpuPercent: Double = 0.0, - val ramPercent: Double = 0.0, - val activeRamKb: Long = 0, - val totalRamKb: Long = 0, - val temperature: String = "N/A" - ) - - private var prevCpuTotal = 0L - private var prevCpuIdle = 0L - - suspend fun collectUsage(): SystemUsage = withContext(Dispatchers.IO) { - try { - val (ramPercent, activeRam, totalRam) = getDetailedRamUsage() - val cpuPercent = getCpuUsage() - val temperature = getTemperature() - SystemUsage(cpuPercent, ramPercent, activeRam, totalRam, temperature) - } catch (e: Exception) { - Log.e(TAG, "Failed to collect system usage", e) - SystemUsage() - } - } - - private suspend fun getCpuUsage(): Double = withContext(Dispatchers.IO) { - try { - val result = Shell.cmd("cat /proc/stat | head -1").exec() - if (result.isSuccess && result.out.isNotEmpty()) { - val parts = result.out[0].trim().split("\\s+".toRegex()) - if (parts.size >= 8) { - val user = parts[1].toLongOrNull() ?: 0L - val nice = parts[2].toLongOrNull() ?: 0L - val system = parts[3].toLongOrNull() ?: 0L - val idle = parts[4].toLongOrNull() ?: 0L - val iowait = parts[5].toLongOrNull() ?: 0L - val total = user + nice + system + idle + iowait - if (prevCpuTotal > 0 && total > prevCpuTotal) { - val totalDelta = total - prevCpuTotal - val idleDelta = idle - prevCpuIdle - val percent = ((totalDelta - idleDelta).toDouble() / totalDelta * 100.0) - .coerceIn(0.0, 100.0) - prevCpuTotal = total; prevCpuIdle = idle - return@withContext percent - } - prevCpuTotal = total; prevCpuIdle = idle - } - } - } catch (e: Exception) { Log.e(TAG, "Error getting CPU usage", e) } - 0.0 - } - - private suspend fun getDetailedRamUsage(): Triple = withContext(Dispatchers.IO) { - try { - val result = Shell.cmd("cat /proc/meminfo | grep -E 'MemTotal|MemAvailable'").exec() - if (result.isSuccess && result.out.size >= 2) { - var memTotal = 0L; var memAvailable = 0L - result.out.forEach { line -> - val parts = line.trim().split("\\s+".toRegex()) - if (parts.size >= 2) when { - line.contains("MemTotal") -> memTotal = parts[1].toLongOrNull() ?: 0L - line.contains("MemAvailable") -> memAvailable = parts[1].toLongOrNull() ?: 0L - } - } - if (memTotal > 0) { - val memUsed = memTotal - memAvailable - val percent = (memUsed.toDouble() / memTotal * 100.0).coerceIn(0.0, 100.0) - return@withContext Triple(percent, memUsed, memTotal) - } - } - } catch (e: Exception) { Log.e(TAG, "Error getting RAM usage", e) } - Triple(0.0, 0L, 0L) - } - - private suspend fun getTemperature(): String = withContext(Dispatchers.IO) { - val paths = listOf( - "/sys/class/thermal/thermal_zone0/temp", - "/sys/class/thermal/thermal_zone1/temp", - "/sys/devices/virtual/thermal/thermal_zone0/temp" - ) - for (path in paths) { - try { - val result = Shell.cmd("cat $path 2>/dev/null").exec() - if (result.isSuccess && result.out.isNotEmpty()) { - val mC = result.out[0].trim().toLongOrNull() - if (mC != null && mC > 0) return@withContext String.format("%.1f°C", mC / 1000.0) - } - } catch (_: Exception) {} - } - "N/A" - } -} diff --git a/Android/app/src/main/java/com/droidspaces/app/util/BinaryInstaller.kt b/Android/app/src/main/java/com/droidspaces/app/util/BinaryInstaller.kt index 674848a5..26e39a29 100644 --- a/Android/app/src/main/java/com/droidspaces/app/util/BinaryInstaller.kt +++ b/Android/app/src/main/java/com/droidspaces/app/util/BinaryInstaller.kt @@ -26,16 +26,7 @@ object BinaryInstaller { /** * Map Android architecture to binary name suffix */ - private fun getArchitectureSuffix(): String { - val arch = Build.SUPPORTED_ABIS[0] // Primary ABI - return when { - arch.contains("arm64") || arch.contains("aarch64") -> "aarch64" - arch.contains("armeabi") || arch.contains("arm") -> "armhf" - arch.contains("x86_64") -> "x86_64" - arch.contains("x86") -> "x86" - else -> "aarch64" // Default to aarch64 - } - } + private fun getArchitectureSuffix(): String = DeviceArch.suffix() /** * Get droidspaces binary name for architecture @@ -55,16 +46,7 @@ object BinaryInstaller { /** * Get human-readable architecture name */ - fun getArchitectureName(): String { - val arch = Build.SUPPORTED_ABIS[0] - return when { - arch.contains("arm64") || arch.contains("aarch64") -> "ARM64 (aarch64)" - arch.contains("armeabi") || arch.contains("arm") -> "ARM (armhf)" - arch.contains("x86_64") -> "x86_64" - arch.contains("x86") -> "x86" - else -> arch - } - } + fun getArchitectureName(): String = DeviceArch.displayName() /** * Install droidspaces binary with progress updates diff --git a/Android/app/src/main/java/com/droidspaces/app/util/Constants.kt b/Android/app/src/main/java/com/droidspaces/app/util/Constants.kt index 00de26c4..902aea0a 100644 --- a/Android/app/src/main/java/com/droidspaces/app/util/Constants.kt +++ b/Android/app/src/main/java/com/droidspaces/app/util/Constants.kt @@ -22,6 +22,9 @@ object Constants { const val DAEMON_MODE_FILE = "/data/local/Droidspaces/.daemon_mode" const val DAEMON_PID_FILE = "/data/local/Droidspaces/droidspacesd.pid" const val CONTAINER_CONFIG_FILE = "container.config" + // Default network mode — the single source of truth for both the ContainerInfo + // model default and the config parser default (they used to disagree). + const val DEFAULT_NET_MODE = "nat" // Preferences keys const val PREFS_NAME = "droidspaces_prefs" diff --git a/Android/app/src/main/java/com/droidspaces/app/util/ContainerCommandBuilder.kt b/Android/app/src/main/java/com/droidspaces/app/util/ContainerCommandBuilder.kt index 8268d15a..c0904f33 100644 --- a/Android/app/src/main/java/com/droidspaces/app/util/ContainerCommandBuilder.kt +++ b/Android/app/src/main/java/com/droidspaces/app/util/ContainerCommandBuilder.kt @@ -58,14 +58,6 @@ object ContainerCommandBuilder { return "$DROIDSPACES_BINARY_PATH --config=${quote(getConfigPath(container))} restart" } - /** - * Build uptime command for a container. - */ - @Deprecated("Use buildUsageCommand instead for synchronized metrics", ReplaceWith("buildUsageCommand(containerName)")) - fun buildUptimeCommand(containerName: String): String { - return "$DROIDSPACES_BINARY_PATH --name=${quote(containerName)} uptime" - } - /** * Build unified usage command for a container (UPTIME, RAM, CPU). */ diff --git a/Android/app/src/main/java/com/droidspaces/app/util/ContainerConfigState.kt b/Android/app/src/main/java/com/droidspaces/app/util/ContainerConfigState.kt new file mode 100644 index 00000000..2de58003 --- /dev/null +++ b/Android/app/src/main/java/com/droidspaces/app/util/ContainerConfigState.kt @@ -0,0 +1,114 @@ +package com.droidspaces.app.util + +/** + * Immutable holder for the ~28 editable container-configuration fields that the + * Create wizard ([com.droidspaces.app.ui.screen.ContainerConfigScreen]) and the + * Edit screen ([com.droidspaces.app.ui.screen.EditContainerScreen]) both drive + * through the shared [com.droidspaces.app.ui.component.ContainerConfigForm]. + * + * Replaces the four hand-maintained copies of this field list that previously + * lived as: editable `var`s, parallel `saved*` `var`s, a 28-arg `onNext` + * callback, and a 28-arg `setConfig`. A single data class means `hasChanges` + * is just `current != saved`, and config plumbing can never transpose fields. + * + * Note: `name`, `hostname`, `rootfsPath`, sparse-image and runtime fields are + * NOT part of this state — they are owned by the surrounding screens/ViewModel. + */ +data class ContainerConfigState( + val netMode: String = "nat", + val disableIPv6: Boolean = false, + val enableAndroidStorage: Boolean = false, + val enableHwAccess: Boolean = false, + val enableGpuMode: Boolean = false, + val enableTermuxX11: Boolean = false, + val tx11ExtraFlags: String = "", + val enableVirgl: Boolean = false, + val virglExtraFlags: String = "", + val enablePulseaudio: Boolean = false, + val selinuxPermissive: Boolean = false, + val allowUserns: Boolean = false, + val volatileMode: Boolean = false, + val bindMounts: List = emptyList(), + val dnsServers: String = "", + val runAtBoot: Boolean = false, + val customInit: String = "", + val staticNatIp: String = "", + val forceCgroupv1: Boolean = false, + val blockNestedNs: Boolean = false, + val privileged: String = "", + val envFileContent: String = "", + val upstreamInterfaces: List = emptyList(), + val portForwards: List = emptyList(), + val gatewayContainer: String = "", + val gatewayNet: String = "", + val gatewayIface: String = "", + val gatewayBridge: String = "", +) + +/** Extract the editable config fields from an existing container. */ +fun ContainerInfo.toConfigState(): ContainerConfigState = ContainerConfigState( + netMode = netMode, + disableIPv6 = disableIPv6, + enableAndroidStorage = enableAndroidStorage, + enableHwAccess = enableHwAccess, + enableGpuMode = enableGpuMode, + enableTermuxX11 = enableTermuxX11, + tx11ExtraFlags = tx11ExtraFlags, + enableVirgl = enableVirgl, + virglExtraFlags = virglExtraFlags, + enablePulseaudio = enablePulseaudio, + selinuxPermissive = selinuxPermissive, + allowUserns = allowUserns, + volatileMode = volatileMode, + bindMounts = bindMounts, + dnsServers = dnsServers, + runAtBoot = runAtBoot, + customInit = customInit, + staticNatIp = staticNatIp, + forceCgroupv1 = forceCgroupv1, + blockNestedNs = blockNestedNs, + privileged = privileged, + envFileContent = envFileContent ?: "", + upstreamInterfaces = upstreamInterfaces, + portForwards = portForwards, + gatewayContainer = gatewayContainer, + gatewayNet = gatewayNet, + gatewayIface = gatewayIface, + gatewayBridge = gatewayBridge, +) + +/** + * Return a copy of this container with the editable config fields replaced by + * [state]. Non-config fields (name, rootfsPath, status, sparse image, + * runAtBootPriority, uuid, …) are preserved. + */ +fun ContainerInfo.withConfig(state: ContainerConfigState): ContainerInfo = copy( + netMode = state.netMode, + disableIPv6 = state.disableIPv6, + enableAndroidStorage = state.enableAndroidStorage, + enableHwAccess = state.enableHwAccess, + enableGpuMode = state.enableGpuMode, + enableTermuxX11 = state.enableTermuxX11, + tx11ExtraFlags = state.tx11ExtraFlags, + enableVirgl = state.enableVirgl, + virglExtraFlags = state.virglExtraFlags, + enablePulseaudio = state.enablePulseaudio, + selinuxPermissive = state.selinuxPermissive, + allowUserns = state.allowUserns, + volatileMode = state.volatileMode, + bindMounts = state.bindMounts, + dnsServers = state.dnsServers, + runAtBoot = state.runAtBoot, + customInit = state.customInit, + staticNatIp = state.staticNatIp, + forceCgroupv1 = state.forceCgroupv1, + blockNestedNs = state.blockNestedNs, + privileged = state.privileged, + envFileContent = state.envFileContent.ifBlank { null }, + upstreamInterfaces = state.upstreamInterfaces, + portForwards = state.portForwards, + gatewayContainer = state.gatewayContainer, + gatewayNet = state.gatewayNet, + gatewayIface = state.gatewayIface, + gatewayBridge = state.gatewayBridge, +) diff --git a/Android/app/src/main/java/com/droidspaces/app/util/ContainerDiskUsageManager.kt b/Android/app/src/main/java/com/droidspaces/app/util/ContainerDiskUsageManager.kt new file mode 100644 index 00000000..1fa01522 --- /dev/null +++ b/Android/app/src/main/java/com/droidspaces/app/util/ContainerDiskUsageManager.kt @@ -0,0 +1,54 @@ +package com.droidspaces.app.util + +import com.topjohnwu.superuser.Shell +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Reports the on-disk footprint of a sparse-image container's rootfs.img. + * + * For a sparse file the apparent size (e.g. 32 GB) is much larger than the blocks + * actually allocated on the host (e.g. 5.6 GB). A single `stat -c '%s %b'` gives both: + * - %s = apparent size in bytes -> total + * - %b = allocated 512-byte blocks -> used = %b * 512 + * + * This is the storage the image currently occupies on the device. Note it reflects + * host-allocated blocks, which only grow: deleting files inside the guest will not + * shrink it unless the guest issues fstrim/discard. Works whether the container is + * running or not - no mount required. + * + * Always uses our bundled busybox (pinned version) rather than the system `stat`/toybox, + * so the `%s %b` output is reproducible across devices regardless of the OEM userspace. + */ +object ContainerDiskUsageManager { + + /** Allocated 512-byte blocks are always 512 bytes regardless of filesystem block size. */ + private const val STAT_BLOCK_SIZE = 512L + + data class DiskUsage(val usedBytes: Long, val totalBytes: Long) + + /** Last measured value per image path - lets a re-opened screen paint instantly. */ + private val lastKnown = HashMap() + + /** Last value measured for [imgPath] in this process, or null if never measured. */ + fun getCached(imgPath: String): DiskUsage? = synchronized(lastKnown) { lastKnown[imgPath] } + + /** + * @param imgPath path to the rootfs.img (use [ContainerInfo.rootfsPath] for a sparse container). + * @return used/total in bytes, or null if the image could not be stat'd. + */ + suspend fun getUsage(imgPath: String): DiskUsage? = withContext(Dispatchers.IO) { + val cmd = "${Constants.BUSYBOX_BINARY_PATH} stat -c '%s %b' ${ContainerCommandBuilder.quote(imgPath)} 2>/dev/null" + val result = Shell.cmd(cmd).exec() + if (!result.isSuccess) return@withContext null + + val parts = result.out.firstOrNull()?.trim()?.split(Regex("\\s+")) ?: return@withContext null + val total = parts.getOrNull(0)?.toLongOrNull() ?: return@withContext null + val blocks = parts.getOrNull(1)?.toLongOrNull() ?: return@withContext null + if (total <= 0L) return@withContext null + + DiskUsage(usedBytes = blocks * STAT_BLOCK_SIZE, totalBytes = total).also { + synchronized(lastKnown) { lastKnown[imgPath] = it } + } + } +} diff --git a/Android/app/src/main/java/com/droidspaces/app/util/ContainerInstaller.kt b/Android/app/src/main/java/com/droidspaces/app/util/ContainerInstaller.kt index 21b7831b..1c1b75b7 100644 --- a/Android/app/src/main/java/com/droidspaces/app/util/ContainerInstaller.kt +++ b/Android/app/src/main/java/com/droidspaces/app/util/ContainerInstaller.kt @@ -37,6 +37,12 @@ object ContainerInstaller { var createdPaths = mutableListOf() try { + // Reject control chars in single-line config values (VULN V11). + ValidationUtils.validateConfigValues(config).errorMessage?.let { + logger.e(it) + return@withContext Result.failure(Exception(it)) + } + // Step 1: Check storage space logger.i("Checking available storage space...") val freeGB = StorageChecker.getFreeSpaceGB() @@ -76,6 +82,10 @@ object ContainerInstaller { logger.i("Tarball copied: ${tempTarball.absolutePath}") + // Step 3.5: Verify the tarball is actually a Linux rootfs before we + // extract anything, so users can't install arbitrary archives. + validateRootfsTarball(context, tempTarball, logger) + // Step 4: Extract tarball (either to directory or sparse image) if (config.useSparseImage) { SparseImageInstaller.extract( @@ -242,6 +252,64 @@ object ContainerInstaller { + /** + * Inspect the tarball (without extracting) to confirm it contains a Linux + * rootfs, so users can't install arbitrary archives (photo backups, source + * zips, etc.). Throws on a confirmed-invalid archive; a validator that fails + * to load is treated as non-fatal (warn and continue). + */ + private suspend fun validateRootfsTarball( + context: Context, + tarball: File, + logger: ContainerLogger + ) { + logger.i("Inspecting tarball to verify it is a Linux rootfs...") + + // Copy validator script from assets + val scriptFile = File("${context.cacheDir}/validate_rootfs.sh") + try { + context.assets.open("validate_rootfs.sh").use { inputStream -> + FileOutputStream(scriptFile).use { outputStream -> + inputStream.copyTo(outputStream) + } + } + } catch (e: Exception) { + // Fail CLOSED: if the validator itself can't be loaded, do not install an + // unverified rootfs — it is later run as root. See FINDINGS_APP_VULN V12. + logger.e("Failed to load rootfs validator: ${e.message}") + throw Exception("Could not verify rootfs: validator unavailable (${e.message})") + } + + try { + // Make script executable + val chmodResult = Shell.cmd("chmod 755 \"${scriptFile.absolutePath}\" 2>&1").exec() + if (!chmodResult.isSuccess) { + // Fail CLOSED — see FINDINGS_APP_VULN V12. + logger.e("Failed to make rootfs validator executable") + throw Exception("Could not verify rootfs: validator not executable") + } + + val result = Shell.cmd( + "BUSYBOX_PATH=$BUSYBOX_PATH \"${scriptFile.absolutePath}\" \"${tarball.absolutePath}\" 2>&1" + ).exec() + + if (!result.isSuccess) { + val reason = result.out + .map { it.trim() } + .firstOrNull { it.isNotEmpty() } + ?: "selected file does not look like a Linux rootfs" + logger.e(reason) + throw Exception(reason) + } + } finally { + try { + scriptFile.delete() + } catch (e: Exception) { + logger.w("Warning: Failed to clean up validator script: ${e.message}") + } + } + } + /** * Apply post-extraction fixes to the rootfs (both sparse and directory modes). */ @@ -315,7 +383,7 @@ object ContainerInstaller { private suspend fun cleanup(paths: List, logger: ContainerLogger) { paths.reversed().forEach { path -> try { - val result = Shell.cmd("rm -rf $path 2>&1").exec() + val result = Shell.cmd("rm -rf ${ContainerCommandBuilder.quote(path)} 2>&1").exec() if (result.isSuccess) { logger.d("Cleaned up: $path") } else { diff --git a/Android/app/src/main/java/com/droidspaces/app/util/ContainerManager.kt b/Android/app/src/main/java/com/droidspaces/app/util/ContainerManager.kt index 267d5127..029ee473 100644 --- a/Android/app/src/main/java/com/droidspaces/app/util/ContainerManager.kt +++ b/Android/app/src/main/java/com/droidspaces/app/util/ContainerManager.kt @@ -29,7 +29,7 @@ data class ContainerInfo( val name: String, val hostname: String, val rootfsPath: String, - val netMode: String = "nat", + val netMode: String = Constants.DEFAULT_NET_MODE, val disableIPv6: Boolean = false, val enableAndroidStorage: Boolean = false, val enableHwAccess: Boolean = false, @@ -40,10 +40,12 @@ data class ContainerInfo( val virglExtraFlags: String = "", val enablePulseaudio: Boolean = false, val selinuxPermissive: Boolean = false, + val allowUserns: Boolean = false, val volatileMode: Boolean = false, val bindMounts: List = emptyList(), val dnsServers: String = "", val runAtBoot: Boolean = false, + val runAtBootPriority: Int = 0, val status: ContainerStatus = ContainerStatus.STOPPED, val pid: Int? = null, val useSparseImage: Boolean = false, @@ -54,6 +56,10 @@ data class ContainerInfo( val forceCgroupv1: Boolean = false, val blockNestedNs: Boolean = false, val staticNatIp: String = "", + val gatewayContainer: String = "", + val gatewayNet: String = "", + val gatewayIface: String = "", + val gatewayBridge: String = "", val privileged: String = "", val customInit: String = "", val uuid: String = "" @@ -79,6 +85,7 @@ data class ContainerInfo( if (virglExtraFlags.isNotBlank()) appendLine("virgl_extra_flags=$virglExtraFlags") appendLine("enable_pulseaudio=${if (enablePulseaudio) "1" else "0"}") appendLine("selinux_permissive=${if (selinuxPermissive) "1" else "0"}") + appendLine("allow_userns=${if (allowUserns) "1" else "0"}") appendLine("volatile_mode=${if (volatileMode) "1" else "0"}") if (bindMounts.isNotEmpty()) { appendLine("bind_mounts=${bindMounts.joinToString(",") { "${it.src}:${it.dest}${if (it.ro) ":ro" else ""}" }}") @@ -96,11 +103,22 @@ data class ContainerInfo( appendLine("dns_servers=$dnsServers") } appendLine("run_at_boot=${if (runAtBoot) "1" else "0"}") + if (runAtBoot && runAtBootPriority > 0) { + appendLine("run_at_boot_priority=$runAtBootPriority") + } appendLine("force_cgroupv1=${if (forceCgroupv1) "1" else "0"}") appendLine("block_nested_ns=${if (blockNestedNs) "1" else "0"}") if (netMode == "nat" && staticNatIp.isNotEmpty()) { appendLine("static_nat_ip=$staticNatIp") } + // Gateway-mode keys (the C runtime parses gateway_lan_ifname, not gateway_iface). + // gateway_container is required in gateway mode; the rest are optional overrides. + if (netMode == "gateway") { + if (gatewayContainer.isNotBlank()) appendLine("gateway_container=$gatewayContainer") + if (gatewayNet.isNotBlank()) appendLine("gateway_net=$gatewayNet") + if (gatewayIface.isNotBlank()) appendLine("gateway_lan_ifname=$gatewayIface") + if (gatewayBridge.isNotBlank()) appendLine("gateway_bridge=$gatewayBridge") + } appendLine("use_sparse_image=${if (useSparseImage) "1" else "0"}") if (sparseImageSizeGB != null) { appendLine("sparse_image_size_gb=$sparseImageSizeGB") @@ -219,7 +237,7 @@ object ContainerManager { private fun loadContainerConfig(configPath: String, defaultName: String): ContainerInfo? { try { // Read config file using shell (quoted for safety) - val readResult = Shell.cmd("cat \"$configPath\" 2>/dev/null").exec() + val readResult = Shell.cmd("cat ${ContainerCommandBuilder.quote(configPath)} 2>/dev/null").exec() if (!readResult.isSuccess || readResult.out.isEmpty()) { return null @@ -255,6 +273,12 @@ object ContainerManager { // Build ContainerInfo from config val containerName = configMap["name"] ?: defaultName + // Drop a container whose on-disk name carries shell metacharacters before + // it can reach a root command (VULN V10). Over-length-but-safe names still load. + if (!ValidationUtils.isSafeContainerName(containerName)) { + android.util.Log.w("ContainerManager", "Skipping container with unsafe name in config") + return null + } val useSparseImage = configMap["use_sparse_image"] == "1" val sparseImageSizeGB = configMap["sparse_image_size_gb"]?.toIntOrNull() @@ -294,7 +318,7 @@ object ContainerManager { } else { getRootfsPath(containerName) }, - netMode = configMap["net_mode"] ?: "host", + netMode = configMap["net_mode"] ?: Constants.DEFAULT_NET_MODE, disableIPv6 = configMap["disable_ipv6"] == "1", enableAndroidStorage = configMap["enable_android_storage"] == "1", enableHwAccess = configMap["enable_hw_access"] == "1", @@ -305,10 +329,12 @@ object ContainerManager { virglExtraFlags = configMap["virgl_extra_flags"] ?: "", enablePulseaudio = configMap["enable_pulseaudio"] == "1", selinuxPermissive = configMap["selinux_permissive"] == "1", + allowUserns = configMap["allow_userns"] == "1", volatileMode = configMap["volatile_mode"] == "1", bindMounts = bindMounts, dnsServers = configMap["dns_servers"] ?: "", runAtBoot = configMap["run_at_boot"] == "1", + runAtBootPriority = configMap["run_at_boot_priority"]?.toIntOrNull() ?: 0, status = ContainerStatus.STOPPED, useSparseImage = useSparseImage, sparseImageSizeGB = sparseImageSizeGB, @@ -318,6 +344,10 @@ object ContainerManager { forceCgroupv1 = configMap["force_cgroupv1"] == "1", blockNestedNs = configMap["block_nested_ns"] == "1", staticNatIp = configMap["static_nat_ip"] ?: "", + gatewayContainer = configMap["gateway_container"] ?: "", + gatewayNet = configMap["gateway_net"] ?: "", + gatewayIface = configMap["gateway_lan_ifname"] ?: "", + gatewayBridge = configMap["gateway_bridge"] ?: "", privileged = configMap["privileged"] ?: "", customInit = configMap["custom_init"] ?: "", uuid = configMap["uuid"] ?: "" @@ -332,7 +362,7 @@ object ContainerManager { */ private fun loadEnvFileContent(containerName: String): String? { val envFilePath = "${getContainerDirectory(containerName)}/.env" - val readResult = Shell.cmd("cat \"$envFilePath\" 2>/dev/null").exec() + val readResult = Shell.cmd("cat ${ContainerCommandBuilder.quote(envFilePath)} 2>/dev/null").exec() return if (readResult.isSuccess && readResult.out.isNotEmpty()) { readResult.out.joinToString("\n") } else { @@ -435,6 +465,10 @@ object ContainerManager { newConfig: ContainerInfo ): Result = withContext(Dispatchers.IO) { try { + // Reject control chars in single-line config values (VULN V11). + ValidationUtils.validateConfigValues(newConfig).errorMessage?.let { + return@withContext Result.failure(Exception(it)) + } val sanitizedName = sanitizeContainerName(containerName) val configPath = "$CONTAINERS_BASE_PATH/$sanitizedName/${Constants.CONTAINER_CONFIG_FILE}" @@ -443,7 +477,7 @@ object ContainerManager { val configToWrite = if (newConfig.uuid.isNotEmpty()) { newConfig } else { - val existingContent = Shell.cmd("cat \"$configPath\" 2>/dev/null").exec() + val existingContent = Shell.cmd("cat ${ContainerCommandBuilder.quote(configPath)} 2>/dev/null").exec() .out.joinToString("\n") val existingUuid = existingContent.lines() .firstOrNull { it.startsWith("uuid=") } @@ -455,12 +489,12 @@ object ContainerManager { // Handle .env file val envFilePath = "${getContainerDirectory(containerName)}/.env" if (newConfig.envFileContent.isNullOrBlank()) { - Shell.cmd("rm -f \"$envFilePath\"").exec() + Shell.cmd("rm -f ${ContainerCommandBuilder.quote(envFilePath)}").exec() } else { val tempEnvFile = File("${context.cacheDir}/.env_${sanitizedName}") tempEnvFile.writeText(newConfig.envFileContent + "\n") - Shell.cmd("cp \"${tempEnvFile.absolutePath}\" \"$envFilePath\"").exec() - Shell.cmd("chmod 644 \"$envFilePath\"").exec() + Shell.cmd("cp ${ContainerCommandBuilder.quote(tempEnvFile.absolutePath)} ${ContainerCommandBuilder.quote(envFilePath)}").exec() + Shell.cmd("chmod 644 ${ContainerCommandBuilder.quote(envFilePath)}").exec() tempEnvFile.delete() } @@ -471,7 +505,7 @@ object ContainerManager { // Copy temp config to final location using shell (root required) // Quote paths to handle spaces and special characters - val copyResult = Shell.cmd("cp \"${tempConfigFile.absolutePath}\" \"$configPath\" 2>&1").exec() + val copyResult = Shell.cmd("cp ${ContainerCommandBuilder.quote(tempConfigFile.absolutePath)} ${ContainerCommandBuilder.quote(configPath)} 2>&1").exec() if (!copyResult.isSuccess) { // Check both stdout and stderr for error messages val errorOutput = (copyResult.out + copyResult.err).joinToString("\n").trim() @@ -481,7 +515,7 @@ object ContainerManager { } // Set proper permissions - val chmodResult = Shell.cmd("chmod 644 \"$configPath\" 2>&1").exec() + val chmodResult = Shell.cmd("chmod 644 ${ContainerCommandBuilder.quote(configPath)} 2>&1").exec() if (!chmodResult.isSuccess) { // Non-fatal, but log warning } @@ -566,7 +600,7 @@ object ContainerManager { logger.i("Container path: $containerPath") // Use rm -rf to recursively delete the entire container directory - val deleteCommand = "rm -rf \"$containerPath\" 2>&1" + val deleteCommand = "rm -rf ${ContainerCommandBuilder.quote(containerPath)} 2>&1" logger.i("Executing: $deleteCommand") val deleteResult = Shell.cmd(deleteCommand).exec() @@ -597,7 +631,7 @@ object ContainerManager { // Verify deletion logger.i("") logger.i("Verifying deletion...") - val verifyResult = Shell.cmd("test -d \"$containerPath\" && echo 'exists' || echo 'deleted' 2>&1").exec() + val verifyResult = Shell.cmd("test -d ${ContainerCommandBuilder.quote(containerPath)} && echo 'exists' || echo 'deleted' 2>&1").exec() if (verifyResult.out.any { it.contains("exists") }) { logger.e("Warning: Container directory still exists after deletion attempt!") return@withContext Result.failure(Exception("Container directory still exists after deletion")) diff --git a/Android/app/src/main/java/com/droidspaces/app/util/ContainerOpenRCManager.kt b/Android/app/src/main/java/com/droidspaces/app/util/ContainerOpenRCManager.kt index 117f9ec5..8a4a8948 100644 --- a/Android/app/src/main/java/com/droidspaces/app/util/ContainerOpenRCManager.kt +++ b/Android/app/src/main/java/com/droidspaces/app/util/ContainerOpenRCManager.kt @@ -201,18 +201,28 @@ object ContainerOpenRCManager { }.sortedWith(compareByDescending { it.isRunning }.thenBy { it.name }) } + // Every service name is validated against the shared allow-list + // (ServiceManagerBase) before it is interpolated into the host-root + // `run '...'` payload — see FINDINGS_APP_VULN V2. + private suspend fun runRC(containerName: String, serviceName: String, buildCommand: (String) -> String): CommandResult { + if (!ServiceManagerBase.isSafeServiceName(serviceName)) { + return CommandResult(exitCode = 2, output = emptyList(), error = listOf("Invalid service name: $serviceName")) + } + return executeRCCommand(containerName, buildCommand(serviceName)) + } + suspend fun startService(containerName: String, serviceName: String) = - executeRCCommand(containerName, "rc-service $serviceName start") + runRC(containerName, serviceName) { "rc-service $it start" } suspend fun stopService(containerName: String, serviceName: String) = - executeRCCommand(containerName, "rc-service $serviceName stop") + runRC(containerName, serviceName) { "rc-service $it stop" } suspend fun restartService(containerName: String, serviceName: String) = - executeRCCommand(containerName, "rc-service $serviceName restart") + runRC(containerName, serviceName) { "rc-service $it restart" } suspend fun enableService(containerName: String, serviceName: String) = - executeRCCommand(containerName, "rc-update add $serviceName default") + runRC(containerName, serviceName) { "rc-update add $it default" } suspend fun disableService(containerName: String, serviceName: String) = - executeRCCommand(containerName, "rc-update del $serviceName") + runRC(containerName, serviceName) { "rc-update del $it" } } diff --git a/Android/app/src/main/java/com/droidspaces/app/util/ContainerProcdManager.kt b/Android/app/src/main/java/com/droidspaces/app/util/ContainerProcdManager.kt new file mode 100644 index 00000000..adabe285 --- /dev/null +++ b/Android/app/src/main/java/com/droidspaces/app/util/ContainerProcdManager.kt @@ -0,0 +1,224 @@ +package com.droidspaces.app.util + +import android.content.Context +import android.util.Base64 +import android.util.Log +import com.topjohnwu.superuser.Shell +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException + +/** + * OpenWrt/procd service manager for containers. + * + * Uses a base64-encoded chkprocd.sh helper for service discovery and + * /etc/init.d/ for procd-compatible service actions. + */ +object ContainerProcdManager { + private const val TAG = "ContainerProcdManager" + private const val COMMAND_TIMEOUT_MS = 15_000L + + private val safeActions = setOf("start", "stop", "restart", "reload", "enable", "disable", "status") + + private var scriptBase64: String? = null + + /** + * procd does not expose systemd's masked/static concepts. Runtime state can + * also be unavailable for some init scripts, so UNKNOWN is explicit. + */ + enum class ServiceStatus { + ENABLED_RUNNING, + ENABLED_STOPPED, + DISABLED_STOPPED, + ABNORMAL, + UNKNOWN + } + + data class ServiceInfo( + val name: String, + val description: String, + val status: ServiceStatus, + val isEnabled: Boolean, + val isRunning: Boolean + ) + + enum class ServiceFilter { + RUNNING, + ENABLED, + DISABLED, + ABNORMAL, + UNKNOWN, + ALL + } + + data class CommandResult( + val exitCode: Int, + val output: List, + val error: List + ) { + val isSuccess: Boolean get() = exitCode == 0 + } + + fun initialize(context: Context) { + if (scriptBase64 == null) { + try { + val script = context.assets.open("chkprocd.sh").bufferedReader().readText() + scriptBase64 = Base64.encodeToString(script.toByteArray(), Base64.NO_WRAP) + } catch (e: Exception) { + Log.e(TAG, "Failed to load chkprocd.sh from assets", e) + } + } + } + + private suspend fun runScript(containerName: String, flag: String): Pair> = + withContext(Dispatchers.IO) { + val b64 = scriptBase64 ?: return@withContext Pair(false, emptyList()) + try { + val cmd = "${Constants.DROIDSPACES_BINARY_PATH} --name=${ContainerCommandBuilder.quote(containerName)} run 'echo $b64 | base64 -d | sh -s -- $flag'" + val result = Shell.cmd(cmd).exec() + Pair(result.isSuccess, result.out) + } catch (e: Exception) { + Log.e(TAG, "Error running procd query script", e) + Pair(false, emptyList()) + } + } + + /** + * Check if the container looks like an OpenWrt/procd system. + * + * Do not use /etc/init.d alone: OpenRC and SysV-style systems also expose + * that directory. /etc/openwrt_release is the distro-level discriminator; + * procd/ubus/service binaries confirm that the OpenWrt service stack exists. + */ + suspend fun isProcdAvailable(containerName: String): Boolean = withContext(Dispatchers.IO) { + try { + val cmd = "${Constants.DROIDSPACES_BINARY_PATH} --name=${ContainerCommandBuilder.quote(containerName)} run '[ -f /etc/openwrt_release ] && { [ -x /sbin/procd ] || [ -x /sbin/ubusd ] || command -v ubus >/dev/null 2>&1 || command -v service >/dev/null 2>&1; }'" + Shell.cmd(cmd).exec().isSuccess + } catch (e: Exception) { + Log.e(TAG, "Error checking procd availability", e) + false + } + } + + suspend fun getAllServices(containerName: String): List = withContext(Dispatchers.IO) { + try { + val (success, lines) = runScript(containerName, "--all") + if (!success) return@withContext emptyList() + + lines.mapNotNull { line -> + val parts = line.split("|", limit = 4) + if (parts.size < 4) return@mapNotNull null + + val name = parts[0].trim() + if (!isSafeServiceName(name)) return@mapNotNull null + + val description = parts[1].trim() + val enabled = parts[2].trim() == "enabled" + val runningState = parts[3].trim() + val running = runningState == "running" + + val status = when { + runningState == "unknown" -> ServiceStatus.UNKNOWN + enabled && running -> ServiceStatus.ENABLED_RUNNING + enabled && !running -> ServiceStatus.ENABLED_STOPPED + !enabled && running -> ServiceStatus.ABNORMAL + else -> ServiceStatus.DISABLED_STOPPED + } + + ServiceInfo( + name = name, + description = description, + status = status, + isEnabled = enabled, + isRunning = running + ) + }.sortedBy { it.name } + } catch (e: Exception) { + Log.e(TAG, "Error fetching procd services", e) + emptyList() + } + } + + fun filterServices(services: List, filter: ServiceFilter): List { + return when (filter) { + ServiceFilter.ALL -> services + ServiceFilter.RUNNING -> services.filter { it.isRunning && it.isEnabled } + ServiceFilter.ENABLED -> services.filter { it.isEnabled && !it.isRunning && it.status != ServiceStatus.UNKNOWN } + ServiceFilter.DISABLED -> services.filter { !it.isEnabled && !it.isRunning && it.status != ServiceStatus.UNKNOWN } + ServiceFilter.ABNORMAL -> services.filter { it.isRunning && !it.isEnabled } + ServiceFilter.UNKNOWN -> services.filter { it.status == ServiceStatus.UNKNOWN } + }.sortedWith(compareByDescending { it.isRunning }.thenBy { it.name }) + } + + private fun isSafeServiceName(serviceName: String): Boolean = ServiceManagerBase.isSafeServiceName(serviceName) + + private fun validateServiceAction(serviceName: String, action: String): CommandResult? { + if (!isSafeServiceName(serviceName)) { + return CommandResult( + exitCode = 2, + output = emptyList(), + error = listOf("Invalid OpenWrt service name: $serviceName") + ) + } + if (action !in safeActions) { + return CommandResult( + exitCode = 2, + output = emptyList(), + error = listOf("Unsupported OpenWrt service action: $action") + ) + } + return null + } + + private suspend fun executeProcdCommand( + containerName: String, + serviceName: String, + action: String + ): CommandResult = withContext(Dispatchers.IO) { + validateServiceAction(serviceName, action)?.let { return@withContext it } + + val command = "/etc/init.d/$serviceName $action" + val fullCmd = "${Constants.DROIDSPACES_BINARY_PATH} --name=${ContainerCommandBuilder.quote(containerName)} run '$command 2>&1'" + val executor = Executors.newSingleThreadExecutor() + try { + val future = executor.submit { Shell.cmd(fullCmd).exec() } + try { + val result = future.get(COMMAND_TIMEOUT_MS, TimeUnit.MILLISECONDS) + CommandResult(exitCode = result.code, output = result.out, error = result.err) + } catch (e: TimeoutException) { + future.cancel(true) + Log.e(TAG, "Command timed out: $command") + CommandResult( + exitCode = 124, + output = listOf("Command timed out after ${COMMAND_TIMEOUT_MS / 1000} seconds"), + error = emptyList() + ) + } + } catch (e: Exception) { + Log.e(TAG, "Error executing: $command", e) + CommandResult(exitCode = 1, output = emptyList(), error = listOf(e.message ?: "Unknown error")) + } finally { + executor.shutdownNow() + } + } + + suspend fun startService(containerName: String, serviceName: String) = + executeProcdCommand(containerName, serviceName, "start") + + suspend fun stopService(containerName: String, serviceName: String) = + executeProcdCommand(containerName, serviceName, "stop") + + suspend fun restartService(containerName: String, serviceName: String) = + executeProcdCommand(containerName, serviceName, "restart") + + suspend fun reloadService(containerName: String, serviceName: String) = + executeProcdCommand(containerName, serviceName, "reload") + + suspend fun enableService(containerName: String, serviceName: String) = + executeProcdCommand(containerName, serviceName, "enable") + + suspend fun disableService(containerName: String, serviceName: String) = + executeProcdCommand(containerName, serviceName, "disable") +} diff --git a/Android/app/src/main/java/com/droidspaces/app/util/ContainerRuntime.kt b/Android/app/src/main/java/com/droidspaces/app/util/ContainerRuntime.kt new file mode 100644 index 00000000..fd45991b --- /dev/null +++ b/Android/app/src/main/java/com/droidspaces/app/util/ContainerRuntime.kt @@ -0,0 +1,24 @@ +package com.droidspaces.app.util + +import com.topjohnwu.superuser.Shell + +/** + * Thin gateway over the root shell for container-runtime operations. + * + * The single place ViewModels reach the backend binary, so command construction + * and quoting live here instead of being inlined in the UI/ViewModel layer. Any + * dynamic or user-supplied argument passed through this object MUST be quoted + * via [ContainerCommandBuilder.quote]. + */ +object ContainerRuntime { + + /** + * Run the backend `scan` to reconcile on-disk containers. Blocking; call + * from a background dispatcher. The command is built from constants only + * (no user input), so no argument quoting is required here. + */ + fun scan(): Shell.Result { + val command = "${Constants.getDroidspacesCommand()} scan" + return Shell.cmd(command).exec() + } +} diff --git a/Android/app/src/main/java/com/droidspaces/app/util/ContainerSystemdManager.kt b/Android/app/src/main/java/com/droidspaces/app/util/ContainerSystemdManager.kt index 70024e2a..ada50ed3 100644 --- a/Android/app/src/main/java/com/droidspaces/app/util/ContainerSystemdManager.kt +++ b/Android/app/src/main/java/com/droidspaces/app/util/ContainerSystemdManager.kt @@ -291,25 +291,34 @@ object ContainerSystemdManager { }.sortedWith(compareByDescending { it.isRunning }.thenBy { it.name }) } - // Systemctl commands + // Systemctl commands. Every service name is validated against the shared + // allow-list (ServiceManagerBase) before it is interpolated into the + // host-root `run 'systemctl ...'` payload — see FINDINGS_APP_VULN V1. + private suspend fun runSystemctl(containerName: String, action: String, serviceName: String): CommandResult { + if (!ServiceManagerBase.isSafeServiceName(serviceName)) { + return CommandResult(exitCode = 2, output = emptyList(), error = listOf("Invalid unit name: $serviceName")) + } + return executeSystemctlCommand(containerName, "$action $serviceName") + } + suspend fun startService(containerName: String, serviceName: String) = - executeSystemctlCommand(containerName, "start --no-block $serviceName") + runSystemctl(containerName, "start --no-block", serviceName) suspend fun stopService(containerName: String, serviceName: String) = - executeSystemctlCommand(containerName, "stop $serviceName") + runSystemctl(containerName, "stop", serviceName) suspend fun restartService(containerName: String, serviceName: String) = - executeSystemctlCommand(containerName, "restart $serviceName") + runSystemctl(containerName, "restart", serviceName) suspend fun enableService(containerName: String, serviceName: String) = - executeSystemctlCommand(containerName, "enable $serviceName") + runSystemctl(containerName, "enable", serviceName) suspend fun disableService(containerName: String, serviceName: String) = - executeSystemctlCommand(containerName, "disable $serviceName") + runSystemctl(containerName, "disable", serviceName) suspend fun maskService(containerName: String, serviceName: String) = - executeSystemctlCommand(containerName, "mask $serviceName") + runSystemctl(containerName, "mask", serviceName) suspend fun unmaskService(containerName: String, serviceName: String) = - executeSystemctlCommand(containerName, "unmask $serviceName") + runSystemctl(containerName, "unmask", serviceName) } diff --git a/Android/app/src/main/java/com/droidspaces/app/util/DaemonModeRepository.kt b/Android/app/src/main/java/com/droidspaces/app/util/DaemonModeRepository.kt new file mode 100644 index 00000000..6f7f37bd --- /dev/null +++ b/Android/app/src/main/java/com/droidspaces/app/util/DaemonModeRepository.kt @@ -0,0 +1,34 @@ +package com.droidspaces.app.util + +import com.topjohnwu.superuser.Shell + +/** + * Owns the root-shell I/O for the backend daemon-mode flag, which lives in a + * root-protected file on disk ([Constants.DAEMON_MODE_FILE]). + * + * Extracted out of [PreferencesManager] so the preference store stays a plain + * key-value store and does not execute root shell commands itself. The value + * written ("1"/"0") and the path are constants, so no argument quoting applies. + */ +object DaemonModeRepository { + + /** Persist the daemon-mode flag to the root-protected file (non-blocking). */ + fun writeToDisk(enabled: Boolean) { + val value = if (enabled) "1" else "0" + val path = Constants.DAEMON_MODE_FILE + Shell.cmd("echo '$value' > '$path'").submit() + } + + /** + * Read the daemon-mode flag from disk. Returns null when the file does not + * exist or cannot be read (blocking; call from a background dispatcher). + */ + fun readFromDisk(): Boolean? { + val path = Constants.DAEMON_MODE_FILE + val result = Shell.cmd("cat '$path' 2>/dev/null").exec() + if (result.isSuccess && result.out.isNotEmpty()) { + return result.out[0].trim() == "1" + } + return null + } +} diff --git a/Android/app/src/main/java/com/droidspaces/app/util/DeviceArch.kt b/Android/app/src/main/java/com/droidspaces/app/util/DeviceArch.kt new file mode 100644 index 00000000..7b3b4101 --- /dev/null +++ b/Android/app/src/main/java/com/droidspaces/app/util/DeviceArch.kt @@ -0,0 +1,35 @@ +package com.droidspaces.app.util + +import android.os.Build + +/** + * Single source of truth for mapping the device's primary ABI to the arch + * strings Droidspaces uses (the bundled-binary suffix, which also matches the + * arch field in rootfs.json), plus a human-readable name. Previously this + * mapping was duplicated across BinaryInstaller and RootfsRepository. + */ +object DeviceArch { + /** Binary-suffix / rootfs arch: aarch64, armhf, x86_64, x86 (defaults to aarch64). */ + fun suffix(): String { + val abi = Build.SUPPORTED_ABIS[0] + return when { + abi.contains("arm64") || abi.contains("aarch64") -> "aarch64" + abi.contains("armeabi") || abi.contains("arm") -> "armhf" + abi.contains("x86_64") -> "x86_64" + abi.contains("x86") -> "x86" + else -> "aarch64" + } + } + + /** Human-readable architecture name for display. */ + fun displayName(): String { + val abi = Build.SUPPORTED_ABIS[0] + return when { + abi.contains("arm64") || abi.contains("aarch64") -> "ARM64 (aarch64)" + abi.contains("armeabi") || abi.contains("arm") -> "ARM (armhf)" + abi.contains("x86_64") -> "x86_64" + abi.contains("x86") -> "x86" + else -> abi + } + } +} diff --git a/Android/app/src/main/java/com/droidspaces/app/util/PreferencesManager.kt b/Android/app/src/main/java/com/droidspaces/app/util/PreferencesManager.kt index 1cb600fc..677240f8 100644 --- a/Android/app/src/main/java/com/droidspaces/app/util/PreferencesManager.kt +++ b/Android/app/src/main/java/com/droidspaces/app/util/PreferencesManager.kt @@ -3,6 +3,10 @@ package com.droidspaces.app.util import android.content.Context import android.content.SharedPreferences import android.os.Build +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.distinctUntilChanged /** * Ultra-optimized PreferencesManager with zero-allocation hot paths. @@ -136,7 +140,7 @@ class PreferencesManager private constructor(context: Context) { get() = prefs.getBoolean(KEY_DAEMON_MODE_ENABLED, false) set(value) { prefs.edit().putBoolean(KEY_DAEMON_MODE_ENABLED, value).apply() - syncDaemonMode(value) + DaemonModeRepository.writeToDisk(value) } var isSymlinkEnabled: Boolean @@ -146,32 +150,35 @@ class PreferencesManager private constructor(context: Context) { } /** - * Sync daemon mode preference to the root-protected file on disk. - * writes 1 if enabled, 0 if disabled. + * Reactive stream of the daemon-mode preference — emits the current value and + * every later change (including writes from [syncDaemonModeFromDisk] / + * DaemonModeRepository), so the UI can collect it with + * collectAsStateWithLifecycle() instead of a hand-rolled change listener. */ - private fun syncDaemonMode(enabled: Boolean) { - val value = if (enabled) "1" else "0" - val path = Constants.DAEMON_MODE_FILE - // Use non-blocking shell command to write the file - com.topjohnwu.superuser.Shell.cmd("echo '$value' > '$path'").submit() - } + val daemonModeFlow: Flow = booleanPrefFlow(KEY_DAEMON_MODE_ENABLED, false) + + /** Reactive stream of the symlink-enabled preference. */ + val symlinkEnabledFlow: Flow = booleanPrefFlow(KEY_SYMLINK_ENABLED, false) + + private fun booleanPrefFlow(key: String, default: Boolean): Flow = callbackFlow { + trySend(prefs.getBoolean(key, default)) + val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, changedKey -> + if (changedKey == key || changedKey == null) trySend(prefs.getBoolean(key, default)) + } + prefs.registerOnSharedPreferenceChangeListener(listener) + awaitClose { prefs.unregisterOnSharedPreferenceChangeListener(listener) } + }.distinctUntilChanged() /** * Sync daemon mode preference from the root-protected file on disk. * Updates SharedPreferences if the file exists and differs. */ fun syncDaemonModeFromDisk() { - val path = Constants.DAEMON_MODE_FILE - // Use blocking shell command to read the file state accurately - val result = com.topjohnwu.superuser.Shell.cmd("cat '$path' 2>/dev/null").exec() - if (result.isSuccess && result.out.isNotEmpty()) { - val diskValue = result.out[0].trim() - val enabled = diskValue == "1" - if (isDaemonModeEnabled != enabled) { - // Update SharedPreferences ONLY (avoiding recursive syncDaemonMode call) - // This will trigger the OnSharedPreferenceChangeListener in the UI - prefs.edit().putBoolean(KEY_DAEMON_MODE_ENABLED, enabled).apply() - } + val enabled = DaemonModeRepository.readFromDisk() ?: return + if (isDaemonModeEnabled != enabled) { + // Update SharedPreferences ONLY (avoiding a recursive setter/disk write). + // This triggers the OnSharedPreferenceChangeListener in the UI. + prefs.edit().putBoolean(KEY_DAEMON_MODE_ENABLED, enabled).apply() } } @@ -281,6 +288,8 @@ class PreferencesManager private constructor(context: Context) { } fun addCustomRepo(name: String, url: String) { + // Only HTTPS repos — the rootfs manifest/payload must not be MITM-able (V13). + if (!url.startsWith("https://", ignoreCase = true)) return val current = getCustomRepos().toMutableList() if (current.any { it.second == url }) return current.add(name to url) diff --git a/Android/app/src/main/java/com/droidspaces/app/util/RootfsRepository.kt b/Android/app/src/main/java/com/droidspaces/app/util/RootfsRepository.kt index b35cca2b..bf16c929 100644 --- a/Android/app/src/main/java/com/droidspaces/app/util/RootfsRepository.kt +++ b/Android/app/src/main/java/com/droidspaces/app/util/RootfsRepository.kt @@ -47,16 +47,7 @@ sealed class RepoResult { } /** Maps the device's primary ABI to the arch string used in rootfs.json. */ -fun deviceArch(): String { - val abi = Build.SUPPORTED_ABIS[0] - return when { - abi.contains("arm64") || abi.contains("aarch64") -> "aarch64" - abi.contains("armeabi") || abi.contains("arm") -> "armhf" - abi.contains("x86_64") -> "x86_64" - abi.contains("x86") -> "x86" - else -> "aarch64" - } -} +fun deviceArch(): String = DeviceArch.suffix() object RootfsRepository { @@ -111,6 +102,8 @@ object RootfsRepository { } private fun httpGet(url: String): String? { + // Refuse cleartext: the rootfs supply chain must not be MITM-able (V13). + if (!url.startsWith("https://", ignoreCase = true)) return null val conn = (URL(url).openConnection() as HttpURLConnection).apply { connectTimeout = CONNECT_TIMEOUT readTimeout = READ_TIMEOUT diff --git a/Android/app/src/main/java/com/droidspaces/app/util/ServiceManagerBase.kt b/Android/app/src/main/java/com/droidspaces/app/util/ServiceManagerBase.kt new file mode 100644 index 00000000..23b72b9e --- /dev/null +++ b/Android/app/src/main/java/com/droidspaces/app/util/ServiceManagerBase.kt @@ -0,0 +1,29 @@ +package com.droidspaces.app.util + +/** + * Shared safety choke point for the per-init-system service managers + * ([ContainerSystemdManager], [ContainerOpenRCManager], [ContainerProcdManager]). + * + * Service/unit names are DISCOVERED from inside the container — which may be a + * hostile third-party rootfs — and then interpolated into a host-level command: + * + * droidspaces --name= run ' ...' + * + * that executes in the app's real-host-root libsu shell. Because the name sits + * inside the single-quoted `run '...'` payload, a name containing a single quote + * (or `$`, `;`, backtick, whitespace, …) would break out of that payload and run + * arbitrary commands as host root on a single UI tap (see FINDINGS_APP_VULN V1/V2). + * + * Every service manager MUST pass a name through [isSafeServiceName] before + * building a command. The allow-list covers exactly the characters real + * systemd / OpenRC / procd service names use and excludes every shell + * metacharacter, so a validated name can be embedded literally and safely. + */ +object ServiceManagerBase { + // Letters, digits, and the punctuation init systems actually allow in unit / + // service names (systemd templates use '@', some units use ':'). NO quotes, + // '$', ';', '|', '&', backtick, whitespace, parentheses or newlines. + private val SAFE_SERVICE_NAME = Regex("^[A-Za-z0-9_.@:+-]+$") + + fun isSafeServiceName(name: String): Boolean = SAFE_SERVICE_NAME.matches(name) +} diff --git a/Android/app/src/main/java/com/droidspaces/app/util/ValidationUtils.kt b/Android/app/src/main/java/com/droidspaces/app/util/ValidationUtils.kt index 403773da..3029184a 100644 --- a/Android/app/src/main/java/com/droidspaces/app/util/ValidationUtils.kt +++ b/Android/app/src/main/java/com/droidspaces/app/util/ValidationUtils.kt @@ -10,6 +10,17 @@ import com.droidspaces.app.R object ValidationUtils { const val MAX_CONTAINER_NAME_LENGTH = 17 // 63 - len("/data/local/Droidspaces/Containers/") - len("/rootfs.img") + /** + * Normalizes a container name before it is stored or used to build paths. + * Trims leading/trailing whitespace and collapses internal whitespace runs to a + * single space, so that e.g. "alpine " -> "alpine" and "alpine container" -> + * "alpine container". This must run BEFORE sanitizeContainerName so the directory + * name, the config "name=" field, and the --name passed to the backend all agree. + */ + fun normalizeContainerName(name: String): String { + return name.trim().replace(Regex("\\s+"), " ") + } + /** * Validates container name: letters, numbers, hyphens, underscores, spaces, and dots allowed. */ @@ -34,6 +45,15 @@ object ValidationUtils { } } + /** + * The character-safety half of [validateContainerName], WITHOUT the length + * limit. Used to drop a container whose on-disk config name carries shell + * metacharacters before it can reach a root command — while still loading + * over-length-but-safe legacy names. See VULN V10. + */ + fun isSafeContainerName(name: String): Boolean = + name.isNotEmpty() && name.matches(Regex("^[a-zA-Z0-9_\\s.-]+$")) + /** * Validates hostname: only numbers, letters (lowercase and uppercase), and dashes allowed. * Empty is allowed (will use container name as default). @@ -59,6 +79,145 @@ object ValidationUtils { .replace(Regex("[^a-zA-Z0-9-]"), "") .trim('-') } + + /** Count non-comment `key=value` lines in an env-file body. */ + fun countEnvVars(content: String?): Int { + if (content.isNullOrBlank()) return 0 + return content.lines() + .map { it.trim() } + .count { it.isNotEmpty() && !it.startsWith("#") && it.contains("=") } + } + + /** + * Reject line breaks / control characters in the single-line container-config + * values. Each is written as a `key=value` line into the root-owned + * container.config parsed by the privileged backend, so a newline would inject + * an extra trusted key. `envFileContent` is intentionally excluded — it is + * legitimately multi-line and written to a separate `.env` file. See VULN V11. + */ + fun validateConfigValues(config: ContainerInfo): ValidationResult { + fun hasControl(v: String) = v.any { it.isISOControl() } + val invalid = listOf( + config.dnsServers, config.staticNatIp, config.customInit, + config.tx11ExtraFlags, config.virglExtraFlags, config.privileged, + config.gatewayContainer, config.gatewayNet, config.gatewayIface, config.gatewayBridge + ).any { hasControl(it) } + return if (invalid) { + ValidationResult.Error("Configuration values must not contain line breaks") + } else { + ValidationResult.Success + } + } + + // ---- Gateway networking mode -------------------------------------------- + + // Linux IFNAMSIZ is 16 incl. NUL, so interface/bridge names get 15 usable chars. + const val IFNAME_MAX = 15 + private val IFNAME_REGEX = Regex("^[a-zA-Z0-9_-]+$") + + /** Effective LAN segment name (empty -> "lan"), mirrors the C runtime default. */ + fun effGatewayNet(net: String): String = net.ifBlank { "lan" } + + /** Effective interface name inside the gateway (empty -> "eth1"). */ + fun effGatewayIface(iface: String): String = iface.ifBlank { "eth1" } + + /** + * Effective host bridge name. Mirrors gateway_bridge_name() in src/net/network.c: + * explicit bridge wins, else "ds-" + (net filtered to [A-Za-z0-9_-], first 9 chars, + * or "lan" if empty). + */ + fun effGatewayBridge(net: String, bridge: String): String { + if (bridge.isNotBlank()) return bridge + val clean = effGatewayNet(net) + .filter { it.isLetterOrDigit() || it == '_' || it == '-' } + .take(9) + return "ds-" + clean.ifEmpty { "lan" } + } + + private fun ifaceLikeError(value: String, context: Context?): String? = when { + value.isBlank() -> null + value.length > IFNAME_MAX -> + context?.getString(R.string.error_iface_too_long) ?: "Too long — max $IFNAME_MAX characters" + !value.matches(IFNAME_REGEX) -> + context?.getString(R.string.error_gateway_name_chars) ?: "Use only letters, digits, _ or -" + else -> null + } + + /** + * Validates a gateway-mode configuration against every OTHER installed container, + * returning a per-field error set. Enforces the collision rules from + * Documentation/Networking-From-Zero.md (Part 10) so two segments never share a + * host bridge and one gateway never reuses an interface name across segments. + */ + fun validateGatewayConfig( + selfName: String, + gatewayContainer: String, + net: String, + iface: String, + bridge: String, + installed: List, + context: Context? = null + ): GatewayErrors { + // Field-format checks (independent of other containers). + val netErr = if (net.isNotBlank() && !net.matches(IFNAME_REGEX)) + (context?.getString(R.string.error_gateway_name_chars) ?: "Use only letters, digits, _ or -") + else null + var ifaceErr = ifaceLikeError(iface, context) + var bridgeErr = ifaceLikeError(bridge, context) + + // Gateway container must exist, differ from self, and be installed. + val installedNames = installed.map { it.name } + val containerErr = when { + gatewayContainer.isBlank() -> + context?.getString(R.string.error_gateway_required) ?: "Select a gateway container" + gatewayContainer == selfName -> + context?.getString(R.string.error_gateway_self) ?: "A container cannot be its own gateway" + gatewayContainer !in installedNames -> + context?.getString(R.string.error_gateway_not_installed) ?: "Gateway container is not installed" + else -> null + } + + // Cross-container collisions (only when the basics are already sound). + if (containerErr == null && netErr == null && ifaceErr == null && bridgeErr == null) { + val myBridge = effGatewayBridge(net, bridge) + val myNet = effGatewayNet(net) + val myIface = effGatewayIface(iface) + for (o in installed) { + if (o.name == selfName || o.netMode != "gateway" || o.gatewayContainer.isBlank()) continue + val oBridge = effGatewayBridge(o.gatewayNet, o.gatewayBridge) + val oNet = effGatewayNet(o.gatewayNet) + val oIface = effGatewayIface(o.gatewayIface) + // Same host bridge but a different gateway, or a different segment of the + // same gateway -> two routers / two segments on one switch. + if (oBridge == myBridge && (o.gatewayContainer != gatewayContainer || oNet != myNet)) { + bridgeErr = context?.getString(R.string.error_gateway_bridge_taken, o.name) + ?: "Bridge $myBridge is already used by '${o.name}'. Use a different LAN name." + break + } + // Same gateway, different segment, but the same interface name reused. + if (o.gatewayContainer == gatewayContainer && oBridge != myBridge && oIface == myIface) { + ifaceErr = context?.getString(R.string.error_gateway_iface_taken, o.name) + ?: "Interface $myIface is already used by another segment ('${o.name}'). Use a different one." + break + } + } + } + + return GatewayErrors(containerErr, netErr, ifaceErr, bridgeErr) + } +} + +/** + * Per-field validation result for gateway mode. A null field means "no error". + */ +data class GatewayErrors( + val container: String? = null, + val net: String? = null, + val iface: String? = null, + val bridge: String? = null +) { + val isValid: Boolean + get() = container == null && net == null && iface == null && bridge == null } /** diff --git a/Android/app/src/main/res/drawable/ic_launcher_foreground.xml b/Android/app/src/main/res/drawable/ic_launcher_foreground.xml index f1068cf3..3d653ae1 100644 --- a/Android/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/Android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -1,6 +1,6 @@ + + + + + + diff --git a/Android/app/src/main/res/drawable/ic_shortcut_panel.xml b/Android/app/src/main/res/drawable/ic_shortcut_panel.xml new file mode 100644 index 00000000..24d683f9 --- /dev/null +++ b/Android/app/src/main/res/drawable/ic_shortcut_panel.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/Android/app/src/main/res/drawable/ic_shortcut_settings.xml b/Android/app/src/main/res/drawable/ic_shortcut_settings.xml new file mode 100644 index 00000000..417ccc07 --- /dev/null +++ b/Android/app/src/main/res/drawable/ic_shortcut_settings.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/Android/app/src/main/res/values-cs/plurals.xml b/Android/app/src/main/res/values-cs/plurals.xml new file mode 100644 index 00000000..7fa6f83a --- /dev/null +++ b/Android/app/src/main/res/values-cs/plurals.xml @@ -0,0 +1,21 @@ + + + + %d kontejner + %d kontejnery + %d kontejnerů + %d kontejnerů + + + %d commit + %d commity + %d commitů + %d commitů + + + Je spuštěna %d relace + Jsou spuštěny %d relace + Je spuštěno %d relací + Je spuštěno %d relací + + diff --git a/Android/app/src/main/res/values-cs/strings.xml b/Android/app/src/main/res/values-cs/strings.xml new file mode 100644 index 00000000..544b694a --- /dev/null +++ b/Android/app/src/main/res/values-cs/strings.xml @@ -0,0 +1,512 @@ + + + Linuxové kontejnery. Nativně. Bez režie. + Začít + Skutečné linuxové kontejnery + Stejná technologie jmenných prostorů jádra jako u LXC, Podmanu a Dockeru. Bez emulace a bez chrootu. + Nulová režie + Komunikuje přímo s linuxovým jádrem hostitele. Bez hypervizoru a bez vrstvy virtuálního počítače. + Neukončitelné už z principu + Podporuje nativní integraci s init.rc a dvouvrstvé démony v uživatelském prostoru. Nelze jej vůbec ukončit. + systemd / OpenWrt procd / OpenRC jako PID 1 + Plná podpora inicializačních systémů. Skutečná správa služeb, nikoli simulované prostředí. + Úplná izolace + Soukromý strom PID, tabulka připojení, název hostitele, IPC a hierarchie cgroup pro každý kontejner. + Plný přístup k hardwaru/GPU + Zpřístupní kontejnerům přímo hardware hostitele (GPU, senzory, USB a bloková zařízení). + Plně privilegované kontejnery + Pro pokročilé uživatele umožňuje v privilegovaném režimu uvolnit bezpečnostní omezení. + Automatické spuštění při startu + Automaticky spustí kontejnery při startu, i když je obrazovka zamknutá a data zašifrovaná. + Kontrola přístupu roota + Kontroluje se přístup roota... + Přístup roota udělen + Přístup roota odepřen + Ke správě kontejnerů je vyžadován přístup roota. + Zkontrolovat přístup roota + Klepnutím níže zkontrolujte přístup roota + Nyní můžete používat Droidspaces + Přeskočit + Pokračovat + Domů + Kontejnery + Panel + Nastavení + Zpět + Zavřít + Obnovit + Hledat + Vymazat + Kontejner + Spuštěno + Backend: probíhá kontrola... + Backend: nainstalován + Backend: je dostupná aktualizace + Backend: není nainstalován + Backend: poškozen + Přístup roota není dostupný. + Chcete-li pokračovat v procházení aplikace Droidspaces, udělte přístup roota! + Neznámé + Je dostupná aktualizace backendu, klepnutím ji nainstalujete + Backend: je vyžadován zásah + Klepnutím opravíte konfiguraci systému + Backend + Přeinstalovat backend + Přeinstaluje binární soubory a spouštěcí modul + Požadavky + Požadavky na jádro + Požadavky na Termux + Požadavky na jádro bez GKI (<=4.19) + Požadavky na jádro GKI (>=5.4) + Kopírovat + Průvodce + Vzhled + Jazyk + Výchozí nastavení systému + Používat motiv systému + Automaticky se přizpůsobí motivu systému + Tmavý motiv + Zapne tmavý motiv + Režim AMOLED + Čistě černé pozadí pro displeje OLED + Použít výchozí paletu barev systému + Použije motiv Monet systému Android 12+ (Material You) + Barva zvýraznění + Režim démona + Spustí Droidspaces jako trvalého systémového démona, který obchází blokování seccomp v Magisku/APatchi, GrapheneOS apod. Získáváním a spouštěním příkazů z uživatelského prostoru tím umožní vysoce privilegované operace (vyžaduje restart). + Začlenit Droidspaces do systémové cesty + Vytvoří symbolický odkaz v /system/bin, aby byl příkaz droidspaces dostupný v rootovém shellu Androidu. VAROVÁNÍ: ZAPNUTÍ ZPŮSOBÍ BOOTLOOP, POKUD VÁŠ SYSTÉM PŘIPOJENÍ POUŽÍVÁ METAMODULY OVERLAYFS. Tuto možnost používejte pouze se systémem připojení založeným na Magic Mount (vyžaduje restart) + O aplikaci + O aplikaci Droidspaces + Správce kontejnerů ve stylu LXC pro Android + Vývojáři + Správce projektu + Kanál na Telegramu + Přispěvatelé + GitHub + Zdrojový kód + Vytvořit hlášení chyby + Shromáždí systémové informace a protokoly pro ladění + Hlášení chyby + OK + Nejsou nainstalovány žádné kontejnery + Nainstalujte kontejner pomocí tlačítka níže + Nainstalovat kontejner + Nainstalovat + Vyberte soubor .tar.xz nebo .tar.gz. Vybráno: %1$s + Vyberte soubor .tar.xz nebo .tar.gz. Typ souboru se nepodařilo zjistit. + Zastavuje se kontejner... + Odinstalovává se... + Kontejner %1$s se nepodařilo zastavit + Kontejner %1$s se nepodařilo odinstalovat + Kontejner %1$s byl úspěšně odinstalován + Operace se nezdařila! + Operace byla úspěšně dokončena! + Operace „%1$s“ s kontejnerem %2$s selhala + Odinstalovat kontejner? + Tímto trvale odstraníte kontejner „%1$s“ a všechna jeho data. Tuto akci nelze vrátit zpět.\n\nPokud je kontejner spuštěný, bude nejprve zastaven. + Odinstalovat + Potvrďte akci zadáním přesného názvu kontejneru: + Zrušit + Zobrazit protokoly + Upravit konfiguraci kontejneru + Odinstalovat kontejner + SPUŠTĚNO + RESTARTUJE SE + ZASTAVENO + PID: %1$d + Název hostitele: %1$s + Možnosti: %1$s + Spustit + Zastavit + Restartovat + Zakázat IPv6 + Úložiště + HW + GPU + X11 + VirGL + PulseAudio + Permisivní režim SELinuxu + Nestálý režim + cgroup v1 + Ochrana před deadlockem + Privilegovaný režim + Uživatelské jmenné prostory + Souhrn instalace + Zkontrolovat konfiguraci + Archiv tar + Adresář + Cesta instalace + Nejsou zapnuty žádné další možnosti + Možnosti + %1$s: %2$s + %1$s: %2$s + Informace o kontejneru + Distribuce + Verze + Název hostitele + IP adresa + Doba běhu + Informace o kontejneru nelze načíst + Využití disku + Využito %1$s z %2$s + Počítá se… + systemd + Probíhá kontrola + Není dostupné + Spravovat + Upravit kontejner: %1$s + Kontejner je spuštěný + Změny se projeví po restartování + Zakázat IPv6 + Zakáže v tomto kontejneru IPv6. Zakázání IPv6 může narušit aplikace VPN spuštěné v hostiteli. + V režimech sítě NAT a Žádná je IPv6 vždy zakázán. + Úložiště Androidu + Připojí úložiště Androidu + Přístup k hardwaru + Plný přístup k hardwaru + Přístup ke GPU + Zrcadlí uzly GPU do izolovaného /dev (bez úplného přímého předání hardwaru) + Nastavit Termux:X11 + Zapne připojení soketu X11 pro Termux:X11 + Nastavit PulseAudio + Zapne podporu zvuku prostřednictvím PulseAudio + Další příznaky Termux:X11 + např. -dpi 160 -force-sysvshm + Nastavit 3D akceleraci VirGL + Zapne hardwarovou 3D akceleraci prostřednictvím VirGL + Další příznaky VirGL + např. --multi-clients --angle-vulkan + Permisivní režim SELinuxu + Nastaví SELinux do permisivního režimu + Povolit uživatelské jmenné prostory + Povolí neprivilegované uživatelské jmenné prostory, které vyžadují Flatpak, bwrap a Firefox. Bez opraveného jádra však můžete být zranitelní vůči některým exploitům, jako je Fragnesia + Není podporováno. V jádře není zapnuto CONFIG_USER_NS + Spouštět při startu + Spustí kontejner při startu + Priorita automatického spuštění + Pořadí, ve kterém se spustí kontejnery nastavené ke spuštění při startu + Pořadí změníte přetažením. Kontejnery se spouštějí shora dolů – bránu (např. OpenWRT) umístěte jako první, aby se spustila před kontejnery, které přes ni směrují provoz. + Žádný kontejner nemá zapnuté spouštění při startu. + Přetažením změníte pořadí + Vynutit cgroup v1 + Vynutí použití starší hierarchie cgroup v1 + Ruční ochrana před deadlockem + Blokováním vytváření vnořených jmenných prostorů brání deadlockům VFS na úrovni jádra ve starších jádrech (např. 4.14.x).\n\nVAROVÁNÍ: Zapnutí této možnosti zablokuje uvnitř kontejneru podporu sandboxingu Dockeru, Podmanu, LXC a systemd. + Síť + Integrace a hardware + Zabezpečení a spouštění + Pokročilé možnosti + Režim sítě + Hostitel + NAT (výchozí) + Žádná síť (izolováno) + Hostitel + NAT + Žádná + Brána (virtuální směrovač) + Brána + Nastavení brány + Směruje tento kontejner přes jiný kontejner, který funguje jako směrovač (např. OpenWRT). Tato brána musí být spuštěna jako první. + Kontejner brány + Název sítě LAN + lan + Rozhraní v bráně + eth1 + Most hostitele + ds-lan + Nastavit bránu + Nastavit bránu + Tato nastavení určují, jak se kontejner připojí k virtuální síti LAN brány. Výchozí hodnoty fungují pro jednu síť LAN za jednou bránou – měňte je pouze při provozu více segmentů LAN nebo více bran. + Název rozhraní, pod kterým se toto připojení zobrazí UVNITŘ kontejneru brány (např. eth1 v OpenWRT). Musí odpovídat rozhraní, na kterém má brána poskytovat DHCP. Každý samostatný segment stejné brány musí mít jedinečný název. Výchozí: eth1. + Označení tohoto segmentu LAN. Kontejnery se stejnou bránou a názvem sítě LAN se připojí ke stejnému virtuálnímu přepínači a navzájem se uvidí. Chcete-li vytvořit druhý izolovaný segment, použijte jiný název. Výchozí: lan. + Název virtuálního přepínače vytvořeného na hostiteli Androidu. Ponecháte-li pole prázdné, název se vytvoří automaticky z názvu sítě LAN (ds-<lan>). Nastavujte jej pouze v případě, že potřebujete přesný název mostu. Nejvýše 15 znaků. + Vyberte kontejner brány + Kontejner nemůže být svou vlastní bránou + Tento kontejner brány není nainstalován + Nainstalujte jiný kontejner, který bude sloužit jako brána + Použijte pouze písmena, číslice, _ nebo - + Příliš dlouhé – nejvýše 15 znaků + Most již používá „%1$s“. Použijte jiný název sítě LAN. + Rozhraní již používá jiný segment („%1$s“). Použijte jiné. + Nastavení NAT + Výstupní rozhraní (volitelné) + Přiřadí přístup k internetu ke konkrétním rozhraním. Ponecháte-li pole prázdné, aktivní výstupní spojení se zjistí automaticky. + Přidat výstupní rozhraní + Dostupná systémová rozhraní: + Nebo jej zadejte ručně (jsou podporovány i zástupné znaky): + Název rozhraní (např. wlan0, v4-rmnet_data*) + Přesměrování portů + Přidat přesměrování portu + Port hostitele (1–65535 nebo rozsah 100–200) + Port kontejneru (1–65535 nebo rozsah) + Protokol + Volitelné (např. 8080 nebo 1000–2000) + Pro symetrické mapování ponechte prázdné + Neplatný formát rozsahu (ZAČÁTEK–KONEC) + Porty musí být čísla + Porty musí být v rozsahu 1–65535 + Počáteční hodnota musí být menší než koncová + Port musí být číslo + Rozsahy hostitele a kontejneru mají různou šířku + Překrývá se s existujícím pravidlem přesměrování portů + (Symetrické) + Příklady: 8080 (jeden port), 8000–8010 (rozsah). Pro symetrické mapování ponechte cíl prázdný. + Uloženo + Ukládá se... + Uložit změny + Statická IP adresa + Nastaví tomuto kontejneru statickou adresu IPv4 v podsíti 172.28.x.x. Ponecháte-li pole prázdné, adresa se při prvním spuštění přidělí automaticky. + Oktet %1$d + Rozsah: 1–254 + Tato IP adresa je již přiřazena kontejneru „%1$s“ + Konfiguraci se nepodařilo aktualizovat + Nestálý režim + Režim pouze v RAM (změny po zastavení zmizí) + Vazebná připojení + Přidat + Přidat vazebné připojení + Zadejte cestu v kontejneru + např. /mnt/sdcard + Cesta v hostiteli: %1$s + Cesta v kontejneru: %1$s + Jen pro čtení + Servery DNS + např. 1.1.1.1,8.8.8.8 (volitelné) + Pouze číslice, tečky a dvojtečky + Proměnné prostředí + Nastavené proměnné: %1$d + Nenastaveno + Každý řádek obsahuje jednu dvojici KLÍČ=HODNOTA + KLÍČ + HODNOTA + Přidat proměnnou + Vlastní binární soubor init + /sbin/init (výchozí) + Chcete-li použít výchozí init (/sbin/init), ponechte pole prázdné + Cesta musí být absolutní (začínat znakem /) + Toto nastavení neměňte, pokud přesně nevíte, co děláte. Nesprávný binární soubor init zabrání spuštění kontejneru. + Vlastní init + Nastavení kontejneru + Informace o kontejneru + Název kontejneru * + Pro výchozí hodnotu ponechte prázdné + Ponecháte-li pole prázdné, jako název hostitele se použije název kontejneru + Kontejner s tímto názvem již existuje + Další: konfigurace + Konfigurace + Možnosti kontejneru + Další: úložiště + Instaluje se kontejner + Instalace byla dokončena + Instalace se nezdařila + Hotovo + Instaluje se modul + Instaluje se Droidspaces + Modul Magisk byl úspěšně nainstalován + Backend Droidspaces byl úspěšně nainstalován + Došlo k neznámé chybě + Instalace modulu se nezdařila + Instalace binárního souboru se nezdařila + Odstraňuje se starý modul... + Rozbalují se soubory modulu... + Instaluje se modul... + Nastavují se oprávnění... + Ověřuje se instalace... + Připravuje se instalace modulu... + Zjištěná architektura: %1$s + Vytvářejí se adresáře... + Instaluje se binární soubor: %1$s + Udělují se oprávnění... + Připravuje se instalace... + Filtrujte nebo zadejte absolutní cestu… + Žádné shody + Prázdný adresář + Vybrat složku + TCP + UDP + CPU: + RAM: + Protokoly terminálu + Protokoly instalace + Terminál + 8 + 4-512 + Nejsou spuštěny žádné kontejnery + Nejprve spusťte kontejner, aby se zde zobrazil + Využití CPU + Využití RAM + %1$.1f%% + %1$d MB (%2$.1f%%) + Počet spuštěných relací: %1$d · klepnutím obnovíte + Spuštěna 1 relace · klepnutím obnovíte + Služby systemd + Služby OpenWrt + OpenWrt + Byl zjištěn OpenWrt/procd + Správa služeb pro %1$s bude přidána v příští opravě. + Služby OpenRC + OpenRC + Inicializační systém + systemd/OpenWrt/OpenRC není dostupný + V tomto kontejneru není nainstalován systemd, OpenWrt/procd ani OpenRC + Načítají se služby... + Hledat služby... + Povolené + Zakázané + Statické + Nestandardní + Neznámé + Maskované + Vše + Odmaskovat + Povolit + Zakázat + Restartovat službu + Znovu načíst službu + Maskovat službu + Akce „%1$s“ pro službu %2$s proběhla úspěšně + Akce „%1$s“ pro službu %2$s selhala + Chyba: %1$s + Nebyly nalezeny žádné služby + „%1$s“ nebylo v repozitáři nalezeno + Žádné spuštěné služby + Žádné povolené služby + Žádné zakázané služby + Žádné statické služby + Žádné nestandardní služby + Žádné služby s neznámým stavem + Žádné maskované služby + Jádro + Stav SELinuxu + Verze Androidu + Architektura + Podporovaná ABI + Načítá se... + Backend není dostupný + Backend Droidspaces není nainstalován nebo je poškozený. Nejprve jej nainstalujte. + Přístup roota není dostupný + Tato stránka je zakázána, protože přístup roota není dostupný + Operace se nezdařila + Není dostupný žádný výstup + Zavřít + Počkejte prosím na zpracování požadavku... + Dostupní uživatelé + Přihlašovací příkaz pro uživatele %1$s byl zkopírován + Obnovit uživatele + Nebyli nalezeni žádní místní uživatelé + Vybrat uživatele + Kopírovat přihlašovací příkaz + , + %1$d GB + Nastavení řídkého obrazu + Nastavení úložiště + O řídkých obrazech + Řídké obrazy jsou doporučeny při používání kontejnerů v šifrovaném úložišti, na oddílech /data se systémem F2FS nebo v jiných souborových systémech, které mohou mít potíže s přímým připojením adresáře. + Řídký obraz vytvoří vyhrazený soubor s obrazem souborového systému ext4, který se připojí jako smyčkové zařízení, a zajistí tak lepší izolaci a kompatibilitu. + Použít řídký obraz + Vytvoří kontejner v souboru s řídkým obrazem + Velikost obrazu + Je vyžadována velikost + Neplatné číslo + Minimální velikost je 4 GB + Maximální velikost je 512 GB + Velikost (GB) + Zadejte velikost od 4 do 512 GB + Protokoly: %1$s + Vymazat protokoly + Kopírovat protokoly + Název kontejneru nesmí být prázdný + Název kontejneru smí obsahovat pouze písmena, číslice, spojovníky (-), podtržítka (_), tečky (.) a mezery + Název je příliš dlouhý – nejvýše 17 znaků (omezení cesty smyčkového zařízení jádra) + Název hostitele smí obsahovat pouze písmena, číslice a spojovníky (-) + Zkontrolovat požadavky + Kontrola požadavků + Požadavky pro Droidspaces + Kontrola požadavků byla úspěšně dokončena + Kontrola požadavků byla dokončena s chybami + Požadavky na jádro byly zkopírovány do schránky + Protokoly byly zkopírovány do schránky + Terminál + Obnovit + Otevře interaktivní shell uvnitř tohoto kontejneru + Otevřít + Otevřít terminál + Vyberte uživatele, pod kterým chcete vstoupit do kontejneru: + Oprávnění k oznámením + Droidspaces musí zobrazovat trvalé oznámení, aby relace terminálu zůstaly spuštěné na pozadí. Bez něj může Android (zejména na zařízeních Samsung) službu terminálu neočekávaně ukončit. + Udělit oprávnění + Rozumím + Terminál Droidspaces + UKONČIT + Zámek probuzení: ZAPNUT + Zámek probuzení: VYPNUT + Relace terminálu + Aktivní terminálové relace kontejnerů + Převést na řídký obraz + Změnit velikost řídkého obrazu + Exportovat kontejner jako archiv tar + Kontejner %1$s se nepodařilo exportovat + Spouští se export... + Převést na řídký obraz + Změnit velikost řídkého obrazu + Převodem kontejneru „%1$s“ na řídký obraz se vytvoří vyhrazený souborový systém ext4. Přerušení této operace může poškodit data. Zálohujte si data. + Mění se velikost řídkého obrazu kontejneru „%1$s“. Zvětšení je bezpečnější než zmenšení. Zálohujte si data. + Spouští se převod... + Spouští se změna velikosti... + Začínáte s Droidspaces? + Klepnutím sem se dozvíte, jak nastavit svůj první linuxový kontejner! + Privilegovaný režim + Nastavte dílčí uvolnění bezpečnostních omezení pro vnořenou virtualizaci nebo pokročilý přístup k hardwaru. + Vypne ochranné maskování a povolí zápis do /proc a /sys. Poznámka: /sys zůstane jen pro čtení, pokud není zapnutý také přístup k hardwaru. + Neodebírá žádné schopnosti (capabilities) Linuxu (plný přístup roota). + Vypne filtrování systémových volání seccomp. Tím povolí pokročilé funkce jádra, například neprivilegované uživatelské jmenné prostory, které jsou nezbytné pro vnořený sandboxing (Bubblewrap, Flatpak), a také načítání modulů jádra mimo strom. + Zapne sdílené šíření připojení MS_SHARED do hostitele. + Obejde filtrování zařízení. Zobrazí se všechny uzly /dev hostitele. + Zapne všechny privilegované příznaky najednou. + NEBEZPEČNÁ OBLAST + Zapnutím těchto možností ohrožujete zabezpečení svého zařízení. NENESEME odpovědnost za ztrátu dat, znefunkčněná zařízení ani podivné chyby. Potíže vzniklé při používání těchto režimů NEHLASTE. + Potvrďte zadáním přesné fráze „ŽÁDNÉ CHYBY HLÁSIT NEBUDU“ velkými písmeny: + ŽÁDNÉ CHYBY HLÁSIT NEBUDU + K použití pravidel SELinuxu a spuštění démona při startu se doporučuje zařízení restartovat. + Od verze Droidspaces 6.1.0 má udevd skutečný přístup k subsystémům USB, blokových a vstupních zařízení, TTY a sítě. Kontejner může převzít hardware, který právě používá Android.\n\nPokud po spuštění kontejneru přestane v Androidu fungovat klávesnice, karta SD nebo zařízení USB, NEJDE o chybu. Hardware převzal kontejner. Vyžádali jste si plný přístup k hardwaru.\n\nPotíže způsobené konflikty hardwaru nebo panikou jádra při používání tohoto režimu NEHLASTE. + Repozitář rootfs + Repozitář + Obnovit + Archivy tar rootfs v části „Droidspaces developers“ jsou open source a jejich obsah je transparentní. Klepnutím na tuto kartu můžete naše změny ručně stáhnout nebo prozkoumat. + Repozitář se nepodařilo načíst + Stáhnout + Zrušit + Nainstalovat + Zkusit znovu + Stahování rootfs pro Droidspaces + V nejnovějším vydání nebyly nalezeny žádné archivy tar rootfs + Chyba sítě + Neznámá chyba + Ve stahování nelze pokračovat + Úložiště nebylo nalezeno + Soubor již existuje + Chyba souborového systému + Chyba dat HTTP + Nedostatek místa v úložišti + Příliš mnoho přesměrování + Neočekávaná odpověď HTTP + Soubor nebyl nalezen (404) + Kód chyby %1$d + PŘIPRAVENO + STAHUJE SE + SELHALO + Přidat vlastní repozitář + Spravovat repozitáře + Odebírejte vlastní zdroje rootfs.json. + Název repozitáře + Adresa URL souboru rootfs.json + Přihlásit odběr + Odstranit + Název nesmí být prázdný + Adresa URL nesmí být prázdná + Adresa URL musí začínat https:// + Zatím neodebíráte žádné vlastní repozitáře. + Ladění + diff --git a/Android/app/src/main/res/values-de/strings.xml b/Android/app/src/main/res/values-de/strings.xml index 9c98c4a2..60dfe64e 100644 --- a/Android/app/src/main/res/values-de/strings.xml +++ b/Android/app/src/main/res/values-de/strings.xml @@ -178,8 +178,8 @@ Dienste durchsuchen... Aktiviert Deaktiviert - Abnormal - Anormal + Statisch + Abnormal Maskiert Alle Unmaskiert @@ -195,8 +195,8 @@ Keine laufenden Dienste Keine aktivierten Dienste Keine deaktivierten Dienste - Keine abnormalen Dienste - Keine abnormalen Dienste + Keine statische Dienste + Keine abnormale Dienste Keine maskierten Dienste Kernel @@ -264,7 +264,7 @@ Kommuniziert direkt mit dem Host Linux-Kernel. Kein Hypervisor, keine Virtualisierungsschicht. Von Grund auf unkillbar Unterstützt die native Integration von init.rc und zweistufige Daemons im Userspace. Absolut unkillbar. - systemd / OpenRC mit PID 1 + systemd / OpenWrt procd / OpenRC mit PID 1 Vollständige Unterstützung des Init-Systems. Echte Dienstverwaltung, keine simulierte Umgebung. Vollständige Isolation Privater PID-Baum, Einhängepunkte, Hostname, IPC und cgroup-Hierarchie pro Container. @@ -325,8 +325,8 @@ NAT Ohne NAT Einstellungen - Upstream-Schnittstellen (obligatorisch) - Für den NAT-Modus muss mindestens eine Upstream-Schnittstelle angeben werden. + Upstream-Schnittstelle (optional) + Internetzugang an bestimmte Schnittstelle(n) binden. Leer lassen, um den aktiven Uplink automatisch zu erkennen. Upstream-Schnittstelle hinzufügen Verfügbare Systemschnittstellen: Oder manuell eingeben (Platzhalter werden ebenfalls unterstützt): @@ -484,4 +484,45 @@ URL muss mit https:// anfangen Keine benutzerdefinierten Repositories abonniert. Schreibgeschützt + Termux-Anforderungen + VirGL + PulseAudio + PulseAudio konfigurieren + Aktiviert Audiounterstützung mittels PulseAudio + Termux:X11 Parameter + z.B. -dpi 160 -force-sysvshm + VirGL 3D-Beschleunigung konfigurieren + Aktiviert 3D-Hardwarebeschleunigung mittels VirGL + VirGL Parameter + z.B. --multi-clients --angle-vulkan + Autostart-Priorität + Reihenfolge in der die Autostart Container starten + Ziehen zum Neuanordnen. Die Container werden von oben nach unten gestartet – ein Gateway (z. B. OpenWRT) an erster Stelle einfügen, damit es vor den Containern startet, die Daten über dieses Gateway weiterleiten. + Kein Container mit aktivierten Autostart vorhanden. + Ziehen zum Neuanordnen + Gateway (Virtueller Router) + Gateway + Gateway Einstellungen + Diesen Container über einen anderen Container weiterleiten, der als Router fungiert (z. B. OpenWRT). Das Gateway muss bereits laufen. + Gateway Container + LAN Name + lan + Schnittstelle im Gateway + eth1 + Host-Brücke + ds-lan + Gateway konfigurieren + Gateway konfigurieren + Diese Einstellungen legen fest, wie dieser Container in das virtuelle LAN des Gateways eingebunden wird. Die Standardeinstellungen gelten für ein einzelnes LAN hinter einem Gateway – nur ändern, wenn mehrere LAN-Segmente oder mehr als ein Gateway betrieben wird. + Der Name der Schnittstelle, unter dem diese Verbindung innerhalb des Gateway-Containers angezeigt wird (z. B. eth1 in OpenWRT). Er muss mit der Schnittstelle übereinstimmen, auf der das Gateway DHCP bereitstellt. Jedes einzelne Segment desselben Gateways benötigt einen eigenen, eindeutigen Namen. Standard: eth1. + Eine Bezeichnung für dieses LAN-Segment. Container, die denselben Gateway- und LAN-Namen verwenden, werden demselben virtuellen Switch zugeordnet und können sich gegenseitig sehen. Mit einem anderen Namen lässt sich ein isoliertes zweites Segment einrichten. Standard: lan. + Name des auf dem Android-Host erstellten virtuellen Switches. Felder leer lassen, damit der Name automatisch aus dem LAN-Namen (ds-<lan>) generiert wird. Nur einstellen, wenn ein bestimmter Brückenname erforderlich ist. Maximal 15 Zeichen. + Gateway Container auswählen + Ein Container kann nicht sein eigenes Gateway sein + Der Gateway Container ist nicht installiert + Einen anderen Container installieren, der als Gateway fungiert + Nur Buchstaben, Ziffern, _ oder - verwenden + Zu lang - maximal 15 Zeichen + Brücke wird bereits von \'%1$s\' verwendet. Anderen LAN Namen verwenden. + Die Schnittstelle wird bereits von einem anderen Segment (\'%1$s\') verwendet. Verwenden Sie eine andere. diff --git a/Android/app/src/main/res/values-es/plurals.xml b/Android/app/src/main/res/values-es/plurals.xml index 57ffb2de..0dc20dea 100644 --- a/Android/app/src/main/res/values-es/plurals.xml +++ b/Android/app/src/main/res/values-es/plurals.xml @@ -2,6 +2,7 @@ %d contenedor + %d contenedores %d contenedores diff --git a/Android/app/src/main/res/values-es/strings.xml b/Android/app/src/main/res/values-es/strings.xml index 46587ab6..b7eb71ba 100644 --- a/Android/app/src/main/res/values-es/strings.xml +++ b/Android/app/src/main/res/values-es/strings.xml @@ -53,7 +53,7 @@ Gestor de contenedores Linux (estilo LXC) para Android Desarrolladores ravindu644 - Backend, IU + Mantenedor Canal de Telegram Colaboradores GitHub @@ -87,7 +87,7 @@ Iniciar Detener Reiniciar - IPv6 + Deshabilitar IPv6 Almacenamiento HW Info del contenedor @@ -228,4 +228,69 @@ Requisitos comprobados con éxito Comprobación de requisitos con errores Requisitos del kernel copiados al portapapeles + Contenedores de Linux. Nativo. Sin sobrecarga. + Contenedores de Linux reales + La misma tecnología de nombre del núcleo que LXC, Podman y Docker. Sin emulación, sin chroot. + Sobrecarga cero + Habla directamente con el kernel de Linux host. Sin hipervisor, sin capa de máquina virtual. + No matable por diseño + Soporta la integración init.rc nativa y daemons espacio a 2 capas. Completamente ‘inmatable’. + systemd / OpenWrt procd / OpenRC en PID 1 + Soporte completo del sistema de init. Gestión real de servicios, no un entorno simulado. + Aislamiento completo + Árbol privado PID, tabla de montaje, nombre del host, IPC y jerarquía de ‘cgrous’ por contenedor. + Acceso completo de hardware/GPU + Exponer hardware host (GPU, sensores, USB, dispositivos de bloque) directamente a sus contenedores. + Contenedores completos privilegiados + Relaja barreras de seguridad con modo privilegiado para usuarios avanzados. + Arranque automático al encender + Inicio automático de contenedores al arranque, incluso si la pantalla está bloqueada y los datos están cifrados. + Segundo plano: acción requerida + Pulse para reparar configuración del sistema + Requerimientos de Temux + Requisitos del kernel sin GRI (≤4.19) + Requisitos del kernel GRI (≥5.4) + Guía + Color de acento + Modo de dæmonio + Ejecutar Droidspaces como un daemon persistente a nivel de sistema para evitar bloques de seccomp de Magisk/APatch, GrapheneOS, etc. Esto permite operaciones de alto privilegio al buscar y ejecutar comandos desde el espacio de usuario (Reinicio requerido). + Integra Droidspaces a la ruta de sistema + Cree un enlace en /system/bin para acceder al comando droidspaces en Android root shell. ADVERTENCIA: ESTE CAUSES BUCLE DE ARRANQUE SI SU MONTADO DEL SISTEMA ES SOBRECARGADO. Únicamente utilice esta opción cuando use el sistema de montaje basado en Magic Mount (Reinicio requerido) + Código fuente + Generar un informe de fallo + Recoge informe del sistema y bitácoras para depuración + Informe de fallo + Instalar + Teclee el nombre del contenedor exactamente para confirmar: + GPU + X11 + VirGL + PulseAudio + SELinux Permisivo + Volátil + Cgroup V1 + Escudo de punto muerto + Privilegiado + Espacios de nombres de usuario + Resumen de instalación + Configuración de revisión + Tarball + Directorio + Ruta de instalación + Ninguna opción adicional habilitada + Opciones + %1$s: %2$s + %1$s: %2$s + Tiempo activo + Empleo de disco + %1$s / %2$s utilizado + Calculando… + Acceso a GPU + Nodos de GPU del espejo interno al /dev aislado (traspaso de hardware incompleto) + Configurar Termux:X11 + Habilita zócalo X11 montando en Termux:X11 + Configurar PulseAudio + Habilita asistencia de sonido por PulseAudio + Indicadores adicionales de Termux:X11 + p. ej. -dpi 160 -fuerza-sysvshm diff --git a/Android/app/src/main/res/values-in/strings.xml b/Android/app/src/main/res/values-in/strings.xml index 4d332604..dc1764eb 100644 --- a/Android/app/src/main/res/values-in/strings.xml +++ b/Android/app/src/main/res/values-in/strings.xml @@ -145,8 +145,8 @@ NAT (Default) Tidak ada (terisolasi secara fisik) Pengaturan NAT - Antarmuka Upstream (Wajib) - Anda harus menentukan setidaknya satu antarmuka upstream untuk mode NAT. + Antarmuka Upstream (Opsional) + Sematkan akses internet ke antarmuka tertentu. Kosongkan untuk mendeteksi uplink aktif secara otomatis. Tambahkan Antarmuka Upstream Antarmuka Sistem yang Tersedia: Atau masukkan secara manual (karakter pengganti juga didukung): @@ -225,8 +225,8 @@ Layanan OpenRC OpenRC Sistem Init - Systemd/OpenRC Tidak Tersedia - Container ini tidak memiliki systemd atau OpenRC yang terpasang + Systemd/OpenWrt/OpenRC Tidak Tersedia + Container ini tidak memiliki systemd, OpenWrt/procd, atau OpenRC yang terpasang Sedang memuat layanan... Mencari layanan... Aktif @@ -333,7 +333,7 @@ Berkomunikasi langsung dengan kernel Linux host. Tanpa hypervisor, tanpa lapisan mesin virtual. Tidak dapat dihentikan paksa Mendukung integrasi init.rc native dan daemon userspace 2 lapis. Sepenuhnya tidak dapat dihentikan paksa. - systemd / OpenRC sebagai PID 1 + systemd / OpenWrt procd / OpenRC sebagai PID 1 Dukungan sistem init penuh. Manajemen layanan nyata, bukan lingkungan simulasi. Isolasi lengkap Pohon PID, tabel mount, nama host, IPC, dan hierarki cgroup privat per container. @@ -450,4 +450,54 @@ URL tidak boleh kosong URL harus diawali dengan https:// Belum ada repositori kustom yang dilanggan. + Persyaratan Termux + VirGL + PulseAudio + Konfigurasikan PulseAudio + Aktifkan dukungan audio melalui PulseAudio + Flag Tambahan Termux:X11 + mis. -dpi 160 -force-sysvshm + Konfigurasikan Akselerasi 3D VirGL + Aktifkan akselerasi perangkat keras 3D melalui VirGL + Flag Tambahan VirGL + mis. --multi-clients --angle-vulkan + Prioritas Boot Otomatis + Urutan container yang dijalankan saat boot dimulai + Seret untuk menyusun ulang. Container dimulai dari atas ke bawah - letakkan gateway (mis. OpenWRT) di atas agar boot lebih dulu sebelum container yang merutekan lewatnya. + Tidak ada container yang mengaktifkan Jalankan saat Boot. + Seret untuk menyusun ulang + Host + Gateway (Router Virtual) + Gateway + Pengaturan Gateway + Rutekan container ini melalui container lain yang bertindak sebagai router (mis. OpenWRT). Gateway tersebut harus berjalan lebih dulu. + Container Gateway + Nama LAN + lan + Antarmuka di Gateway + eth1 + Bridge Host + ds-lan + Konfigurasikan Gateway + Konfigurasikan Gateway + Pengaturan ini mengontrol bagaimana container ini terhubung ke LAN virtual gateway. Nilai bawaan cocok untuk satu LAN di belakang satu gateway - ubah hanya saat menjalankan beberapa segmen LAN atau lebih dari satu gateway. + Nama antarmuka tempat koneksi ini muncul DI DALAM container gateway (mis. eth1 di OpenWRT). Harus cocok dengan antarmuka tempat gateway Anda melayani DHCP. Setiap segmen terpisah pada gateway yang sama memerlukan nama uniknya sendiri. Bawaan: eth1. + Label untuk segmen LAN ini. Container yang memakai gateway dan nama LAN yang sama akan bergabung ke virtual switch yang sama dan dapat saling melihat. Gunakan nama berbeda untuk membuat segmen kedua yang terisolasi. Bawaan: lan. + Nama virtual switch yang dibuat pada host Android. Biarkan kosong agar nama dibuat otomatis dari nama LAN (ds-<lan>). Isi hanya jika Anda memerlukan nama bridge yang persis. Maks. 15 karakter. + Pilih container gateway + Container tidak dapat menjadi gateway bagi dirinya sendiri + Container gateway ini tidak terpasang + Pasang container lain untuk bertindak sebagai gateway + Gunakan hanya huruf, angka, _ atau - + Terlalu panjang - maks. 15 karakter + Bridge sudah digunakan oleh \'%1$s\'. Gunakan nama LAN yang berbeda. + Antarmuka sudah digunakan oleh segmen lain (\'%1$s\'). Gunakan yang berbeda. + Hanya baca + Layanan OpenWrt + OpenWrt + OpenWrt/procd terdeteksi + Manajemen layanan untuk %1$s akan ditambahkan pada patch berikutnya. + Tidak diketahui + Muat ulang layanan + Tidak ada layanan dengan status tidak diketahui diff --git a/Android/app/src/main/res/values-it/strings.xml b/Android/app/src/main/res/values-it/strings.xml index 4c189944..9388075d 100644 --- a/Android/app/src/main/res/values-it/strings.xml +++ b/Android/app/src/main/res/values-it/strings.xml @@ -161,8 +161,8 @@ NAT (Predefinita) Nessuna (Air-gapped) Impostazioni NAT - Interfacce Upstream (Obbligatorie) - Devi specificare almeno un\'interfaccia upstream per la modalità NAT. + Interfaccia Upstream (Opzionale) + Vincola la connessione a internet a interfacce specifiche. Lascia vuoto per rilevare automaticamente la connessione attiva. Aggiungi interfaccia upstream Interfacce di sistema disponibili: O inseriscila manualmente (i wildcard sono supportati): diff --git a/Android/app/src/main/res/values-ja/strings.xml b/Android/app/src/main/res/values-ja/strings.xml index 86ce6ed4..98a36584 100644 --- a/Android/app/src/main/res/values-ja/strings.xml +++ b/Android/app/src/main/res/values-ja/strings.xml @@ -253,8 +253,8 @@ ディストリビューション ネットワークの方式がNATやNoneであるときには、IPv6は常に無効です。 GPUのノードを隔離された/dev にミラーリングする (完全なるハードウェア透過はしない) - 上流インタフェース(必須) - NATモードでは上流インタフェースを少なくとも1つは指定しなければなりません。 + 上流インタフェース(任意) + インターネット接続を特定のインタフェースに固定します。空欄にすると、アクティブな上流回線を自動検出します。 上流インタフェースの追加 利用可能なシステム・インタフェース: 対称割当の場合は空欄にしてください diff --git a/Android/app/src/main/res/values-km/strings.xml b/Android/app/src/main/res/values-km/strings.xml index 9116562e..b0fc9c6d 100644 --- a/Android/app/src/main/res/values-km/strings.xml +++ b/Android/app/src/main/res/values-km/strings.xml @@ -88,7 +88,7 @@ កុងតឺន័រ Linux ពិតប្រាកដ បើកសិទ្ធិឱ្យកុងតឺន័ររបស់អ្នក ចូលប្រើប្រាស់ Hardware របស់ម៉ាស៊ីនផ្ទាល់ (GPU, សេនស័រ, USB, ឧបករណ៍ Block) ដោយផ្ទាល់។ លុបកុងតឺន័រចេញ - ប្រើប្រាស់ systemd / OpenRC ជា PID 1 + ប្រើប្រាស់ systemd / OpenWrt procd / OpenRC ជា PID 1 កុងតឺន័រដែលមានសិទ្ធិពេញលេញ ចុចដើម្បីជួសជុលការកំណត់រចនាសម្ព័ន្ធប្រព័ន្ធ គាំទ្រប្រព័ន្ធ Init ពេញលេញ (Full init system support)។ ការគ្រប់គ្រងសេវាកម្មពិតប្រាកដ មិនមែនជាបរិស្ថានធ្វើត្រាប់តាម (Simulated environment) នោះទេ។ @@ -111,7 +111,7 @@ ចាប់ផ្ដើមដំណើរការដោយស្វ័យប្រវត្តិនៅពេលបើកម៉ាស៊ីន តម្លើង ឈ្មោះម៉ាស៊ីន: %1$s - បង្កើត Symlink នៅត្រង់ /system/bin ដើម្បីចូលប្រើប្រាស់បញ្ជា (Command) droidspaces នៅក្នុង Android Root Shell។\n\nការព្រមាន៖ ការបើកមុខងារនេះនឹងបង្កឱ្យមានបញ្ហាគាំងបើកឧបករណ៍មិនឡើង (Bootloop) ប្រសិនបើប្រព័ន្ធ Mount របស់អ្នកជាប្រភេទ OverlayFS Meta Modules។ សូមប្រើប្រាស់ជម្រើសនេះ តែក្នុងករណីដែលអ្នកកំពុងប្រើប្រាស់ប្រព័ន្ធ Mount ផ្អែកលើ Magic Mount តែប៉ុណ្ណោះ (តម្រូវឱ្យចាប់ផ្តើមឧបករណ៍ឡើងវិញ)។ + បង្កើត Symlink នៅត្រង់ /system/bin ដើម្បីចូលប្រើប្រាស់បញ្ជា (Command) droidspaces នៅក្នុង Android Root Shell។ការព្រមាន៖ ការបើកមុខងារនេះនឹងបង្កឱ្យមានបញ្ហាគាំងបើកឧបករណ៍មិនឡើង (Bootloop) ប្រសិនបើប្រព័ន្ធ Mount របស់អ្នកជាប្រភេទ OverlayFS Meta Modules។ សូមប្រើប្រាស់ជម្រើសនេះ តែក្នុងករណីដែលអ្នកកំពុងប្រើប្រាស់ប្រព័ន្ធ Mount ផ្អែកលើ Magic Mount តែប៉ុណ្ណោះ (តម្រូវឱ្យចាប់ផ្តើមឧបករណ៍ឡើងវិញ)។ រចនាឡើងមិនអាចបំផ្លាញបាន តម្រូវការខឺណែលមិនមែន GKI (<=4.19) តម្រូវការខឺណែល GKI (>=5.4) @@ -183,8 +183,8 @@ NAT គ្មាន ការកំណត់ NAT - ចំណុចប្រទាក់​ខាងលើ (ចាំបាច់) - អ្នកត្រូវតែបញ្ជាក់យ៉ាងហោចណាស់ចំណុចប្រទាក់ខាងលើមួយសម្រាប់របៀប NAT។ + ចំណុចប្រទាក់ខាងលើ (ស្រេចចិត្ត) + ភ្ជាប់អ៊ីនធឺណិតទៅចំណុចប្រទាក់ជាក់លាក់។ ទុកឱ្យនៅទទេ ដើម្បីរកឃើញការតភ្ជាប់សកម្មដោយស្វ័យប្រវត្តិ។ បន្ថែមចំណុចប្រទាក់ខាងលើ ចំណុចប្រទាក់ប្រព័ន្ធដែលអាចប្រើបាន៖ ឬបញ្ចូលវាដោយដៃ (អក្សរជំនួសក៏ត្រូវបានគាំទ្រផងដែរ)៖ @@ -296,8 +296,8 @@ សេវាកម្ម OpenRC OpenRC ប្រព័ន្ធ Init - មិនអាចប្រើប្រព័ន្ធ Systemd/OpenRC បានទេ - កុងតឺន័រនេះមិនមាន systemd ឬ OpenRC ដែលបានដំឡើងទេ + ប្រព័ន្ធ Systemd / OpenWrt / OpenRC មិនមានទេ + ខនតឺន័រនេះមិនមានដំឡើង systemd, OpenWrt/procd ឬ OpenRC ទេ កំពុងទាញយកសេវាកម្ម... ស្វែងរកសេវាកម្ម... បានបើកដំណើរការ @@ -403,7 +403,7 @@ https://github.com/ravindu644/Droidspaces-OSS/blob/main/Documentation/Installation-Android.md របៀប Privileged កំណត់រចនាសម្ព័ន្ធបន្ធូរបន្ថយសន្តិសុខតាមកម្រិតលម្អិត សម្រាប់ការធ្វើនិម្មិតកម្មត្រួតគ្នា (Nested Virtualization) ឬការចូលប្រើប្រាស់ Hardware កម្រិតខ្ពស់។ - "បិទ Jail Masks ដើម្បីអនុញ្ញាតឱ្យមានសិទ្ធិកែប្រែ (Write Access) ទៅលើ /proc និង /sys។ សម្គាល់៖ /sys នៅតែរក្សាស្ថានភាពសម្រាប់តែអានប៉ុណ្ណោះ (Read-only) លុះត្រាតែមុខងារចូលប្រើប្រាស់ Hardware ត្រូវបានបើកដំណើរការផងដែរ។" + បិទ Jail Masks ដើម្បីអនុញ្ញាតឱ្យមានសិទ្ធិកែប្រែ (Write Access) ទៅលើ /proc និង /sys។ សម្គាល់៖ /sys នៅតែរក្សាស្ថានភាពសម្រាប់តែអានប៉ុណ្ណោះ (Read-only) លុះត្រាតែមុខងារចូលប្រើប្រាស់ Hardware ត្រូវបានបើកដំណើរការផងដែរ។ មិនលុបបំបាត់សមត្ថភាព Linux ណាមួយឡើយ (ទទួលបានអំណាច root ពេញលេញ)។ បិទការច្រោះ Seccomp Syscall។ មុខងារនេះនឹងបើកដំណើរការលក្ខណៈពិសេសកម្រិតខ្ពស់របស់ Kernel ដូចជា Unprivileged User Namespaces ដែលជាលក្ខខណ្ឌតម្រូវដាច់ខាតសម្រាប់ Nested Sandboxing (Bubblewrap, Flatpak) ហើយវាក៏អនុញ្ញាតឱ្យផ្ទុក Out-of-tree Kernel Modules ផងដែរ។ បើកដំណើរការការបញ្ជូនទិន្នន័យម៉ោន (Mount Propagation) បែប MS_SHARED ទៅកាន់ម៉ាស៊ីនផ្ទាល់ (Host)។ @@ -414,7 +414,7 @@ វាយពាក្យ \"I WON\'T REPORT ANY BUGS\" ឱ្យបានត្រឹមត្រូវជាអក្សរធំ ដើម្បីបញ្ជាក់៖ I WON\'T REPORT ANY BUGS ណែនាំឱ្យចាប់ផ្តើមឧបករណ៍ឡើងវិញ (Reboot) ដើម្បីអនុវត្តច្បាប់ SELinux និងចាប់ផ្តើមដំណើរការ Daemon រៀងរាល់ពេលបើកឧបករណ៍។ - ចាប់ផ្តើមពី Droidspaces v6.1.0 ទៅ udevd មានសិទ្ធិចូលប្រើប្រាស់ពិតប្រាកដទៅលើ Subsystems របស់ USB, Block, Input, TTY, និង Network។ កុងតឺន័រ (Container) របស់អ្នកអាចកាន់កាប់ Hardware ដែល Android កំពុងប្រើប្រាស់នាពេលបច្ចុប្បន្នបាន។\n\nប្រសិនបើក្តារចុច (Keyboard) កាត SD ឬឧបករណ៍ USB របស់អ្នកឈប់ដំណើរការនៅក្នុង Android បន្ទាប់ពីបើកដំណើរការកុងតឺន័រ — នោះមិនមែនជាបញ្ហាបច្ចេកទេស (Bug) ឡើយ។ គឺកុងតឺន័របានយកវាទៅប្រើ។ អ្នកបានស្នើសុំសិទ្ធិចូលប្រើប្រាស់ Hardware ពេញលេញដោយខ្លួនឯង។\n\nសូមកុំរាយការណ៍ពីបញ្ហាដែលបង្កឡើងដោយការជម្លោះ Hardware (Hardware Conflicts) ឬ Kernel Panics អំឡុងពេលកំពុងប្រើប្រាស់របៀប (Mode) នេះ។ + ចាប់ផ្តើមពី Droidspaces v6.1.0 ទៅ udevd មានសិទ្ធិចូលប្រើប្រាស់ពិតប្រាកដទៅលើ Subsystems របស់ USB, Block, Input, TTY, និង Network។ កុងតឺន័រ (Container) របស់អ្នកអាចកាន់កាប់ Hardware ដែល Android កំពុងប្រើប្រាស់នាពេលបច្ចុប្បន្នបាន។\n\nប្រសិនបើក្តារចុច (Keyboard) កាត SD ឬឧបករណ៍ USB របស់អ្នកឈប់ដំណើរការនៅក្នុង Android បន្ទាប់ពីបើកដំណើរការកុងតឺន័រ — នោះមិនមែនជាបញ្ហាបច្ចេកទេស (Bug) ឡើយ។ គឺកុងតឺន័របានយកវាទៅប្រើ។ អ្នកបានស្នើសុំសិទ្ធិចូលប្រើប្រាស់ Hardware ពេញលេញដោយខ្លួនឯង។\n\nសូមកុំរាយការណ៍ពីបញ្ហាដែលបង្កឡើងដោយការជម្លោះ Hardware ឬ Kernel Panics អំឡុងពេលកំពុងប្រើប្រាស់របៀប (Mode) នេះ។ មិនអាចកែបាន រកមិនឃើញទិន្នន័យ %1$s ឃ្លាំងផ្ទុក Rootfs @@ -454,4 +454,62 @@ URL មិនអាចទទេបានទេ URL ត្រូវតែចាប់ផ្តើមដោយ https:// មិនទាន់មានការជាវ Repository ផ្ទាល់ខ្លួននៅឡើយទេ។ + តម្រូវការប្រព័ន្ធសម្រាប់ Termux + VirGL + PulseAudio + កំណត់រចនាសម្ព័ន្ធ PulseAudio + បើកដំណើរការមុខងារសំឡេងតាមរយៈ PulseAudio + ប៉ារ៉ាម៉ែត្របន្ថែមសម្រាប់ Termux:X11 + ឧទាហរណ៍៖ -dpi 160 -force-sysvshm + កំណត់រចនាសម្ព័ន្ធមុខងារបង្កើនល្បឿន VirGL 3D + បើកដំណើរការមុខងារបង្កើនល្បឿនផ្នែករឹង 3D តាមរយៈ VirGL + ប៉ារ៉ាម៉ែត្របន្ថែមសម្រាប់ VirGL + ឧទាហរណ៍៖ --multi-clients --angle-vulkan + ពណ៌សម្គាល់ + វាយបញ្ចូលឈ្មោះកុងតែន័រឱ្យបានត្រឹមត្រូវដើម្បីបញ្ជាក់៖ + User Namespaces + ការប្រើប្រាស់ថាស + បានប្រើប្រាស់ %1$s / \\%2$s + កំពុងគណនា… + អនុញ្ញាត User Namespaces + អនុញ្ញាតឱ្យប្រើប្រាស់ unprivileged user namespaces ដែល Flatpak/bwrap/Firefox ត្រូវការ។ ប៉ុន្តែ អ្នកប្រហែលជាប្រឈមនឹងការវាយប្រហារមួយចំនួនដូចជា Fragnesia ប្រសិនបើមិនទាន់បានអាប់ដេត kernel (patched kernel) ទេ + មិនគាំទ្រទេ​. CONFIG_USER_NS មិនត្រូវបានបើកដំណើរការនៅក្នុង kernel ទេ. + អាទិភាពចាប់ផ្ដើមស្វ័យប្រវត្តិ + លំដាប់នៃការចាប់ផ្តើមខនតឺន័រដំណើរការពេលចាប់ផ្ដើមប្រព័ន្ធ + អូសដើម្បីប្ដូរលំដាប់។ ខនតឺន័រចាប់ផ្ដើមពីលើចុះក្រោម - សូមដាក់ច្រកទ្វារ (ឧ. OpenWRT) មុនគេ ដើម្បីវាចាប់ផ្ដើមមុនខនតឺន័រដែលត្រូវតភ្ជាប់ឆ្លងកាត់វា។ + គ្មានខនតឺន័រណាមួយត្រូវបានបើកដំណើរការពេលចាប់ផ្ដើមប្រព័ន្ធទេ។ + អូសដើម្បីប្ដូរលំដាប់ + ច្រកទ្វារ (រ៉ោតទ័រនិម្មិត) + ច្រកទ្វារ (Gateway) + ការកំណត់ច្រកទ្វារ + បញ្ជូនបណ្តាញរបស់ខនតឺន័រនេះឆ្លងកាត់ខនតឺន័រផ្សេងទៀតដែលធ្វើជាតួអង្គរ៉ោតទ័រ (ឧ. OpenWRT)។ ខនតឺន័រច្រកទ្វារនោះត្រូវតែដំណើរការមុន។ + ខនតឺន័រច្រកទ្វារ + ឈ្មោះបណ្តាញ LAN + បណ្តាញ lan + ចំណុចប្រទាក់ក្នុងច្រកទ្វារ (Interface in Gateway) + eth1 + ស្ពានតភ្ជាប់ប្រព័ន្ធគោល (Host Bridge) + ds-lan + កំណត់រចនាសម្ព័ន្ធច្រកទ្វារ + កំណត់រចនាសម្ព័ន្ធច្រកទ្វារ + ការកំណត់ទាំងនេះគ្រប់គ្រងពីរបៀបដែលខនតឺន័រនេះភ្ជាប់ទៅនឹងបណ្តាញ LAN និម្មិតរបស់ច្រកទ្វារ។ តម្លៃលំនាំដើមគឺសាកសមសម្រាប់បណ្តាញ LAN តែមួយនៅពីក្រោយច្រកទ្វារតែមួយ - សូមផ្លាស់ប្តូរវាលុះត្រាតែអ្នកដំណើរការបណ្តាញ LAN ច្រើន ឬមានច្រកទ្វារច្រើនជាងមួយ។ + ឈ្មោះចំណុចប្រទាក់ដែលការតភ្ជាប់នេះបង្ហាញ ខាងក្នុង ខនតឺន័រច្រកទ្វារ (ឧ. eth1 ក្នុង OpenWRT)។ វាត្រូវតែត្រូវគ្នាទៅនឹងចំណុចប្រទាក់ដែលច្រកទ្វាររបស់អ្នកត្រូវបានកំណត់ឱ្យផ្ដល់សេវា DHCP។ សេនហ្កាម (Segment) នីមួយៗនៅលើច្រកទ្វារតែមួយ ត្រូវការឈ្មោះផ្ទាល់ខ្លួនដែលមិនត្រួតស៊ីគ្នា។ លំនាំដើម៖ eth1។ + ស្លាកសម្រាប់បណ្តាញ LAN នេះ។ ខនតឺន័រដែលប្រើប្រាស់ច្រកទ្វារ និងឈ្មោះបណ្តាញ LAN ដូចគ្នា នឹងចូលរួមក្នុងស្វ៊ីចនិម្មិត (virtual switch) តែមួយ និងអាចមើលឃើញគ្នាទៅវិញទៅមក។ សូមប្រើប្រាស់ឈ្មោះផ្សេងដើម្បីបង្កើតបណ្តាញដាច់ដោយឡែកទីពីរ។ លំនាំដើម៖ lan។ + ឈ្មោះរបស់ស្ពាននិម្មិត (virtual switch) ដែលបានបង្កើតនៅលើប្រព័ន្ធប្រតិបត្តិការ Android គោល។ សូមទុកវាឱ្យនៅទว่างដើម្បីដាក់ឈ្មោះដោយស្វ័យប្រវត្តិផ្អែកលើឈ្មោះ LAN (ds-)។ កំណត់វាលុះត្រាតែអ្នកត្រូវការឈ្មោះស្ពានពិតប្រាកដប៉ុណ្ណោះ។ អតិបរមា ១៥ តួអក្សរ។ + ជ្រើសរើសខនតឺន័រច្រកទ្វារ (Select a gateway container) + ខនតឺន័រមិនអាចធ្វើជាច្រកទ្វារខ្លួនឯងបានទេ + ខនតឺន័រច្រកទ្វារនេះមិនទាន់ត្រូវបានដំឡើងទេ + ដំឡើងខនតឺន័រផ្សេងទៀតដើម្បីធ្វើជាច្រកទ្វារ + ប្រើប្រាស់តែអក្សរ លេខ(_) ឬសញ្ញា hyphen (-) ប៉ុណ្ណោះ + វែងពេក - អតិបរមា ១៥ តួអក្សរ + ស្ពានត្រូវបានប្រើប្រាស់រួចហើយដោយ \'%1$s\'។ សូមប្រើឈ្មោះបណ្តាញ LAN ផ្សេង។ + ចំណុចប្រទាក់ត្រូវបានប្រើប្រាស់រួចហើយដោយបណ្តាញផ្សេង (\'%1$s\')។ សូមប្រើចំណុចប្រទាក់ផ្សេង។ + សេវាកម្មOpenWrt + OpenWrt + បានរកឃើញ OpenWrt/procd + ការគ្រប់គ្រងសេវាកម្មសម្រាប់ %1$s នឹងត្រូវបន្ថែមនៅក្នុងកំណែបច្ចុប្បន្នភាពបន្ទាប់។ + មិនស្គាល់ + ផ្ទុកសេវាកម្មឡើងវិញ (Reload service) + គ្មានសេវាកម្មដែលមានស្ថានភាពមិនស្គាល់ទេ + ការបំបាត់កំហុស (Debugging) diff --git a/Android/app/src/main/res/values-ro/plurals.xml b/Android/app/src/main/res/values-ro/plurals.xml index 702af8da..9025c341 100644 --- a/Android/app/src/main/res/values-ro/plurals.xml +++ b/Android/app/src/main/res/values-ro/plurals.xml @@ -10,4 +10,9 @@ %d commit-uri Commit-urile %d + + %d sesiune activă + %d sesiuni active + %d de sesiuni active + diff --git a/Android/app/src/main/res/values-ro/strings.xml b/Android/app/src/main/res/values-ro/strings.xml index bf72d0b7..7992c139 100644 --- a/Android/app/src/main/res/values-ro/strings.xml +++ b/Android/app/src/main/res/values-ro/strings.xml @@ -126,8 +126,8 @@ NAT (implicit) Niciunul (fără nicio conexiune la rețea) Setări NAT - Interfețe de uplink (obligatorii) - Trebuie specificată minim o interfață de uplink pentru modul NAT. + Interfață de uplink (opțională) + Fixează accesul la internet pe anumite interfețe. Lasă gol pentru a detecta automat uplink-ul activ. Adaugă interfață de uplink Interfețe de sistem disponibile: Sau scrie-le manual (de asemenea, wildcard-urile sunt suportate): @@ -379,4 +379,26 @@ Scrie \"NU VOI RAPORTA NICIUN BUG\" în majuscule pentru a confirma: NU VOI RAPORTA NICIUN BUG Începând cu Droidspaces v6.1.0, udevd are acces adevărat la subsistemele USB, block, input, TTY și network (rețea). Container-ul tău poate accesa hardware pe care îl utilizează Android.\n\nDacă tastatura, cardul SD, sau dispozitivele USB nu mai funcționează în Android după ce pornești un container - acela NU este un bug. Container-ul l-a luat, fiindcă ai dorit acces complet la hardware.\n\nNU raporta probleme cauzate de conflicte de hardware sau kernel panics în timp ce utilizezi acest mod. + Backend + Cerințe pentru Termux + Culoare de accent + GitHub + OK + Introduceți numele exact al containerului pentru confirmare: + PID: %1$d + HW + GPU + X11 + VirGL + PulseAudio + Cgroup V1 + Namespace-uri de utilizator (user namespaces) + Arhivă tar + %1$s: %2$s + %1$s: %2$s + Utilizare spațiu de stocare + %1$s / %2$s utilizat + Se calculează… + Systemd + Configurare PulseAudio diff --git a/Android/app/src/main/res/values-si/strings.xml b/Android/app/src/main/res/values-si/strings.xml index 4f378806..b6bb7a68 100644 --- a/Android/app/src/main/res/values-si/strings.xml +++ b/Android/app/src/main/res/values-si/strings.xml @@ -137,8 +137,8 @@ NAT (පෙරනිමිය) කිසිවක් නැත (Air-gapped) NAT සැකසුම් - ප්‍රභව අතුරුමුහුණත් (අනිවාර්ය) - NAT ක්‍රමය සඳහා අවම වශයෙන් එක් ප්‍රභව අතුරුමුහුණතක් වත් සඳහන් කළ යුතුය. + ප්‍රභව අතුරුමුහුණත (විකල්ප) + අන්තර්ජාල ප්‍රවේශය නිශ්චිත අතුරුමුහුණතකට සම්බන්ධ කරන්න. සක්‍රිය ප්‍රභවය ස්වයංක්‍රීයව හඳුනා ගැනීමට හිස්ව තබන්න. ප්‍රභව අතුරුමුහුණතක් එක් කරන්න පද්ධතියේ පවතින අතුරුමුහුණත්: නැතිනම් එය ඔබම ඇතුළත් කරන්න (Wildcards සඳහාද සහාය දක්වයි): diff --git a/Android/app/src/main/res/values-ta/plurals.xml b/Android/app/src/main/res/values-ta/plurals.xml new file mode 100644 index 00000000..78e6aa09 --- /dev/null +++ b/Android/app/src/main/res/values-ta/plurals.xml @@ -0,0 +1,15 @@ + + + + %d உறுதி + %d உறுதியளிக்கிறது + + + %d அமர்வு இயங்குகிறது + %d அமர்வுகள் இயங்குகின்றன + + + %d கொள்கலன் + %d கொள்கலன்கள் + + diff --git a/Android/app/src/main/res/values-ta/strings.xml b/Android/app/src/main/res/values-ta/strings.xml new file mode 100644 index 00000000..fc4b33ac --- /dev/null +++ b/Android/app/src/main/res/values-ta/strings.xml @@ -0,0 +1,499 @@ + + + ரூட் அணுகல் சோதனை + எந்த லினக்ச் திறன்களையும் (முழு ரூட் சக்தி) கைவிட வேண்டாம். + சேமிப்பக கட்டமைப்பு + நீங்கள் என்ன செய்கிறீர்கள் என்பது உங்களுக்குத் தெரியாவிட்டால் இதை மாற்ற வேண்டாம். தவறான init பைனரியை அமைப்பது, கொள்கலன் பூட் செய்வதைத் தடுக்கும். + %1$d மாறி(கள்) உள்ளமைக்கப்பட்டது + நிராகரி + ரூட் அணுகல் மறுக்கப்பட்டது + SELinux இசைவு + தெரியாத நிலையில் சேவைகள் இல்லை + தொகுதிக் கோப்புகளைப் பிரித்தெடுக்கிறது... + பதிவிறக்கத்தை மீண்டும் தொடங்க முடியாது + தார்பால் + ரேம் பயன்பாடு + விருப்பத்தேர்வு (எ.கா. 8080 அல்லது 1000-2000) + மூடு + ஆண்ட்ராய்டு க்கான LXC-பாணி கொள்கலன் மேலாளர் + OLED காட்சிகளுக்கான தூய கருப்பு பின்னணி + மதிப்பாய்வு கட்டமைப்பு + பராமரிப்பாளர் + மாற்றங்களைச் சேமிக்கவும் + \"%1$s\" ஐ ஒரு சிறிய படத்திற்கு மாற்றுவது ஒரு பிரத்யேக ext4 கோப்பு முறைமையை உருவாக்கும். இந்த செயல்பாடு குறுக்கிடப்பட்டால் அழிவுகரமானது. உங்கள் தரவை காப்புப் பிரதி எடுக்கவும். + மாறியைச் சேர்க்கவும் + Droidspacesக்கு புதியதா? + உள்ளமை பெயர்வெளி உருவாக்கத்தைத் தடுப்பதன் மூலம் மரபு கர்னல்களில் (எ.கா., 4.14.x) கர்னல்-நிலை VFS முட்டுக்கட்டைகளைத் தடுக்கிறது. \n\nஎச்சரிக்கை: இதை இயக்குவது கொள்கலனுக்குள் Docker, Podman, LXC மற்றும் Systemd சாண்ட்பாக்சிங் ஆதரவைத் தடுக்கும். + அகற்று + பின்தளம்: நடவடிக்கை தேவை + தெரியவில்லை + VirGL கூடுதல் கொடிகள் + பதிவுகளைப் பார்க்கவும் + இயல்புநிலைக்கு காலியாக விடவும் + இந்த ஐபி ஏற்கனவே \"%1$s\"க்கு ஒதுக்கப்பட்டுள்ளது + அசாதாரணமானது + பின்தளம்: நிறுவப்படவில்லை + கொள்கலன் தகவலைப் படிக்க முடியவில்லை + அரிதான படத்தைப் பயன்படுத்தவும் + முகவரி கண்டிப்பாக https:// உடன் தொடங்க வேண்டும் + நான் எந்த பிழைகளையும் புகாரளிக்க மாட்டேன் + VirGL 3D முடுக்கத்தை உள்ளமைக்கவும் + Force Cgroup V1 + ஆண்ட்ராய்டு 12+ Monet தீமிங்கைப் பயன்படுத்தவும் (மெட்டீரியல் யூ) + Init அமைப்பு + Termux:X11க்கு X11 சாக்கெட் மவுண்டிங்கை இயக்கு + கொள்கலனில் எழுத்துக்கள், எண்கள், ஐபன்கள் (-), அடிக்கோடுகள் (_), புள்ளிகள் (.) மற்றும் இடைவெளிகள் மட்டுமே இருக்க வேண்டும் + பின்தளம்: நிறுவப்பட்டது + முழு வன்பொருள்/சிபியு அணுகல் + கொள்கலனை நாடாகாப்பகபந்து ஆக ஏற்றுமதி செய்யவும் + லினக்ச் கொள்கலன்கள். பழமை. மேல்நிலை இல்லை. + சேமிப்பு + ச்பார்ச் படத்திற்கு நகர்த்தவும் + சிபியு பயன்பாடு + டிராய்ச்பேச்களை தொடர்ந்து ஆய்வு செய்ய ரூட் அணுகலை வழங்கவும்! + தனிப்பட்ட PID மரம், மவுண்ட் டேபிள், ஓச்ட்பெயர், IPC மற்றும் ஒரு கொள்கலனுக்கு cgroup படிநிலை. + எ.கா. --மல்டி-கிளையண்ட்ச் --ஆங்கிள்-வல்கன் + துறைமுகம் ஒரு எண்ணாக இருக்க வேண்டும் + உண்மையான லினக்ச் கொள்கலன்கள் + ஒரு ச்பேர்ச் படமானது ஒரு பிரத்யேக ext4 கோப்பு முறைமை படக் கோப்பை உருவாக்குகிறது, அது ஒரு லூப் சாதனமாக ஏற்றப்பட்டு, சிறந்த தனிமைப்படுத்தல் மற்றும் இணக்கத்தன்மையை வழங்குகிறது. + பைனரியை நிறுவுகிறது: %1$s + போதிய சேமிப்பு இல்லை + SELinux அனுமதியை அமைக்கவும் + தொடங்குங்கள் + முனையத்தைத் திறக்கவும் + Droidspaces க்கான தேவைகள் + /proc மற்றும் /sys க்கு எழுதும் அணுகலை அனுமதிக்க சிறை முகமூடிகளை முடக்கவும். குறிப்பு: வன்பொருள் அணுகலும் இயக்கப்பட்டாலன்றி, /sys படிக்க மட்டுமே இருக்கும். + பிணையம் பிழை + HW + Droidspaces v6.1.0 இலிருந்து தொடங்கி, udevd ஆனது USB, block, input, TTY மற்றும் பிணைய துணை அமைப்புகளுக்கான உண்மையான அணுகலைக் கொண்டுள்ளது. உங்கள் கன்டெய்னர் ஆண்ட்ராய்டு தற்போது பயன்படுத்தும் வன்பொருளை சொந்தமாக வைத்திருக்க முடியும். \n\nஉங்கள் விசைப்பலகை, SD அட்டை அல்லது USB சாதனம் ஒரு கண்டெய்னரை அறிமுகப்படுத்திய பிறகு ஆண்ட்ராய்டு இல் வேலை செய்வதை நிறுத்தினால் - அது ஒரு பிழை அல்ல. கொள்கலன் அதை எடுத்தது. முழு வன்பொருள் அணுகலைக் கேட்டுள்ளீர்கள். \n\nஇந்த பயன்முறையைப் பயன்படுத்தும் போது வன்பொருள் முரண்பாடுகள் அல்லது கர்னல் பீதியால் ஏற்படும் சிக்கல்களைப் புகாரளிக்க வேண்டாம். + அனைத்து சலுகை பெற்ற கொடிகளையும் ஒரே நேரத்தில் இயக்கவும். + அரிதான படத்தின் அளவை மாற்றவும் + முகமூடி + புரவலன் வன்பொருளை (GPU, சென்சார்கள், USB, பிளாக் சாதனங்கள்) நேரடியாக உங்கள் கொள்கலன்களுக்கு வெளிப்படுத்துங்கள். + பைண்ட் மவுண்ட்டைச் சேர்க்கவும் + டீமான் பயன்முறை + Seccomp syscall வடிகட்டலை முடக்கவும். இது, உள்ளமை சாண்ட்பாக்சிங்கிற்கு (Bubblewrap, Flatpak) கண்டிப்பாகத் தேவைப்படும் சலுகையற்ற பயனர் பெயர்வெளிகள் போன்ற மேம்பட்ட கர்னல் அம்சங்களை செயல்படுத்துகிறது மற்றும் மரத்திற்கு வெளியே உள்ள கர்னல் தொகுதிகளை ஏற்றவும் அனுமதிக்கிறது. + கர்னல் + கோப்பு ஏற்கனவே உள்ளது + %1$s %2$s இல் தோல்வியடைந்தது + கொள்கலன் செய்தி + கொள்கலனைத் திருத்து: %1$s + ஆபத்தான பிரதேசம் + நுழைவாயில் உள்ள இடைமுகம் + ஆண்ட்ராய்டு ஓச்டில் உருவாக்கப்பட்ட மெய்நிகர் சுவிட்சின் பெயர். LAN பெயரிலிருந்து (ds-<lan>) தானாகப் பெயரிட காலியாக விடவும். உங்களுக்கு சரியான பாலத்தின் பெயர் தேவைப்பட்டால் மட்டுமே இதை அமைக்கவும். அதிகபட்சம் 15 எழுத்துகள். + Systemd/OpenWrt/OpenRC கிடைக்கவில்லை + பயனரைத் தேர்ந்தெடுக்கவும் + கொள்கலன் அமைப்பு + சேவையை மீண்டும் ஏற்றவும் + கொள்கலனை நிறுவல் நீக்கவும் + உள்ளக பயனர்கள் இல்லை + எ.கா. /mnt/sdcard + Droidspaces ஐ நிறுவுதல் + சேமிக்கப்பட்டது + பிழை அறிக்கை + கொள்கலன்கள் நிறுவப்படவில்லை + கொள்கலனை உள்ளிட பயனரைத் தேர்ந்தெடுக்கவும்: + நுழைவாயில் கொள்கலனைத் தேர்ந்தெடுக்கவும் + systemd / OpenWrt procd / OpenRC என PID 1 + ஐபி முகவரி + \"Droidspaces உருவாக்குபவர்கள்\" கீழ் Rootfs tarballs திறந்த மூல மற்றும் வெளிப்படையானவை. இந்தக் கார்டைத் தட்டுவதன் மூலம் எங்கள் மாற்றங்களை நீங்கள் கைமுறையாகப் பதிவிறக்கலாம் அல்லது ஆய்வு செய்யலாம். + இந்த கொள்கலனில் IPv6 ஐ முடக்கவும். IPv6 ஐ முடக்குவது ஓச்டில் இயங்கும் VPN பயன்பாடுகளை உடைக்கக்கூடும். + ஒரு கொள்கலன் அதன் சொந்த நுழைவாயிலாக இருக்க முடியாது + விருப்ப துவக்கம் + மீண்டும் முயற்சிக்கவும் + கணினி கருப்பொருள் தானாக பொருந்தும் + பற்றி + IPv6 ஐ முடக்கு + GKI கர்னல் தேவைகள் (>=5.4) + (சமச்சீர்) + 172.28.x.x சப்நெட்டில் இந்தக் கொள்கலனுக்கான நிலையான IPv4 ஐ அமைக்கவும். முதல் துவக்கத்தில் தானாக ஒதுக்க அதை காலியாக விடவும். + லேன் பெயர் + உள்ளமைக்கப்பட்ட மெய்நிகராக்கம் அல்லது மேம்பட்ட வன்பொருள் அணுகலுக்கான சிறுமணி பாதுகாப்பு தளர்வை உள்ளமைக்கவும். + GPU முனைகளை தனிமைப்படுத்தப்பட்ட / dev இல் பிரதிபலிக்கவும் (முழு வன்பொருள் பாச்த்ரூ இல்லை) + முழு சலுகை பெற்ற கொள்கலன்கள் + தெளிவு + eth1 + Droidspaces rootfs பதிவிறக்கம் + தெரியவில்லை + தொடக்கம் முடிவை விட குறைவாக இருக்க வேண்டும் + துறைமுகங்கள் எண்களாக இருக்க வேண்டும் + ரன்-அட்-பூட் கொள்கலன்கள் தொடங்கும் வரிசை + மறுதொடக்கத்திற்குப் பிறகு மாற்றங்கள் நடைமுறைக்கு வரும் + பின்தளம் கிடைக்கவில்லை + இயக்கப்பட்ட சேவைகள் இல்லை + உள்நுழைவை நகலெடுக்கவும் + நிறுவல் பாதை + %1$s பயனருக்கு உள்நுழைவு கட்டளை நகலெடுக்கப்பட்டது + தொகுதியை நிறுவுதல் + இடைமுகம் ஏற்கனவே மற்றொரு பிரிவால் பயன்படுத்தப்பட்டது (\'%1$s\'). வேறு ஒன்றைப் பயன்படுத்தவும். + பின்பக்கம்: சரிபார்க்கிறது... + கொள்கலன்கள் + மறைகுறியாக்கப்பட்ட சேமிப்பகம், F2FS /தரவு பகிர்வுகள் அல்லது நேரடி அடைவு மவுண்டிங்கில் பொருந்தக்கூடிய சிக்கல்களைக் கொண்ட பிற கோப்பு முறைமைகளில் கண்டெய்னர்களைப் பயன்படுத்தும் போது அரிதான படங்கள் பரிந்துரைக்கப்படுகின்றன. + களஞ்சியத்தின் பெயர் + பைனரி நிறுவல் தோல்வியடைந்தது + கணினி உள்ளமைவை சரிசெய்ய தட்டவும் + %1$s / %2$s பயன்படுத்தப்பட்டது + தேவைகள் சரிபார்ப்பு வெற்றிகரமாக முடிந்தது + நிறுவலைச் சரிபார்க்கிறது... + முழு வன்பொருள் அணுகல் + புதுப்பிப்பு + முழு init கணினி உதவி. உண்மையான பணி மேலாண்மை, உருவகப்படுத்தப்பட்ட சூழல் அல்ல. + அறிவிலிமையம் + %1$d sessions running · tap பெறுநர் restore + முடக்கப்பட்டது + மேசிச்க் தொகுதி வெற்றிகரமாக நிறுவப்பட்டது + இடம்பெயர்வு செயல்பாட்டைத் தொடங்குகிறது... + வெடிமையுடைய + எந்த கொள்கலன்களும் ரன் அட் பூட்டில் இயக்கப்படவில்லை. + நுழைவாயில் கொள்கலன் + கர்னல் தேவைகள் + மீட்டமை + இந்த LAN பிரிவுக்கான சிட்டை. ஒரே கேட்வே மற்றும் லேன் பெயரைப் பகிர்ந்து கொள்ளும் கன்டெய்னர்கள் ஒரே மெய்நிகர் சுவிட்சில் இணைகின்றன, மேலும் ஒன்றையொன்று பார்க்க முடியும். தனிமைப்படுத்தப்பட்ட இரண்டாவது பிரிவைச் சுழற்ற வேறு பெயரைப் பயன்படுத்தவும். இயல்புநிலை: lan. + நகலெடு + அடைவு + இந்த விருப்பங்களை இயக்குவதன் மூலம், உங்கள் சாதனத்தின் பாதுகாப்பை நீங்கள் வேறுபாடின்மை செய்கிறீர்கள். தரவு இழப்பு, ப்ரிக் செய்யப்பட்ட சாதனங்கள் அல்லது வித்தியாசமான பிழைகளுக்கு நாங்கள் பொறுப்பல்ல. இந்த முறைகளைப் பயன்படுத்தும் போது ஏற்பட்ட சிக்கல்களைப் புகாரளிக்க வேண்டாம். + சரக்குப் பெட்டகம் + Cgroup V1 + கோப்பு கிடைக்கவில்லை (404) + கொள்கலனை நிறுவவும் + கீழே உள்ள பொத்தானைப் பயன்படுத்தி கொள்கலனை நிறுவவும் + இயக்கப்பட்டது + TCP + கிடைக்கும் பயனர்கள் + ரூட் அணுகல் வழங்கப்பட்டது + துறைமுகம் பகிர்தல் + கண்டறியப்பட்ட கட்டமைப்பு: %1$s + OpenRC சேவைகள் + விர்சிஎல் + பின்தளத்தில் புதுப்பிப்பு உள்ளது, புதுப்பிக்க இங்கே சொடுக்கு செய்யவும் + இசைவு வழங்கவும் + தொகுதியை நிறுவுகிறது... + SELinux விதிகளைப் பயன்படுத்த மறுதொடக்கம் பரிந்துரைக்கப்படுகிறது மற்றும் துவக்கத்தில் டீமானைத் தொடங்கவும். + புரவலன் பாலம் + ரேம்: + %1$s களஞ்சியத்தில் இல்லை + பல்ச் ஆடியோவை உள்ளமைக்கவும் + இந்த கொள்கலனில் systemd, OpenWrt/procd அல்லது OpenRC நிறுவப்படவில்லை + ஆக்டெட் %1$d + LXC, Podman மற்றும் Docker போன்ற அதே கர்னல் பெயர்வெளி தொழில்நுட்பம். எமுலேசன் இல்லை, க்ரூட் இல்லை. + Droidspaces பின்தளம் வெற்றிகரமாக நிறுவப்பட்டது + பிணையம் பயன்முறை + தேவைகளை சரிபார்க்கவும் + மூலக் குறியீடு + Droidspaces ஆனது உங்கள் முனைய அமர்வுகளை பின்னணியில் இயங்க வைப்பதற்கு ஒரு நிலையான அறிவிப்பைக் காட்ட வேண்டும். இது இல்லாமல், ஆண்ட்ராய்டு (குறிப்பாக சாம்சங் சாதனங்களில்) எதிர்பாராத விதமாக டெர்மினல் சேவையை அழிக்கக்கூடும். + கொள்கலனை நிறுவல் நீக்கவா? + மறுதொடக்கம் + தவறான எண் + முழுமையான பாதையை வடிகட்டவும் அல்லது தட்டச்சு செய்யவும்… + எழுத்துக்கள், இலக்கங்கள், _ அல்லது - ஆகியவற்றை மட்டும் பயன்படுத்தவும் + விருந்தோம்பி + நீங்கள் இப்போது Droidspaces ஐப் பயன்படுத்தலாம் + HTTP தரவு பிழை + இது \"%1$s\" கண்டெய்னரையும் அதன் எல்லா தரவையும் நிரந்தரமாக நீக்கும். இந்தச் செயலைச் செயல்தவிர்க்க முடியாது. \n\nகொள்கலன் இயங்கினால், அது முதலில் நிறுத்தப்படும். + இந்த இணைப்பின் இடைமுகப் பெயர் கேட்வே கொள்கலனின் உள்ளே (எ.கா. OpenWRT இல் eth1) எனக் காட்டுகிறது. இது DHCP இல் பணி செய்ய உங்கள் நுழைவாயில் அமைக்கப்பட்ட இடைமுகத்துடன் பொருந்த வேண்டும். ஒரே நுழைவாயிலில் உள்ள ஒவ்வொரு தனிப் பிரிவுக்கும் அதன் சொந்தப் பெயர் தேவை. இயல்புநிலை: eth1. + களஞ்சியத்தை ஏற்ற முடியவில்லை + புரவலன்பெயர் + நிறுவல் நீக்குகிறது... + வன்பொருள் அணுகல் + சேமிப்பிடம் கிடைக்கவில்லை + பின் + அதை இங்கே பார்க்க முதலில் ஒரு கொள்கலனைத் தொடங்கவும் + Magisk/APatch, GrapheneOS, போன்றவற்றிலிருந்து seccomp பிளாக்சைத் தவிர்க்க, Droidspaces ஒரு நிலையான கணினி-நிலை டீமானாக இயக்கவும். இது பயனர் இடத்திலிருந்து கட்டளைகளைப் பெற்று இயக்குவதன் மூலம் உயர்-சலுகை செயல்பாடுகளை செயல்படுத்துகிறது (மறுதொடக்கம் தேவை). + மறுவரிசைப்படுத்த இழுக்கவும் + கணக்கிடுகிறது… + கேட்வேயின் மெய்நிகர் LAN இல் இந்தக் கொள்கலன் எவ்வாறு செருகப்படுகிறது என்பதை இவை கட்டுப்படுத்துகின்றன. இயல்புநிலைகள் ஒரு நுழைவாயிலுக்குப் பின்னால் ஒரு லேனுக்கு வேலை செய்கின்றன - பல லேன் பிரிவுகள் அல்லது ஒன்றுக்கு மேற்பட்ட கேட்வேகளை இயக்கும் போது மட்டுமே அவற்றை மாற்றவும். + நுழைவாயிலை உள்ளமைக்கவும் + OpenWrt/procd கண்டறியப்பட்டது + தொடங்கு + அதிகபட்ச அளவு 512 சிபி + கொள்கலன் பெயர் * + தோற்றம் + ரூட் அணுகலை சரிபார்க்க கீழே சொடுக்கு செய்யவும் + ஓச்ட்பெயரில் எழுத்துக்கள், எண்கள் மற்றும் கோடுகள் மட்டுமே இருக்க வேண்டும் (-) + துறைமுகங்கள் 1-65535 ஆக இருக்க வேண்டும் + இயக்க நேரம் + ஓச்டுக்கு MS_SHARED மவுண்ட் பிரசாரத்தை இயக்கவும். + பதிப்பு + நிறுவவும் + எனக்கு புரிகிறது + அரிதான படத்திற்கு நகர்த்தவும் + படிக்க மட்டும் + ரேம் மட்டும் பயன்முறை (நிறுத்தத்திற்குப் பிறகு மாற்றங்கள் மறைந்துவிடும்) + சாதன வடிகட்டலைத் தவிர்க்கவும். அனைத்து புரவலன் /டெவ் முனைகளும் தெரியும். + சரி + நுழைவாயிலை உள்ளமைக்கவும் + அறுவை மருத்தீடு வெற்றிகரமாக முடிந்தது! + நிறுத்தப்பட்டது + பைனரிகள் மற்றும் துவக்க தொகுதியை மீண்டும் நிறுவவும் + டெட்லாக் சீல்ட் + GPU + ச்பார்ச் படத்தின் அளவை மாற்றவும் + நிறுத்து + துவக்கத்தில் இயக்கவும் + அனுமதிகளை அமைக்கிறது... + 4-512 + நெட்வொர்க்கிங் + கொள்கலன் இயங்குகிறது + DNS சேவையகங்கள் + %1$dGB + பின்தளம் + PID: %1$d + தானியங்கு துவக்க முன்னுரிமை + மேம்பட்ட பயனர்களுக்கு சலுகை பெற்ற பயன்முறையுடன் பாதுகாப்பு தடைகளைத் தளர்த்தவும். + ஆதரிக்கப்படும் ஏபிஐக்கள் + அரிதான படங்கள் பற்றி + அடுத்து: கட்டமைப்பு + பாலம் ஏற்கனவே \'%1$s\' ஆல் பயன்படுத்தப்பட்டது. வேறு LAN பெயரைப் பயன்படுத்தவும். + ஆவியாகும் முறை + அளவு (சிபி) + %1$s கண்டெய்னரை நிறுத்த முடியவில்லை + கிடைக்கவில்லை + நிறுவல் நீக்கவும் + கோப்பகங்களை உருவாக்குகிறது... + சிறப்புரிமை பெற்றது + விருப்பங்கள்: %1$s + ds-lan + கர்னல் தேவைகள் இடைநிலைப்பலகைக்கு நகலெடுக்கப்பட்டது + கோப்பு முறைமை பிழை + முகமூடி சேவைகள் இல்லை + தவிர் + தேவைகள் சரிபார்ப்பு பிழைகளுடன் முடிந்தது + களஞ்சியம் + நிறுவல் தயாராகிறது... + உள்ளமைவு + தொகுதி நிறுவலைத் தயாராகிறது... + வெளியீடு இல்லை + ரூட் அணுகலைச் சரிபார்க்கவும் + கொள்கலன் பாதை: %1$s + நிறுவல் தோல்வியடைந்தது + NAT (இயல்புநிலை) + களஞ்சியங்களை நிர்வகிக்கவும் + அறியப்படாத பிழை ஏற்பட்டது + பதிவுகள்: %1$s + திரை பூட்டப்பட்டிருந்தாலும், தரவு குறியாக்கம் செய்யப்பட்டிருந்தாலும், பூட்டில் கொள்கலன்களைத் தானாகத் தொடங்கவும். + இந்த கொள்கலனை திசைவியாக செயல்படும் மற்றொரு கொள்கலன் வழியாக இயக்கவும் (எ.கா. OpenWRT). அந்த நுழைவாயில் முதலில் இயங்க வேண்டும். + சுற்றுச்சூழல் மாறிகள் + எதிர்பாராத HTTP பதில் + வேக் லாக்: ஆஃப் + வெற்று அடைவு + நிலையான சேவைகள் இல்லை + பதிவுகளை அழிக்கவும் + பங்களிப்பாளர்கள் + இருண்ட கருப்பொருள் இயக்கவும் + ஆபரேசன் தோல்வி! + விருந்தோம்பி + நெறிமுறை + தனிப்பயன் களஞ்சியங்கள் எதுவும் இதுவரை குழுசேரவில்லை. + துறைமுகம் முன்னோக்கி சேர்க்கவும் + இயல்புநிலை init (/sbin/init) பயன்படுத்த காலியாக விடவும் + மறுதொடக்கம் + விருப்பங்கள் + தேடல் சேவைகள்... + ரூட் அணுகல் இல்லை + OpenRC + இந்தப் பெயரில் ஒரு கொள்கலன் ஏற்கனவே உள்ளது + எதுவும் இல்லை (காற்று இடைவெளி) + செயலில் உள்ள கொள்கலன் முனைய அமர்வுகள் + Termux:X11 கூடுதல் கொடிகள் + முடக்கு + கேட்வே (மெய்நிகர் திசைவி) + ஒவ்வொரு வரிசையும் ஒரு KEY=VALUE இணை + வேக் லாக்: ஆன் + ஏற்றுமதியைத் தொடங்குகிறது... + கணினியின் இயல்புநிலை வண்ணத் தட்டுகளைப் பயன்படுத்தவும் + /sbin/init (இயல்புநிலை) + கொள்கலன் பாதையை உள்ளிடவும் + ரூட் அணுகல் இல்லாததால் இந்தப் பக்கம் முடக்கப்பட்டுள்ளது + குழுசேர் + பரவல் + பெயர் காலியாக இருக்கக்கூடாது + கொள்கலன் விருப்பங்கள் + கோப்புறையைத் தேர்ந்தெடு + பின்தளத்தை மீண்டும் நிறுவவும் + %1$sing கண்டெய்னரில் %2$s தோல்வி + டெர்மினல் பதிவுகள் + முழுமையான தனிமைப்படுத்தல் + ரூட்ஃப்ச் களஞ்சியம் + மேனுவல் டெட்லாக் சீல்டு + சலுகை பெற்ற பயன்முறை + பல வழிமாற்றுகள் + எ.கா. 1.1.1.1,8.8.8.8 (விரும்பினால்) + UDP + Droidspaces பின்தளம் நிறுவப்படவில்லை அல்லது சிதைக்கப்படவில்லை. முதலில் அதை நிறுவவும். + ஓடுகிறது + %1$s: %2$s + இயக்கு + %1$.1f%% + டெர்மினல் அமர்வுகள் + பழைய தொகுதியை நீக்குகிறது... + மரபு Cgroup V1 படிநிலையின் கட்டாயப் பயன்பாடு + ஆண்ட்ராய்டு பதிப்பு + %1$s கண்டெய்னரை நிறுவல் நீக்குவதில் தோல்வி + தவறான வரம்பு வடிவம் (START-END) + கொள்கலன் துறைமுகம் (1-65535 அல்லது வரம்பு) + முனையத்தில் + கணினி பாதையில் Droidspaces ஐ ஒருங்கிணைக்கவும் + ஆபரேசன் தோல்வியடைந்தது + தனிப்பயன் rootfs.json ஊட்டங்களுக்கு குழுசேரவும். + VirGL வழியாக 3D வன்பொருள் முடுக்கத்தை இயக்கவும் + இருண்ட கருப்பொருள் + ரத்துசெய் + முகமூடியை அவிழ்த்து விடுங்கள் + முக்கிய + SELinux இசைவு + IPv6 ஐ முடக்கு + அடுத்து: சேமிப்பு + GKI அல்லாத கர்னல் தேவைகள் (<=4.19) + சேவைகளைப் பெறுகிறது... + எ.கா. -dpi 160 -force-sysvshm + பதிவுகளை நகலெடுக்கவும் + புதுப்பிப்பு + திற + பிழைக் குறியீடு %1$d + NAT + தயவுசெய்து .tar.xz அல்லது .tar.gz கோப்பைத் தேர்ந்தெடுக்கவும். கோப்பு வகையைத் தீர்மானிக்க முடியவில்லை. + எதுவுமில்லை + பாதுகாப்பு & துவக்கம் + நுழைவாயில் அமைப்புகள் + ஏற்கனவே உள்ள துறைமுகம் முன்னோக்கி விதியுடன் மேலெழுகிறது + உங்கள் கோரிக்கையை நாங்கள் செயல்படுத்தும் வரை காத்திருக்கவும்... + குழு + %1$d MB (%2$.1f%%) + கொள்கலன் %1$s வெற்றிகரமாக நிறுவல் நீக்கப்பட்டது + SELinux நிலை + X11 + நுழைவாயிலாக செயல்பட மற்றொரு கொள்கலனை நிறுவவும் + புரவலன் லினக்ச் கர்னலுடன் நேரடியாகப் பேசுகிறது. ஐப்பர்வைசர் இல்லை, மெய்நிகர் இயந்திர அடுக்கு இல்லை. + தயவுசெய்து .tar.xz அல்லது .tar.gz கோப்பைத் தேர்ந்தெடுக்கவும். தேர்ந்தெடுக்கப்பட்டது: %1$s + AMOLED பயன்முறை + முனையத்தில் + rootfs.json முகவரி + சுழியம் மேல்நிலை + அனைத்து + பதிவிறக்கம் + பதிவிறக்கம் + மொழி + நிறுவல் பதிவுகள் + புரவலன் பாதை: %1$s + Systemd + தேடல் + புரவலன் துறைமுகம் (1-65535 அல்லது வரம்பு 100-200) + பின்பக்கம்: சிதைந்துள்ளது + Termux:X11ஐ உள்ளமைக்கவும் + நேட்டிவ் init.rc ஒருங்கிணைப்பு மற்றும் 2-லேயர் யூசர்ச்பேச் டெமான்களை ஆதரிக்கிறது. முற்றிலும் அழிக்க முடியாதது. + பின்தளம்: புதுப்பிப்பு உள்ளது + CPU: + கட்டிடக்கலை + அனுமதிகளை வழங்குகிறது... + Termux தேவைகள் + படத்தின் அளவு + பல்ச் ஆடியோ + கணினி கருப்பொருள் பின்பற்றவும் + நிர்வகிக்கவும் + துவக்கத்தில் கொள்கலனைத் தொடங்கவும் + வழிகாட்டி + பிழைத்திருத்தத்திற்கான கணினி செய்தி மற்றும் பதிவுகளை சேகரிக்கவும் + கொள்கலன்கள் இயங்கவில்லை + தேவைகள் சரிபார்ப்பு + ரத்துசெய் + GPU அணுகல் + கொள்கலன்களை நிர்வகிக்க ரூட் அணுகல் தேவை. + மறுவரிசைப்படுத்த இழுக்கவும். கொள்கலன்கள் மேலிருந்து கீழாகத் தொடங்குகின்றன - முதலில் ஒரு நுழைவாயிலை (எ.கா. OpenWRT) வைக்கவும், அதனால் அதன் வழியாக செல்லும் கொள்கலன்களுக்கு முன் அது பூட் ஆகும். + அசாதாரண சேவைகள் இல்லை + முடக்கப்பட்ட சேவைகள் இல்லை + உறுதிப்படுத்த, \"எந்த பிழைகளையும் நான் புகாரளிக்க மாட்டேன்\" என்று பெரிய எழுத்துக்களில் தட்டச்சு செய்க: + கொள்கலன் உள்ளமைவைத் திருத்து + வீடு + %1$sக்கான பணி நிர்வாகம் அடுத்த பேட்சில் சேர்க்கப்படும். + டெலிகிராம் சேனல் + \"%1$s\"க்கான ச்பேச் படத்தின் அளவை மாற்றுகிறது. சுருங்குவதை விட அளவை அதிகரிப்பது பாதுகாப்பானது. உங்கள் தரவை காப்புப் பிரதி எடுக்கவும். + இயங்கும் சேவைகள் இல்லை + அண்மைக் கால வெளியீட்டில் ரூட்ஃப்ச் டார்பால்கள் எதுவும் இல்லை + சரிபார்க்கிறது + நிலையான ஐபி முகவரி + குறைந்தபட்ச அளவு 4 சிபி + நுழைவாயில் + ஓடுகிறது + பிழை அறிக்கையை உருவாக்கவும் + %1$s %2$s செய் + ஒருங்கிணைப்பு & வன்பொருள் + சேமிக்கிறது... + லான் + ஓச்ட்/கன்டெய்னர் வரம்பு அகலம் பொருந்தவில்லை + பொருத்தங்கள் இல்லை + நிறுவல் முடிந்தது + OpenWrt + நிலையான + முகவரி காலியாக இருக்கக்கூடாது + அமைப்புகள் + கொள்கலனை நிறுத்துகிறது... + 1 அமர்வு இயங்குகிறது · மீட்டமைக்க தட்டவும் + ஏற்றுகிறது... + 4 முதல் 512 சிபி வரை அளவை உள்ளிடவும் + அறிவிப்பு இசைவு + எடுத்துக்காட்டுகள்: 8080 (ஒற்றை), 8000-8010 (வரம்பு). சமச்சீர் மேப்பிங்கிற்கு இலக்கை காலியாக விடவும். + NAT அமைப்புகள் + ஆயத்தம் + மிக நீளமானது - அதிகபட்சம் 15 எழுத்துகள் + %1$s கண்டெய்னரை ஏற்றுமதி செய்ய முடியவில்லை + ஒரு சிறிய படக் கோப்பில் கொள்கலனை உருவாக்கவும் + வெளியேறு + காலியாக இருந்தால், கொள்கலன் பெயரை ஓச்ட்பெயராகப் பயன்படுத்தும் + கட்டமைக்கப்படவில்லை + உள்ளமைவைப் புதுப்பிக்க முடியவில்லை + Droidspaces பற்றி + வட்டு பயன்பாடு + கொள்கலனை நிறுவுதல் + தனிப்பயன் களஞ்சியத்தைச் சேர்க்கவும் + வரம்பு: 1-254 + பவர் அப் ஆன் ஆட்டோ பூட் + தொடரவும் + ஆண்ட்ராய்டு சேமிப்பகத்தை ஏற்றவும் + ச்பேர்ச் பட கட்டமைப்பு + நிறுவவும் + 8 + பெயர் மிக நீளமானது - அதிகபட்சம் 17 எழுத்துகள் (கர்னல் லூப் சாதன பாதை வரம்பு) + %1$s: %2$s + அளவை மாற்றும் செயல்பாட்டைத் தொடங்குகிறது... + டெவலப்பர்(கள்) + ஆண்ட்ராய்டு சேமிப்பிடம் + , + கூடுதல் விருப்பங்கள் எதுவும் இயக்கப்படவில்லை + சேவைகள் எதுவும் இல்லை + தோல்வியடைந்தது + எண்கள், புள்ளிகள் மற்றும் பெருங்குடல்கள் மட்டுமே + கூட்டு + தொகுதி நிறுவல் தோல்வியடைந்தது + Systemd சேவைகள் + OpenWrt சேவைகள் + தேவைகள் + பாதை முழுமையானதாக இருக்க வேண்டும் (/ உடன் தொடங்கவும்) + ஓச்ட்பெயர்: %1$s + ரூட் அணுகல் இல்லை. + ஆண்ட்ராய்டு ரூட் செல்லில் droidspaces கட்டளையை அணுக /system/bin இல் ஒரு சிம்லிங்கை உருவாக்கவும். எச்சரிக்கை: உங்கள் மவுண்ட் சிச்டம் ஓவர்லேஃப்ச் மேவு மாட்யூல்களாக இருந்தால், இதை இயக்குவது பூட்லூப்பை ஏற்படுத்துகிறது. மேசிக் மவுண்ட் அடிப்படையிலான மவுண்ட் சிச்டத்தைப் பயன்படுத்தும் போது மட்டுமே இந்த விருப்பத்தைப் பயன்படுத்தவும் (மறுதொடக்கம் தேவை) + தனிப்பயன் தொடக்க பைனரி + நிறுவல் சுருக்கம் + ரூட் அணுகலைச் சரிபார்க்கிறது... + பயனர்களைப் புதுப்பிக்கவும் + வடிவமைப்பால் அழிக்க முடியாதது + முகமூடி பணி + சமச்சீர் மேப்பிங்கிற்கு காலியாக விடவும் + அறியப்படாத பிழை + மதிப்பு + இடைநிலைப்பலகைக்கு பதிவுகள் நகலெடுக்கப்பட்டன + அளவு தேவை + கொள்கலனின் பெயர் காலியாக இருக்கக்கூடாது + NAT மற்றும் எதுவுமில்லை பிணையம் முறைகளில் IPv6 எப்போதும் முடக்கப்பட்டிருக்கும். + பைண்ட் மவுண்ட்ச் + PulseAudio வழியாக ஆடியோ ஆதரவை இயக்கவும் + பிழை: %1$s + இந்த கொள்கலனுக்குள் ஒரு ஊடாடும் செல்லைத் திறக்கவும் + மேம்பட்ட விருப்பங்கள் + Droidspaces டெர்மினல் + கணினி இயல்புநிலை + சேவையை மீண்டும் தொடங்கவும் + இந்த நுழைவாயில் கொள்கலன் நிறுவப்படவில்லை + கொள்கலன் செய்தி + உங்கள் முதல் லினக்ச் கொள்கலனை எவ்வாறு அமைப்பது என்பதை அறிய இங்கே தட்டவும்! + முடிந்தது + diff --git a/Android/app/src/main/res/values-tr/strings.xml b/Android/app/src/main/res/values-tr/strings.xml index 692461a7..ff64bac9 100644 --- a/Android/app/src/main/res/values-tr/strings.xml +++ b/Android/app/src/main/res/values-tr/strings.xml @@ -148,8 +148,8 @@ NAT (Izole) Yok NAT Ayarları - Upstream Arayüzleri (Zorunlu) - NAT modu için en az bir adet upstream arabirim belirtmeniz gerekir. + Upstream Arayüzü (İsteğe bağlı) + İnternet erişimini belirli arayüze sabitler. Etkin bağlantıyı otomatik algılamak için boş bırakın. Upstream Arayuzu Ekle Kullanılabilir Sistem Arayüzleri: Ya da manuel olarak girin (joker karakterler de desteklenmektedir): diff --git a/Android/app/src/main/res/values-zh-rCN/strings.xml b/Android/app/src/main/res/values-zh-rCN/strings.xml index a24dd3ab..b52946cc 100644 --- a/Android/app/src/main/res/values-zh-rCN/strings.xml +++ b/Android/app/src/main/res/values-zh-rCN/strings.xml @@ -119,7 +119,7 @@ 重新安装二进制文件和模块 复制 未知 - 維護者 + 维护者 主机名:%1$s 启动 停止 @@ -136,8 +136,8 @@ NAT(默认) 无(完全隔离) NAT 设置 - 上游网络接口(必填) - 您必须为 NAT 模式指定至少一个上游网络接口。 + 上游网络接口(可选) + 将网络访问固定到指定接口。留空则自动检测当前活动的上游接口。 添加上游网络接口 可用的系统接口: 或手动输入(支持通配符): @@ -308,8 +308,8 @@ OpenRC 服务 OpenRC Init 系统 - Systemd/OpenRC 不可用 - 此容器未安装 systemd 或 OpenRC + Systemd/OpenWrt/OpenRC 不可用 + 此容器未安装 systemd、OpenWrt/procd 或 OpenRC 安装概要 刷新用户 屏蔽服务 @@ -344,7 +344,7 @@ 直接与宿主 Linux 内核交互。无需 Hypervisor,亦无虚拟机层。 由于设计层面而无法被终止 原生支持 init.rc 集成与双层用户空间守护进程。不会被轻易终止。 - 以 systemd 或 OpenRC 作为 PID 1 + 以 systemd / OpenWrt procd / OpenRC 作为 PID 1 支持完整的 Init 系统。具备真实的服务管理能力,并非简单的模拟环境。 完全隔离 为每个容器提供独立的 PID 树、挂载表、主机名、IPC 及 cgroup 层级。 @@ -457,4 +457,62 @@ URL 不能为空 添加自定义仓库 只读 + VirGL + PulseAudio + 配置 PulseAudio + 通过 PulseAudio 启用音频支持 + Termux:X11 额外参数 + 例如: -dpi 160 -force-sysvshm + 配置 VirGL 3D 加速功能 + 通过 VirGL 启用 3D 硬件加速 + VirGL 额外参数 + 例如: --multi-clients --angle-vulkan + Termux 要求 + 自启动优先级 + 系统启动时运行的容器的启动顺序 + 拖动即可重新排列顺序。容器从上到下排列——先放置网关(例如 OpenWRT),这样它会先启动,然后才是通过它的其他容器。 + 没有容器设置了 “开机时自启”。 + 拖动以重新排序 + 网关(虚拟路由器) + 网关 + 网关设置 + 把这个容器通过另一个充当路由器的容器(例如 OpenWRT)路由。该网关必须先运行起来。 + 网关容器 + 局域网名称 + 局域网 + 网关中的接口 + eth1 + 主机桥 + ds-lan + 配置网关 + 配置网关 + 这些控制这个容器如何接入网关的虚拟局域网。默认设置适用于单个网关后的单个局域网——只有在运行多个局域网段或多个网关时才需要更改它们。 + 这个连接显示的接口名称在网关容器内部出现(例如 OpenWRT 中的 eth1)。它必须与网关设置为提供 DHCP 的接口匹配。同一网关上的每个独立网络段都需要一个唯一的名称。默认值:eth1。 + 这个 LAN 段的标签。共享相同网关和 LAN 名称的容器会加入同一个虚拟交换机,并且可以彼此互相访问。使用不同的名称可以创建一个隔离的第二个段。默认值:lan。 + 在 Android 主机上创建的虚拟交换机名称。留空可根据 LAN 名称自动命名(ds-<lan>)。只有在你需要指定精确的桥接名称时才设置。最多 15 个字符。 + 选择一个网关容器 + 一个容器不能是它自己的网关 + 这个网关容器没有安装 + 安装另一个容器来充当网关 + 仅使用字母、数字、_ 或 - + 太长了 - 最多15个字符 + 桥接已被 \'%1$s\' 使用。请使用不同的局域网名称。 + 接口已被另一个段(\'%1$s\')使用。请使用其他接口。 + OpenWrt 服务 + 将在下一个补丁中加入 %1$s 的服务管理功能。 + 未知 + 重新加载服务 + 没有未知状态的服务 + 已检测到 OpenWrt/procd + OpenWrt + 磁盘占用 + 已使用 %1$s / %2$s + 计算中… + 不支持:内核未启用 CONFIG_USER_NS 配置项 + 允许用户命名空间 + 用户命名空间 + 允许非特权用户命名空间,Flatpak/bwrap/Firefox等软件需要此功能。但是,若内核未打补丁,设备可能易受某些漏洞利用攻击影响(如 Fragnesia) + 强调色 + 完整输入容器名称以确认: + 调试 diff --git a/Android/app/src/main/res/values-zh-rTW/strings.xml b/Android/app/src/main/res/values-zh-rTW/strings.xml index d18875e0..8ea163d4 100644 --- a/Android/app/src/main/res/values-zh-rTW/strings.xml +++ b/Android/app/src/main/res/values-zh-rTW/strings.xml @@ -165,7 +165,7 @@ 無法取得Root 權限 已存在同名容器 無(完全隔離) - 上游網路介面(必填) + 上游網路介面(可選) 停用 使用系統預設方案 輸入容器路徑 @@ -269,8 +269,8 @@ OpenRC 服務 OpenRC Init 系統 - Systemd/OpenRC 不可用 - 此容器未安裝或執行 systemd 或 OpenRC + Systemd/OpenWrt/OpenRC 不可用 + 此容器未安裝 systemd 或 OpenWrt/OpenRC 需求檢測 主機名稱:%1$s 沒有root權限。 @@ -278,7 +278,7 @@ 安裝概要 正在檢查root存取權限... 重新整理用戶 - 您必須為NAT 模式指定至少一個上游網路介面。 + 將網路存取固定到指定介面。留空則自動偵測目前作用中的上游介面。 屏蔽服務 留空表示對稱映射 必須指定大小 @@ -375,7 +375,7 @@ 直接與主機 Linux 核心通訊。無需虛擬機器管理程序,也無需虛擬機器層。 設計上即不可終止 支援原生 init.rc 整合和雙層使用者空間守護程式。完全無法終止。 - systemd / OpenRC 作為 PID 1 + systemd /OpenWrt,procd/ OpenRC 作為 PID 1 完整支援init系統。真正的服務管理,而非模擬環境。 完全隔離 每個容器擁有私有的 PID 樹、掛載表、主機名稱、IPC 和 cgroup 層次結構。 @@ -452,4 +452,62 @@ 網址必須以 https:// 開頭 目前還沒有訂閱任何自訂儲存庫。 唯讀 + VirGL + PulseAudio + 設定 PulseAudio + 透過 PulseAudio 啟用音訊支援 + Termux:X11 額外參數標記 + 例如: -dpi 160 -force-sysvshm + 配置 VirGL 3D 加速 + 透過 VirGL 啟用 3D 硬體加速 + VirGL 額外參數標記 + 例如: --multi-clients --angle-vulkan + Termux 需求 + 自動啟動優先權 + 容器啟動時的運行順序 + 拖曳即可重新排序。容器從上到下排列-首先放置網關(例如 OpenWRT),以便它在路由經過它的容器之前啟動。 + 所有容器均未啟用「啟動時運行」功能。 + 拖曳以重新排序 + 閘道器(虛擬路由器) + 網路閘道器 + 網路閘道器設定 + ds-lan + 設置網路閘道器 + 設置網路閘道器 + 這些設定控制此容器如何連接到網關的虛擬區域網路。預設設定適用於單一網關後的單一區域網路-僅在執行多個區域網路區段或多個閘道時才需要變更這些設定。 + 此連接的介面名稱顯示在網關容器內部(例如,OpenWRT 中的 eth1)。它必須與網關用於提供 DHCP 服務的介面名稱相符。同一網關上的每個獨立網段都需要有其唯一的名稱。預設值:eth1。 + 此 LAN 網段的標籤。共用相同網關和 LAN 名稱的容器將加入同一個虛擬交換機,彼此可以互相存取。使用不同的名稱可以啟動一個獨立的第二個網段。預設值:lan。 + 在 Android 主機上建立的虛擬交換器的名稱。留空則會根據 LAN 名稱自動命名 (ds-<lan>)。僅當需要精確的網橋名稱時才設定此項目。最多 15 個字元。 + 選擇網關容器 + 容器不能成為它本身的網關 + 此網關容器未安裝 + 安裝另一個容器作為網關 + 僅使用字母、數字、底線或連字符 + 字數過多 - 最多 15 個字符 + 網橋已被 \'%1$s\' 使用。請使用其他區域網路名稱。 + 介面已被其他區段 (\'%1$s\') 使用。請使用其他介面。 + OpenWrt 服務 + OpenWrt + 偵測到 OpenWrt/procd + 下一個補丁將新增對 %1$s 的服務管理。 + 未知 + 重載服務 + 沒有狀態未知的服務 + 將此容器的流量路由到另一個充當路由器的容器(例如 OpenWRT)。該網關必須先運作。 + 網關容器 + LAN名稱 + lan + 網關介面 + eth1 + 主機網橋 + 磁碟使用情況 + %1$s / %2$s 已使用 + 正在計算… + 使用者命名空間 + 允許使用者命名空間 + 允許非特權使用者命名空間(Flatpak、bwrap 和 Firefox 均需要此功能)。不過,若內核未打補丁,您可能會面臨諸如 Fragnesia 之類漏洞的攻擊風險 + 不支援。核心中未啟用 CONFIG_USER_NS + 強調色 + 請準確輸入容器名稱以進行確認: + 偵錯 diff --git a/Android/app/src/main/res/values/strings.xml b/Android/app/src/main/res/values/strings.xml index 68160097..63fa7aa4 100644 --- a/Android/app/src/main/res/values/strings.xml +++ b/Android/app/src/main/res/values/strings.xml @@ -13,7 +13,7 @@ Talks to the host Linux kernel directly. No hypervisor, no virtual machine layer. Unkillable by design Supports native init.rc integration and 2-layer userspace daemons. Completely unkillable. - systemd / OpenRC as PID 1 + systemd / OpenWrt procd / OpenRC as PID 1 Full init system support. Real service management, not a simulated environment. Complete isolation Private PID tree, mount table, hostname, IPC, and cgroup hierarchy per container. @@ -70,6 +70,7 @@ Reinstall binaries and boot module Requirements Kernel Requirements + Termux Requirements non-GKI Kernel Requirements (<=4.19) GKI Kernel Requirements (>=5.4) Copy @@ -85,6 +86,7 @@ Pure black background for OLED displays Apply System\'s Default Color Palette Use Android 12+ Monet theming (Material You) + Accent Color Daemon Mode Run Droidspaces as a persistent system-level daemon to bypass seccomp blocks from Magisk/APatch, GrapheneOS, etc. This enables high-privilege operations by fetching and running commands from userspace (Reboot required). Integrate Droidspaces to the system path @@ -122,6 +124,7 @@ Uninstall Container? This will permanently delete container \"%1$s\" and all its data. This action cannot be undone.\n\nIf the container is running, it will be stopped first. Uninstall + Type the container name exactly to confirm: Cancel View logs Edit Container Configuration @@ -149,6 +152,7 @@ Cgroup V1 Deadlock Shield Privileged + User Namespaces Installation Summary Review Configuration Tarball @@ -169,6 +173,9 @@ IP Address Uptime Unable to read container information + Disk Usage + %1$s / %2$s used + Calculating… Systemd Checking Not Available @@ -199,8 +206,16 @@ e.g. --multi-clients --angle-vulkan SELinux Permissive Set SELinux permissive + Allow User Namespaces + Allow unprivileged user namespaces, which Flatpak/bwrap/Firefox requires. However, you may be vulnerable to certain exploits like Fragnesia without a patched kernel + Not supported. CONFIG_USER_NS is not enabled in the kernel Run at Boot Start container on boot + Auto Boot Priority + Order in which run-at-boot containers start + Drag to reorder. Containers start top to bottom - put a gateway (e.g. OpenWRT) first so it boots before the containers that route through it. + No containers have Run at Boot enabled. + Drag to reorder Force Cgroup V1 Force use of legacy Cgroup V1 hierarchy Manual Deadlock Shield @@ -219,9 +234,36 @@ Host NAT None + Gateway (Virtual Router) + Gateway + + + Gateway Settings + Route this container through another container acting as a router (e.g. OpenWRT). That gateway must be running first. + Gateway Container + LAN Name + lan + Interface in Gateway + eth1 + Host Bridge + ds-lan + Configure Gateway + Configure Gateway + These control how this container plugs into the gateway\'s virtual LAN. The defaults work for a single LAN behind one gateway - change them only when running multiple LAN segments or more than one gateway. + The interface name this connection shows up as INSIDE the gateway container (e.g. eth1 in OpenWRT). It must match the interface your gateway is set to serve DHCP on. Each separate segment on the same gateway needs its own unique name. Default: eth1. + A label for this LAN segment. Containers that share the same gateway and LAN name join the same virtual switch and can see each other. Use a different name to spin up an isolated second segment. Default: lan. + Name of the virtual switch created on the Android host. Leave empty to auto-name it from the LAN name (ds-<lan>). Only set this if you need an exact bridge name. Max 15 characters. + Select a gateway container + A container cannot be its own gateway + This gateway container is not installed + Install another container to act as the gateway + Use only letters, digits, _ or - + Too long - max 15 characters + Bridge already used by \'%1$s\'. Use a different LAN name. + Interface already used by another segment (\'%1$s\'). Use a different one. NAT Settings - Upstream Interfaces (Mandatory) - You must specify at least one upstream interface for NAT mode. + Upstream Interface (Optional) + Pin internet access to specific interface(s). Leave empty to auto-detect the active uplink. Add Upstream Interface Available System Interfaces: Or enter it manually (wildcards are supported too): @@ -349,11 +391,15 @@ Systemd Services + OpenWrt Services + OpenWrt + OpenWrt/procd detected + Service management for %1$s will be added in the next patch. OpenRC Services OpenRC Init System - Systemd/OpenRC Not Available - This container does not have systemd or OpenRC installed + Systemd/OpenWrt/OpenRC Not Available + This container does not have systemd, OpenWrt/procd, or OpenRC installed Fetching services... Search services... @@ -361,12 +407,14 @@ Disabled Static Abnormal + Unknown Masked All Unmask Enable Disable Restart service + Reload service Mask service %1$s %2$s successful Failed to %1$s %2$s @@ -379,6 +427,7 @@ No disabled services No static services No abnormal services + No services with unknown state No masked services @@ -545,4 +594,5 @@ URL cannot be empty URL must start with https:// No custom repositories subscribed yet. + Debugging diff --git a/Android/app/src/main/res/xml/locales_config.xml b/Android/app/src/main/res/xml/locales_config.xml index f553c1ef..558b4696 100644 --- a/Android/app/src/main/res/xml/locales_config.xml +++ b/Android/app/src/main/res/xml/locales_config.xml @@ -19,6 +19,7 @@ + diff --git a/Android/app/src/main/res/xml/network_security_config.xml b/Android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 00000000..827dede2 --- /dev/null +++ b/Android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/Android/app/src/main/res/xml/shortcuts.xml b/Android/app/src/main/res/xml/shortcuts.xml new file mode 100644 index 00000000..c7eefc36 --- /dev/null +++ b/Android/app/src/main/res/xml/shortcuts.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + diff --git a/Documentation/Features.md b/Documentation/Features.md index d166cea5..c48488e1 100644 --- a/Documentation/Features.md +++ b/Documentation/Features.md @@ -29,14 +29,16 @@ Linux namespaces are a kernel feature that partitions system resources so that e ### Network Namespace Isolation (`--net`) -Droidspaces supports three networking modes that determine whether a network namespace (`CLONE_NEWNET`) is used: +Droidspaces supports four networking modes that determine whether a network namespace (`CLONE_NEWNET`) is used: 1. **Host Mode (`--net=host`) - Default**: Droidspaces deliberately does **not** unshare the network namespace. The container shares the host's network stack. This greatly simplifies setup: containers get internet access immediately without virtual bridges, NAT, or firewall rules. On Android, where networking is already complex (cellular, Wi-Fi, VPN), this avoids a whole category of connectivity issues. -2. **NAT Mode (`--net=nat`)**: The container is placed in a private network namespace. It is connected to the host via a virtual bridge or veth pair, providing **Pure Network Isolation** while maintaining internet access through the host's upstream interfaces. Compatible with the vast majority of Android devices. +2. **NAT Mode (`--net=nat`)**: The container is placed in a private network namespace. It is connected to the host via a virtual bridge or veth pair, providing **Pure Network Isolation** while maintaining internet access through the host's active internet uplink, which is detected automatically (or pinned manually with `--upstream`, see below). Compatible with the vast majority of Android devices. 3. **None Mode (`--net=none`)**: The container is placed in a private, air-gapped network namespace with only the loopback interface enabled for maximum security. +4. **Gateway Mode (`--net=gateway`)**: The container's LAN is delegated to *another* running container (typically OpenWRT). Droidspaces does only the L2 plumbing (bridge + veth pairs) and lets the gateway container own all policy - DHCP, DNS, firewall, routing, VPN. Ideal for VPN killswitches, segmented LANs, and traffic analysis. See the dedicated [Networking From Zero](Networking-From-Zero.md) guide for the full deep dive. + ### How It Compares to Chroot A `chroot` only changes the apparent root directory for a process. It provides no process isolation, no mount isolation, no hostname isolation, and no IPC isolation. Any process inside a chroot shares the host's PID space, can see and signal other processes, and cannot run an init system like systemd. @@ -187,7 +189,7 @@ For GUI application support, Droidspaces automatically bind-mounts the X11 socke > X11 support can be enabled independently using the `--termux-x11` (`-X`) flag. This is the recommended way to use GUI applications on Android if you do not need full GPU/hardware access, as it preserves a higher level of isolation. -After starting the container, set `DISPLAY=:0` inside the container to use the X11 display. +Droidspaces automatically injects `DISPLAY=:5` and (if VirGL is enabled) `GALLIUM_DRIVER=virpipe` into the container environment via `/run/droidspaces.env`, symlinked from `/etc/profile.d/droidspaces_env.sh`. Shells like `bash` and `sh` source this automatically. If you use `zsh`, `fish`, or another non-login shell, source it manually: `source /run/droidspaces.env`. ### Supported GPU Families @@ -249,9 +251,9 @@ Droidspaces validates bind mount targets with two protections: --- -## Network Isolation (3 Modes) +## Network Isolation (4 Modes) -Droidspaces provides three distinct networking modes to balance ease-of-use with advanced isolation. +Droidspaces provides four distinct networking modes to balance ease-of-use with advanced isolation. ### 1. Host Mode (`--net=host`) - Default The container shares the host's network namespace. @@ -263,15 +265,23 @@ The container is placed in a private network namespace (`CLONE_NEWNET`) and conn - **Deterministic IP**: Each container is assigned a unique IP in the `172.28.0.0/16` range, derived from its PID. - **Embedded DHCP**: Droidspaces includes a minimal, built-in DHCP server to automatically configure the container's `eth0`. - **Pure Isolation**: The container cannot see or interact with the host's network interfaces directly. -- **Mandatory Upstream**: You **must** specify which host interfaces provide internet access via `--upstream` (e.g., `--upstream wlan0,rmnet0`). Wildcards are also supported (e.g., `rmnet*`, `wlan0`, `v4-rmnet_data*`). +- **Automatic Uplink Detection**: No configuration needed. Droidspaces reads the kernel's own ground truth to find the interface that provides internet access - on Android, the policy-routing rule netd installs for the active default network; on standard Linux, the main routing table's default route. CLAT (464xlat) interfaces on IPv6-only mobile networks are handled automatically. > [!IMPORTANT] -> NAT mode is **IPv4 only**. If your upstream interface lacks an IPv4 address (IPv6-only network), internet access will not work. See [IPv4 NAT Quirks](Troubleshooting.md#ipv4-quirks) for a workaround. +> NAT mode is **IPv4 only**. If the host's uplink lacks an IPv4 address (IPv6-only network), internet access will not work. See [IPv4 NAT Quirks](Troubleshooting.md#ipv4-quirks) for a workaround. ### 3. None Mode (`--net=none`) The container gets a private network namespace with only the loopback (`lo`) interface enabled. - **Use Case**: Maximum security for offline tasks. +### 4. Gateway Mode (`--net=gateway`) +The container is placed on an isolated L2 bridge whose **policy is owned by another running container** (typically OpenWRT) instead of by Droidspaces. Droidspaces does only the plumbing - it creates the bridge and the veth pairs and moves them into place; the gateway container provides DHCP, DNS, firewall, routing and VPN. +- **Required flag**: `--gateway=NAME` names the running container that acts as the router. +- **Segments**: `--gateway-net=NAME` (default `lan`) selects which bridge/segment the client lands on. Multiple clients sharing a `--gateway-net` share a LAN; different `--gateway-net` values are isolated segments through the same gateway. +- **Interface naming**: `--gateway-iface=IFACE` (default `eth1`) controls what the LAN interface is called *inside* the gateway container, so it matches the gateway's own config. +- **Self-healing**: wiring is driven entirely from the host side, so clients are (re)wired automatically when the gateway container starts or reboots - no client restart needed. +- **Use Cases**: VPN killswitch for selected containers, VLAN-style segmented LANs, single-chokepoint traffic analysis, gateway-wide DNS filtering. See [Networking From Zero](Networking-From-Zero.md) for the complete walkthrough. + ### Port Forwarding (NAT Mode) In NAT mode, you can expose container services to the host or local network using the `--port` flag. Supported formats: @@ -290,9 +300,30 @@ In NAT mode, you can expose container services to the host or local network usin --port 2222:22/tcp --port 5000-5050:5000-5050/udp ``` +Forwarded ports are reachable from any network the host belongs to - including clients connected to the phone's own hotspot or USB tethering on Android. + + +### Real-Time Uplink Monitoring +On Android, the connection often hops between Wi-Fi and Mobile Data. Droidspaces includes a **Route Monitor** that subscribes to kernel routing events (FIB rules, routes, links, addresses). The moment Android switches its default network (e.g., you walk out of Wi-Fi range), the monitor updates the kernel's policy routing to keep the container connected - no configuration, no restart. The same monitor works on desktop Linux (e.g. a Wi-Fi to ethernet handoff), where it follows the main routing table's default route. + +### Manual Uplink Pinning (`--upstream`) +By default the uplink is fully automatic. When you want the container's WAN to **ignore the host's active network** and go out through a specific interface instead, pin it with `--upstream`. This switches auto-detection off entirely - the listed interface(s) become the *only* WAN candidates. + +```bash +# Single interface +--upstream=wlan0 + +# Priority-ordered list with wildcards (comma-separated) +--upstream=wlan0,rmnet* +``` + +- **Authoritative, not a fallback**: traffic never hops to whatever `netd` marks active - only to interfaces you listed. +- **Priority failover *within* the list**: the Route Monitor re-resolves on every link/route change and uses the first listed interface that is up and has internet. `wlan0,rmnet*` prefers Wi-Fi and falls back to mobile data, then back to Wi-Fi when it returns. +- **Literals and wildcards** (`*`, `?`): use `rmnet*` for mobile data, whose interface number is not stable across reconnects. +- **Disappear/reappear** mid-session is handled: no WAN until a pinned interface is up, then it wires automatically. +- **Use cases**: pin `tun0` to route the container exclusively through a phone-side VPN (a free killswitch), or pin `rmnet*` (with "Mobile data always active") to keep the container on cellular while the phone stays on Wi-Fi. -### Upstream Interface Monitoring -On Android, the connection often hops between Wi-Fi and Mobile Data. Droidspaces includes a **Route Monitor** that tracks your declared `--upstream` interfaces. If your active interface changes (e.g., you walk out of Wi-Fi range), the monitor automatically updates the kernel's policy routing to keep the container connected without a restart. +> `--upstream` is only valid with `--net=nat`; it is ignored (with a warning) in other modes. --- diff --git a/Documentation/GPU-Acceleration.md b/Documentation/GPU-Acceleration.md deleted file mode 100644 index 86109a49..00000000 --- a/Documentation/GPU-Acceleration.md +++ /dev/null @@ -1,195 +0,0 @@ - - -# Droidspaces GPU Acceleration Guide - -This guide provides step-by-step instructions for enabling GPU acceleration in your Droidspaces containers. Whether you are running on an Android device or a Linux desktop, Droidspaces offers multiple ways to leverage hardware acceleration for a smooth graphical experience. - -### Quick Navigation - -- [**Android Devices**](#android) - - [01. Termux-X11 + llvmpipe (Software Rendering)](#termux-x11) - - [02. Termux-X11 + VirGL (Non-Qualcomm GPUs)](#virgl) - - [03. Turnip (Native Qualcomm/Adreno)](#turnip) -- [**Linux Desktop (AMD/Intel)**](#linux) - ---- - - - -## Android - -Hardware acceleration on Android is achieved by bridging the container's graphics stack with a host-side X server (Termux-X11). Droidspaces handles the complex mount management and security contexts required to make this seamless. - -> [!TIP] -> -> If you want to enjoy an out-of-the-box XFCE desktop environment experience, you can download our pre-configured XFCE tarballs from the [Rootfs repository](Usage-Android-App.md#rootfs-repository) or from the official [Droidspaces Rootfs Builder Releases](https://github.com/Droidspaces/Droidspaces-rootfs-builder/releases). - - - -### 01. Termux-X11 + llvmpipe - -This method uses **software rendering** via `llvmpipe`. While it doesn't provide full hardware acceleration, it is the most stable way to run GUI applications when a compatible GPU driver isn't available. - -#### The "Unified Tmpfs Bridge" -When you enable the **Termux X11** toggle in the Droidspaces app, the following sequence occurs: - -1. **Host-side Preparation**: Droidspaces creates a `tmpfs` mount on top of Termux's `/data/data/com.termux/files/usr/tmp` in the host's mount namespace. - -2. **Bypassing FBE Encryption**: While a direct bind-mount of `/data/data/com.termux/files/usr/tmp` to the container is possible, it frequently breaks applications like `apt` or any tool performing heavy I/O in `/tmp`. This happens because Termux's data directory is protected by Android's File-Based Encryption (FBE), leading to "Required key not available" (ENOKEY) errors. By bridging the path via `tmpfs`, X11 sockets and temporary files become fully readable and writable by the container. - -3. **Bind Mounting**: This "Unified Tmpfs Bridge" is then bind-mounted to the container's `/tmp` directory, enabling seamless communication between the container and the Termux-X11 app. - -#### Setup Requirements - -- **Termux**: `pkg install x11-repo && pkg install termux-x11` -- **Container**: `sudo apt install mesa-utils` (for testing with `glxgears`) - -#### Implementation Steps -1. **Configure Container**: In the Droidspaces app, navigate to your container's configuration. - -2. **Enable X11**: Toggle **Termux-X11** to `ON` (**Hardware Access** is not required for software rendering). - -3. **Environment**: Add `DISPLAY=:0` to the **Environment Variables** section and save. - -4. **Start Container**: Launch your container. - -5. **Launch X Server**: Open the Termux app and run: - - ```bash - termux-x11 :0 - ``` - -6. **Verify**: Run `glxgears` inside the container terminal. The output will render in the Termux-X11 app. - -7. **Start Desktop Environment**: To launch the full XFCE desktop (if installed), run: - - ```bash - dbus-launch --exit-with-session startxfce4 - ``` - ---- - - - -### 02. Termux-X11 + VirGL - -This method provides **GPU acceleration for non-Qualcomm devices (Mali/PowerVR)** via a `virglrenderer` bridge. It translates OpenGL calls from the container into commands that the host Android OS can execute. - -#### Setup Requirements - -- **Termux**: `pkg install x11-repo && pkg install termux-x11 virglrenderer-android` -- **Container**: `sudo apt install mesa-utils` (for testing with `glxgears`) - -#### Implementation Steps - -1. **Container Configuration**: Enable **Termux-X11** in the Droidspaces container settings. Then, add the following to the **Environment Variables** section: - ```bash - DISPLAY=:0 - GALLIUM_DRIVER=virpipe - ``` - -2. **Start Container**: Launch your container. - -3. **Start VirGL Server**: Open Termux and run the server in the background: - ```bash - virgl_test_server_android & - ``` - -4. **Start X Server**: In Termux, run: - ```bash - termux-x11 :0 - ``` - -5. **Verify Acceleration**: Run `glxinfo -B` and look for "VirGL" in the renderer string. - -6. **Start Desktop Environment**: To launch the full XFCE desktop (if installed), run: - - ```bash - dbus-launch --exit-with-session startxfce4 - ``` - -> [!TIP] -> -> **If the renderer fails to initialize,** try starting the VirGL server with the Vulkan backend: -> -> `virgl_test_server_android --angle-vulkan &` - ---- - - - -### 03. Turnip (Native Qualcomm/Adreno) - -For Qualcomm Adreno GPUs, Droidspaces supports **native hardware acceleration** using the Turnip driver. This bypasses the need for `virgl` and provides near-native performance. - -#### Requirements - -- **Custom Mesa driver**: Installed following the instructions from the [Mesa for Android Container repository](https://github.com/lfdevs/mesa-for-android-container). -- **Termux**: `pkg install x11-repo && pkg install termux-x11` - -#### Implementation Steps - -1. **Install the custom Mesa driver** from the [Mesa for Android Container repository](https://github.com/lfdevs/mesa-for-android-container). - -2. **Enable GPU Access**: In the container settings, enable **GPU Access** and **Termux X11**. - -3. **Set Display**: Add `DISPLAY=:0` to your environment variables. - -4. **Launch Sequence**: - - Start the container via Droidspaces. - - Open Termux and run `termux-x11 :0` - -5. **Permission Management (Non-Root Users)**: - If you are using a non-root user, you must grant them access to the GPU device nodes: - - ```bash - sudo usermod -aG droidspaces-gpu - ``` - -6. **Start Desktop Environment**: To launch the full XFCE desktop (if installed), run: - - ```bash - dbus-launch --exit-with-session startxfce4 - ``` - -> [!TIP] -> -> **If you encounter any problems related to DRI3,** try editing `/data/adb/modules/droidspaces/sepolicy.rule` and uncommenting the line: -> -> `allow untrusted_app_27 droidspacesd fd use` - ---- - - - -## Linux Desktop (AMD/Intel) - -On Linux-based hosts, GPU acceleration works natively with zero additional configuration within Droidspaces. - -#### Requirements -- An active X11 or Wayland session on your host. -- Functional GPU drivers (Mesa/Intel/AMD). - -#### Implementation Steps - -1. **Enable Hardware Access**: Ensure the **Hardware Access** toggle is enabled in your container configuration (or use the `--hw-access` CLI flag). - -2. **Xhost Permission**: On your host machine, allow the container to connect to your X server: - - ```bash - xhost +local: - ``` - -3. **Set Display Variable**: Add the host's `DISPLAY` number to the container's environment (usually `:0`): - - ```bash - echo "DISPLAY=:0" >> /etc/environment - ``` - -4. **Run Applications**: GUI applications launched from the container will render natively with full hardware acceleration. diff --git a/Documentation/Graphics-and-Audio.md b/Documentation/Graphics-and-Audio.md new file mode 100644 index 00000000..7def9206 --- /dev/null +++ b/Documentation/Graphics-and-Audio.md @@ -0,0 +1,400 @@ + + +# Droidspaces Display, Audio & Desktop Guide + +This guide covers display setup, GPU acceleration, sound (PulseAudio), and desktop environment auto-boot for Droidspaces containers. On Android, as of **v6.3.0**, the X server, VirGL server, and PulseAudio daemon all launch automatically when a container starts - no manual Termux commands required. + +> [!IMPORTANT] +> +> **Droidspaces does not ship PulseAudio, Termux:X11, or virglrenderer-android.** These are upstream Termux packages installed by the setup script. Droidspaces only manages their lifecycle (launching, socket bridging, environment injection). If you encounter issues with audio quality, device compatibility, crashes, or rendering problems with any of these packages, report them to the [Termux packages](https://github.com/termux/termux-packages) project, not to Droidspaces. + +### Quick Navigation + +- [**Universal Requirements (Android)**](#requirements) +- [**Android Display & GPU**](#android) + - [01. Termux-X11 + llvmpipe (Software Rendering)](#termux-x11) + - [02. Termux-X11 + VirGL (Non-Qualcomm GPUs)](#virgl) + - [03. Turnip (Native Qualcomm/Adreno)](#turnip) +- [**Android Sound (PulseAudio)**](#pulseaudio) +- [**Desktop Environment Auto-Boot**](#de-autoboot) +- [**Linux Desktop (AMD/Intel)**](#linux) + +--- + + + +## Universal Requirements (Android) + +Before setting up any display, GPU, or audio feature, ensure all of the following are met: + +1. **Droidspaces v6.3.0 or higher** - after updating, a **full reboot is mandatory** to apply the updated SELinux rules. + +2. **Both [Termux](https://github.com/termux/termux-app) and [Termux:X11](https://github.com/termux/termux-x11) apps must be installed** on your device. + +3. **Run the Droidspaces setup script inside Termux** (mandatory - installs Termux:X11, VirGL, PulseAudio, and patches the audio config): + + ```bash + curl -fsSL https://github.com/ravindu644/Droidspaces-OSS/raw/refs/heads/dev/scripts/setup-termux.sh | bash + ``` + +> [!IMPORTANT] +> +> This script only needs to be run once. It installs all required display and audio dependencies into Termux. + +--- + + + +## Android Display & GPU + +As of v6.3.0, Droidspaces automatically launches the Termux:X11 X server (and VirGL server, if configured) when a container starts. It also injects the required environment variables into the container via `/run/droidspaces.env`, which is symlinked from `/etc/profile.d/droidspaces_env.sh` for automatic sourcing. + +> [!TIP] +> +> **Using `zsh`, `fish`, or another non-login shell?** +> +> These shells may not source `/etc/profile.d` automatically. Run this manually inside your container after booting: `source /run/droidspaces.env` +> +> This sets `DISPLAY=:5` and (if VirGL is enabled) `GALLIUM_DRIVER=virpipe`. + +--- + +> [!TIP] +> **Want an out-of-the-box desktop experience?** +> +> Our official XFCE rootfs tarballs have **built-in XFCE auto-boot support**. When you start the container with Termux:X11 (or VirGL) enabled, XFCE launches automatically and appears in the Termux:X11 app - no terminal commands needed. +> +> Download from the [Rootfs Repository](Usage-Android-App.md#rootfs-repository) (search "XFCE") or directly from [Droidspaces Rootfs Builder Releases](https://github.com/Droidspaces/Droidspaces-rootfs-builder/releases/latest). + +--- + + + +### 01. Termux-X11 + llvmpipe + +Software rendering via `llvmpipe`. The most compatible method - works on any device regardless of GPU vendor. + +#### Setup + +1. Open the Droidspaces app and navigate to **Edit container configuration**. +2. Enable the **Configure Termux:X11** toggle and save. +3. Start the container. Droidspaces will automatically launch the Termux:X11 X server - the Termux:X11 app will display the **"X" cursor**, confirming the server is ready. +4. Open a terminal inside the container and run any GUI application: + + ```bash + glxgears + ``` + + ```bash + startxfce4 + ``` + + The window will appear in the Termux:X11 app. + +> [!NOTE] +> +> **Hardware Access/GPU Access** is not required for software rendering. + +--- + + + +### 02. Termux-X11 + VirGL + +Hardware-accelerated rendering for **non-Qualcomm devices (Mali, PowerVR)** via a `virglrenderer` bridge. Translates OpenGL calls from the container into commands the host Android GPU can execute. + +Droidspaces automatically starts both the X server and the VirGL server, and injects `DISPLAY=:5` and `GALLIUM_DRIVER=virpipe` into the container environment. + +#### Setup + +1. Open **Edit container configuration**. +2. Enable both **Configure Termux:X11** and **Configure VirGL 3D Acceleration** toggles and save. +3. Start the container. Both servers launch automatically. The Termux:X11 app will show the **"X" cursor** once ready. +4. Run any GUI application inside the container: + + ```bash + glxgears + ``` + + Look for **"VirGL"** in the renderer string to confirm acceleration is active. + + ```bash + startxfce4 + ``` + +> [!TIP] +> +> **If the VirGL renderer fails to initialize**, you can pass the Vulkan backend flag via the **VirGL Extra Flags** field in the container configuration: +> +> `--angle-vulkan` + +--- + + + +### 03. Turnip (Native Qualcomm/Adreno) + +Near-native hardware acceleration for **Qualcomm Adreno GPUs** using the open-source Turnip Mesa driver. This bypasses VirGL entirely for direct GPU access. + +#### Requirements + +- A custom Mesa driver installed from the [Mesa for Android Container repository](https://github.com/lfdevs/mesa-for-android-container). + +#### Setup + +1. Install the custom Mesa driver following the instructions at [Mesa for Android Container](https://github.com/lfdevs/mesa-for-android-container). + +2. Open **Edit container configuration** and apply the following: + - Enable **GPU Access** and **Configure Termux:X11**. + - **Disable** the **Configure VirGL 3D Acceleration** toggle (VirGL must be off for Turnip). + - Add these two environment variables: + + ``` + MESA_LOADER_DRIVER_OVERRIDE=kgsl + TU_DEBUG=noconform + ``` + +3. Start the container. Droidspaces launches the X server automatically. + +4. Run a GUI application inside the container - Turnip GPU acceleration will be active. + +> [!NOTE] +> +> **Permission Management (Non-Root Users):** If you are using a non-root user, you must grant them access to the GPU device nodes: `sudo usermod -aG droidspaces-gpu ` + +--- + + + +## Android Sound (PulseAudio) + +Droidspaces bridges Android's audio stack into your container using PulseAudio. When enabled, a PulseAudio daemon runs on the host as the Termux user (so Android's audio HAL grants it access to the device speaker), and its UNIX socket is bind-mounted into the container at `/tmp/.pulse-socket`. The environment variable `PULSE_SERVER=unix:/tmp/.pulse-socket` is injected automatically, so any application inside the container that speaks PulseAudio will produce sound with no manual configuration. + +> [!WARNING] +> +> PulseAudio audio passthrough may not work on all devices. Compatibility depends on the Android version, OEM audio HAL implementation, and the Termux PulseAudio build. If audio does not work on your device, this is a known limitation of the upstream packages on that platform. + +#### Requirements + +- PulseAudio must be installed in Termux - the [setup script](#requirements) handles this automatically. + +#### Setup + +1. Open the Droidspaces app and navigate to **Edit container configuration**. +2. Enable the **Configure PulseAudio** toggle and save. +3. Start the container. Droidspaces will: + - Launch the PulseAudio daemon as the Termux user. + - Wait for the socket at `/tmp/.pulse-socket` to appear before proceeding. + - Run `pactl set-default-sink AAudio_sink` to route audio to the device speaker. + - Bind-mount the socket into the container and inject `PULSE_SERVER`. + + You can also enable PulseAudio via the CLI flag `--pulse-audio`. + +4. Install and run any audio application inside the container. Because `PULSE_SERVER` is already set in the container environment, most apps work without any additional configuration: + + ```bash + # Test audio output + paplay /path/to/sound.wav + ``` + + ```bash + # Verify the PulseAudio connection + pactl info + ``` + +> [!NOTE] +> +> PulseAudio sound is **Android-only**. On Linux desktop hosts, audio passthrough works via the host's own PulseAudio/PipeWire setup - no special configuration is needed in Droidspaces. + +> [!NOTE] +> +> **Samsung One UI 6.1+ devices:** Droidspaces automatically injects `libskcodec.so` via `LD_PRELOAD` before starting PulseAudio. This fixes a hidden dependency in the OpenSL ES audio module specific to Samsung firmware. No action is required from you. + +--- + + + +## Desktop Environment Auto-Boot + +As of v6.3.0, when Termux:X11 is enabled in a container's configuration, Droidspaces guarantees the X server socket (`/tmp/.X11-unix/X5`) is live before the container's init system reaches `graphical.target`. This makes proper systemd-based DE auto-start possible with no race conditions. + +### How the Official XFCE Tarballs Wire It + +Our official XFCE rootfs tarballs ship with a fully pre-configured auto-boot setup. Here's exactly how it works: + +**1. The `xfce-autostart.service` systemd unit** is installed at `/etc/systemd/system/xfce-autostart.service` and enabled under `graphical.target`: + +```ini +[Unit] +Description=XFCE Autostart +After=graphical.target + +[Service] +Type=simple +User=root +ExecCondition=/bin/sh -c "grep -q 'enable_termux_x11=1' /run/droidspaces/container.config" +ExecCondition=/bin/sh -c "test -S /tmp/.X11-unix/X5" +ExecStart=/usr/local/bin/xfce-start +Restart=on-failure + +[Install] +WantedBy=graphical.target +``` + +Two `ExecCondition` guards ensure XFCE only starts when it makes sense: the container must have Termux:X11 enabled in its config, and the X server socket must actually exist. If either condition fails, systemd skips the service silently - no errors, no crash loops. + +**2. The `/usr/local/bin/xfce-start` launcher script** handles environment sourcing and user switching: + +```sh +#!/bin/sh + +ENV_FILE=/run/droidspaces.env +CONFIG=/run/droidspaces/container.config + +if [ -f "$ENV_FILE" ]; then + . "$ENV_FILE" + WHITELIST=$(sed -n 's/^export \([A-Za-z_][A-Za-z0-9_]*\)=.*/\1/p' "$ENV_FILE" | tr '\n' ',' | sed 's/,$//') +else + export DISPLAY=:5 + WHITELIST=DISPLAY + if grep -q 'enable_pulseaudio=1' "$CONFIG" 2>/dev/null; then + export PULSE_SERVER=unix:/tmp/.pulse-socket + WHITELIST="$WHITELIST,PULSE_SERVER" + fi + if grep -q 'enable_virgl=1' "$CONFIG" 2>/dev/null; then + export GALLIUM_DRIVER=virpipe + WHITELIST="$WHITELIST,GALLIUM_DRIVER" + fi +fi + +if [ -n "$XFCE_USER" ]; then + exec su -l -w "$WHITELIST" "$XFCE_USER" -c 'exec /usr/bin/startxfce4' +else + exec /usr/bin/startxfce4 +fi +``` + +The script first tries to source `/run/droidspaces.env` (written by Droidspaces at boot; contains `DISPLAY=:5`, `GALLIUM_DRIVER=virpipe` if VirGL is enabled, and `PULSE_SERVER` if PulseAudio is enabled). If the env file is missing for any reason, it falls back to reading the container config directly and building the environment manually. This makes the script robust against any init system startup ordering. + +### The `XFCE_USER` Variable + +By default, `xfce-start` runs XFCE as `root`. If you want XFCE to run as a non-root user, set the `XFCE_USER` environment variable in your container's **Environment Variables** configuration in the Droidspaces app: + +``` +XFCE_USER=youruser +``` + +When `XFCE_USER` is set, the script uses `su -l -w "$WHITELIST"` to switch to that user while explicitly passing through only the required environment variables (`DISPLAY`, `GALLIUM_DRIVER`, `PULSE_SERVER`, etc.). This keeps the session clean - no root env leaking into the user session. + +> [!TIP] +> +> Make sure `youruser` exists inside the container and has a valid home directory before setting `XFCE_USER`. You can create one with `useradd -m youruser` inside the container. + +--- + +### Power Users: Wire Any Desktop Environment + +You can replicate this exact pattern for any DE (Plasma, GNOME, MATE, i3, etc.) in any container. + +**Step 1:** Create the launcher script at `/usr/local/bin/de-start`: + +```sh +#!/bin/sh + +ENV_FILE=/run/droidspaces.env +CONFIG=/run/droidspaces/container.config + +if [ -f "$ENV_FILE" ]; then + . "$ENV_FILE" + WHITELIST=$(sed -n 's/^export \([A-Za-z_][A-Za-z0-9_]*\)=.*/\1/p' "$ENV_FILE" | tr '\n' ',' | sed 's/,$//') +else + export DISPLAY=:5 + WHITELIST=DISPLAY + if grep -q 'enable_pulseaudio=1' "$CONFIG" 2>/dev/null; then + export PULSE_SERVER=unix:/tmp/.pulse-socket + WHITELIST="$WHITELIST,PULSE_SERVER" + fi + if grep -q 'enable_virgl=1' "$CONFIG" 2>/dev/null; then + export GALLIUM_DRIVER=virpipe + WHITELIST="$WHITELIST,GALLIUM_DRIVER" + fi +fi + +DE_CMD="startplasma-x11" # replace with your DE's start command + +if [ -n "$XFCE_USER" ]; then + exec su -l -w "$WHITELIST" "$XFCE_USER" -c "exec $DE_CMD" +else + exec $DE_CMD +fi +``` + +```bash +chmod +x /usr/local/bin/de-start +``` + +**Step 2:** Create the systemd service at `/etc/systemd/system/de-autostart.service`: + +```ini +[Unit] +Description=Desktop Environment Autostart +After=graphical.target + +[Service] +Type=simple +ExecCondition=/bin/sh -c "grep -q 'enable_termux_x11=1' /run/droidspaces/container.config" +ExecCondition=/bin/sh -c "test -S /tmp/.X11-unix/X5" +ExecStart=/usr/local/bin/de-start +Restart=on-failure + +[Install] +WantedBy=graphical.target +``` + +**Step 3:** Enable it: + +```bash +systemctl enable de-autostart.service +``` + +On next container boot with Termux:X11 enabled, your DE will appear in the Termux:X11 app automatically. + +> [!NOTE] +> +> The `XFCE_USER` variable name is a convention from our official tarballs. You can rename it to anything in your own script - what matters is the `su -l -w "$WHITELIST"` pattern for clean user switching with env passthrough. + +--- + + + +## Linux Desktop (AMD/Intel) + +On Linux-based hosts, GPU acceleration works natively with zero additional configuration within Droidspaces. + +#### Requirements +- An active X11 or Wayland session on your host. +- Functional GPU drivers (Mesa/Intel/AMD). + +#### Implementation Steps + +1. **Enable Hardware Access**: Ensure the **Hardware Access** toggle is enabled in your container configuration (or use the `--hw-access` CLI flag). + +2. **Xhost Permission**: On your host machine, allow the container to connect to your X server: + + ```bash + xhost +local: + ``` + +3. **Set Display Variable**: Add the host's `DISPLAY` number to the container's environment (usually `:0`): + + ```bash + echo "DISPLAY=:0" >> /etc/environment + ``` + +4. **Run Applications**: GUI applications launched from the container will render natively with full hardware acceleration. diff --git a/Documentation/Installation-Android.md b/Documentation/Installation-Android.md index b5a548c7..5b7dbd2d 100644 --- a/Documentation/Installation-Android.md +++ b/Documentation/Installation-Android.md @@ -74,5 +74,5 @@ You can verify your system status at any time: ## Next Steps - [Android App Usage Guide](Usage-Android-App.md) for management details. -- [GPU Acceleration Guide](GPU-Acceleration.md) to enable hardware-accelerated desktop environments. +- [Display, Audio & Desktop Guide](Graphics-and-Audio.md) to enable GPU acceleration, sound, and desktop environment auto-boot. - [Linux CLI Guide](Linux-CLI.md) for expert command-line access. diff --git a/Documentation/Kernel-Configuration.md b/Documentation/Kernel-Configuration.md index 1b00a05b..dbe7be14 100644 --- a/Documentation/Kernel-Configuration.md +++ b/Documentation/Kernel-Configuration.md @@ -120,6 +120,9 @@ CONFIG_IP_NF_NAT=y # Disable this on older kernels to make internet work CONFIG_ANDROID_PARANOID_NETWORK=n + +# Fix for docker unsafe procfs error +CONFIG_USER_NS=y ``` ### Step 2: Firewall Support (UFW/Fail2ban) - Optional @@ -244,6 +247,9 @@ CONFIG_NETFILTER_XT_MATCH_ADDRTYPE=y # --- Below configs are optional but recommended --- +# Fix for docker unsafe procfs error +CONFIG_USER_NS=y + # UFW support CONFIG_NETFILTER_XT_TARGET_REJECT=y CONFIG_NETFILTER_XT_TARGET_LOG=y diff --git a/Documentation/Linux-CLI.md b/Documentation/Linux-CLI.md index 8e788b5f..20c3ef4d 100644 --- a/Documentation/Linux-CLI.md +++ b/Documentation/Linux-CLI.md @@ -110,12 +110,23 @@ sudo droidspaces --name=web,db,app stop | Option | Short | Description | |--------|-------|-------------| -| `--net=MODE` | | Networking mode: `host` (default), `nat`, or `none`. | -| `--upstream IFACE[,..]` | | Upstream internet interface(s) for NAT mode (e.g., `wlan0,rmnet0`). Wildcards are supported (e.g., `rmnet*`, `v4-rmnet_data*`). **Mandatory for NAT**. | +| `--net=MODE` | | Networking mode: `host` (default), `nat`, `none`, or `gateway`. | +| `--upstream=IFACE` | | Pin the NAT WAN to specific interface(s); disables automatic uplink detection. Comma-separated, priority-ordered, supports wildcards. Example: `--upstream=wlan0,rmnet*`. NAT mode only. | | `--port HOST:CONT[/proto]` | | Forward host port to container (NAT mode). Supports TCP/UDP. | | `--dns=SERVERS` | `-d` | Custom DNS servers, comma-separated. Example: `--dns=1.1.1.1,8.8.8.8` | | `--disable-ipv6` | | Disable IPv6 networking support (Host mode only). | +#### Gateway Mode + +Delegate a container's LAN to another running container (e.g. OpenWRT), which then owns DHCP, DNS, firewall and routing. Droidspaces only does the L2 plumbing. See [Networking From Zero](Networking-From-Zero.md) for the full guide. + +| Option | Short | Description | +|--------|-------|-------------| +| `--gateway=NAME` | | **Required** for `--net=gateway`. The running container that acts as the router. | +| `--gateway-net=NAME` | | LAN segment name / host bridge suffix (default: `lan`). Clients sharing a value share a LAN; different values are isolated segments. | +| `--gateway-iface=IFACE` | | Interface name as seen *inside* the gateway container (default: `eth1`). Each segment needs a unique name. | +| `--gateway-bridge=BR` | | Override the host bridge name (default: `ds-{gateway-net}`). | + ### Feature Flags | Option | Short | Description | @@ -237,11 +248,31 @@ sudo droidspaces \ --name=server \ --rootfs-img=/path/to/rootfs.img \ --net=nat \ - --upstream=wlan0,rmnet0 \ --port=8080:80 \ start ``` +### NAT with a Pinned Uplink +Force the container's internet out through a specific interface instead of following the host's active network. Pin a VPN tunnel (`tun0`) as a killswitch, or `rmnet*` to stay on mobile data while the phone uses Wi-Fi: +```bash +sudo droidspaces \ + --name=vpnbox \ + --rootfs-img=/path/to/rootfs.img \ + --net=nat \ + --upstream=tun0 \ + start +``` + +### Gateway Mode (LAN owned by OpenWRT) +Start the router container first (in NAT mode), then attach clients to it: +```bash +# 1. the router +sudo droidspaces --name=openwrt --rootfs=/data/openwrt --net=nat start + +# 2. a client whose LAN/DHCP/firewall is owned by openwrt +sudo droidspaces --name=kali --rootfs=/data/kali --net=gateway --gateway=openwrt start +``` + ### Ephemeral Testing ```bash sudo droidspaces --name=test --rootfs=/path/to/rootfs --volatile start diff --git a/Documentation/Networking-From-Zero.md b/Documentation/Networking-From-Zero.md new file mode 100644 index 00000000..05a6e2e6 --- /dev/null +++ b/Documentation/Networking-From-Zero.md @@ -0,0 +1,730 @@ + + +# Networking From Zero: Understanding Droidspaces Gateway Mode + +### Quick Navigation + +- [Part 1: The Absolute Basics](#part-1-the-absolute-basics) + - [What is an IP address?](#what-is-an-ip-address) + - [What is a network?](#what-is-a-network) + - [LAN - Local Area Network](#lan---local-area-network) + - [WAN - Wide Area Network](#wan---wide-area-network) + - [Gateway](#gateway) +- [Part 2: How Data Actually Gets Delivered](#part-2-how-data-actually-gets-delivered) + - [MAC address vs IP address](#mac-address-vs-ip-address) + - [What is a packet?](#what-is-a-packet) +- [Part 3: DHCP (How You Get an IP Address)](#part-3-dhcp-how-you-get-an-ip-address) +- [Part 4: DNS (How Names Become Addresses)](#part-4-dns-how-names-become-addresses) +- [Part 5: NAT (The Magic Your Router Does)](#part-5-nat-the-magic-your-router-does) + - [NAT in Droidspaces](#nat-in-droidspaces) + - [How NAT mode picks the WAN uplink (automatic)](#how-nat-mode-picks-the-wan-uplink-automatic) + - [Pinning the uplink manually with --upstream](#pinning-the-uplink-manually-with---upstream) + - [Use cases for --upstream](#use-cases-for---upstream) +- [Part 6: Bridges and Virtual Cables (Linux Plumbing)](#part-6-bridges-and-virtual-cables-linux-plumbing) + - [What is a network bridge?](#what-is-a-network-bridge) + - [What is a veth pair?](#what-is-a-veth-pair) + - [How NAT mode uses bridges and veths](#how-nat-mode-uses-bridges-and-veths) +- [Part 7: Network Namespaces (How Containers Are Isolated)](#part-7-network-namespaces-how-containers-are-isolated) +- [Part 8: OpenWRT and What It Is](#part-8-openwrt-and-what-it-is) +- [Part 9: The New Gateway Mode - Putting It All Together](#part-9-the-new-gateway-mode---putting-it-all-together) + - [Why does gateway mode exist?](#why-does-gateway-mode-exist) + - [The architecture](#the-architecture) + - [Step by step - what happens when you start a gateway-mode container](#step-by-step---what-happens-when-you-start-a-gateway-mode-container) + - [What "lazy attachment" means](#what-lazy-attachment-means) + - [Why resolv.conf is left alone in gateway mode](#why-resolvconf-is-left-alone-in-gateway-mode) + - [Why bridge-nf-call-iptables is set to 0](#why-bridge-nf-call-iptables-is-set-to-0) + - [The gateway container must be running first](#the-gateway-container-must-be-running-first) + - [What happens when containers stop](#what-happens-when-containers-stop) +- [Part 10: Gateway Mode Flags and Configuration](#part-10-gateway-mode-flags-and-configuration) + - [Required vs optional flags](#required-vs-optional-flags) + - [What --gateway-net does](#what---gateway-net-does) + - [What --gateway-iface does](#what---gateway-iface-does) + - [The flag conflict you must avoid](#the-flag-conflict-you-must-avoid) + - [Validation rules and kernel requirements](#validation-rules-and-kernel-requirements) +- [Part 11: Comparing All Networking Modes](#part-11-comparing-all-networking-modes) +- [Part 12: Real-World Use Cases for Gateway Mode](#part-12-real-world-use-cases-for-gateway-mode) +- [Quick Reference - Terms](#quick-reference---terms) + +--- + +## Part 1: The Absolute Basics + +### What is an IP address? + +Every device that wants to talk over a network needs an address so other devices know where to send data. That address is called an **IP address**. + +Think of it like a house address. If someone wants to mail you a letter, they need your address. Same idea: if your phone wants to send data to Google, it needs to know Google's address, and Google needs to know your phone's address to send the reply back. + +An IP address looks like this: `192.168.1.5` + +It is four numbers (0-255) separated by dots. Each number is called an **octet**. + +### What is a network? + +A **network** is just a group of devices that can talk to each other directly. + +Imagine a room with 5 laptops all connected to the same Wi-Fi router. Those 5 laptops are on the same network. They can send files to each other without going through the internet. + +### LAN - Local Area Network + +**LAN** = the network *inside your home* (or office, or in our case, *inside the container world*). + +It is called "local" because it is physically nearby: your phone, laptop, smart TV, all connected to your home Wi-Fi router. They all live on the same LAN. They can talk to each other directly. + +LAN addresses usually look like: +- `192.168.x.x` +- `10.x.x.x` +- `172.16.x.x` to `172.31.x.x` + +These are called **private IP ranges**. They are reserved for local networks and are never used on the public internet. + +### WAN - Wide Area Network + +**WAN** = the network *outside your home*: the internet itself. + +Your router has two sides: +- The **LAN side** faces your devices at home +- The **WAN side** faces your internet provider (ISP) + +Your ISP gives your router one public IP address for the WAN side. Everything inside your home shares that one public IP to reach the internet. + +``` +[Your Phone]--+ +[Your Laptop]-+--[Router]--[ISP]--[The Internet] +[Your TV]-----+ + (LAN side) (WAN side) +``` + +### Gateway + +A **gateway** is the device that connects two different networks together. + +In your home, the router *is* the gateway. Your phone's IP is `192.168.1.5` (LAN). When your phone wants to reach Google at `142.250.80.46` (WAN/internet), it does not know how to get there directly. So it sends the data to the gateway (router), and the router figures out how to forward it to the internet. + +**Rule:** Every device on a LAN is configured with a "default gateway" - the address they send all traffic to when they do not know where else to send it. + +--- + +## Part 2: How Data Actually Gets Delivered + +### MAC address vs IP address + +There are actually *two* kinds of addresses in networking: + +| Type | Looks like | Purpose | +|---|---|---| +| **IP address** | `192.168.1.5` | Logical address - used for routing across networks | +| **MAC address** | `a4:c3:f0:12:34:56` | Physical address - used for delivery on the *same* network | + +Think of it this way: +- The IP address is the **city and street** - used to navigate across the country +- The MAC address is the **apartment number** - used once you arrive at the building + +When your laptop sends a packet to your router, it uses the router's MAC address (because they are on the same LAN). The router then uses IP addresses to figure out where to send it next. + +### What is a packet? + +Data traveling over a network is broken into small chunks called **packets**. Each packet contains: +- Where it came from (source IP) +- Where it is going (destination IP) +- A small piece of the actual data + +The network reassembles all the packets at the destination. + +--- + +## Part 3: DHCP (How You Get an IP Address) + +### The problem + +Every device needs an IP address to join a network. But you cannot have two devices with the same IP - that would be like two houses with the same postal address. Mail would get lost. + +You *could* manually assign a unique IP to every device, but that is painful. What if you have 50 devices? + +### The solution: DHCP + +**DHCP** = Dynamic Host Configuration Protocol + +It is a system where one device (the **DHCP server**) automatically hands out IP addresses to every new device that joins the network. + +The conversation goes like this: + +``` +New Device: "Hello? Anyone there? I just joined this network and I need an IP address." +DHCP Server: "I heard you. Here, take 192.168.1.42. Also, your gateway is 192.168.1.1, + and for DNS use 1.1.1.1. Your lease lasts 24 hours." +New Device: "Got it, thanks!" +``` + +The new device now has everything it needs to work on the network: +- Its own IP address +- The gateway address (so it knows where to send traffic) +- The DNS address (explained next) + +In your home, the **router runs the DHCP server**. It hands out IPs to every device that connects. + +In **Droidspaces NAT mode**, Droidspaces itself runs a mini DHCP server that hands the container its IP (in the `172.28.x.x` range). The IP is deterministic: it is derived from the container's name, persisted to its config file, and offered again on every boot, so the same container always keeps the same address across restarts. + +--- + +## Part 4: DNS (How Names Become Addresses) + +### The problem + +IP addresses are hard to remember. Nobody types `142.250.80.46` when they want to visit Google. They type `google.com`. + +But computers only understand IP addresses. So there needs to be a system that converts human-readable names to IP addresses. + +### The solution: DNS + +**DNS** = Domain Name System + +It is basically the internet's phone book. You give it a name (`google.com`), it gives you back an IP address (`142.250.80.46`). + +The conversation: +``` +Your Browser: "What is the IP address of google.com?" +DNS Server: "It is 142.250.80.46" +Your Browser: "Thanks." [now connects to 142.250.80.46] +``` + +Every device is configured with a DNS server address. In most home networks, the router *is* the DNS server (it forwards your questions to your ISP's DNS or a public one like `1.1.1.1`). + +In **Droidspaces NAT mode**, Droidspaces writes a `resolv.conf` file inside the container pointing to a DNS server (default `1.1.1.1` and `8.8.8.8`, or whatever you pass via `--dns`). The same DNS servers are also advertised inside the DHCP lease itself. + +--- + +## Part 5: NAT (The Magic Your Router Does) + +### The problem + +Your ISP gives you *one* public IP address. But you have 10 devices at home. How do all 10 devices use the internet at the same time? + +### The solution: NAT + +**NAT** = Network Address Translation + +Your router keeps a secret table. When a device inside your LAN sends a packet to the internet, the router: +1. Rewrites the source IP from the device's private IP (`192.168.1.5`) to the router's public IP +2. Remembers which device sent it +3. When the reply comes back from the internet, rewrites the destination back to the device's private IP and forwards it + +From the internet's perspective, all your home devices appear to be *one device*: the router. + +``` +[Laptop: 192.168.1.5] --sends packet--> [Router] + | + | rewrites source to public IP + v + [Internet] + | + | reply comes back + v + [Router] + | + | rewrites destination back to 192.168.1.5 + v + [Laptop: 192.168.1.5] +``` + +### NAT in Droidspaces + +In Droidspaces NAT mode, Droidspaces acts *exactly like your home router* - but for containers: + +- The container gets a private IP (`172.28.x.x`) +- Droidspaces installs iptables `MASQUERADE` rules (that is the Linux name for the NAT target), plus FORWARD-accept and MSS-clamp rules so traffic actually flows +- The container can reach the internet; the internet sees Android's IP, not the container's IP +- Droidspaces also runs an embedded DHCP server for the container and configures its DNS +- On Android, a background route monitor detects the active internet uplink automatically (by reading the kernel's routing rules) and re-points container traffic the moment the active network changes (for example, Wi-Fi to mobile data handoff) + +### How NAT mode picks the WAN uplink (automatic) + +A NAT container has to know *which* of Android's real interfaces currently has internet, so it can MASQUERADE through it. Phones make this hard: the active network hops between Wi-Fi, mobile data, USB-ethernet and VPN tunnels, and the interface names themselves are unstable (the mobile-data interface might be `rmnet0` one minute and `rmnet8` after a reconnect). + +By default Droidspaces handles all of this for you - **there is nothing to configure**: + +- It reads the kernel's *own* ground truth for "what is the internet interface right now." On Android that is the policy-routing rule `netd` installs for the active default network; on desktop Linux it is the main routing table's default route. +- A background **route monitor** subscribes to kernel routing events (rule, route, link and address changes). The instant the host switches networks - you walk out of Wi-Fi range and it falls back to mobile data, or you plug in a USB-ethernet dongle on a laptop - the monitor re-points the container's traffic. No restart, no config. +- CLAT/464xlat interfaces (the `v4-rmnet...` interfaces phones synthesise on IPv6-only mobile networks) are picked up automatically. + +This automatic mode is the right choice for the vast majority of users. The rest of this section is only relevant if you want to *override* it. + +### Pinning the uplink manually with `--upstream` + +Sometimes you do **not** want the container to follow whatever network the host is using. You want to force its internet out through one specific interface and keep it there. That is what `--upstream` does. + +When you pass `--upstream`, automatic detection is switched off entirely. The interface(s) you list become the *only* candidates the container will ever use for WAN - it never hops to whatever the host marks as its active default network. + +```bash +# Force the container's internet out through Wi-Fi, always +droidspaces --name=box --rootfs=/data/box --net=nat --upstream=wlan0 start +``` + +You can list **multiple interfaces, comma-separated**, and use **wildcards** (`*`, `?`): + +```bash +droidspaces --name=box --rootfs=/data/box --net=nat --upstream=wlan0,rmnet* start +``` + +The list is **priority-ordered**. The route monitor walks it top to bottom and uses the first interface that is currently up and actually has internet. So `wlan0,rmnet*` means "prefer Wi-Fi; if Wi-Fi is down, fall back to mobile data" - and when Wi-Fi returns, it switches back. The failover stays strictly *inside your list*; it never falls back to an interface you did not list. This is the key difference from auto mode - the WAN is yours to decide, not the host's. + +If a pinned interface is missing when the container starts, or disappears mid-session and later reappears, that is handled too: the container simply has no WAN until one of your pinned interfaces is up, then it wires up automatically. + +> **Why wildcards matter on mobile data:** Android does not give the mobile-data interface a stable number - it might be `rmnet0`, `rmnet8`, `rmnet_data2`, and the number can change across reconnects. Pinning a literal `rmnet0` will break the next time it comes up as something else. Pin `rmnet*` instead and it keeps working. + +### Use cases for `--upstream` + +**1. Route the container's WAN through an Android VPN (`tun0`)** + +Connect a VPN on the phone itself - ProtonVPN, WireGuard, OpenVPN, any app that creates a `tun0` interface. Then pin the container to it: + +```bash +droidspaces --name=box --rootfs=/data/box --net=nat --upstream=tun0 start +``` + +Now all of the container's traffic goes out through the VPN tunnel, and *only* the tunnel. If the VPN drops, `tun0` disappears and the container loses internet instead of leaking out over your real connection - a simple killswitch, for free. + +**2. Container on mobile data while the phone stays on Wi-Fi** + +Android can keep the cellular radio up even while you are on Wi-Fi. Enable **"Mobile data always active"** in Developer Options, connect to Wi-Fi, then turn mobile data on. Both networks are now live at once. Pin the container to the mobile-data interface: + +```bash +droidspaces --name=box --rootfs=/data/box --net=nat --upstream=rmnet* start +``` + +The container's traffic goes out over mobile data while the rest of the phone keeps using Wi-Fi. Handy for testing from a different IP/network, putting a container's bandwidth onto cellular, or simply running something on a separate connection from everything else on the phone. + +--- + +## Part 6: Bridges and Virtual Cables (Linux Plumbing) + +Now we go one level deeper, into how Linux actually connects containers to each other. + +### What is a network bridge? + +A **network bridge** works like a network switch. A physical network switch is a box you plug multiple ethernet cables into; all devices connected to it can talk to each other. + +A Linux **bridge** is a virtual switch, entirely in software. You can create it with a command, and then "plug" virtual network interfaces into it. + +``` +Physical world: Linux world: ++--------------+ +--------------+ +| Switch | | Bridge | (software, no physical box) +| port1 port2 | | port1 port2 | ++--+------+---+ +--+------+---+ + | | | | +[PC1] [PC2] [veth1] [veth2] (virtual cables) +``` + +### What is a veth pair? + +**veth** = virtual ethernet + +A veth pair is a pair of virtual network interfaces that are connected to each other like a pipe. Anything you send into one end comes out the other end. + +Think of it as a virtual ethernet cable with two plugs. You put one plug inside a container, and the other plug stays on the host (or goes into a bridge). + +``` +[Container netns] [Host netns] + eth0 --------------------- ds-veth0 + (plug inside container) (plug on host side) +``` + +### How NAT mode uses bridges and veths + +In Droidspaces NAT mode: + +``` +[Container netns] + eth0 (e.g. 172.28.137.42) + | + | veth pair (virtual cable) + | +[Host side] + ds-v ---- ds-br0 (bridge, has IP 172.28.0.1) + | + iptables MASQUERADE + | + wlan0 / rmnet0 + (Android's real network) +``` + +The bridge `ds-br0` owns the gateway IP `172.28.0.1`, which every NAT container uses as its default gateway. The veth pair is named after the container's init process ID: the host side is `ds-v`, and the container side starts life as `ds-p` before being renamed to `eth0` inside the container. + +Droidspaces runs a small per-container DHCP server listening on the container's host-side veth. The whole `172.28.0.0/16` subnet belongs to Droidspaces (the `172.28.0.x` row is reserved for the gateway itself, so containers always land in `172.28.1.x` through `172.28.254.x`), and everything from it is NATted out through Android's real interface. + +--- + +## Part 7: Network Namespaces (How Containers Are Isolated) + +### What is a namespace? + +Linux has a feature called **namespaces** that lets you create isolated views of system resources. + +A **network namespace** is an isolated copy of the entire networking stack. It has its own: +- Network interfaces +- Routing table +- iptables rules +- Everything networking-related + +When Droidspaces starts a container, it creates a new network namespace for it. The container lives in that namespace. It cannot see the host's network interfaces at all - only what Droidspaces explicitly puts inside its namespace. + +The veth pair is the tunnel between the host's namespace and the container's namespace: +- One end of the veth goes inside the container's network namespace (appears as `eth0`) +- The other end stays in the host's network namespace (Droidspaces connects it to a bridge) + +--- + +## Part 8: OpenWRT and What It Is + +### What is OpenWRT? + +**OpenWRT** is a Linux distribution designed specifically for routers. Normally it runs on physical router hardware, but it can also run on a regular Linux system or inside a container. + +When OpenWRT is running, it provides: +- **netifd** - network interface daemon (manages network interfaces, DHCP client/server, etc.) +- **dnsmasq** - DNS and DHCP server +- **firewall3** or **nftables** - firewall +- **LuCI** - web UI for configuration +- Everything a real router does, in software + +This means you can run OpenWRT inside a Droidspaces container, and it will behave exactly like a real router: managing networks, handing out DHCP leases, doing DNS, applying firewall rules, routing VPN traffic, etc. + +--- + +## Part 9: The New Gateway Mode - Putting It All Together + +### Why does gateway mode exist? + +In NAT mode, Droidspaces is the router. It does everything. This is fine for most cases. + +But what if you want **OpenWRT to be the router** for other containers? You want OpenWRT's firewall rules, OpenWRT's DHCP, OpenWRT's VPN routing, and you want other containers (like a Kali Linux container) to be on OpenWRT's LAN, getting everything from OpenWRT. + +The problem: if Droidspaces also tries to install NAT, DHCP, and DNS for those containers, it will *conflict* with what OpenWRT is trying to do. Two DHCP servers fighting over who gives the IP address. Two firewalls applying contradictory rules. + +**Gateway mode solves this.** Droidspaces steps back. It only does the L2 plumbing (the virtual cables and switch), and lets OpenWRT own all the policy: DHCP, DNS, firewall, routing. + +### The architecture + +``` +Android host kernel +| ++-- wlan0 (Android's real Wi-Fi - WAN) +| ++-- [OpenWRT container - net=nat mode] +| netns: owns eth0 (WAN, gets NAT from Droidspaces) +| eth1 (LAN side - plugged into ds-lan bridge by gateway mode) +| Runs: dnsmasq, netifd, firewall, VPN +| ++-- ds-lan (host bridge - NO IP address, just a switch) +| | +| +-- ds-g[hash] (veth host-side, connected to OpenWRT's netns as eth1) +| +-- ds-c[pid] (veth host-side, connected to Kali's netns as eth0) +| ++-- [Kali container - net=gateway mode] + netns: owns eth0 (LAN side - plugged into ds-lan bridge) + Gets DHCP from OpenWRT's dnsmasq + Routing decisions made by OpenWRT + Firewall rules applied by OpenWRT +``` + +### Step by step - what happens when you start a gateway-mode container + +**Step 1 - You start OpenWRT first (in NAT mode)** + +```bash +droidspaces --name=openwrt --rootfs=/data/openwrt --net=nat start +``` + +OpenWRT boots. It has: +- `eth0` on the WAN side (Droidspaces manages NAT for this) +- No LAN side yet - OpenWRT is waiting for one + +**Step 2 - You start Kali (in gateway mode)** + +```bash +droidspaces --name=kali --rootfs=/data/kali --net=gateway --gateway=openwrt start +``` + +Droidspaces does the following (only plumbing, no policy): + +1. Finds OpenWRT's running process ID so it can reach its network namespace +2. Creates a bridge called `ds-lan` on the host with no IP address on it +3. Disables `bridge-nf-call-iptables` so Android's host firewall does NOT intercept traffic on this bridge, keeping OpenWRT's firewall as the only authority +4. Creates a veth pair for OpenWRT's LAN side - one end goes into OpenWRT's netns (appears as `eth1`), the other end plugs into the `ds-lan` bridge +5. Creates a veth pair for Kali - one end goes into Kali's netns (appears as `eth0`), the other end plugs into the `ds-lan` bridge +6. Does NOT install NAT, DHCP, DNS, or any firewall rules + +**Step 3 - OpenWRT takes over** + +OpenWRT's `netifd` detects that `eth1` appeared. It configures it as the LAN interface. +OpenWRT's `dnsmasq` starts answering DHCP requests on `eth1`. + +Kali's `eth0` sends a DHCP request and OpenWRT's `dnsmasq` replies with: +- IP address: `192.168.1.100` (or whatever OpenWRT's DHCP range is) +- Gateway: `192.168.1.1` (OpenWRT itself) +- DNS: `192.168.1.1` (OpenWRT's dnsmasq) + +Kali is now fully configured, with OpenWRT as its router. + +**Step 4 - Traffic flows through OpenWRT** + +When Kali tries to reach the internet: + +``` +Kali eth0 --> ds-lan bridge --> OpenWRT eth1 + | + OpenWRT firewall rules applied here + | + OpenWRT routes to eth0 (WAN) + | + Droidspaces NAT (eth0 -> wlan0) + | + Android wlan0 --> Internet +``` + +OpenWRT's firewall sees all of Kali's traffic and can apply any rules: block certain sites, redirect through VPN, shape bandwidth, log connections - exactly like a real router would. + +### What "lazy attachment" means + +The gateway veth is "lazily attached." This means: + +- When you start OpenWRT, it does NOT immediately get an `eth1` +- `eth1` only appears inside OpenWRT **when the first gateway-mode container starts** +- This is intentional - OpenWRT boots with just its WAN side (`eth0`), and its LAN cable (`eth1`) is plugged in later, on demand + +This mimics how you might physically plug a cable into a router's LAN port after the router is already running. + +### Why resolv.conf is left alone in gateway mode + +In NAT mode, Droidspaces writes `/etc/resolv.conf` inside the container, pointing to `1.1.1.1` or `8.8.8.8`. + +In gateway mode, Droidspaces does NOT write a static `resolv.conf` (unless you explicitly pass `--dns`). This is because OpenWRT's `dnsmasq` hands the DNS server address to the container via the DHCP lease. If Droidspaces also wrote a `resolv.conf`, it would conflict with what dnsmasq is providing - the container would use the wrong DNS and bypass OpenWRT's DNS filtering/caching entirely. + +How this is wired depends on the client's init system: + +- **systemd containers:** `/etc/resolv.conf` is symlinked to `/run/systemd/resolve/resolv.conf`, which systemd-resolved populates from the DHCP lease. +- **non-systemd containers:** Droidspaces leaves `/etc/resolv.conf` entirely alone, so the container's own DHCP client (udhcpc/dhclient) writes the gateway-supplied nameserver from the lease. (Earlier builds wrote a hardcoded `1.1.1.1`/`8.8.8.8` here, which silently bypassed the gateway's DNS - that is fixed.) If a minimal rootfs ships no DHCP resolv.conf hook, pass `--dns` to set one explicitly. + +### Why bridge-nf-call-iptables is set to 0 + +The bridge `ds-lan` carries traffic between OpenWRT and Kali. By default, Linux can pass bridged traffic through the host's iptables. This would mean Android's iptables rules (which might drop or NAT things unexpectedly) would interfere with traffic that OpenWRT is supposed to be managing. + +Setting it to `0` tells Linux: "do not run iptables on bridged traffic." This keeps OpenWRT's firewall as the *only* firewall that sees this traffic, which is exactly what we want. + +### Start order and automatic self-healing + +All wiring for a gateway-mode client is done **from the host side** by a single function, `gateway_wire_client()`: it ensures the bridge and the gateway-side cable, creates the client's app veth, and moves+renames the peer into the client's namespace as `eth0` (pinned MAC, brought up) — the client's own boot code only brings up `lo`. Because the host owns every step, the same function wires a client whether it is just starting or already running. + +This gives a deliberately simple rule keyed on **gateway liveness**: + +- **Gateway already running when a client starts** → the client wires immediately (its own monitor calls `gateway_wire_client`). +- **Gateway not running when a client starts** → the client wires **nothing at all** (no bridge, no veth, no `eth0`) and just boots. The work is deferred entirely to the gateway. + +Healing is therefore driven by **the gateway itself**, not by the clients. On every boot cycle the gateway container's monitor calls `ds_net_rewire_gateway_clients()`: it scans the running containers, finds the ones that delegate to this gateway, and runs `gateway_wire_client` for each — establishing the gateway-side `eth1` cable and every client's `eth0` into the gateway's *current* namespace. So when the gateway **starts or reboots**, every running client is (re)wired with **no client restart required**. + +Skipping all client wiring while the gateway is down (rather than half-wiring a bridge and a danging veth) also closes a race: a client started before its gateway can have its gateway/LAN settings (`--gateway-net`, `--host-bridge`, …) edited before the gateway comes up, and the gateway then wires every client from each client's *current* config — never a stale one. + +There is exactly **one actor** (the gateway) doing the wiring, so there is nothing to poll and no thundering herd. Wiring is serialised per segment (an advisory file lock) to keep concurrent client starts and the gateway's re-wire from racing. Both `eth1` (gateway side) and each `eth0` (client side) keep a **stable MAC** and are moved+renamed into their namespace in a single atomic step, so the container's own `netifd`/DHCP sees one persistent device rather than re-initialising a churning one. + +### What happens when containers stop + +Cleanup in gateway mode is deliberately minimal, matching the "plumbing only" philosophy: + +- **A client stops:** only that client's own veth is removed (gateway clients use the `ds-c` prefix, distinct from NAT's `ds-v`). The bridge and the gateway's `eth1` stay up, so other clients on the segment are untouched. +- **The last client stops while the gateway is still running:** the bridge is **kept** (not reaped). Tearing it down would flap the gateway's live `eth1` carrier and occasionally make netifd report "device initialization failed"; an idle IP-less bridge is harmless and the next client reuses it. +- **The gateway stops:** the gateway-side veth disappears with its namespace. Once no clients remain *and* the gateway is gone, the now-idle bridge is reaped. + +--- + +## Part 10: Gateway Mode Flags and Configuration + +### Required vs optional flags + +Only **one flag is mandatory** when using `--net=gateway`: + +```bash +--gateway= +``` + +If you omit it, Droidspaces will print an error and refuse to start. Everything else has a working default: + +| Flag | Default | What it controls | +|---|---|---| +| `--gateway=NAME` | *(none - required)* | Which running container is the router | +| `--gateway-net=NAME` | `lan` | The LAN segment name - see below | +| `--gateway-iface=IFACE` | `eth1` | Interface name inside the gateway container | +| `--gateway-bridge=BR` | `ds-{gateway-net}` | Override the host bridge name entirely | + +So the minimal valid command is: + +```bash +droidspaces --name=client --net=gateway --gateway=openwrt start +``` + +That is identical to spelling out all defaults explicitly: + +```bash +droidspaces --name=client --net=gateway --gateway=openwrt \ + --gateway-net=lan \ + --gateway-iface=eth1 \ + start +``` + +### What --gateway-net does + +This flag controls two things at once, both derived from the same name. + +**1. It names the host bridge.** + +The bridge Droidspaces creates on the host is named `ds-{NAME}`: + +``` +--gateway-net=lan -> host bridge: ds-lan +--gateway-net=vpn -> host bridge: ds-vpn +--gateway-net=iot -> host bridge: ds-iot +``` + +**2. It is the segment identifier - which bridge clients land on.** + +The veth names for the gateway's LAN side are generated by hashing the string `{gateway_container}:{gateway_net}` together. The same hash = the same veth = the same bridge segment. This means multiple client containers that share the same `--gateway` and `--gateway-net` all end up on the same bridge, and all get DHCP from the same OpenWRT interface. + +This is the real power of `--gateway-net`: running multiple isolated LAN segments through the same gateway container. + +```bash +# These two land on ds-lan - they see each other, OpenWRT routes them as one LAN +droidspaces --name=kali --net=gateway --gateway=openwrt --gateway-net=lan start +droidspaces --name=ubuntu --net=gateway --gateway=openwrt --gateway-net=lan start + +# This one lands on ds-vpn - a completely separate bridge +# OpenWRT can apply different firewall/VPN rules to this segment +droidspaces --name=torbox --net=gateway --gateway=openwrt --gateway-net=vpn start +``` + +Inside OpenWRT, the `lan` clients come in on `eth1` and the `vpn` clients come in on `eth2` (each segment gets its own veth, because the hash of `openwrt:lan` and `openwrt:vpn` are different). + +### What --gateway-iface does + +This controls **what the LAN interface is called inside the gateway container's network namespace**. + +When Droidspaces creates the gateway veth for a segment, it moves one end into OpenWRT's netns and renames it from its raw hash name (`ds-hXXXXXXXX`) to whatever you pass here (default `eth1`). + +**Why does this matter?** OpenWRT's configuration is built around interface names. If your OpenWRT `/etc/config/network` says: + +``` +config interface 'lan' + option device 'eth1' +``` + +...then the interface that appears inside OpenWRT **must** be named `eth1`, or OpenWRT will not recognize it as its LAN and will not serve DHCP on it. `--gateway-iface=eth1` makes that happen. + +For a second segment you would pass `--gateway-iface=eth2` so OpenWRT sees it as a separate interface and you can add a second UCI network block for it. + +**Important detail:** `--gateway-iface` only has any effect when the gateway veth for that segment is being created for the first time - which is when the very first client container on that segment starts. The gateway veth is shared by all clients on the same `--gateway-net`; it is created once and reused. Every client after the first one skips the gateway veth creation entirely and just wires its own app veth into the existing bridge. + +This means if you start two containers on `--gateway-net=lan` and both pass `--gateway-iface=eth1`, it works perfectly fine: the first container creates the veth and renames it `eth1`, the second container sees the veth already exists and does nothing with `--gateway-iface` at all. + +### The flag conflict you must avoid + +The problem only happens when you use **two different `--gateway-net` segments but the same `--gateway-iface`**: + +```bash +# segment 1 - creates eth1 inside OpenWRT +droidspaces --name=kali --net=gateway --gateway=openwrt --gateway-net=lan --gateway-iface=eth1 start + +# segment 2 - WRONG: also tries to create eth1 inside OpenWRT +droidspaces --name=torbox --net=gateway --gateway=openwrt --gateway-net=vpn --gateway-iface=eth1 start +``` + +When the second command runs, Droidspaces tries to move a new veth peer into OpenWRT and rename it `eth1`. But `eth1` already exists inside OpenWRT from the first segment. Instead of failing loudly, the code detects the conflict and just brings the existing `eth1` up again, leaving the new veth peer with its raw hash name (`ds-hYYYYYYYY`) inside OpenWRT. OpenWRT has no config for `ds-hYYYYYYYY` and silently ignores it. The `vpn` segment gets no gateway-side interface: no DHCP, no routing, containers on it are effectively isolated. + +**The rule:** every `--gateway-net` segment must have a unique `--gateway-iface` name. + +```bash +# Correct: two segments, two interface names +--gateway-net=lan --gateway-iface=eth1 -> eth1 inside OpenWRT (LAN segment) +--gateway-net=vpn --gateway-iface=eth2 -> eth2 inside OpenWRT (VPN segment) +``` + +### Validation rules and kernel requirements + +Droidspaces enforces a few rules at startup and refuses to boot if they are violated: + +- A container cannot use **itself** as its gateway (`--gateway` must name a different container) +- Interface and bridge names must be shorter than 16 characters (the Linux `IFNAMSIZ` limit) and may only contain letters, digits, `_`, and `-` +- The kernel must support network namespaces (`CONFIG_NET_NS`), veth pairs (`CONFIG_VETH`), and bridges (`CONFIG_BRIDGE`). Droidspaces probes for all three before starting and exits with a fatal error if any is missing + +Two more things to know: + +- `--port` only makes sense in NAT mode. In gateway mode it is ignored with a warning - port forwarding and uplink selection are the gateway container's job now. +- When the host bridge name is auto-derived from `--gateway-net`, the name is sanitized (only letters, digits, `_`, `-` survive) and truncated to 9 characters, producing `ds-` plus at most 9 characters. If you need an exact bridge name, set it explicitly with `--gateway-bridge`. + +--- + +## Part 11: Comparing All Networking Modes + +| Feature | NAT Mode | Host Mode | None Mode | Gateway Mode | +|---|---|---|---|---| +| Who assigns IPs? | Droidspaces DHCP | Android (shared) | Nobody (loopback only) | OpenWRT dnsmasq | +| Who does NAT? | Droidspaces iptables | Android | N/A | OpenWRT (via Droidspaces NAT on OpenWRT's WAN) | +| Who manages firewall? | Droidspaces | Android | N/A | OpenWRT | +| Who manages DNS? | Droidspaces | Android | Nobody | OpenWRT dnsmasq | +| Container isolated from host network? | Yes | No | Yes | Yes | +| Internet access? | Yes | Yes | No | Yes (via gateway container) | +| Needs a second container to function? | No | No | No | Yes (the gateway container) | +| Good for | Simple internet access | Maximum performance, zero overhead | Offline / sandboxed workloads | Router appliance, VPN gateway, segmented LANs | + +--- + +## Part 12: Real-World Use Cases for Gateway Mode + +### 1. VPN killswitch for specific containers + +Run OpenWRT with a WireGuard or OpenVPN client. Configure OpenWRT's firewall to drop all traffic that does not go through the VPN tunnel. Any container using gateway mode will be unable to leak traffic outside the VPN - OpenWRT enforces it at the bridge level, not inside the individual containers. + +### 2. Multiple isolated LAN segments + +Use `--gateway-net` to create separate segments on the same OpenWRT. Containers on `--gateway-net=lan` cannot reach containers on `--gateway-net=vpn` unless OpenWRT explicitly routes between them. You get VLAN-style isolation with a single gateway container. + +### 3. Traffic analysis + +Run OpenWRT with `tcpdump` or `nftables` logging enabled. Every packet from every gateway-mode container flows through OpenWRT, so you get a single chokepoint to observe all network activity across multiple containers at once. + +### 4. Custom DNS filtering + +Run OpenWRT with a `dnsmasq` blocklist (or with Adblock installed via opkg). Every container on the gateway LAN gets filtered DNS without touching each container individually. + +### 5. Bandwidth shaping + +OpenWRT's `tc` (traffic control) and `sqm-scripts` can shape bandwidth per container, since OpenWRT sees each container as a separate MAC address arriving on its LAN interface. + +--- + +## Quick Reference - Terms + +| Term | One-line definition | +|---|---| +| **IP address** | The numerical address of a device on a network (e.g. `192.168.1.5`) | +| **MAC address** | The hardware address of a network interface, used for delivery within the same network | +| **LAN** | Local network - devices near each other that can talk directly | +| **WAN** | Wide network - the internet, outside your local network | +| **Gateway** | A device that connects two networks and routes traffic between them | +| **DHCP** | Protocol for automatically assigning IP addresses to devices | +| **DNS** | System that converts human-readable names (`google.com`) to IP addresses | +| **NAT** | Technique for sharing one public IP across many private-IP devices | +| **Bridge** | A virtual (or physical) switch that connects multiple network interfaces | +| **veth pair** | A pair of virtual network interfaces connected like a pipe - what goes in one end comes out the other | +| **Network namespace** | An isolated copy of the Linux networking stack - containers live in their own namespace | +| **OpenWRT** | A Linux distro designed to run as a router/gateway - runs dnsmasq, netifd, firewall | +| **netifd** | OpenWRT's network interface daemon - manages interfaces and DHCP | +| **dnsmasq** | Lightweight DHCP and DNS server used by OpenWRT | +| **MASQUERADE** | The Linux iptables rule that implements NAT (rewrites source IPs) | +| **Delegated LAN** | The bridge network Droidspaces creates in gateway mode - policy owned by the gateway container, not Droidspaces | +| **Segment** | One isolated LAN identified by `--gateway-net` - each segment gets its own bridge and its own interface inside the gateway container | +| **Lazy attachment** | The gateway's LAN-side veth is only created when the first client container starts, not when the gateway container starts | diff --git a/Documentation/Usage-Android-App.md b/Documentation/Usage-Android-App.md index 316c2456..acf707ab 100644 --- a/Documentation/Usage-Android-App.md +++ b/Documentation/Usage-Android-App.md @@ -68,21 +68,24 @@ The repository supports third-party rootfs sources in the same JSON format. ## Networking Configuration -When editing or creating a container, you can choose from three networking modes: +When editing or creating a container, you can choose from four networking modes: - **Host (Default)**: Shares host network directly. - **NAT (Isolated)**: Private network namespace with deterministic IP and port forwarding support. - **None**: No network access. +- **Gateway**: The container's LAN is delegated to another running container (typically OpenWRT), which owns DHCP, DNS, firewall and routing. Select the gateway container and (optionally) the LAN segment, interface and bridge in the **Gateway** settings. See [Networking From Zero](Networking-From-Zero.md) for the full guide. -### Configuring Upstream Interfaces (NAT Mode) -If you select **NAT (Isolated)** mode, you **must** specify one or more upstream interfaces for the container to have internet access. The app provides a convenient auto-detection workflow: +### Internet Uplink (NAT Mode) +If you select **NAT (Isolated)** mode, the internet uplink is detected fully automatically - there is nothing to configure. Droidspaces reads the kernel's own routing state to find the interface Android is currently using for internet (Wi-Fi, mobile data, ethernet) and a background Route Monitor keeps the container connected in real time as you switch networks. -1. **Detect Wi-Fi**: Connect to your Wi-Fi network and press the refresh button in the "Upstream Interfaces" menu. Select the interface (usually `wlan0`) that appears. -2. **Detect Mobile Data**: Disable Wi-Fi and connect to mobile data. Press the refresh button again and select the mobile data interface (e.g., `rmnet0`, `ccmni1`). -3. **Save**: Both interfaces will now be used by the Route Monitor to keep your container connected as you switch networks. +#### Upstream Interface (Optional) +If you want the container to **ignore the active network** and pin its internet to specific interface(s), add them under **Upstream Interface**. This disables auto-detection and forces the WAN through your list only: -> [!TIP] -> You can manually enter wildcards in the Upstream Interfaces list (e.g., `rmnet*`) to ensure connectivity even if your carrier cycles through different interface names (like `rmnet_data0` and `rmnet_data1`). +- The list is **priority-ordered** and supports **wildcards** - e.g. `wlan0, rmnet*` prefers Wi-Fi and falls back to mobile data (use `rmnet*` because the mobile-data interface number is not stable). +- **Example - VPN killswitch**: run a VPN app on the phone and pin `tun0` so the container can only reach the internet through the tunnel. +- **Example - cellular while on Wi-Fi**: enable *Mobile data always active* in Developer Options, connect Wi-Fi, turn on mobile data, and pin `rmnet*` so the container uses cellular while the phone stays on Wi-Fi. + +Leave it empty to auto-detect the active uplink (the default, recommended for most users). > [!NOTE] > NAT mode is IPv4 only. If your carrier only provides IPv6, see the [IPv4 NAT Workaround](Troubleshooting.md#ipv4-quirks). diff --git a/Documentation/community-supported-devices.md b/Documentation/community-supported-devices.md index 93ebc4b4..4ebb0f2e 100644 --- a/Documentation/community-supported-devices.md +++ b/Documentation/community-supported-devices.md @@ -26,159 +26,218 @@ This document is a community-maintained compatibility list for Android devices k | Device Name | Model Number | Android / ROM | Baseband / Build | Kernel version | Root Method | Kernel Source | Download Link | Droidspaces Mode | GPU Acceleration | Status | Maintainer | Additional notes | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| **OnePlus 7 Pro** | GM1911 | LineageOS 17.1 - Android 10 | `QQ3A.200805.001` | `4.14.117` | Magisk | [Source](https://github.com/charan-gn/guacamole-droidspaces-kernel) | [Download](https://github.com/charan-gn/guacamole-droidspaces-kernel/releases/tag/v1.0) | Both | Turnip & Virgl | Working | [@charan-gn](https://github.com/charan-gn) | AnyKernel3 flashable on any ROM. | | **Galaxy S10 (Japanese variant)** | SCV41 / SM-G973J | One UI 4.1 - Android 12 | `SCV41KDU1DWC1` | `4.14.190` | KernelSU-Next v3.1.0 | [Source](https://github.com/ravindu644/samsung_kernel_SCV41_droidspaces) | [Download](https://github.com/ravindu644/samsung_kernel_SCV41_droidspaces/releases/download/v2/Droidspaces-KSUN-Samsung-SCV41.tar) | Both | Turnip & Virgl | Working | [@ravindu644](https://github.com/ravindu644) | - | | **Poco X2** | M1912G7Bx | BlissROM-v16.5 - Android 13 | - | `4.14.274` | KernelSU-Next v1.1.1 | [Source](https://github.com/ravindu644/phoenix-blissROM-A13-droidspaces) | [Download](https://github.com/ravindu644/phoenix-blissROM-A13-droidspaces/releases/download/v1/Droidspaces-KSUNv1.1.1-phoenix.zip) | Both | Turnip & Virgl | Working | [@ravindu644](https://github.com/ravindu644) | - | | **Realme 6, 6i, 6s** | RMX2001L1 | RUI 2.0 - Android 11 | - | `4.14.336` | KSU-Next v3.2.0 / ReSukiSU v4.1.0 | [Source](https://github.com/Prime-TITAN-CameraMan/android_kernel_realme_nemo) | [Download](https://github.com/Prime-TITAN-CameraMan/android_kernel_realme_nemo/releases/tag/v1.1) | Both | Virgl | Working | [@Prime-TITAN-CameraMan](https://github.com/Prime-TITAN-CameraMan) | RUI 2.0 only & No Firewall Support (UFW/Fail2ban) | -| **Redmi K20 Pro (China)** | M1903F11A | YAAP 16 - Banshee (Android 16) | `1.0.c3-0061-1017_2229_f7c3bc9ce` | `4.14.353` | KernelSU v3.2.4-gee75714a | [Source](https://github.com/Alhkxsj/CRom-KSU-Builder) | [Download](https://github.com/Alhkxsj/CRom-KSU-Builder/releases/download/raphael_YAAP-sixteen_KernelSU-by-xx_20260514_1438/raphael_YAAP-sixteen_KernelSU-by-xx_20260514_1438.zip) | Both | Turnip & Virgl | Working | [@Alhkxsj](https://github.com/Alhkxsj) | Kernel source is stock YAAP kernel; Droidspaces configs applied via automated build workflow | +| **Redmi K20 Pro (China)** | M1903F11A | YAAP 16 - Banshee (Android 16) | `1.0.c3-0061-1017_2229_f7c3bc9ce` | `4.14.353` | KernelSU v3.2.4-gee75714a | [Source](https://github.com/Alhkxsj/CRom-KSU-Builder) | [Download](https://github.com/Alhkxsj/CRom-KSU-Builder/releases/latest/download/raphael_YAAP-sixteen_KernelSU-by-xx.zip) | Both | Turnip & Virgl | Working | [@Alhkxsj](https://github.com/Alhkxsj) | Kernel source is stock YAAP kernel; Droidspaces configs applied via automated build workflow | +| **Redmi note 7 pro (violet)** | M1901F7S | LineagOS 23.2 - Android 16 | `.c2-00029-SM6150_GEN_PACK-1.352094.1.355031.2` | `4.14.357` | Magisk | [source](https://github.com/subhu2008/android_kernel_xiaomi_sm6150) | [Download](https://github.com/subhu2008/android_kernel_xiaomi_sm6150/releases/tag/stable) | Both | Turnip & Virgl | Working | [@subhu2008](https://github.com/subhu2008) | LineageOS 23.2 only. If it works on other AOSP ROM's too, then good. | | **Oneplus 8, 8T, 8Pro, 9R** | IN2010 | Lineage OS 23.2 - Android 16 | `Q_V1_P14` | `4.19.325` | none | [Source](https://github.com/JackA1ltman/dreamworld_oneplus_sm8250) | [Download](https://github.com/JackA1ltman/dreamworld_oneplus_sm8250/releases/tag/v1.2) | Both | Turnip & Virgl | Working | [@JackA1ltman](https://github.com/JackA1ltman) | Custom ROM only. Supported NTSYNC. | ## GKI Devices (Kernel 5.4 and up) | Device Name | Model Number | Android / ROM | Kernel version | Root Method | Kernel Source | Download Link | Droidspaces Mode | GPU Acceleration | Status | Maintainer | Additional notes | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| **5.4 Devices**| - | - | 5.4.x | - | - | - | - | - | - | -| - | +| **Reno10 5G** | PHW110 | ColorOS15-16.1 - Android 15-16 | `5.4.254` | ReSukiSU | [Source](https://github.com/miaizhe/Kernel_oppo_sm7325_Reno10) | [Download](https://github.com/miaizhe/Kernel_oppo_sm7325_Reno10/releases) | Both | Turnip | Working | [@miaizhe](https://github.com/miaizhe) | - | +| **Xiaomi 11T Pro (vili)** | 2107113 | DerpFest A16 | `5.4.302` | KernelSU-Next | [Source](https://github.com/loystonpais/android_kernel_qcom_sm8350) | [Download](https://github.com/loystonpais/android_kernel_qcom_sm8350/releases/latest) | Both | Turnip & Virgl | Working | [@loystonpais](https://github.com/loystonpais) | - | +| **Nothing Phone (1)** | A063 | crDroid - Android 16 | `5.4.302` | KernelSU-Next | [Source](https://github.com/crdroidandroid/android_kernel_nothing_sm7325) | [Download](https://crdroid.net/Spacewar/12) | Both | Turnip & Virgl | Working | [@MySelly](https://github.com/MySelly) | - | +| **Motorola Edge 30** | XT2203 | Lineage OS 23.2 - Android 16 | `5.4.302` | KernelSU-Next | [Source](https://github.com/tuxdotrs/android_kernel_motorola_sm7325) | [Download](https://github.com/tuxdotrs/android_kernel_motorola_sm7325/releases/latest) | Both | Turnip & Virgl | Working | [@tuxdotrs](https://github.com/tuxdotrs) | - | +| **Motorola Moto G Stylus 5G (2022) | XT2215 | Android 16 QPR2 (LineageOS 23.2 or equivalent) | `5.4.306` | ReSukiSU | [Source](https://github.com/chararomandroid/android_kernel_motorola_sm6375) | [Download](https://github.com/CharaROMAndroid/android_kernel_motorola_sm6375/releases/tag/chaoskernel-milanf) | Both | Turnip | Working | [@GarryStraitYT](https://github.com/GarryStraitYT) | Tested on CharaROM 1.0 Beta 2, should work on any LineageOS 23.2 equivalent. Tested with Retail US variant of XT2215-4 (8GB RAM + 256GB ROM), but should work on any variant. +| **5.10 Devices**| - | - | 5.10.x | - | - | - | - | - | - | -| - | +| **OnePlus Ace 竞速版 (Chinese variant)** | PGZ110 | ColorOS 15.0 - Android 15 | `5.10.167` | Resukisu | [Source](https://github.com/Numbersf/Action-Build) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_ReSukiSU_oki_5.10.226_Gold_bug_%E5%A4%A9%E7%8E%91%E7%89%B9%E4%BE%9B.zip) | Both | bridge & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | +| **OnePlus 10R** | CPH2411 | Android14 (OOS/COS 14) | 5.10.168 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_u_14.0.0_10r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE** | PGKM10 | Android14 (OOS/COS 14) | 5.10.168 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_u_14.0.0_ace) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 2V** | PHP110 | Android14 (OOS/COS 14) | 5.10.168 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus_mt6983_u_14.0.0_oneplus_ace_2v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE Race** | PGZ110 | Android14 (OOS/COS 14) | 5.10.168 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_u_14.0.0_ace_race) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord 3** | CPH2491, CPH2493 | Android14 (OOS/COS 14) | 5.10.198 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus_mt6983_u_14.0.0_nord_3_5g) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 10 Pro** | NE2210, NE2211, NE2213, NE2215, NE2217 | Android14 (OOS/COS 14) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8450/tree/oneplus/sm8450_u_14.0.0_oneplus_10pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 10 Pro** | NE2210, NE2211, NE2213, NE2215, NE2217 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8450/tree/210ba17c8f19beed0212eb4f209109e35ef35746) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 10R** | CPH2411 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_v_15.0.0_oneplus_10r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 10T** | CPH2415, CPH2413, CPH2417, CPH2419 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/bdebb03938ab5f6719abc50af454d76a1b12df67) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 11R** | CPH2487 | Android14 (OOS/COS 14) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_u_14.0.0_oneplus_11r_5g) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 11R** | CPH2487 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/6821a3751c4fd016a7ee4da5aea635d42a8b3d43) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE** | PGKM10 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/e5f7c2a470fd9d8b43c5082aed8b58a6d5a2bf7c) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 2** | PHK110 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/ef7ca029d0f6beddf38e60f6b9016935d0f1ac55) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 2V** | PHP110 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/8a49e1e297768839d78730f15e8f34e0d0b59f2b) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE Race** | PGZ110 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/7a64b08a000cc7040d99543cc7093aaa13e0c786) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord 3** | CPH2491, CPH2493 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/b9b40be7bbe4e2c201ba263ac72f2e1b10171998) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad Go** | OPD2304, OPD2305 | Android14 (OOS/COS 14) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6789/tree/oneplus/mt6789_u_14.0.0_onepluspad_go) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad MT6983** | OPD2203 | Android14 (OOS/COS 14) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus_mt6983_u_14.0.0_oneplus_pad) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad MT6983** | OPD2203 | Android15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/4790201712d757f4f337cd55e5709255edc8bee5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 10 Pro** | NE2210, NE2211, NE2213, NE2215, NE2217 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8450/tree/oneplus/sm8450_v_15.0.0_oneplus_10_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 10T** | CPH2415, CPH2413, CPH2417, CPH2419 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/45235533105840dc875a80e9d5b4cd881e453472) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 11R** | CPH2487 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_v_15.0.0_oneplus_11r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE** | PGKM10 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_v_15.0.0_ace) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 2** | PHK110 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_v_15.0.0_ace_2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 2V** | PHP110 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus/mt6983_v_15.0.0_ace_2v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE Race** | PGZ110 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/6409af87cfda3739caff4ee29eb28e77abe8a290) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord 3** | CPH2491, CPH2493 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus/mt6983_v_15.0.0_nord_3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad Lite** | OPD2480, OPD2481 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6789/tree/oneplus/mt6789_v_15.0.1_pad_lite) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad MT6983** | OPD2203 | Android15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus/mt6983_v_15.0.0_oneplus_pad) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 10 Pro** | NE2210, NE2211, NE2213, NE2215, NE2217 | Android16 (OOS/COS 16) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8450/tree/oneplus/sm8450_b_16.0_oneplus_10_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 10T** | CPH2415, CPH2413, CPH2417, CPH2419 | Android15 (OOS/COS 15) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_v_15.0.0_oneplus_10t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 11R** | CPH2487 | Android16 (OOS/COS 16) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_b_16.0.0_oneplus_11r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 2** | PHK110 | Android16 (OOS/COS 16) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_b_16.0.0_ace_2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 2V** | PHP110 | Android16 (OOS/COS 16) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus/mt6983_b_16.0.0_ace_2v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE Race** | PGZ110 | Android15 (OOS/COS 15) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_v_15.0.0_ace_race) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad MT6983** | OPD2203 | Android16 (OOS/COS 16) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus/mt6983_b_16.0.0_oneplus_pad) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **Xiaomi Pad 6 Max (yudi)** | 2307BRPDCC | HyperOS 3.0 - Android 15 | `5.10.252` | none | [Source](https://github.com/Star-ZER0/android_gki_kernel_5.10_common) | [Download](https://github.com/Star-ZER0/android_gki_kernel_5.10_common/releases/latest) | Both | Turnip & Virgl | Working | [@Star-ZER0](https://github.com/Star-ZER0) | Supports NTsync | +| **Xiaomi Pad 6 Pro (liuqin)** | 2307BRPDCC | HyperOS 3.0 - Android 15 | `5.10.252` | none | [Source](https://github.com/Star-ZER0/android_gki_kernel_5.10_common) | [Download](https://github.com/Star-ZER0/android_gki_kernel_5.10_common/releases/latest) | Both | Turnip & Virgl | Working | [@Star-ZER0](https://github.com/Star-ZER0) | Supports NTsync | +| **Redmi K60 / POCO F5 Pro (mondrian)** | 23013RK75C | HyperOS 3.0 - Android 15 | `5.10.252` | none | [Source](https://github.com/Star-ZER0/android_gki_kernel_5.10_common) | [Download](https://github.com/Star-ZER0/android_gki_kernel_5.10_common/releases/latest) | Both | Turnip & Virgl | Working | [@Star-ZER0](https://github.com/Star-ZER0) | Supports NTsync | Applicable to the international version | +| **Redmi K50 Ultra / Xiaomi 12T Pro (diting)** | 22081212C | HyperOS 3.0 - Android 15 | `5.10.252` | none | [Source](https://github.com/Star-ZER0/android_gki_kernel_5.10_common) | [Download](https://github.com/Star-ZER0/android_gki_kernel_5.10_common/releases/latest) | Both | Turnip & Virgl | Working | [@Star-ZER0](https://github.com/Star-ZER0) | Supports NTsync | Applicable to the international version | +| **5.15 Devices** | - | - | 5.15.x | - | - | - | - | - | - | -| - | +| **OnePlus 11** | PHB110, CPH2449, CPH2447, CPH2451 | Android14 (OOS/COS 14) | 5.15.123 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_u_14.0.0_oneplus11) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | | **Galaxy A16 5G** | SM-A166P | One UI 6.1 - Android 14 | `5.15.148` | KernelSU-Next v3.1.0 | [Source](https://github.com/ravindu644/android_kernel_a166p) | [Download](https://github.com/ravindu644/android_kernel_a166p/releases/download/r20260402-c17abb78/KernelSU-SM-A166P-OneUI6-A166PXXS4AYC1-r20260402-c17abb78.tar.zip) | Both | Virgl only | Working | [@ravindu644](https://github.com/ravindu644) | - | +| **OnePlus 11** | PHB110, CPH2449, CPH2447, CPH2451 | Android15 (OOS/COS 15) | 5.15.149 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/7d9f64c901ac11ce80bf78682d33fd336c825fd9) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 12R** | CPH2609, CPH2585, CPH2611 | Android15 (OOS/COS 15) | 5.15.149 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/86a89359565410962004fd838e3d2e3b42a047ca) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 2 Pro** | PJA110 | Android15 (OOS/COS 15) | 5.15.149 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/95b9da8a7cd99053f65e7d4cebec310b5f5d5744) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 3** | PJE110 | Android15 (OOS/COS 15) | 5.15.149 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/3a51ce558bfa3a1c7eeaaab69ee54d5e6bd03afb) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord 4 CE** | CPH2613 | Android15 (OOS/COS 15) | 5.15.149 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7550/tree/d8187f64c9e60fee0969a678a67ac77114a926d9) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Open** | CPH2551 | Android15 (OOS/COS 15) | 5.15.149 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/2a845b8afd7afc3d72679ddbe5adb07c390966bc) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | | **Galaxy A16 5G** | SM-A166P | One UI 7.0 - Android 15 | `5.15.167` | KernelSU-Next v1.1.1 | [Source](https://github.com/ravindu644/android_kernel_a166p) | [Download](https://github.com/ravindu644/android_kernel_a166p/releases/download/ubuntu-x-docker/Ubuntu-x-KernelSU-Next-SM-A166P-dev.zip) | Both | Virgl only | Working | [@ravindu644](https://github.com/ravindu644) | - | | **Galaxy M14 4G** | SM-M145F | One UI 7.0 - Android 15 | `5.15.167` | KernelSU-Next v1.1.1 | [Source](https://github.com/ravindu644/android_kernel_sm_m145f) | [Download](https://github.com/ravindu644/android_kernel_sm_m145f/releases/download/v1/SM-M145F-Droidspaces-KSUNv1.1.1-M145FXXS8DYH1.tar.zip) | Both | Turnip & Virgl | Working | [@ravindu644](https://github.com/ravindu644) | - | +| **OnePlus Ace 3 (Chinese variant)** | PJE110 | ColorOS 15.0 - Android 15 | `5.15.167` | none | [Source](https://github.com/Numbersf/Action-Build) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_5.15.167_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | Suitable for OnePlus 12R | +| **OnePlus 11** | PHB110, CPH2449, CPH2447, CPH2451 | Android15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_v_15.0.0_oneplus11) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 11** | PHB110, CPH2449, CPH2447, CPH2451 | Android16 (OOS/COS 16) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/bee3255be1f407e7cff7b0b8f1c26daaccbbb510) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 12R** | CPH2609, CPH2585, CPH2611 | Android15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_v_15.0.0_oneplus_12r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 12R** | CPH2609, CPH2585, CPH2611 | Android16 (OOS/COS 16) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/717aea450444dfd00db7d60c99cff77fbcbf014e) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 2 Pro** | PJA110 | Android15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_v_15.0.0_ace_2_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 3** | PJE110 | Android15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_v_15.0.0_ace_3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 3** | PJE110 | Android16 (OOS/COS 16) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/3dc000f085497a728cdf21b28fab9d3ef632f5fd) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord 4 CE** | CPH2613 | Android15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7550/tree/0d28103245225a507d4f4b3ca657d3e27aae2f20) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Open** | CPH2551 | Android15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_v_15.0.0_oneplus_open) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Open** | CPH2551 | Android16 (OOS/COS 16) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/1e88584a40b3b055e5ff3796f9a184655ef2d0b8) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **Xiaomi Pad 6S Pro (Chinese variant)** | 24018RPACC | HyperOS 3.0 - Android 16 | `5.15.178` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/Anykernel3_Resukisu_gki_NTsync_5.15.178_github%40mlfc666.zip) | Both | Turnip & Virgl | Working | [@mlfc666](https://github.com/mlfc666) | Applicable to the international version
Supports NTsync | +| **Redmi K70 (Chinese variant)** | 23113RKC6C | HyperOS 3.0 - Android 16 | `5.15.178` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/Anykernel3_Resukisu_gki_NTsync_5.15.178_github%40mlfc666.zip) | Both | Turnip & Virgl | Working | [@mlfc666](https://github.com/mlfc666) | Supports NTsync | | **Galaxy A25 5G** | SM-A253Z | One UI 8.0 - Android 16 | `5.15.180` | Magisk | [Source](https://github.com/yoshi3jp/android_kernel_samsung_a25ex_mt6835) | [Download](https://github.com/yoshi3jp/android_kernel_samsung_a25ex_mt6835/releases/download/r20260421-89a95a08/DroidSpaces-SM-A253Z-OneUI8-A253ZSCS1BZA2-r20260421-89a95a08.tar.zip) | Both | none | Working | [@yoshi3jp](https://github.com/yoshi3jp) | - | -| **OnePlus Pad 2 Pro (Chinese variant)** | OPD2413 | ColorOS 16.0 - Android 16 | `6.6.89` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.89_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | Suitable for OnePlus Pad 3 | -| **OnePlus 13 (Chinese variant)** | PJZ110 | ColorOS 16.0 - Android 16 | `6.6.89` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.89_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | Applicable to the international version | -| **OnePlus 13 (Chinese variant)** | PJZ110 | ColorOS 15.0 - Android 15 | `6.6.66` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.66_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | Applicable to the international version | -| **OnePlus Ace 5 Pro (Chinese variant)** | PKR110 | ColorOS 16.0 - Android 16 | `6.6.89` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.89_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | -| **OnePlus Ace 5 Pro (Chinese variant)** | PKR110 | ColorOS 15.0 - Android 15 | `6.6.66` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.66_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | -| **OnePlus 13T (Chinese variant)** | PKX110 | ColorOS 16.0 - Android 16 | `6.6.89` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.89_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | -| **OnePlus 13T (Chinese variant)** | PKX110 | ColorOS 15.0 - Android 15 | `6.6.66` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.66_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | -| **OnePlus Ace 6 (Chinese variant)** | PLR110 | ColorOS 16.0 - Android 16 | `6.6.89` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.89_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | -| **OnePlus Ace 3 Pro (Chinese variant)** | PJX110 | ColorOS 16.0 - Android 16 | `6.1.141` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.141_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | +| **OnePlus Ace 3 (Chinese variant)** | PJE110 | ColorOS 16.0 - Android 16 | `5.15.180` | none | [Source](https://github.com/Numbersf/Action-Build) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_5.15.180_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | Suitable for OnePlus 12R | +| **OnePlus 11** | PHB110, CPH2449, CPH2447, CPH2451 | Android16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_oneplus_11) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 12R** | CPH2609, CPH2585, CPH2611 | Android16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_oneplus_12r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 2 Pro** | PJA110 | Android16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_ace_2_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 3** | PJE110 | Android16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_ace_3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord 4 CE** | CPH2613 | Android15 (OOS/COS 15) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7550/tree/oneplus/sm7550_v_15.0.0_nord_ce4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord 4 CE** | CPH2613 | Android16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7550/tree/oneplus/sm7550_b_16.0.0_nord_ce4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Open** | CPH2551 | Android16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_oneplus_open) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **Redmi Note 13 4G / NFC** | 23124RA7EO | HyperOS 2.0 - Android 15 | `5.15.202` | Wild-KSU v3.1.2 | [Source](https://github.com/superuseryu/kernel_sapphire_SM6225) | [Download](https://github.com/superuseryu/kernel_sapphire_SM6225/releases/tag/v1.5.2%2B_R27) | Both | Turnip | Working | [@superuseryu](https://github.com/superuseryu) | Other KSU variants may fail due to SELinux context reset. | +| **6.1 Devices** | - | - | 6.1.x | - | - | - | - | - | - | -| - | +| **红魔 9 SPro+ (Chinese variant)** | NX769J | RedMagicOS11 - Android 16 | `6.1.25` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_Resukisu_gki_6.1.25_KPM_BBG_RE-kernel_%E7%BA%A2%E9%AD%949spro%2B_github%40sjszero.zip) | Both | Turnip & Virgl | Working | [@sjszero](https://github.com/sjszero) | - | +| **OnePlus 12** | PJD110, CPH2573, CPH2581, CPH2583 | Android14 (OOS/COS 14) | 6.1.57 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_u_14.0.0_oneplus12) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 12** | PJD110, CPH2573, CPH2581, CPH2583 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/c461b745bc361581d0b419effd284367d85f1e57) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 13R** | CPH2645, CPH2691, CPH2647 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/d68f486cb00b246abf4b7b66375082dc8bcb884e) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 3 Pro** | PJX110 | Android14 (OOS/COS 14) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_u_14.1.0_oneplus_ace3pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 3 Pro** | PJX110 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/1cee682fb60cc9ffd86a2342fa8051bcde77116f) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 3V** | PJF110 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/ac51a5e4c2b0f13f06e9234e415bfa68fb24a8b5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 5** | PKG110 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/8c797fd3ff3f76248b44a619410ace1277692907) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord 4** | CPH2663, CPH2661 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/a3c5659c859e888102a144be0884fbf3731b170f) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord CE4 Lite** | CPH2619, CPH2621 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm6375/tree/oneplus/sm6375_v_15.0.0_nord_ce4_lite_5g) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad 2 SM8650** | OPD2403 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/f5a170ce44c7657894247de3de41d23a89e248f7) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad 3 MT6897** | OPD2407 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6897/tree/cbae97472bca105f69ffaef126065f9ab06e1c12) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad Pro** | OPD2404 | Android14 (OOS/COS 14) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_u_14.1.0_onepluspad_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad Pro** | OPD2404 | Android15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/dcb47d3dcba84e13ae678f18b4629b22333e8898) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **Yoga Tab Plus (International variant)** | TB520FU | ZUI 17.5.10.096 (ROW) - Android14 | `6.1.112` | SukiSU-Ultra v4.1.3 (40796) | [Source](https://github.com/da-ai-xian-zun/tb520fu-droidspaces-gki) | [Download](https://github.com/da-ai-xian-zun/tb520fu-droidspaces-gki/releases/download/v1.0.0/tb520fu-droidspaces-phase2-images.zip) | Both | Turnip | Partial | [@da-ai-xian-zun](https://github.com/da-ai-xian-zun) | Maintainer path verified with **locked bootloader** via 9008 four-image manual flash (not AnyKernel3), see [MANUAL_FLASH.md](https://github.com/da-ai-xian-zun/tb520fu-droidspaces-gki/blob/main/docs/MANUAL_FLASH.md); verified on this ZUI build (ROW, `UKQ1.240826.001`) only—bundled `init_boot`/`vbmeta` are tied to this AVB chain; if bootloader is unlocked, use fastboot with your own `init_boot`/vbmeta for looser AVB constraints; use directory mode for containers; App sparse install unresolved; VirGL not tested | +| **OnePlus ACE 5 Racing** | PLF110 | Android15 (OOS/COS 15) | 6.1.115 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6989/tree/oneplus/mt6989_v_15.0.2_ace5_race) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord CE5** | CPH2719, CPH2717 | Android15 (OOS/COS 15) | 6.1.115 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6897/tree/oneplus/mt6897_v_15.0.2_nord_ce5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | | **OnePlus Ace 3 Pro (Chinese variant)** | PJX110 | ColorOS 16.0 - Android 16 | `6.1.118` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.118_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | -| **OnePlus Ace 5 (Chinese variant)** | PKG110 | ColorOS 16.0 - Android 16 | `6.1.141` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.141_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | Suitable for OnePlus 13R | | **OnePlus Ace 5 (Chinese variant)** | PKG110 | ColorOS 16.0 - Android 16 | `6.1.118` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.118_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | Suitable for OnePlus 13R | -| **OnePlus 12 (Chinese variant)** | PJD110 | ColorOS 16.0 - Android 16 | `6.1.141` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.141_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | Applicable to the international version | | **OnePlus 12 (Chinese variant)** | PJD110 | ColorOS 16.0 - Android 16 | `6.1.118` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.118_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | Applicable to the international version | -| **OnePlus Pad Pro (Chinese variant)** | OPD2403 | ColorOS 16.0 - Android 16 | `6.1.141` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.141_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | Suitable for OnePlus Pad 2 (International version) | | **OnePlus Pad Pro (Chinese variant)** | OPD2403 | ColorOS 16.0 - Android 16 | `6.1.118` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.118_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | Suitable for OnePlus Pad 2 (International version) | +| **Xiaomi Pad 7 (Chinese variant)** | 2410CRP4CC | HyperOS 2.0 - Android 15 | `6.1.118` | Sukisu Ultra | [Source](https://github.com/HuangJunLin8/uke_GKI_KernelSU_SUSFS.git) | [Download](https://github.com/HuangJunLin8/uke_GKI_KernelSU_SUSFS/releases/tag/release) | Both | Turnip & Zink | Working | [HuangJunLin8](https://github.com/HuangJunLin8) | Supports NTsync | +| **OnePlus 12** | PJD110, CPH2573, CPH2581, CPH2583 | Android15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_oneplus12) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 12** | PJD110, CPH2573, CPH2581, CPH2583 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/2936373a255da66646a05384e993d55a8dfecc80) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 13R** | CPH2645, CPH2691, CPH2647 | Android15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_oneplus_13r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 13R** | CPH2645, CPH2691, CPH2647 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/35f7c28cf36e19e3243a9db8496d5e70b94e34ce) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 3 Pro** | PJX110 | Android15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_ace_3_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 3 Pro** | PJX110 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_ace_3_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 3V** | PJF110 | Android15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/oneplus/sm7675_v_15.0.0_ace_3v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 3V** | PJF110 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/65c3035422d899d95bfde4e30dc426712ffe52f2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 5** | PKG110 | Android15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_oneplus_ace5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 5** | PKG110 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/7499247fd6e0669a062ec44cce948ec1e9d4c75e) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord 4** | CPH2663, CPH2661 | Android15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/oneplus/sm7675_v_15.0.0_nord_4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord 4** | CPH2663, CPH2661 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/975bcbc2e288c3b7c2d2799421c7f356f9c78ce4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord 5** | CPH2709, CPH2707 | Android15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8635/tree/oneplus/sm8635_v_15.0.2_nord5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord 5** | CPH2709, CPH2707 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8635/tree/fbeaa9ff25bef199618b9e47004f2eb8fe135f9f) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord CE4 Lite** | CPH2619, CPH2621 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm6375/tree/oneplus/sm6375_b_16.0.0_nord_ce4_lite_5g) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad 2 SM8650** | OPD2403 | Android15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_pad2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad 2 SM8650** | OPD2403 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_pad2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad Pro** | OPD2404 | Android15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_pad_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad Pro** | OPD2404 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_pad_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Turbo 6V** | PLY110 | Android16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7635/tree/oneplus/sm7635_b_16.0.0_turbo_6v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **红魔 9 Pro (Chinese variant)** | NX769J | RedMagicOS11 - Android 16 | `6.1.124` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_Resukisu_gki_6.1.124_KPM_BBG_RE-kernel_%E7%BA%A2%E9%AD%949pro_github%40sjszero.zip) | Both | Turnip & Virgl | Working | [@sjszero](https://github.com/sjszero) | - | +| **OnePlus Pad 3 MT6897** | OPD2407 | Android15 (OOS/COS 15) | 6.1.128 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6897/tree/oneplus/mt6897_v_15.0.0_oneplus_pad) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 5 Racing** | PLF110 | Android16 (OOS/COS 16) | 6.1.134 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6989/tree/oneplus/mt6989_b_16.0.0_ace5_race) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord CE5** | CPH2719, CPH2717 | Android16 (OOS/COS 16) | 6.1.134 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6897/tree/oneplus/mt6897_b_16.0.0_nord_ce5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad 3 MT6897** | OPD2407 | Android16 (OOS/COS 16) | 6.1.134 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6897/tree/oneplus/mt6897_b_16.0.0_oneplus_pad) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad Go 2** | OPD2504, OPD2505 | Android16 (OOS/COS 16) | 6.1.134 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6878/tree/oneplus/mt6878_b_16.0.0_pad_go_2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **Xiaomi 14 (Chinese variant)** | 23127PN0CC | HyperOS 3.0 - Android 16 | `6.1.138` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_Resukisu_gki_6.1.138_coolapk%40%E7%88%B1%E4%BD%A0%E4%B9%88%E4%B9%88%E5%93%92qcjl%E5%93%9F.zip) | Both | Turnip & Virgl | Working | [爱你么么哒qcjl哟](https://www.coolapk.com/u/29859482) | Applicable to the international version | +| **OnePlus Ace 3 Pro (Chinese variant)** | PJX110 | ColorOS 16.0 - Android 16 | `6.1.141` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.141_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | +| **OnePlus Ace 5 (Chinese variant)** | PKG110 | ColorOS 16.0 - Android 16 | `6.1.141` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.141_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | Suitable for OnePlus 13R | +| **OnePlus 12 (Chinese variant)** | PJD110 | ColorOS 16.0 - Android 16 | `6.1.141` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.141_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | Applicable to the international version | +| **OnePlus Pad Pro (Chinese variant)** | OPD2403 | ColorOS 16.0 - Android 16 | `6.1.141` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.141_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | Suitable for OnePlus Pad 2 (International version) | | **Realme GT 5 Pro (Chinese variant)** | RMX3888 | RealmeUI 7.0 - Android 16 | `6.1.141` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.118_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | -| **OnePlus Ace 3 (Chinese variant)** | PJE110 | ColorOS 16.0 - Android 16 | `5.15.180` | none | [Source](https://github.com/Numbersf/Action-Build) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_5.15.180_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | Suitable for OnePlus 12R | -| **OnePlus Ace 3 (Chinese variant)** | PJE110 | ColorOS 15.0 - Android 15 | `5.15.167` | none | [Source](https://github.com/Numbersf/Action-Build) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_5.15.167_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | Suitable for OnePlus 12R | -| **OnePlus Ace 竞速版 (Chinese variant)** | PGZ110 | ColorOS 15.0 - Android 15 | `5.10.167` | Resukisu | [Source](https://github.com/Numbersf/Action-Build) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_ReSukiSU_oki_5.10.226_Gold_bug_%E5%A4%A9%E7%8E%91%E7%89%B9%E4%BE%9B.zip) | Both | bridge & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | +| **OnePlus 12** | PJD110, CPH2573, CPH2581, CPH2583 | Android16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_oneplus12) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 13R** | CPH2645, CPH2691, CPH2647 | Android16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_oneplus_13r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 3V** | PJF110 | Android16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/oneplus/sm7675_b_16.0.0_ace_3v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 5** | PKG110 | Android16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_ace_5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord 4** | CPH2663, CPH2661 | Android16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/oneplus/sm7675_b_16.0.0_nord_4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord 5** | CPH2709, CPH2707 | Android16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8635/tree/oneplus/sm8635_b_16.0.0_nord_5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | | **OnePlus SM8650 (All LineageOS OnePlus 6.1 Devices)** | SM8650 | LineageOS 23.2 - Android 16 QPR 2 | `6.1.166` | none | [Source](https://github.com/LineageOS/android_kernel_oneplus_sm8650) | [Download](https://github.com/linx3141/CRom-KSU-Builder/releases/tag/sm8650_LineageOS-lineage-23.2_None_20260512_1647) | Both | Turnip & Virgl | Working | [@linx3141](https://github.com/linx3141) | - | +| **POCO F6 / Redmi Turbo 3 (peridot)** | 24069PC21I | VoltageOS (AOSP-based) - Android 16 | `6.1.175` | KernelSU-Next | [Source](https://github.com/Mohithash/kernel_xiaomi_sm8635/tree/peridot-6.1.175) | [Download](https://github.com/Mohithash/kernel_xiaomi_sm8635/releases/download/droidspaces-v1/Theettam-2.0-KSUN3.3.0-SUSFS2.2.0-DroidSpaces-EXPERIMENTAL-peridot-6.1.175-android14-11-ga3b9c44908dd-ab13320413.zip) | Container/LXC | Untested | Working | [@Mohithash](https://github.com/Mohithash) | AnyKernel3 flashable, Image-only. Container/LXC verified with a live Alpine Linux 3.23.5. | +| **6.6 Devices** | - | - | 6.6.x | - | - | - | - | - | - | -| - | +| **OnePlus 13** | CPH2655, CPH2653, PJZ110, CPH2649 | Android15 (OOS/COS 15) | 6.6.30 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/f9c1a38fe1bf6bf39f25e4167e8322813c7707b9) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 5 Pro** | PKR110 | Android15 (OOS/COS 15) | 6.6.30 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/94969654b1ac03071c42c4757cd9333535612415) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord N30 SE** | CPH2605 | Android15 (OOS/COS 15) | 6.6.30 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6833/tree/oneplus/mt6833_v_15.0.0_nord_n30_se_5g) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad 2 Pro** | OPD2413 | Android15 (OOS/COS 15) | 6.6.30 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/68c46d941cc1818d49f8a8805c79e63fbee01eac) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad 3 SM8750** | OPD2415 | Android15 (OOS/COS 15) | 6.6.30 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/35366be96d7e73d1d0d7093e1cf2fafd58f9c7d4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 5 Ultra** | PLC110 | Android15 (OOS/COS 15) | 6.6.50 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6991/tree/06b1dfbb8f777d329a25d6f932f58f0e45d87627) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 13 CPH** | CPH2655, CPH2653, CPH2649 | Android15 (OOS 15) | 6.6.56 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/cec4a1056a32dfdd68c23767f6c88bb6c698f1bf) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 13S** | CPH2723 | Android15 (OOS/COS 15) | 6.6.56 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.2_oneplus_13s) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 13T** | PKX110 | Android15 (OOS/COS 15) | 6.6.56 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/06be241a7b9eefa01db78447cc7877339b3685f3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad 2 Pro** | OPD2413 | Android15 (OOS/COS 15) | 6.6.57 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.1_pad_2_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad 3 SM8750** | OPD2415 | Android15 (OOS/COS 15) | 6.6.57 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.1_pad_3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 13 (Chinese variant)** | PJZ110 | ColorOS 15.0 - Android 15 | `6.6.66` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.66_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | Applicable to the international version | +| **OnePlus Ace 5 Pro (Chinese variant)** | PKR110 | ColorOS 15.0 - Android 15 | `6.6.66` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.66_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | +| **OnePlus 13T (Chinese variant)** | PKX110 | ColorOS 15.0 - Android 15 | `6.6.66` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.66_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | +| **OnePlus 13 PJZ** | PJZ110 | Android15 (COS 15) | 6.6.66 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.0_oneplus_13) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 13T** | PKX110 | Android15 (OOS/COS 15) | 6.6.66 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.2_oneplus_13t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 5 Pro** | PKR110 | Android15 (OOS/COS 15) | 6.6.66 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.0_oneplus_ace5_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **Xiaomi Pad 8 Pro (Chinese variant)** | 25091RP04C | HyperOS 3.0 - Android 16 | `6.6.77` | Resukisu & NoRoot | [Source](https://github.com/404-GCross/Droidspaces_GKI_Buildin_Local) | [ReSukiSU](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_ReSukiSU-34924_6.6.77_NTsync_github@404-GCross.zip)
[NoRoot](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.6.77_NTsync_github@404-GCross.zip) | Both | Turnip & Virgl | Working | [404-GCross](https://github.com/404-GCross) | Applicable to the international version
Supports NTsync
ReSukiSU version 34924 | +| **Xiaomi 15 (Chinese variant)** | 24129PN74C | HyperOS 3.0 - Android 16 | `6.6.77` | Resukisu & NoRoot | [Source](https://github.com/404-GCross/Droidspaces_GKI_Buildin_Local) | [ReSukiSU](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_ReSukiSU-34924_6.6.77_NTsync_github@404-GCross.zip)
[NoRoot](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.6.77_NTsync_github@404-GCross.zip) | Both | Turnip & Virgl | Working | [404-GCross](https://github.com/404-GCross) | Applicable to the international version
Supports NTsync
ReSukiSU version 34924 | +| **OnePlus Pad 2 Pro (Chinese variant)** | OPD2413 | ColorOS 16.0 - Android 16 | `6.6.89` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.89_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | Suitable for OnePlus Pad 3 | +| **OnePlus 13 (Chinese variant)** | PJZ110 | ColorOS 16.0 - Android 16 | `6.6.89` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.89_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | Applicable to the international version | +| **OnePlus Ace 5 Pro (Chinese variant)** | PKR110 | ColorOS 16.0 - Android 16 | `6.6.89` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.89_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | +| **OnePlus 13T (Chinese variant)** | PKX110 | ColorOS 16.0 - Android 16 | `6.6.89` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.89_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | +| **OnePlus Ace 6 (Chinese variant)** | PLR110 | ColorOS 16.0 - Android 16 | `6.6.89` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.89_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | +| **OnePlus 13** | CPH2655, CPH2653, PJZ110, CPH2649 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_oneplus_13) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 13 CPH** | CPH2655, CPH2653, CPH2649 | Android15 (OOS 15) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.0_oneplus_13_global) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 13S** | CPH2723 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_oneplus_13s) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 13T** | PKX110 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_oneplus_13t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 5 Pro** | PKR110 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_oneplus_ace5_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 5 Ultra** | PLC110 | Android15 (OOS/COS 15) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6991/tree/oneplus/mt6991_v_15.0.2_ace5_ultra) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 5 Ultra** | PLC110 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6991/tree/oneplus/mt6991_b_16.0.0_oneplus_ace5_ultra) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 6** | PLQ110 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_ace_6) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Nord 6** | CPH2793, CPH2795 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8735/tree/oneplus/sm8735_b_16.0.0_nord_6) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad 2 MT6991** | OPD2508 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6991/tree/oneplus/mt6991_b_16.0.0_pad_2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad 2 Pro** | OPD2413 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_pad_2_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad 3 SM8750** | OPD2415 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_pad_3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Turbo 6** | PLU110 | Android16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8735/tree/oneplus/sm8735_b_16.0.0_turbo_6) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **6.12 Devices** | - | - | 6.12.x | - | - | - | - | - | - | -| - | | **Xiaomi 17 (Chinese variant)** | 25113PN0EC | HyperOS 3.0 - Android 16 | `6.12.23` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8850) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.12.23_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | Applicable to the international version | | **Xiaomi 17 Pro (Chinese variant)** | 2509FPN0BC | HyperOS 3.0 - Android 16 | `6.12.23` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8850) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.12.23_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | | **Xiaomi 17 Pro max (Chinese variant)** | 25113PN0EC | HyperOS 3.0 - Android 16 | `6.12.23` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8850) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.12.23_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | +| **Redmi K90 Pro max (Chinese variant)** | 25102RKBEC | HyperOS 3.0 - Android 16 | `6.12.23` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8850) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.12.23_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | +| **Poco F8 Ultra** | 25102PCBEG | HyperOS 3.0 - Android 16 | `6.12.23` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8850) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.12.23_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | | **红魔 11 Pro (Chinese variant)** | NX809J | RedMagicOS11 - Android 16 | `6.12.23` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8850) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.12.23_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | | **HONOR Magic Pad 3 Pro 13.3 (Chinese variant)** | YLP-W00 | MagicOS10 - Android 16 | `6.12.23` | none | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8850) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.12.23_Gold_bug.zip) | Both | Turnip & Virgl | Working | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | -| **Xiaomi Pad 7 (Chinese variant)** | 2410CRP4CC | HyperOS 2.0 - Android 15 | `6.1.118` | Sukisu Ultra | [Source](https://github.com/HuangJunLin8/uke_GKI_KernelSU_SUSFS.git) | [Download](https://github.com/HuangJunLin8/uke_GKI_KernelSU_SUSFS/releases/tag/release) | Both | Turnip & Zink | Working | [HuangJunLin8](https://github.com/HuangJunLin8) | Supports NTsync | -| **Xiaomi Pad 8 Pro (Chinese variant)** | 25091RP04C | HyperOS 3.0 - Android 16 | `6.6.77` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.6.77_NTsync_github%40404-GCross.zip) | Both | Turnip & Virgl | Working | [404-GCross](https://github.com/404-GCross) | Applicable to the international version
Supports NTsync | -| **Xiaomi 15 (Chinese variant)** | 24129PN74C | HyperOS 3.0 - Android 16 | `6.6.77` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.6.77_NTsync_github%40404-GCross.zip) | Both | Turnip & Virgl | Working | [404-GCross](https://github.com/404-GCross) | Applicable to the international version
Supports NTsync | -| **Xiaomi 14 (Chinese variant)** | 23127PN0CC | HyperOS 3.0 - Android 16 | `6.1.138` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_Resukisu_gki_6.1.138_coolapk%40%E7%88%B1%E4%BD%A0%E4%B9%88%E4%B9%88%E5%93%92qcjl%E5%93%9F.zip) | Both | Turnip & Virgl | Working | [爱你么么哒qcjl哟](https://www.coolapk.com/u/29859482) | Applicable to the international version | -| **红魔 9 Pro (Chinese variant)** | NX769J | RedMagicOS11 - Android 16 | `6.1.124` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_Resukisu_gki_6.1.124_KPM_BBG_RE-kernel_%E7%BA%A2%E9%AD%949pro_github%40sjszero.zip) | Both | Turnip & Virgl | Working | [@sjszero](https://github.com/sjszero) | - | -| **红魔 9 SPro+ (Chinese variant)** | NX769J | RedMagicOS11 - Android 16 | `6.1.25` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_Resukisu_gki_6.1.25_KPM_BBG_RE-kernel_%E7%BA%A2%E9%AD%949spro%2B_github%40sjszero.zip) | Both | Turnip & Virgl | Working | [@sjszero](https://github.com/sjszero) | - | -| **Xiaomi Pad 6S Pro (Chinese variant)** | 24018RPACC | HyperOS 3.0 - Android 16 | `5.15.178` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/Anykernel3_Resukisu_gki_NTsync_5.15.178_github%40mlfc666.zip) | Both | Turnip & Virgl | Working | [@mlfc666](https://github.com/mlfc666) | Applicable to the international version
Supports NTsync | -| **Redmi K70 (Chinese variant)** | 23113RKC6C | HyperOS 3.0 - Android 16 | `5.15.178` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/Anykernel3_Resukisu_gki_NTsync_5.15.178_github%40mlfc666.zip) | Both | Turnip & Virgl | Working | [@mlfc666](https://github.com/mlfc666) | Supports NTsync | -| **Xiaomi Pad 6 Max (yudi)** | 2307BRPDCC | HyperOS 3.0 - Android 15 | `5.10.252` | none | [Source](https://github.com/Star-ZER0/android_gki_kernel_5.10_common) | [Download](https://github.com/Star-ZER0/android_gki_kernel_5.10_common/releases/latest) | Both | Turnip & Virgl | Working | [@Star-ZER0](https://github.com/Star-ZER0) | Supports NTsync | -| **Xiaomi Pad 6 Pro (liuqin)** | 2307BRPDCC | HyperOS 3.0 - Android 15 | `5.10.252` | none | [Source](https://github.com/Star-ZER0/android_gki_kernel_5.10_common) | [Download](https://github.com/Star-ZER0/android_gki_kernel_5.10_common/releases/latest) | Both | Turnip & Virgl | Working | [@Star-ZER0](https://github.com/Star-ZER0) | Supports NTsync | -| **Redmi K60 / POCO F5 Pro (mondrian)** | 23013RK75C | HyperOS 3.0 - Android 15 | `5.10.252` | none | [Source](https://github.com/Star-ZER0/android_gki_kernel_5.10_common) | [Download](https://github.com/Star-ZER0/android_gki_kernel_5.10_common/releases/latest) | Both | Turnip & Virgl | Working | [@Star-ZER0](https://github.com/Star-ZER0) | Supports NTsync | Applicable to the international version -| **Redmi K50 Ultra / Xiaomi 12T Pro (diting)** | 22081212C | HyperOS 3.0 - Android 15 | `5.10.252` | none | [Source](https://github.com/Star-ZER0/android_gki_kernel_5.10_common) | [Download](https://github.com/Star-ZER0/android_gki_kernel_5.10_common/releases/latest) | Both | Turnip & Virgl | Working | [@Star-ZER0](https://github.com/Star-ZER0) | Supports NTsync | Applicable to the international version -| **Xiaomi 11T Pro (vili)** | 2107113 | DerpFest A16 | `5.4` | KernelSU-Next | [Source](https://github.com/loystonpais/android_kernel_qcom_sm8350) | [Download](https://github.com/loystonpais/android_kernel_qcom_sm8350/releases/latest) | Both | Turnip & Virgl | Working | [@loystonpais](https://github.com/loystonpais) | - | -| **Redmi Note 13 4G / NFC** | 23124RA7EO | HyperOS 2.0 - Android 15 | `5.15.202` | Wild-KSU v3.1.2 | [Source](https://github.com/superuseryu/kernel_sapphire_SM6225) | [Download](https://github.com/superuseryu/kernel_sapphire_SM6225/releases/tag/v1.5.2%2B_R27) | Both | Turnip | Working | [@superuseryu](https://github.com/superuseryu) | Other KSU variants may fail due to SELinux context reset. | -| **Nothing Phone (1)** | A063 | crDroid - Android 16 | `5.4.302` | KernelSU-Next | [Source](https://github.com/crdroidandroid/android_kernel_nothing_sm7325) | [Download](https://crdroid.net/Spacewar/12) | Both | Turnip & Virgl | Working | [@MySelly](https://github.com/MySelly) | - | -| **Reno10 5G** | PHW110 | ColorOS15-16.1 - Android 15-16 | `5.4.254` | ReSukiSU | [Source](https://github.com/miaizhe/Kernel_oppo_sm7325_Reno10) | [Download](https://github.com/miaizhe/Kernel_oppo_sm7325_Reno10/releases) | Both | Turnip | Working | [@miaizhe](https://github.com/miaizhe) | - | -| OnePlus 10 Pro | NE2210, NE2211, NE2213, NE2215, NE2217 | Android 14 (OOS/COS 14) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8450/tree/oneplus/sm8450_u_14.0.0_oneplus_10pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 10 Pro | NE2210, NE2211, NE2213, NE2215, NE2217 | Android 15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8450/tree/oneplus/sm8450_v_15.0.0_oneplus_10_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 10 Pro | NE2210, NE2211, NE2213, NE2215, NE2217 | Android 16 (OOS/COS 16) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8450/tree/oneplus/sm8450_b_16.0_oneplus_10_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 10R | CPH2411 | Android 14 (OOS/COS 14) | 5.10.168 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_u_14.0.0_10r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 10R | CPH2411 | Android 15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_v_15.0.0_oneplus_10r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 10T | CPH2415, CPH2413, CPH2417, CPH2419 | Android 15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_v_15.0.0_oneplus_10t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 11 | PHB110, CPH2449, CPH2447, CPH2451 | Android 14 (OOS/COS 14) | 5.15.123 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_u_14.0.0_oneplus11) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 11 | PHB110, CPH2449, CPH2447, CPH2451 | Android 15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_v_15.0.0_oneplus11) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 11 | PHB110, CPH2449, CPH2447, CPH2451 | Android 16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_oneplus_11) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 11R | CPH2487 | Android 14 (OOS/COS 14) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_u_14.0.0_oneplus_11r_5g) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 11R | CPH2487 | Android 15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_v_15.0.0_oneplus_11r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 11R | CPH2487 | Android 16 (OOS/COS 16) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_b_16.0.0_oneplus_11r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 12 | PJD110, CPH2573, CPH2581, CPH2583 | Android 14 (OOS/COS 14) | 6.1.57 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_u_14.0.0_oneplus12) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 12 | PJD110, CPH2573, CPH2581, CPH2583 | Android 15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_oneplus12) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 12 | PJD110, CPH2573, CPH2581, CPH2583 | Android 16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_oneplus12) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 12R | CPH2609, CPH2585, CPH2611 | Android 15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_v_15.0.0_oneplus_12r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 12R | CPH2609, CPH2585, CPH2611 | Android 16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_oneplus_12r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 13 | CPH2655, CPH2653, PJZ110, CPH2649 | Android 16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_oneplus_13) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 13 CPH | CPH2655, CPH2653, CPH2649 | Android 15 (OOS 15) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.0_oneplus_13_global) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 13 PJZ | PJZ110 | Android 15 (COS 15) | 6.6.66 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.0_oneplus_13) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 13R | CPH2645, CPH2691, CPH2647 | Android 15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_oneplus_13r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 13R | CPH2645, CPH2691, CPH2647 | Android 16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_oneplus_13r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 13S | CPH2723 | Android 15 (OOS/COS 15) | 6.6.56 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.2_oneplus_13s) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 13S | CPH2723 | Android 16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_oneplus_13s) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 13T | PKX110 | Android 15 (OOS/COS 15) | 6.6.66 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.2_oneplus_13t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 13T | PKX110 | Android 16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_oneplus_13t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 15 | CPH2747, CPH2745, PLK110, CPH2749 | Android 16 (OOS/COS 16) | 6.12.23 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8850/tree/oneplus/sm8850_b_16.0.0_oneplus_15) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 15R | CPH2769, CPH2767 | Android 16 (OOS/COS 16) | 6.12.38 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8845/tree/oneplus/sm8845_b_16.0.0_oneplus_15r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus 15T | PLZ110 | Android 16 (OOS/COS 16) | 6.12.38 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8850/tree/oneplus/sm8850_b_16.0_oneplus_15t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE | PGKM10 | Android 14 (OOS/COS 14) | 5.10.168 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_u_14.0.0_ace) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE | PGKM10 | Android 15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_v_15.0.0_ace) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 2 | PHK110 | Android 15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_v_15.0.0_ace_2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 2 | PHK110 | Android 16 (OOS/COS 16) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_b_16.0.0_ace_2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 2 Pro | PJA110 | Android 15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_v_15.0.0_ace_2_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 2 Pro | PJA110 | Android 16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_ace_2_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 2V | PHP110 | Android 14 (OOS/COS 14) | 5.10.168 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus_mt6983_u_14.0.0_oneplus_ace_2v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 2V | PHP110 | Android 15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus/mt6983_v_15.0.0_ace_2v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 2V | PHP110 | Android 16 (OOS/COS 16) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus/mt6983_b_16.0.0_ace_2v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 3 | PJE110 | Android 15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_v_15.0.0_ace_3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 3 | PJE110 | Android 16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_ace_3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 3 Pro | PJX110 | Android 14 (OOS/COS 14) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_u_14.1.0_oneplus_ace3pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 3 Pro | PJX110 | Android 15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_ace_3_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 3 Pro | PJX110 | Android 16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_ace_3_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 3V | PJF110 | Android 15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/oneplus/sm7675_v_15.0.0_ace_3v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 3V | PJF110 | Android 16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/oneplus/sm7675_b_16.0.0_ace_3v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 5 | PKG110 | Android 15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_oneplus_ace5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 5 | PKG110 | Android 16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_ace_5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 5 Pro | PKR110 | Android 15 (OOS/COS 15) | 6.6.66 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.0_oneplus_ace5_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 5 Pro | PKR110 | Android 16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_oneplus_ace5_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 5 Racing | PLF110 | Android 15 (OOS/COS 15) | 6.1.115 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6989/tree/oneplus/mt6989_v_15.0.2_ace5_race) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 5 Racing | PLF110 | Android 16 (OOS/COS 16) | 6.1.134 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6989/tree/oneplus/mt6989_b_16.0.0_ace5_racehttps://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 5 Ultra | PLC110 | Android 15 (OOS/COS 15) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6991/tree/oneplus/mt6991_v_15.0.2_ace5_ultra) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 5 Ultra | PLC110 | Android 16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6991/tree/oneplus/mt6991_b_16.0.0_oneplus_ace5_ultra) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 6 | PLQ110 | Android 16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_ace_6) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE 6T | PLR110 | Android 16 (OOS/COS 16) | 6.12.38 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8845/tree/oneplus/sm8845_b_16.0.0_ace_6t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE Race | PGZ110 | Android 14 (OOS/COS 14) | 5.10.168 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_u_14.0.0_ace_race) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus ACE Race | PGZ110 | Android 15 (OOS/COS 15) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_v_15.0.0_ace_race) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Nord 3 | CPH2491, CPH2493 | Android 14 (OOS/COS 14) | 5.10.198 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus_mt6983_u_14.0.0_nord_3_5g) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Nord 3 | CPH2491, CPH2493 | Android 15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus/mt6983_v_15.0.0_nord_3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Nord 4 | CPH2663, CPH2661 | Android 15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/oneplus/sm7675_v_15.0.0_nord_4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Nord 4 | CPH2663, CPH2661 | Android 16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/oneplus/sm7675_b_16.0.0_nord_4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Nord 4 CE | CPH2613 | Android 15 (OOS/COS 15) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7550/tree/oneplus/sm7550_v_15.0.0_nord_ce4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Nord 4 CE | CPH2613 | Android 16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7550/tree/oneplus/sm7550_b_16.0.0_nord_ce4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Nord 5 | CPH2709, CPH2707 | Android 15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8635/tree/oneplus/sm8635_v_15.0.2_nord5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Nord 5 | CPH2709, CPH2707 | Android 16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8635/tree/oneplus/sm8635_b_16.0.0_nord_5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Nord 6 | CPH2793, CPH2795 | Android 16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8735/tree/oneplus/sm8735_b_16.0.0_nord_6) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Nord CE4 Lite | CPH2619, CPH2621 | Android 15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm6375/tree/oneplus/sm6375_v_15.0.0_nord_ce4_lite_5g) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Nord CE4 Lite | CPH2619, CPH2621 | Android 16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm6375/tree/oneplus/sm6375_b_16.0.0_nord_ce4_lite_5g) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Nord CE5 | CPH2719, CPH2717 | Android 15 (OOS/COS 15) | 6.1.115 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6897/tree/oneplus/mt6897_v_15.0.2_nord_ce5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Nord CE5 | CPH2719, CPH2717 | Android 16 (OOS/COS 16) | 6.1.134 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6897/tree/oneplus/mt6897_b_16.0.0_nord_ce5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Nord N30 SE | CPH2605 | Android 15 (OOS/COS 15) | 6.6.30 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6833/tree/oneplus/mt6833_v_15.0.0_nord_n30_se_5g) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Open | CPH2551 | Android 15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_v_15.0.0_oneplus_open) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Open | CPH2551 | Android 16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_oneplus_open) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad 2 MT6991 | OPD2508 | Android 16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6991/tree/oneplus/mt6991_b_16.0.0_pad_2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad 2 Pro | OPD2413 | Android 15 (OOS/COS 15) | 6.6.57 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.1_pad_2_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad 2 Pro | OPD2413 | Android 16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_pad_2_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad 2 SM8650 | OPD2403 | Android 15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_pad2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad 2 SM8650 | OPD2403 | Android 16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_pad2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad 3 MT6897 | OPD2407 | Android 15 (OOS/COS 15) | 6.1.128 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6897/tree/oneplus/mt6897_v_15.0.0_oneplus_pad) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad 3 MT6897 | OPD2407 | Android 16 (OOS/COS 16) | 6.1.134 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6897/tree/oneplus/mt6897_b_16.0.0_oneplus_pad) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad 3 Pro | OPD2513 | Android 16 (OOS/COS 16) | 6.12.58 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8850/tree/oneplus/sm8850_b_16.0_pad_3_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad 3 SM8750 | OPD2415 | Android 15 (OOS/COS 15) | 6.6.57 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.1_pad_3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad 3 SM8750 | OPD2415 | Android 16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_pad_3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad 4 | OPD2514 | Android 16 (OOS/COS 16) | 6.12.58 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8850/tree/oneplus/sm8850_b_16.0_pad_4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad Go | OPD2304, OPD2305 | Android 14 (OOS/COS 14) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6789/tree/oneplus/mt6789_u_14.0.0_onepluspad_go) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad Go 2 | OPD2504, OPD2505 | Android 16 (OOS/COS 16) | 6.1.134 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6878/tree/oneplus/mt6878_b_16.0.0_pad_go_2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad Lite | OPD2480, OPD2481 | Android 15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6789/tree/oneplus/mt6789_v_15.0.1_pad_lite) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad Lite | OPD2480, OPD2481 | Android 16 (OOS/COS 16) | 6.12.23 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6789/tree/oneplus/mt6789_b_16.0_pad_lite) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad MT6983 | OPD2203 | Android 14 (OOS/COS 14) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus_mt6983_u_14.0.0_oneplus_pad) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad MT6983 | OPD2203 | Android 15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus/mt6983_v_15.0.0_oneplus_pad) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad MT6983 | OPD2203 | Android 16 (OOS/COS 16) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus/mt6983_b_16.0.0_oneplus_pad) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad Pro | OPD2404 | Android 14 (OOS/COS 14) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_u_14.1.0_onepluspad_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad Pro | OPD2404 | Android 15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_pad_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Pad Pro | OPD2404 | Android 16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_pad_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Turbo 6 | PLU110 | Android 16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8735/tree/oneplus/sm8735_b_16.0.0_turbo_6) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | -| OnePlus Turbo 6V | PLY110 | Android 16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7635/tree/oneplus/sm7635_b_16.0.0_turbo_6v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | - +| **OnePlus 15** | CPH2747, CPH2745, PLK110, CPH2749 | Android16 (OOS/COS 16) | 6.12.23 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8850/tree/oneplus/sm8850_b_16.0.0_oneplus_15) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad Lite** | OPD2480, OPD2481 | Android16 (OOS/COS 16) | 6.12.23 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6789/tree/oneplus/mt6789_b_16.0_pad_lite) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 15R** | CPH2769, CPH2767 | Android16 (OOS/COS 16) | 6.12.38 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8845/tree/oneplus/sm8845_b_16.0.0_oneplus_15r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus 15T** | PLZ110 | Android16 (OOS/COS 16) | 6.12.38 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8850/tree/oneplus/sm8850_b_16.0_oneplus_15t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 6T** | PLR110 | Android16 (OOS/COS 16) | 6.12.38 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8845/tree/oneplus/sm8845_b_16.0.0_ace_6t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus ACE 6 Ultra** | PMB110 | Android16 (OOS/COS 16) | 6.12.58 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6993/tree/oneplus/mt6993_b_16.0_ace_6_ultra) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad 3 Pro** | OPD2513 | Android16 (OOS/COS 16) | 6.12.58 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8850/tree/oneplus/sm8850_b_16.0_pad_3_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **OnePlus Pad 4** | OPD2514 | Android16 (OOS/COS 16) | 6.12.58 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8850/tree/oneplus/sm8850_b_16.0_pad_4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | Working | [@fatalcoder524](https://github.com/fatalcoder524) | Check Latest Release Notes for more info and feature availability. Kernel versions can change for devices with active OTA support. | +| **Galaxy A15 4G** | SM-A155F | One UI 7.0 - Android 15| `5.10.226` | KernelSU/Next + SusFS | [Source](https://github.com/poqdavid/android_kernel_samsung_sma155f/tree/kernelsunext) | [Download](https://github.com/poqdavid/android_kernel_samsung_sma155f/releases) | Both | Virgl only | Working | [@poqdavid](https://github.com/poqdavid) | See the latest release notes for more details and feature availability. | ## Contribution guidelines To keep this list useful and reliable, please follow these rules when adding or updating entries: diff --git a/Documentation/zh-CN/Features.md b/Documentation/zh-CN/Features.md index 73e85a00..64587f3f 100644 --- a/Documentation/zh-CN/Features.md +++ b/Documentation/zh-CN/Features.md @@ -33,7 +33,7 @@ Droidspaces 支持三种网络模式,决定是否使用网络命名空间 (`CL 1. **Host 模式 (`--net=host`) - 默认**:Droidspaces 有意**不**隔离网络命名空间。容器共享宿主的网络栈。这大大简化了配置:容器无需虚拟网桥、NAT 或防火墙规则即可立即获取互联网访问。在 Android 上,网络本来就复杂(蜂窝网络、Wi-Fi、VPN),这避免了一整类连接问题。 -2. **NAT 模式 (`--net=nat`)**:容器被放置在私有网络命名空间中。通过虚拟网桥或 veth 对连接到宿主,提供**纯网络隔离**,同时通过宿主的上行接口保持互联网访问。兼容绝大多数 Android 设备。 +2. **NAT 模式 (`--net=nat`)**:容器被放置在私有网络命名空间中。通过虚拟网桥或 veth 对连接到宿主,提供**纯网络隔离**,同时通过宿主当前的互联网上行链路(自动检测)保持互联网访问。兼容绝大多数 Android 设备。 3. **None 模式 (`--net=none`)**:容器被放置在一个私有的、网络隔离的命名空间中,仅启用 loopback 接口,以实现最大安全性。 @@ -188,7 +188,7 @@ droidspaces --name=gpu-test --rootfs=/path/to/rootfs --hw-access start > X11 支持可以使用 `--termux-x11` (`-X`) 标志独立启用。如果您不需要完整的 GPU/硬件访问权限,这是在 Android 上使用 GUI 应用程序的推荐方式,因为它保留了更高级别的隔离性。 -启动容器后,在容器内设置 `DISPLAY=:0` 以使用 X11 显示。 +Droidspaces 会通过 `/run/droidspaces.env`(从 `/etc/profile.d/droidspaces_env.sh` 符号链接)自动将 `DISPLAY=:5` 以及(启用 VirGL 时)`GALLIUM_DRIVER=virpipe` 注入到容器环境中。`bash` 和 `sh` 会自动读取此文件。如果您使用 `zsh`、`fish` 或其他非登录 shell,请手动执行:`source /run/droidspaces.env`。 ### 支持的 GPU 系列 @@ -264,10 +264,10 @@ Droidspaces 提供三种不同的网络模式,以便于在使用便捷性和 - **确定性 IP**:每个容器在 `172.28.0.0/16` 范围内分配唯一 IP,由其 PID 决定。 - **内置 DHCP**:Droidspaces 包含一个最小的内置 DHCP 服务器,用于自动配置容器的 `eth0`。 - **纯净隔离**:容器无法直接看到或与宿主的网络接口交互。 -- **必须指定上行接口**:您**必须**通过 `--upstream` 指定哪些宿主接口提供互联网访问(例如 `--upstream wlan0,rmnet0`)。也支持通配符(例如 `rmnet*`、`wlan0`、`v4-rmnet_data*`)。 +- **自动上行链路检测**:无需任何配置。Droidspaces 直接读取内核的路由状态来确定提供互联网访问的接口——在 Android 上读取 netd 为活跃默认网络安装的策略路由规则;在标准 Linux 上读取主路由表的默认路由。纯 IPv6 移动网络上的 CLAT (464xlat) 接口也会被自动处理。 > [!IMPORTANT] -> NAT 模式仅支持 **IPv4**。如果您的上行接口缺少 IPv4 地址(纯 IPv6 网络),互联网访问将无法正常工作。请参阅[IPv4 NAT 常见问题](./Troubleshooting.md#ipv4-quirks)以获取变通方案。 +> NAT 模式仅支持 **IPv4**。如果宿主的上行链路缺少 IPv4 地址(纯 IPv6 网络),互联网访问将无法正常工作。请参阅[IPv4 NAT 常见问题](./Troubleshooting.md#ipv4-quirks)以获取变通方案。 ### 3. None 模式 (`--net=none`) 容器获得一个私有网络命名空间,仅启用 loopback (`lo`) 接口。 @@ -291,9 +291,11 @@ Droidspaces 提供三种不同的网络模式,以便于在使用便捷性和 --port 2222:22/tcp --port 5000-5050:5000-5050/udp ``` +转发的端口可以从宿主所在的任何网络访问——包括在 Android 上连接到手机自身热点或 USB 网络共享的客户端。 -### 上行接口监控 -在 Android 上,连接经常在 WiFi 和移动数据之间切换。Droidspaces 包含一个**路由监控器**,用于跟踪您声明的 `--upstream` 接口。如果您活跃的接口发生更改(例如您走出了 WiFi 覆盖范围),监控器会自动更新内核的策略路由以保持容器连接,无需重启。 + +### 实时上行链路监控 +在 Android 上,连接经常在 WiFi 和移动数据之间切换。Droidspaces 包含一个**路由监控器**,订阅内核路由事件(FIB 规则、路由、链路、地址)。当 Android 切换其默认网络时(例如您走出了 WiFi 覆盖范围),监控器会立即更新内核的策略路由以保持容器连接——无需配置,无需重启。 --- diff --git a/Documentation/zh-CN/GPU-Acceleration.md b/Documentation/zh-CN/GPU-Acceleration.md deleted file mode 100644 index 58546f6f..00000000 --- a/Documentation/zh-CN/GPU-Acceleration.md +++ /dev/null @@ -1,194 +0,0 @@ - - -# Droidspaces GPU 加速指南 - -本指南提供了在 Droidspaces 容器中启用 GPU 加速的逐步说明。无论你是在 Android 设备上还是在 Linux 桌面上运行,Droidspaces 都提供多种方式来利用硬件加速,以获得流畅的图形体验。 - -### 快速导航 - -- [**Android 设备**](#android) - - [01. Termux-X11 + llvmpipe(软件渲染)](#termux-x11) - - [02. Termux-X11 + VirGL(非 Qualcomm GPU)](#virgl) - - [03. Turnip(原生 Qualcomm/Adreno)](#turnip) -- [**Linux 桌面(AMD/Intel)**](#linux) - ---- - - - -## Android - -在 Android 上的硬件加速是通过将容器的图形栈与宿主端 X 服务器(Termux-X11)桥接来实现的。Droidspaces 处理了实现此无缝衔接所需的复杂挂载管理和安全上下文。 - -> [!TIP] -> -> 如果你想享受开箱即用的 XFCE 桌面环境体验,可以从[Rootfs repository](Usage-Android-App.md#rootfs-repository) 或从官方的 [Droidspaces Rootfs Builder Releases](https://github.com/Droidspaces/Droidspaces-rootfs-builder/releases) 下载我们预配置的 XFCE 压缩包。 - - - -### 01. Termux-X11 + llvmpipe - -此方法通过 `llvmpipe` 使用**软件渲染**。虽然它不提供完整的硬件加速,但在没有兼容 GPU 驱动可用时,这是运行 GUI 应用程序的最稳定方式。 - -#### "统一 Tmpfs 桥接" -当你在 Droidspaces App 中启用 **Termux X11** 开关时,会发生以下流程: - -1. **宿主端准备**:Droidspaces 在宿主端的挂载命名空间中,于 Termux 的 `/data/data/com.termux/files/usr/tmp` 之上创建一个 `tmpfs` 挂载。 - -2. **绕过 FBE 加密**:虽然可以直接将 `/data/data/com.termux/files/usr/tmp` 绑定挂载到容器中,但这经常会导致 `apt` 等应用程序或任何在 `/tmp` 中进行大量 I/O 操作的工具出现问题。这是因为 Termux 的数据目录受到 Android 基于文件的加密(FBE)保护,导致出现"Required key not available"(ENOKEY)错误。通过 `tmpfs` 桥接该路径,X11 socket 和临时文件将对容器变为完全可读可写。 - -3. **绑定挂载**:此"统一 Tmpfs 桥接"随后被绑定挂载到容器的 `/tmp` 目录,从而实现容器与 Termux-X11 App 之间的无缝通信。 - -#### 设置要求 - -- **Termux**:`pkg install x11-repo && pkg install termux-x11` -- **容器**:`sudo apt install mesa-utils`(用于使用 `glxgears` 测试) - -#### 实施步骤 -1. **配置容器**:在 Droidspaces App 中,导航到容器的配置页面。 - -2. **启用 X11**:将 **Termux-X11** 开关切换为 `ON`(软件渲染不需要 **Hardware Access**)。 - -3. **环境变量**:在**环境变量**部分添加 `DISPLAY=:0` 并保存。 - -4. **启动容器**:启动你的容器。 - -5. **启动 X 服务器**:打开 Termux App 并运行: - - ```bash - termux-x11 :0 - ``` - -6. **验证**:在容器终端内运行 `glxgears`。输出将渲染在 Termux-X11 App 中。 - -7. **启动桌面环境**:要启动完整的 XFCE 桌面(如果已安装),运行: - - ```bash - dbus-launch --exit-with-session startxfce4 - ``` - ---- - - - -### 02. Termux-X11 + VirGL - -此方法通过 `virglrenderer` 桥接为**非高通设备(Mali/PowerVR)** 提供 **GPU 加速**。它将容器中的 OpenGL 调用转换为宿主 Android 操作系统可以执行的命令。 - -#### 设置要求 - -- **Termux**:`pkg install x11-repo && pkg install termux-x11 virglrenderer-android` -- **容器**:`sudo apt install mesa-utils`(用于使用 `glxgears` 测试) - -#### 实施步骤 - -1. **容器配置**:在 Droidspaces 容器设置中启用 **Termux-X11**。然后,在**环境变量**部分添加以下内容: - ```bash - DISPLAY=:0 - GALLIUM_DRIVER=virpipe - ``` - -2. **启动容器**:启动你的容器。 - -3. **启动 VirGL 服务器**:打开 Termux 并在后台运行服务器: - ```bash - virgl_test_server_android & - ``` - -4. **启动 X 服务器**:在 Termux 中运行: - ```bash - termux-x11 :0 - ``` - -5. **验证加速**:运行 `glxinfo -B` 并在渲染器字符串中查找"VirGL"。 - -6. **启动桌面环境**:要启动完整的 XFCE 桌面(如果已安装),运行: - - ```bash - dbus-launch --exit-with-session startxfce4 - ``` - -> [!TIP] -> -> **如果渲染器初始化失败,** 请尝试使用 Vulkan 后端启动 VirGL 服务器: -> -> `virgl_test_server_android --angle-vulkan &` - ---- - - - -### 03. Turnip(原生高通/Adreno) - -对于高通 Adreno GPU,Droidspaces 支持使用 Turnip 驱动实现**原生硬件加速**。这绕过了对 `virgl` 的需求,并提供接近原生的性能。 - -#### 要求 - -- **自定义 Mesa 驱动**:按照 [Mesa for Android Container 仓库](https://github.com/lfdevs/mesa-for-android-container) 中的说明进行安装。 -- **Termux**:`pkg install x11-repo && pkg install termux-x11` - -#### 实施步骤 - -1. 从 [Mesa for Android Container 仓库](https://github.com/lfdevs/mesa-for-android-container) **安装自定义 Mesa 驱动**。 - -2. **启用 GPU 访问**:在容器设置中,启用 **GPU Access** 和 **Termux X11**。 - -3. **设置显示变量**:将 `DISPLAY=:0` 添加到你的环境变量中。 - -4. **启动顺序**: - - 通过 Droidspaces 启动容器。 - - 打开 Termux 并运行 `termux-x11 :0` - -5. **权限管理(非 Root 用户)**: - 如果你使用的是非 root 用户,必须授予他们对 GPU 设备节点的访问权限: - - ```bash - sudo usermod -aG droidspaces-gpu - ``` - -6. **启动桌面环境**:要启动完整的 XFCE 桌面(如果已安装),运行: - - ```bash - dbus-launch --exit-with-session startxfce4 - ``` - -> [!TIP] -> **如果你遇到任何与 DRI3 相关的问题,** 请尝试编辑 `/data/adb/modules/droidspaces/sepolicy.rule` 并取消注释以下行: -> -> `allow untrusted_app_27 droidspacesd fd use` - ---- - - - -## Linux 桌面端(AMD/Intel) - -在基于 Linux 的主机上,GPU 加速在 Droidspaces 中原生运行,无需额外配置。 - -#### 要求 -- 主机上有一个活跃的 X11 或 Wayland 会话。 -- 正常工作的 GPU 驱动(Mesa/Intel/AMD)。 - -#### 实施步骤 - -1. **启用硬件访问**:确保在容器配置中启用了 **Hardware Access** 开关(或使用 `--hw-access` CLI 参数)。 - -2. **Xhost 权限**:在你的主机上,允许容器连接到你的 X 服务器: - - ```bash - xhost +local: - ``` - -3. **设置显示变量**:将主机的 `DISPLAY` 编号添加到容器的环境中(通常是 `:0`): - - ```bash - echo "DISPLAY=:0" >> /etc/environment - ``` - -4. **运行应用程序**:从容器中启动的 GUI 应用程序将以完整的硬件加速进行原生渲染。 diff --git a/Documentation/zh-CN/Graphics-and-Audio.md b/Documentation/zh-CN/Graphics-and-Audio.md new file mode 100644 index 00000000..f813573d --- /dev/null +++ b/Documentation/zh-CN/Graphics-and-Audio.md @@ -0,0 +1,401 @@ + + +# Droidspaces 显示、音频与桌面指南 + +本指南介绍如何在 Droidspaces 容器中配置显示、GPU 加速、音效(PulseAudio)以及桌面环境自动启动。在 Android 上,自 **v6.3.0** 起,X 服务器、VirGL 服务器和 PulseAudio 守护进程均会在容器启动时自动拉起 - 无需在 Termux 中手动执行任何命令。 + +> [!IMPORTANT] +> +> **Droidspaces 不提供 PulseAudio、Termux:X11 或 virglrenderer-android 软件包。** 这些均为上游 Termux 软件包,由安装脚本安装。Droidspaces 仅负责管理其生命周期(启动、套接字桥接、环境变量注入)。如遇音频质量、设备兼容性、崩溃或任何这些软件包的渲染问题,请向 [Termux packages](https://github.com/termux/termux-packages) 项目反馈,而非 Droidspaces。 + +### 快速导航 + +- [**通用要求(Android)**](#requirements) +- [**Android 显示与 GPU**](#android) + - [01. Termux-X11 + llvmpipe(软件渲染)](#termux-x11) + - [02. Termux-X11 + VirGL(非高通 GPU)](#virgl) + - [03. Turnip(原生高通/Adreno)](#turnip) +- [**Android 音效(PulseAudio)**](#pulseaudio) +- [**桌面环境自动启动**](#de-autoboot) +- [**Linux 桌面(AMD/Intel)**](#linux) + +--- + + + +## 通用要求(Android) + +在设置任何显示、GPU 或音频功能之前,请确保满足以下所有条件: + +1. **Droidspaces v6.3.0 或更高版本** — 更新后,**必须完整重启设备**以应用更新的 SELinux 规则。 + +2. **必须在设备上安装 [Termux](https://github.com/termux/termux-app) 和 [Termux:X11](https://github.com/termux/termux-x11) 两个应用**。 + +3. **在 Termux 内运行 Droidspaces 安装脚本**(必须执行 — 安装 Termux:X11、VirGL、PulseAudio 并修补音频配置): + + ```bash + curl -fsSL https://github.com/ravindu644/Droidspaces-OSS/raw/refs/heads/dev/scripts/setup-termux.sh | bash + ``` + +> [!IMPORTANT] +> +> 此脚本只需运行一次,它会将所有必要的显示和音频依赖项安装到 Termux 中。 + +--- + + + +## Android 显示与 GPU + +自 v6.3.0 起,Droidspaces 在容器启动时会自动启动 Termux:X11 X 服务器(以及已配置的 VirGL 服务器),并通过 `/run/droidspaces.env`(从 `/etc/profile.d/droidspaces_env.sh` 符号链接)将所需环境变量注入容器。 + +> [!TIP] +> +> **使用 `zsh`、`fish` 或其他非登录 Shell?** +> +> 这些 Shell 可能不会自动读取 `/etc/profile.d`。请在容器启动后手动执行:`source /run/droidspaces.env` +> +> 这将设置 `DISPLAY=:5`,以及(启用 VirGL 时)`GALLIUM_DRIVER=virpipe`。 + +--- + +> [!TIP] +> **想要开箱即用的桌面体验?** +> +> 我们的官方 XFCE rootfs 压缩包内置了 **XFCE 自动启动支持**。启用 Termux:X11(或 VirGL)并启动容器后,XFCE 将自动启动并显示在 Termux:X11 应用中 — 无需任何终端命令。 +> +> 从 [Rootfs 仓库](./Usage-Android-App.md#rootfs-仓库)(搜索"XFCE")下载,或直接从 [Droidspaces Rootfs Builder Releases](https://github.com/Droidspaces/Droidspaces-rootfs-builder/releases/latest) 下载。 + +--- + + + +### 01. Termux-X11 + llvmpipe + +通过 `llvmpipe` 进行软件渲染。兼容性最强的方式 — 适用于任何设备,无论 GPU 厂商。 + +#### 设置步骤 + +1. 打开 Droidspaces 应用,进入**编辑容器配置**。 +2. 启用 **配置 Termux:X11** 开关并保存。 +3. 启动容器。Droidspaces 将自动启动 Termux:X11 X 服务器 — Termux:X11 应用将显示 **"X" 光标**,确认服务器已就绪。 +4. 在容器内打开终端,运行任意 GUI 程序: + + ```bash + glxgears + ``` + + ```bash + startxfce4 + ``` + + 窗口将显示在 Termux:X11 应用中。 + +> [!NOTE] +> +> 软件渲染**不需要**启用 **Hardware Access**。 + +--- + + + +### 02. Termux-X11 + VirGL + +通过 `virglrenderer` 桥接为**非高通设备(Mali、PowerVR)**提供硬件加速渲染。将容器中的 OpenGL 调用转换为宿主 Android GPU 可执行的命令。 + +Droidspaces 会自动启动 X 服务器和 VirGL 服务器,并将 `DISPLAY=:5` 和 `GALLIUM_DRIVER=virpipe` 注入容器环境。 + +#### 设置步骤 + +1. 进入**编辑容器配置**。 +2. 同时启用 **配置 Termux:X11** 和 **配置 VirGL 3D 加速** 开关并保存。 +3. 启动容器。两个服务器将自动启动。Termux:X11 应用就绪后会显示 **"X" 光标**。 +4. 在容器内运行任意 GUI 程序: + + ```bash + glxgears + ``` + + 在渲染器字符串中查找 **"VirGL"** 以确认加速已生效。 + + ```bash + startxfce4 + ``` + +> [!TIP] +> +> **如果 VirGL 渲染器初始化失败**,可在容器配置的 **VirGL 额外参数**字段中传入 Vulkan 后端标志: +> +> `--angle-vulkan` + +--- + + + +### 03. Turnip(原生高通/Adreno) + +使用开源 Turnip Mesa 驱动为**高通 Adreno GPU** 提供接近原生的硬件加速。完全绕过 VirGL,直接访问 GPU。 + +#### 要求 + +- 按照 [Mesa for Android Container 仓库](https://github.com/lfdevs/mesa-for-android-container)的说明安装自定义 Mesa 驱动。 + +#### 设置步骤 + +1. 按照 [Mesa for Android Container](https://github.com/lfdevs/mesa-for-android-container) 的说明安装自定义 Mesa 驱动。 + +2. 进入**编辑容器配置**并进行以下设置: + - 启用 **GPU Access** 和 **配置 Termux:X11**。 + - **禁用** **配置 VirGL 3D 加速** 开关(使用 Turnip 时必须关闭 VirGL)。 + - 添加以下两个环境变量: + + ``` + MESA_LOADER_DRIVER_OVERRIDE=kgsl + TU_DEBUG=noconform + ``` + +3. 启动容器。Droidspaces 自动启动 X 服务器。 + +4. 在容器内运行 GUI 程序 — Turnip GPU 加速将自动生效。 + +> [!NOTE] +> +> **权限管理(非 root 用户):** 如果你使用的是非 root 用户,则必须授予其访问 GPU 设备节点的权限: +`sudo usermod -aG droidspaces-gpu ` + +--- + + + +## Android 音效(PulseAudio) + +Droidspaces 通过 PulseAudio 将 Android 的音频栈桥接到容器中。启用后,PulseAudio 守护进程以 Termux 用户身份在宿主机上运行(从而使 Android 音频 HAL 授予其访问设备扬声器的权限),其 UNIX 套接字以绑定挂载方式挂载到容器的 `/tmp/.pulse-socket`。环境变量 `PULSE_SERVER=unix:/tmp/.pulse-socket` 将自动注入容器,因此容器内支持 PulseAudio 的应用无需任何额外配置即可发出声音。 + +> [!WARNING] +> +> PulseAudio 音频直通并非在所有设备上均可用。兼容性取决于 Android 版本、OEM 音频 HAL 实现以及 Termux PulseAudio 构建版本。若你的设备无法正常使用音频,这属于上游软件包在该平台上的已知限制。 + +#### 要求 + +- Termux 中必须已安装 PulseAudio - [安装脚本](#requirements)会自动完成此操作。 + +#### 设置步骤 + +1. 打开 Droidspaces 应用,进入**编辑容器配置**。 +2. 启用 **配置 PulseAudio** 开关并保存。 +3. 启动容器。Droidspaces 将: + - 以 Termux 用户身份启动 PulseAudio 守护进程。 + - 等待套接字 `/tmp/.pulse-socket` 出现后再继续。 + - 执行 `pactl set-default-sink AAudio_sink`,将音频路由至设备扬声器。 + - 将套接字绑定挂载到容器并注入 `PULSE_SERVER`。 + + 也可以通过 CLI 参数 `--pulse-audio` 启用 PulseAudio。 + +4. 在容器内安装并运行任意音频应用。由于 `PULSE_SERVER` 已在容器环境中设置,大多数应用无需额外配置即可正常使用: + + ```bash + # 测试音频输出 + paplay /path/to/sound.wav + ``` + + ```bash + # 验证 PulseAudio 连接 + pactl info + ``` + +> [!NOTE] +> +> PulseAudio 音效**仅适用于 Android**。在 Linux 桌面端,音频直通通过宿主机自身的 PulseAudio/PipeWire 实现,Droidspaces 无需特殊配置。 + +> [!NOTE] +> +> **三星 One UI 6.1+ 设备:** Droidspaces 在启动 PulseAudio 前会自动通过 `LD_PRELOAD` 注入 `libskcodec.so`,以修复三星固件中 OpenSL ES 音频模块的隐藏依赖问题。无需用户进行任何操作。 + +--- + + + +## 桌面环境自动启动 + +自 v6.3.0 起,当容器配置中启用了 Termux:X11 时,Droidspaces 保证 X 服务器套接字(`/tmp/.X11-unix/X5`)在容器 init 系统到达 `graphical.target` 之前已就绪,使基于 systemd 的桌面环境自动启动得以无竞争条件地运行。 + +### 官方 XFCE 压缩包的实现方式 + +我们的官方 XFCE rootfs 压缩包内置了完整的自动启动配置,以下是其具体实现: + +**1. `xfce-autostart.service` systemd 单元**安装于 `/etc/systemd/system/xfce-autostart.service`,并在 `graphical.target` 下启用: + +```ini +[Unit] +Description=XFCE Autostart +After=graphical.target + +[Service] +Type=simple +User=root +ExecCondition=/bin/sh -c "grep -q 'enable_termux_x11=1' /run/droidspaces/container.config" +ExecCondition=/bin/sh -c "test -S /tmp/.X11-unix/X5" +ExecStart=/usr/local/bin/xfce-start +Restart=on-failure + +[Install] +WantedBy=graphical.target +``` + +两个 `ExecCondition` 守卫确保 XFCE 仅在条件满足时启动:容器配置中必须启用 Termux:X11,且 X 服务器套接字必须实际存在。若任一条件不满足,systemd 将静默跳过该服务,不产生错误或崩溃循环。 + +**2. `/usr/local/bin/xfce-start` 启动脚本**负责环境变量读取和用户切换: + +```sh +#!/bin/sh + +ENV_FILE=/run/droidspaces.env +CONFIG=/run/droidspaces/container.config + +if [ -f "$ENV_FILE" ]; then + . "$ENV_FILE" + WHITELIST=$(sed -n 's/^export \([A-Za-z_][A-Za-z0-9_]*\)=.*/\1/p' "$ENV_FILE" | tr '\n' ',' | sed 's/,$//') +else + export DISPLAY=:5 + WHITELIST=DISPLAY + if grep -q 'enable_pulseaudio=1' "$CONFIG" 2>/dev/null; then + export PULSE_SERVER=unix:/tmp/.pulse-socket + WHITELIST="$WHITELIST,PULSE_SERVER" + fi + if grep -q 'enable_virgl=1' "$CONFIG" 2>/dev/null; then + export GALLIUM_DRIVER=virpipe + WHITELIST="$WHITELIST,GALLIUM_DRIVER" + fi +fi + +if [ -n "$XFCE_USER" ]; then + exec su -l -w "$WHITELIST" "$XFCE_USER" -c 'exec /usr/bin/startxfce4' +else + exec /usr/bin/startxfce4 +fi +``` + +脚本首先尝试读取 `/run/droidspaces.env`(由 Droidspaces 在启动时写入;包含 `DISPLAY=:5`,若启用 VirGL 则含 `GALLIUM_DRIVER=virpipe`,若启用 PulseAudio 则含 `PULSE_SERVER`)。若环境文件因任何原因缺失,则回退到直接读取容器配置并手动构建环境,确保脚本对 init 系统启动顺序具有鲁棒性。 + +### `XFCE_USER` 变量 + +默认情况下,`xfce-start` 以 `root` 身份运行 XFCE。若需以非 root 用户运行,请在 Droidspaces 应用的容器**环境变量**配置中设置 `XFCE_USER`: + +``` +XFCE_USER=youruser +``` + +设置后,脚本使用 `su -l -w "$WHITELIST"` 切换到该用户,同时仅透传必要的环境变量(`DISPLAY`、`GALLIUM_DRIVER`、`PULSE_SERVER` 等),保持会话干净,避免 root 环境泄漏到用户会话中。 + +> [!TIP] +> +> 设置 `XFCE_USER` 前,请确保容器内已存在该用户且有有效的主目录。可通过 `useradd -m youruser` 在容器内创建。 + +--- + +### 高级用户:为任意桌面环境配置自动启动 + +你可以将此模式复制到任意桌面环境(Plasma、GNOME、MATE、i3 等)的任意容器中。 + +**第一步:** 创建启动脚本 `/usr/local/bin/de-start`: + +```sh +#!/bin/sh + +ENV_FILE=/run/droidspaces.env +CONFIG=/run/droidspaces/container.config + +if [ -f "$ENV_FILE" ]; then + . "$ENV_FILE" + WHITELIST=$(sed -n 's/^export \([A-Za-z_][A-Za-z0-9_]*\)=.*/\1/p' "$ENV_FILE" | tr '\n' ',' | sed 's/,$//') +else + export DISPLAY=:5 + WHITELIST=DISPLAY + if grep -q 'enable_pulseaudio=1' "$CONFIG" 2>/dev/null; then + export PULSE_SERVER=unix:/tmp/.pulse-socket + WHITELIST="$WHITELIST,PULSE_SERVER" + fi + if grep -q 'enable_virgl=1' "$CONFIG" 2>/dev/null; then + export GALLIUM_DRIVER=virpipe + WHITELIST="$WHITELIST,GALLIUM_DRIVER" + fi +fi + +DE_CMD="startplasma-x11" # 替换为你的桌面环境启动命令 + +if [ -n "$XFCE_USER" ]; then + exec su -l -w "$WHITELIST" "$XFCE_USER" -c "exec $DE_CMD" +else + exec $DE_CMD +fi +``` + +```bash +chmod +x /usr/local/bin/de-start +``` + +**第二步:** 创建 systemd 服务 `/etc/systemd/system/de-autostart.service`: + +```ini +[Unit] +Description=Desktop Environment Autostart +After=graphical.target + +[Service] +Type=simple +ExecCondition=/bin/sh -c "grep -q 'enable_termux_x11=1' /run/droidspaces/container.config" +ExecCondition=/bin/sh -c "test -S /tmp/.X11-unix/X5" +ExecStart=/usr/local/bin/de-start +Restart=on-failure + +[Install] +WantedBy=graphical.target +``` + +**第三步:** 启用服务: + +```bash +systemctl enable de-autostart.service +``` + +下次启用 Termux:X11 启动容器时,你的桌面环境将自动出现在 Termux:X11 应用中。 + +> [!NOTE] +> +> `XFCE_USER` 变量名是我们官方压缩包的约定,你可以在自己的脚本中自由重命名。关键在于 `su -l -w "$WHITELIST"` 模式,它实现了干净的用户切换与环境变量透传。 + +--- + + + +## Linux 桌面端(AMD/Intel) + +在基于 Linux 的主机上,GPU 加速在 Droidspaces 中原生运行,无需额外配置。 + +#### 要求 +- 主机上有一个活跃的 X11 或 Wayland 会话。 +- 正常工作的 GPU 驱动(Mesa/Intel/AMD)。 + +#### 实施步骤 + +1. **启用硬件访问**:确保在容器配置中启用了 **Hardware Access** 开关(或使用 `--hw-access` CLI 参数)。 + +2. **Xhost 权限**:在你的主机上,允许容器连接到你的 X 服务器: + + ```bash + xhost +local: + ``` + +3. **设置显示变量**:将主机的 `DISPLAY` 编号添加到容器的环境中(通常是 `:0`): + + ```bash + echo "DISPLAY=:0" >> /etc/environment + ``` + +4. **运行应用程序**:从容器中启动的 GUI 应用程序将以完整的硬件加速进行原生渲染。 diff --git a/Documentation/zh-CN/Installation-Android.md b/Documentation/zh-CN/Installation-Android.md index f4979135..da72811e 100644 --- a/Documentation/zh-CN/Installation-Android.md +++ b/Documentation/zh-CN/Installation-Android.md @@ -67,10 +67,10 @@ Droidspaces 在 Android 上旨在提供"零终端"体验。从首次安装到运 你可以随时验证系统状态: 1. 前往**设置**(齿轮图标)-> **需求**。 2. 点击**检查需求**。这会在内部运行完整的 `droidspaces check` 套件。 -3. **内核配置**:如果你是一位内核开发者,你可以找到一���可复制的 `droidspaces.config` defconfig 片段,类似于[此页面](./Kernel-Configuration.md#required-kernel-configuration),以确保你的内核与 Droidspaces 完全兼容。 +3. **内核配置**:如果你是一位内核开发者,你可以找到可复制的 `droidspaces.config` defconfig 片段,类似于[此页面](./Kernel-Configuration.md#required-kernel-configuration),以确保你的内核与 Droidspaces 完全兼容。 ## 下一步 - [Android 应用使用指南](./Usage-Android-App.md):了解管理详情。 -- [GPU 加速指南](./GPU-Acceleration.md):启用硬件加速的桌面环境。 +- [显示、音频与桌面指南](./Graphics-and-Audio.md):配置 GPU 加速、音效与桌面环境自动启动。 - [Linux CLI 指南](./Linux-CLI.md):提供专家级命令行访问。 diff --git a/Documentation/zh-CN/Linux-CLI.md b/Documentation/zh-CN/Linux-CLI.md index 6193db47..3a10263a 100644 --- a/Documentation/zh-CN/Linux-CLI.md +++ b/Documentation/zh-CN/Linux-CLI.md @@ -111,7 +111,6 @@ sudo droidspaces --name=web,db,app stop | 选项 | 简写 | 说明 | |--------|------|-------------| | `--net=MODE` | | 网络模式:`host`(默认)、`nat` 或 `none`。 | -| `--upstream IFACE[,..]` | | NAT 模式的上游互联网接口(例如 `wlan0,rmnet0`)。支持通配符(例如 `rmnet*`、`v4-rmnet_data*`)。**NAT 模式必填**。 | | `--port HOST:CONT[/proto]` | | 将主机端口转发到容器(NAT 模式)。支持 TCP/UDP。 | | `--dns=SERVERS` | `-d` | 自定义 DNS 服务器,逗号分隔。示例:`--dns=1.1.1.1,8.8.8.8` | | `--disable-ipv6` | | 禁用 IPv6 网络支持(仅限 Host 模式)。 | @@ -237,7 +236,6 @@ sudo droidspaces \ --name=server \ --rootfs-img=/path/to/rootfs.img \ --net=nat \ - --upstream=wlan0,rmnet0 \ --port=8080:80 \ start ``` diff --git a/Documentation/zh-CN/Networking-From-Zero.md b/Documentation/zh-CN/Networking-From-Zero.md new file mode 100644 index 00000000..8e30eb0e --- /dev/null +++ b/Documentation/zh-CN/Networking-From-Zero.md @@ -0,0 +1,656 @@ + + +# 从零开始的网络基础:理解 Droidspaces 网关模式 + +### 快速导航 + +- [第一部分:绝对基础](#第一部分绝对基础) + - [什么是 IP 地址?](#什么是-ip-地址) + - [什么是网络?](#什么是网络) + - [LAN——局域网](#lan局域网) + - [WAN——广域网](#wan广域网) + - [网关](#网关) +- [第二部分:数据究竟是如何送达的](#第二部分数据究竟是如何送达的) + - [MAC 地址 vs IP 地址](#mac-地址-vs-ip-地址) + - [什么是数据包?](#什么是数据包) +- [第三部分:DHCP(你是如何获取 IP 地址的)](#第三部分dhcp你是如何获取-ip-地址的) +- [第四部分:DNS(域名如何变成地址)](#第四部分dns域名如何变成地址) +- [第五部分:NAT(你的路由器施展的魔法)](#第五部分nat你的路由器施展的魔法) + - [NAT 在 Droidspaces 中的应用](#nat-在-droidspaces-中的应用) +- [第六部分:网桥与虚拟线缆(Linux 管道)](#第六部分网桥与虚拟线缆linux-管道) + - [什么是网桥?](#什么是网桥) + - [什么是 veth 对?](#什么是-veth-对) + - [NAT 模式如何使用网桥和 veth](#nat-模式如何使用网桥和-veth) +- [第七部分:网络命名空间(容器是如何隔离的)](#第七部分网络命名空间容器是如何隔离的) +- [第八部分:OpenWRT 及其概念](#第八部分openwrt-及其概念) +- [第九部分:全新的网关模式——融会贯通](#第九部分全新的网关模式融会贯通) + - [为什么要有网关模式?](#为什么要有网关模式) + - [架构总览](#架构总览) + - [逐步说明——启动网关模式容器时会发生什么](#逐步说明启动网关模式容器时会发生什么) + - ["延迟挂接"的含义](#延迟挂接的含义) + - [为什么网关模式下不修改 resolv.conf](#为什么网关模式下不修改-resolvconf) + - [为什么 bridge-nf-call-iptables 要设为 0](#为什么-bridge-nf-call-iptables-要设为-0) + - [网关容器必须先运行](#网关容器必须先运行) + - [容器停止时会发生什么](#容器停止时会发生什么) +- [第十部分:网关模式的标志与配置](#第十部分网关模式的标志与配置) + - [必选标志 vs 可选标志](#必选标志-vs-可选标志) + - [--gateway-net 的作用](#--gateway-net-的作用) + - [--gateway-iface 的作用](#--gateway-iface-的作用) + - [必须避免的标志冲突](#必须避免的标志冲突) + - [验证规则与内核要求](#验证规则与内核要求) +- [第十一部分:所有网络模式对比](#第十一部分所有网络模式对比) +- [第十二部分:网关模式的真实使用场景](#第十二部分网关模式的真实使用场景) +- [快速参考——术语表](#快速参考术语表) + +--- + +## 第一部分:绝对基础 + +### 什么是 IP 地址? + +任何想要在网络上通信的设备都需要一个地址,这样其他设备才知道将数据发往何处。这个地址称为 **IP 地址**。 + +可以把它想象成家庭住址。如果有人想给你寄信,就需要你的地址。同理:如果你的手机想给 Google 发送数据,就需要知道 Google 的地址,而 Google 也需要知道你手机的地址才能将回复发回来。 + +IP 地址看起来像这样:`192.168.1.5` + +它是由点号分隔的四个数字(0-255)。每个数字称为一个 **八位组(octet)**。 + +### 什么是网络? + +**网络**就是一组可以相互直接通信的设备。 + +想象一个房间里有 5 台笔记本电脑都连接着同一个 Wi-Fi 路由器。这 5 台笔记本在同一个网络上——它们可以彼此传输文件,而不需要经过互联网。 + +### LAN——局域网 + +**LAN** = 你**家庭内部**的网络(或者办公室内部,或者在我们的场景中,是**容器世界内部**)。 + +之所以叫"局域网",是因为设备在物理上很近:你的手机、笔记本电脑、智能电视,都连接着你的家庭 Wi-Fi 路由器。它们都生活在同一个局域网内,可以直接相互通信。 + +局域网地址通常长这样: +- `192.168.x.x` +- `10.x.x.x` +- `172.16.x.x` 到 `172.31.x.x` + +这些称为**私有 IP 范围**。它们保留给本地网络使用,永远不会在公网上出现。 + +### WAN——广域网 + +**WAN** = 你**家庭之外的**网络:就是互联网本身。 + +你的路由器有两面: +- **LAN 侧**面向家里的设备 +- **WAN 侧**面向互联网服务提供商(ISP) + +ISP 会给你的路由器分配一个公网 IP 地址用于 WAN 侧。家庭内部的所有设备都共享这一个公网 IP 来访问互联网。 + +``` +[你的手机]--+ +[你的笔记本]-+--[路由器]--[ISP]--[互联网] +[你的电视]---+ + (LAN 侧) (WAN 侧) +``` + +### 网关 + +**网关**是将两个不同的网络连接在一起的设备。 + +在你的家里,路由器*就是*网关。你手机的 IP 是 `192.168.1.5`(LAN)。当你的手机想要访问 Google 的 `142.250.80.46`(WAN/互联网),它并不知道如何直接到达那里。于是它将数据发送给网关(路由器),由路由器想办法将其转发到互联网。 + +**规则:** 局域网上的每台设备都配置了一个"默认网关"——当设备不知道往哪发送数据包的时候,所有流量都会发送到的那个地址。 + +--- + +## 第二部分:数据究竟是如何送达的 + +### MAC 地址 vs IP 地址 + +实际上网络中有*两种*地址: + +| 类型 | 外观 | 用途 | +|---|---|---| +| **IP 地址** | `192.168.1.5` | 逻辑地址——用于跨网络路由 | +| **MAC 地址** | `a4:c3:f0:12:34:56` | 物理地址——用于在**同一**网络上传递 | + +可以这样理解: +- IP 地址是**城市和街道**——用于跨国家的导航 +- MAC 地址是**门牌号**——到达大楼后使用的最终定位 + +当你的笔记本电脑向路由器发送数据包时,会使用路由器的 MAC 地址(因为它们在同一局域网上)。然后路由器使用 IP 地址来确定下一步发送到哪里。 + +### 什么是数据包? + +在网络上传输的数据被分割成小块,称为**数据包**。每个数据包包含: +- 它从哪里来(源 IP) +- 它要去哪里(目的 IP) +- 实际数据的一小部分 + +网络在目的地将所有数据包重新组装起来。 + +--- + +## 第三部分:DHCP(你是如何获取 IP 地址的) + +### 问题 + +每台设备都需要一个 IP 地址才能加入网络。但不能有两台设备使用相同的 IP——就像两个房子有同样的邮政地址一样,信件会丢失。 + +你*可以*手动为每台设备分配唯一的 IP,但那会很痛苦。如果有 50 台设备怎么办? + +### 解决方案:DHCP + +**DHCP** = 动态主机配置协议 + +它是一种由一个设备(**DHCP 服务器**)自动为每个加入网络的新设备分发 IP 地址的系统。 + +对话过程如下: + +``` +新设备: "有人在吗?我刚加入这个网络,需要一个 IP 地址。" +DHCP 服务器: "我听到了。来,192.168.1.42拿着。另外,你的网关是 192.168.1.1, + DNS 使用 1.1.1.1。你的租约持续 24 小时。" +新设备: "收到,谢谢!" +``` + +新设备现在拥有了在网络中工作所需的一切: +- 自己的 IP 地址 +- 网关地址(知道将流量发往何处) +- DNS 地址(接下来会解释) + +在你家里,**路由器运行着 DHCP 服务器**。它为每一台连接的设备分配 IP。 + +在 **Droidspaces NAT 模式**中,Droidspaces 自身会运行一个迷你 DHCP 服务器,为容器分配 IP(在 `172.28.x.x` 范围内)。IP 是确定性的:它由容器名称派生而来,持久化到配置文件中,并且在每次启动时重新提供——因此同一个容器在重启后始终拥有相同的地址。 + +--- + +## 第四部分:DNS(域名如何变成地址) + +### 问题 + +IP 地址很难记住。没有人会输入 `142.250.80.46` 来访问 Google。人们都是输入 `google.com`。 + +但计算机只理解 IP 地址。所以需要一个系统将人类可读的名称转换成 IP 地址。 + +### 解决方案:DNS + +**DNS** = 域名系统 + +它基本上是互联网的电话簿。你给它一个名称(`google.com`),它返回一个 IP 地址(`142.250.80.46`)。 + +对话过程: +``` +你的浏览器: "google.com 的 IP 地址是什么?" +DNS 服务器: "是 142.250.80.46" +你的浏览器: "谢了。" [现在连接到 142.250.80.46] +``` + +每台设备都配置了一个 DNS 服务器地址。在大多数家庭网络中,路由器*就*是 DNS 服务器(它将你的查询转发给 ISP 的 DNS 或公共 DNS,如 `1.1.1.1`)。 + +在 **Droidspaces NAT 模式**中,Droidspaces 会在容器内写入 `resolv.conf` 文件,指向 DNS 服务器(默认 `1.1.1.1` 和 `8.8.8.8`,或通过 `--dns` 指定的地址)。同样的 DNS 服务器也会在 DHCP 租约中通告。 + +--- + +## 第五部分:NAT(你的路由器施展的魔法) + +### 问题 + +ISP 只给你**一个**公网 IP 地址。但你家有 10 台设备。这 10 台设备如何同时使用互联网? + +### 解决方案:NAT + +**NAT** = 网络地址转换 + +你的路由器维护着一张秘密表格。当局域网内的一台设备向互联网发送数据包时,路由器会: +1. 将源 IP 从设备的私有 IP(`192.168.1.5`)改写为路由器的公网 IP +2. 记住是哪台设备发送的 +3. 当回复从互联网返回时,将目的地址改写回设备的私有 IP 并转发给它 + +从互联网的视角看,你所有的家庭设备看起来都是*一个设备*:就是路由器。 + +``` +[笔记本: 192.168.1.5] --发送数据包--> [路由器] + | + | 将源 IP 改写为公网 IP + v + [互联网] + | + | 回复回来了 + v + [路由器] + | + | 将目的 IP 改写回 192.168.1.5 + v + [笔记本: 192.168.1.5] +``` + +### NAT 在 Droidspaces 中的应用 + +在 Droidspaces NAT 模式中,Droidspaces 的行为*和你的家庭路由器完全一样*——不过是面向容器: + +- 容器获得一个私有 IP(`172.28.x.x`) +- Droidspaces 安装 iptables `MASQUERADE` 规则(这是 Linux 中实现 NAT 目标的名称),外加 FORWARD 接受规则和 MSS 钳制规则,确保流量真正流通 +- 容器可以访问互联网;互联网看到的是 Android 的 IP,而不是容器的 IP +- Droidspaces 还会为容器运行一个嵌入式 DHCP 服务器并配置其 DNS +- 在 Android 上,后台路由监视器会自动检测活跃的互联网上行链路(通过读取内核的路由规则),并在活跃网络切换时(例如 Wi-Fi 到移动数据切换)立即将容器流量重新指向正确的接口 + +--- + +## 第六部分:网桥与虚拟线缆(Linux 管道) + +现在我们深入一层,看看 Linux 究竟是如何将容器彼此连接起来的。 + +### 什么是网桥? + +**网桥**的工作原理类似于网络交换机。物理网络交换机是一个盒子,可以插入多根以太网线缆;所有连接到它的设备都可以相互通信。 + +Linux 的**网桥**就是一个虚拟交换机,完全由软件实现。你可以用一条命令创建它,然后将虚拟网络接口"插入"其中。 + +``` +物理世界: Linux 世界: ++--------------+ +--------------+ +| 交换机 | | 网桥 | (纯软件,没有物理盒子) +| 端口1 端口2 | | 端口1 端口2 | ++--+------+---+ +--+------+---+ + | | | | +[PC1] [PC2] [veth1] [veth2] (虚拟线缆) +``` + +### 什么是 veth 对? + +**veth** = 虚拟以太网 + +veth 对是一对互相连接的虚拟网络接口,就像一根管道。你从一端发送的任何东西都会从另一端出来。 + +可以把它想象成一根有两端插头的虚拟以太网线缆。你把一端插在容器内部,另一端留在宿主机上(或插入一个网桥)。 + +``` +[容器 netns] [宿主机 netns] + eth0 --------------------- ds-veth0 + (容器内的插头) (宿主机侧的插头) +``` + +### NAT 模式如何使用网桥和 veth + +在 Droidspaces NAT 模式中: + +``` +[容器 netns] + eth0(例如 172.28.137.42) + | + | veth 对(虚拟线缆) + | +[宿主机侧] + ds-v ---- ds-br0(网桥,IP 为 172.28.0.1) + | + iptables MASQUERADE + | + wlan0 / rmnet0 + (Android 的真实网络) +``` + +网桥 `ds-br0` 拥有网关 IP `172.28.0.1`,每个 NAT 容器都将其作为默认网关使用。veth 对以容器的 init 进程 ID 命名:宿主机侧为 `ds-v`,容器侧初始为 `ds-p`,然后在容器内重命名为 `eth0`。 + +Droidspaces 运行一个小型的给每个容器开放的 DHCP 服务器,监听在容器的宿主机侧 veth 上。整个 `172.28.0.0/16` 子网属于 Droidspaces(`172.28.0.x` 段保留给网关自身,所以容器始终落在 `172.28.1.x` 到 `172.28.254.x` 之间),其中所有内容通过 Android 的真实接口进行 NAT 出站。 + +--- + +## 第七部分:网络命名空间(容器是如何隔离的) + +### 什么是命名空间? + +Linux 有一个名为**命名空间**的功能,可以让你创建系统资源的隔离视图。 + +**网络命名空间**是整个网络栈的一份隔离副本。它拥有自己的: +- 网络接口 +- 路由表 +- iptables 规则 +- 与网络相关的一切 + +当 Droidspaces 启动一个容器时,会为它创建一个新的网络命名空间。容器住在该命名空间中。它完全看不到宿主机的网络接口——只能看到 Droidspaces 显式放入其命名空间内的东西。 + +veth 对是宿主机命名空间和容器命名空间之间的隧道: +- veth 的一端进入容器的网络命名空间(显示为 `eth0`) +- 另一端留在宿主机的网络命名空间中(Droidspaces 将其连接到网桥) + +--- + +## 第八部分:OpenWRT 及其概念 + +### 什么是 OpenWRT? + +**OpenWRT** 是专为路由器设计的 Linux 发行版。通常情况下它运行在物理路由器硬件上,但也可以在常规 Linux 系统或容器内运行。 + +当 OpenWRT 运行时,它提供: +- **netifd**——网络接口守护进程(管理网络接口、DHCP 客户端/服务器等) +- **dnsmasq**——DNS 和 DHCP 服务器 +- **firewall3** 或 **nftables**——防火墙 +- **LuCI**——配置用的 Web 界面 +- 真正路由器所做的一切,以软件形式实现 + +这意味着你可以在 Droidspaces 容器内运行 OpenWRT,它会表现得和真正的路由器一模一样:管理网络、分发 DHCP 租约、处理 DNS、应用防火墙规则、路由 VPN 流量等。 + +--- + +## 第九部分:全新的网关模式——融会贯通 + +### 为什么要有网关模式? + +在 NAT 模式中,Droidspaces 就是路由器。它包办一切。这在大多数情况下都很好。 + +但如果你想让 **OpenWRT 成为其他容器的路由器**呢?你想要 OpenWRT 的防火墙规则、OpenWRT 的 DHCP、OpenWRT 的 VPN 路由,并让其他容器(比如 Kali Linux 容器)处在 OpenWRT 的局域网内,一切从 OpenWRT 获取。 + +问题在于:如果 Droidspaces 也试图为这些容器安装 NAT、DHCP 和 DNS,就会与 OpenWRT 正在做的事情*冲突*。两个 DHCP 服务器争抢由谁分配 IP 地址。两个防火墙应用互相矛盾的规则。 + +**网关模式解决了这个问题。** Droidspaces 退后一步。它只做 L2 的管道(虚拟线缆和交换机),让 OpenWRT 掌管所有策略:DHCP、DNS、防火墙、路由。 + +### 架构总览 + +``` +Android 宿主机内核 +| ++-- wlan0(Android 的真实 Wi-Fi——WAN) +| ++-- [OpenWRT 容器——net=nat 模式] +| netns:拥有 eth0(WAN 侧,由 Droidspaces 提供 NAT) +| eth1(LAN 侧——由网关模式插入 ds-lan 网桥) +| 运行:dnsmasq、netifd、防火墙、VPN +| ++-- ds-lan(宿主机网桥——无 IP 地址,仅作为交换机) +| | +| +-- ds-g[hash](veth 宿主机侧,连接到 OpenWRT 的 netns 作为 eth1) +| +-- ds-v[pid] (veth 宿主机侧,连接到 Kali 的 netns 作为 eth0) +| ++-- [Kali 容器——net=gateway 模式] + netns:拥有 eth0(LAN 侧——插入 ds-lan 网桥) + 从 OpenWRT 的 dnsmasq 获取 DHCP + 路由决策由 OpenWRT 做出 + 防火墙规则由 OpenWRT 应用 +``` + +### 逐步说明——启动网关模式容器时发生了什么 + +**第 1 步——先启动 OpenWRT(NAT 模式)** + +```bash +droidspaces --name=openwrt --rootfs=/data/openwrt --net=nat start +``` + +OpenWRT 启动。它拥有: +- WAN 侧的 `eth0`(Droidspaces 为其管理 NAT) +- 还没有 LAN 侧——OpenWRT 正在等待 + +**第 2 步——启动 Kali(网关模式)** + +```bash +droidspaces --name=kali --rootfs=/data/kali --net=gateway --gateway=openwrt start +``` + +Droidspaces 执行以下操作(仅管道,无策略): + +1. 找到 OpenWRT 正在运行的进程 ID,以便访问其网络命名空间 +2. 在宿主机上创建一个名为 `ds-lan` 的网桥,该网桥上不设置 IP 地址 +3. 禁用 `bridge-nf-call-iptables`,使 Android 宿主机防火墙**不**拦截该网桥上的流量,让 OpenWRT 的防火墙成为唯一的权威 +4. 为 OpenWRT 的 LAN 侧创建一个 veth 对——一端进入 OpenWRT 的 netns(显示为 `eth1`),另一端插入 `ds-lan` 网桥 +5. 为 Kali 创建一个 veth 对——一端进入 Kali 的 netns(显示为 `eth0`),另一端插入 `ds-lan` 网桥 +6. **不**安装 NAT、DHCP、DNS 或任何防火墙规则 + +**第 3 步——OpenWRT 接管** + +OpenWRT 的 `netifd` 检测到 `eth1` 出现。它将其配置为 LAN 接口。 +OpenWRT 的 `dnsmasq` 开始在 `eth1` 上响应 DHCP 请求。 + +Kali 的 `eth0` 发送 DHCP 请求,OpenWRT 的 `dnsmasq` 回复: +- IP 地址:`192.168.1.100`(或 OpenWRT DHCP 范围内的任意 IP) +- 网关:`192.168.1.1`(即 OpenWRT 本身) +- DNS:`192.168.1.1`(OpenWRT 的 dnsmasq) + +Kali 现在已完全配置好,以 OpenWRT 作为其路由器。 + +**第 4 步——流量经过 OpenWRT** + +当 Kali 尝试访问互联网时: + +``` +Kali eth0 --> ds-lan 网桥 --> OpenWRT eth1 + | + OpenWRT 防火墙规则在此应用 + | + OpenWRT 路由到 eth0(WAN) + | + Droidspaces NAT(eth0 -> wlan0) + | + Android wlan0 --> 互联网 +``` + +OpenWRT 的防火墙看到 Kali 的全部流量,并可以应用任何规则:阻止特定网站、通过 VPN 重定向、带宽整形、连接日志——完全和真正的路由器一样。 + +### "延迟挂接"的含义 + +网关 veth 是"延迟挂接"的。这意味着: + +- 当你启动 OpenWRT 时,它**不会**立即获得 `eth1` +- `eth1` 仅在**第一个网关模式容器启动时**才会出现在 OpenWRT 内部 +- 这是有意为之——OpenWRT 只带着它的 WAN 侧启动(`eth0`),其 LAN 线缆(`eth1`)稍后按需插入 + +这模拟了你在路由器运行后,再将物理线缆插入路由器 LAN 端口的场景。 + +### 为什么网关模式下不修改 resolv.conf + +在 NAT 模式中,Droidspaces 会在容器内写入 `/etc/resolv.conf`,指向 `1.1.1.1` 或 `8.8.8.8`。 + +在网关模式中,Droidspaces **不**写入 `resolv.conf`(除非你显式传入 `--dns`)。这是因为 OpenWRT 的 `dnsmasq` 会通过 DHCP 租约将 DNS 服务器地址分发给容器。如果 Droidspaces 也写入了 `resolv.conf`,就会与 dnsmasq 提供的内容冲突——容器将使用错误的 DNS,完全绕过 OpenWRT 的 DNS 过滤/缓存。 + +### 为什么 bridge-nf-call-iptables 要设为 0 + +网桥 `ds-lan` 承载着 OpenWRT 和 Kali 之间的流量。默认情况下,Linux 可以将桥接流量通过宿主机的 iptables 处理。这意味着 Android 的 iptables 规则(可能会意外丢弃或 NAT 某些流量)会干扰本该由 OpenWRT 管理的流量。 + +将其设为 `0` 告诉 Linux:"不对桥接流量运行 iptables。"这让 OpenWRT 的防火墙成为查看此流量的*唯一*防火墙——这正是我们想要的效果。 + +### 网关容器必须先运行 + +启动顺序很重要。当一个网关模式容器启动时,Droidspaces 会查找网关容器的实时进程 ID 以访问其网络命名空间。如果此时网关容器没有运行,网络设置会失败并发出警告,客户端容器依然会启动——但完全没有网络(只有 loopback)。它不会自行重试。 + +同样的逻辑也适用于网关重启后。veth 对是一起死亡的:当网关容器停止时,它内部的 `eth1` 端被销毁,这也会销毁宿主机侧的那一端。现有的客户端仍然插在网桥上,但已经没有了路由器。LAN 线缆会在该网段上**任何**网关模式容器的下一次启动时重新插入——所以在重启网关后,重启一个客户端(或启动一个新客户端)即可让网段恢复。 + +### 容器停止时会发生什么 + +网关模式中的清理被刻意最小化,遵循"仅管道"的理念: + +- **客户端停止:** 仅移除该客户端自己的 veth(`ds-v`)。网桥和网关的 `eth1` 保持运行,因此同一网段上的其他客户端不受影响。 +- **网关停止:** 网关侧的 veth 随其命名空间一起消失(见上文),但网桥本身保留。 +- 委托网桥(`ds-lan` 等)永远不会被 Droidspaces 拆除。它在闲置时无害——没有 IP 也不承载任何策略——并持续存在直到你手动删除它或重启设备。 + +--- + +## 第十部分:网关模式的标志与配置 + +### 必选标志 vs 可选标志 + +使用 `--net=gateway` 时**只有一个标志是必选的**: + +```bash +--gateway= +``` + +如果省略它,Droidspaces 会打印错误并拒绝启动。其他所有标志都有可用的默认值: + +| 标志 | 默认值 | 它控制什么 | +|---|---|---| +| `--gateway=NAME` | *(无——必选)* | 哪个运行中的容器是路由器 | +| `--gateway-net=NAME` | `lan` | LAN 网段名称——见下文 | +| `--gateway-iface=IFACE` | `eth1` | 网关容器内的接口名称 | +| `--gateway-bridge=BR` | `ds-{gateway-net}` | 完全覆盖宿主机网桥名称 | + +因此最小有效命令是: + +```bash +droidspaces --name=client --net=gateway --gateway=openwrt start +``` + +这与显式拼出所有默认值完全相同: + +```bash +droidspaces --name=client --net=gateway --gateway=openwrt \ + --gateway-net=lan \ + --gateway-iface=eth1 \ + start +``` + +### --gateway-net 的作用 + +该标志同时控制两件事,均从同一个名称派生。 + +**1. 它命名宿主机网桥。** + +Droidspaces 在宿主机上创建的网桥命名为 `ds-{NAME}`: + +``` +--gateway-net=lan -> 宿主机网桥:ds-lan +--gateway-net=vpn -> 宿主机网桥:ds-vpn +--gateway-net=iot -> 宿主机网桥:ds-iot +``` + +**2. 它是网段标识符——客户端落在哪个网桥上。** + +网关 LAN 侧的 veth 名称是通过对字符串 `{gateway_container}:{gateway_net}` 进行哈希来生成的。相同的哈希 = 相同的 veth = 相同的网桥网段。这意味着共享相同 `--gateway` 和 `--gateway-net` 的多个客户端容器最终都在同一个网桥上,并且都从同一个 OpenWRT 接口获取 DHCP。 + +这就是 `--gateway-net` 真正的威力所在:通过同一个网关容器运行多个隔离的 LAN 网段。 + +```bash +# 这两个落在 ds-lan 上——它们彼此可见,OpenWRT 将它们作为一个局域网路由 +droidspaces --name=kali --net=gateway --gateway=openwrt --gateway-net=lan start +droidspaces --name=ubuntu --net=gateway --gateway=openwrt --gateway-net=lan start + +# 这一个落在 ds-vpn 上——一个完全独立的网桥 +# OpenWRT 可以对这个网段应用不同的防火墙/VPN 规则 +droidspaces --name=torbox --net=gateway --gateway=openwrt --gateway-net=vpn start +``` + +在 OpenWRT 内部,`lan` 客户端通过 `eth1` 传入,`vpn` 客户端通过 `eth2` 传入(每个网段获得自己的 veth,因为 `openwrt:lan` 和 `openwrt:vpn` 的哈希不同)。 + +### --gateway-iface 的作用 + +这控制了**LAN 接口在网关容器的网络命名空间内部使用的名称**。 + +当 Droidspaces 为一个网段创建网关 veth 时,它会将一端移入 OpenWRT 的 netns,并将其从原始的哈希名称(`ds-hXXXXXXXX`)重命名为你在此传入的名称(默认 `eth1`)。 + +**为什么要这么做?** OpenWRT 的配置是围绕接口名称构建的。如果你的 OpenWRT `/etc/config/network` 写着: + +``` +config interface 'lan' + option device 'eth1' +``` + +...那么出现在 OpenWRT 内部的接口**必须**命名为 `eth1`,否则 OpenWRT 不会将其识别为 LAN,也不会在上面提供 DHCP。`--gateway-iface=eth1` 确保了这一点。 + +对于第二个网段,你需要传入 `--gateway-iface=eth2`,这样 OpenWRT 会将其视为一个独立的接口,你可以为其添加第二个 UCI network 配置块。 + +**重要细节:** `--gateway-iface` 仅在该网段的网关 veth 首次创建时生效——也就是该网段上第一个客户端容器启动时。网关 veth 由同一个 `--gateway-net` 上的所有客户端共享;它只创建一次并重复使用。第一个之后的每个客户端完全跳过网关 veth 的创建,仅仅将自己的应用 veth 接入现有的网桥。 + +这意味着如果你在 `--gateway-net=lan` 上启动两个容器,且两个都传入 `--gateway-iface=eth1`,完全没有问题:第一个容器创建 veth 并重命名为 `eth1`,第二个容器发现 veth 已经存在,根本不处理 `--gateway-iface`。 + +### 必须避免的标志冲突 + +问题只会在你使用**两个不同的 `--gateway-net` 网段但相同的 `--gateway-iface`** 时出现: + +```bash +# 网段 1——在 OpenWRT 内部创建 eth1 +droidspaces --name=kali --net=gateway --gateway=openwrt --gateway-net=lan --gateway-iface=eth1 start + +# 网段 2——错误:也尝试在 OpenWRT 内部创建 eth1 +droidspaces --name=torbox --net=gateway --gateway=openwrt --gateway-net=vpn --gateway-iface=eth1 start +``` + +当第二条命令运行时,Droidspaces 尝试将一个新的 veth 对端移入 OpenWRT 并重命名为 `eth1`。但 `eth1` 已经存在于 OpenWRT 内部(来自第一个网段)。代码不会直接报错,而是检测到冲突后仅将现有的 `eth1` 重新启用,让新的 veth 对端以其原始哈希名称(`ds-hYYYYYYYY`)留在 OpenWRT 中。OpenWRT 没有 `ds-hYYYYYYYY` 的配置,会静默忽略它。`vpn` 网段得不到网关侧接口:没有 DHCP、没有路由,其上的容器实际上处于隔离状态。 + +**规则:** 每个 `--gateway-net` 网段必须有唯一的 `--gateway-iface` 名称。 + +```bash +# 正确:两个网段,两个接口名称 +--gateway-net=lan --gateway-iface=eth1 -> OpenWRT 内的 eth1(LAN 网段) +--gateway-net=vpn --gateway-iface=eth2 -> OpenWRT 内的 eth2(VPN 网段) +``` + +### 验证规则与内核要求 + +Droidspaces 在启动时强制执行几条规则,违规则拒绝启动: + +- 容器不能将自己作为自己的网关(`--gateway` 必须命名一个不同的容器) +- 接口和网桥名称必须短于 16 个字符(Linux `IFNAMSIZ` 限制),且只能包含字母、数字、`_` 和 `-` +- 内核必须支持网络命名空间(`CONFIG_NET_NS`)、veth 对(`CONFIG_VETH`)和网桥(`CONFIG_BRIDGE`)。Droidspaces 在启动前检查这三项,缺少任一则致命错误退出 + +另外还有两点需要了解: + +- `--port` 仅在 NAT 模式中有意义。在网关模式中,它会被忽略并发出警告——端口转发和上行链路选择现在是网关容器的工作。 +- 当宿主机网桥名称从 `--gateway-net` 自动派生时,名称会被清理(仅保留字母、数字、`_`、`-`)并截断为 9 个字符,生成 `ds-` 加上最多 9 个字符。如果需要精确的网桥名称,请使用 `--gateway-bridge` 显式设定。 + +--- + +## 第十一部分:所有网络模式对比 + +| 特性 | NAT 模式 | Host 模式 | None 模式 | 网关模式 | +|---|---|---|---|---| +| 谁分配 IP? | Droidspaces DHCP | Android(共享) | 无(仅 loopback) | OpenWRT dnsmasq | +| 谁做 NAT? | Droidspaces iptables | Android | 不适用 | OpenWRT(通过 Droidspaces 为 OpenWRT 的 WAN 做 NAT) | +| 谁管理防火墙? | Droidspaces | Android | 不适用 | OpenWRT | +| 谁管理 DNS? | Droidspaces | Android | 无 | OpenWRT dnsmasq | +| 容器与宿主机网络隔离? | 是 | 否 | 是 | 是 | +| 互联网访问? | 是 | 是 | 否 | 是(通过网关容器) | +| 需要第二个容器才能工作? | 否 | 否 | 否 | 是(网关容器) | +| 适用于 | 简单的互联网访问 | 最大性能,零开销 | 离线 / 沙盒环境 | 路由器设备、VPN 网关、分段局域网 | + +--- + +## 第十二部分:网关模式的真实使用场景 + +### 1. 为特定容器设置 VPN 终止开关 + +运行带有 WireGuard 或 OpenVPN 客户端的 OpenWRT。配置 OpenWRT 的防火墙丢弃所有不经过 VPN 隧道的流量。使用网关模式的任何容器都无法将流量泄漏到 VPN 之外——OpenWRT 在网桥层面强制实施这一点,而不是在各个容器内部。 + +### 2. 多个隔离的 LAN 网段 + +使用 `--gateway-net` 在同一个 OpenWRT 上创建独立的网段。`--gateway-net=lan` 上的容器无法访问 `--gateway-net=vpn` 上的容器,除非 OpenWRT 显式地在它们之间进行路由。你可以通过一个网关容器实现类 VLAN(虚拟局域网)风格的隔离。 + +### 3. 流量分析 + +运行带有 `tcpdump` 或 `nftables` 日志功能的 OpenWRT。来自每个网关模式容器的每个数据包都流经 OpenWRT,因此你获得了一个单一的咽喉点,可以同时观察所有容器的全部网络活动。 + +### 4. 自定义 DNS 过滤 + +运行带有 `dnsmasq` 黑名单(或通过 opkg 安装 Adblock)的 OpenWRT。网关局域网上的每个容器都会获得经过过滤的 DNS,而无需单独触碰每个容器。 + +### 5. 带宽整形 + +OpenWRT 的 `tc`(流量控制)和 `sqm-scripts` 可以按容器进行带宽整形,因为 OpenWRT 将每个容器视为到达其 LAN 接口的一个独立 MAC 地址。 + +--- + +## 快速参考——术语表 + +| 术语 | 一句话定义 | +|---|---| +| **IP 地址** | 网络上设备的数字地址(例如 `192.168.1.5`) | +| **MAC 地址** | 网络接口的硬件地址,用于同一网络内的传递 | +| **LAN** | 局域网——彼此靠近、可以直接通信的设备 | +| **WAN** | 广域网——互联网,本地网络之外的部分 | +| **网关** | 连接两个网络并在它们之间路由流量的设备 | +| **DHCP** | 自动为设备分配 IP 地址的协议 | +| **DNS** | 将域名(`google.com`)转换为 IP 地址的系统 | +| **NAT** | 将一个公网 IP 在多个私有 IP 设备之间共享的技术 | +| **网桥** | 连接多个网络接口的虚拟(或物理)交换机 | +| **veth 对** | 一对像管道一样连接的虚拟网络接口——从一端进,从另一端出 | +| **网络命名空间** | Linux 网络栈的隔离副本——容器生活在自己的命名空间中 | +| **OpenWRT** | 一个为作为路由器/网关运行而设计的 Linux 发行版——运行 dnsmasq、netifd、防火墙 | +| **netifd** | OpenWRT 的网络接口守护进程——管理接口和 DHCP | +| **dnsmasq** | OpenWRT 使用的轻量级 DHCP 和 DNS 服务器 | +| **MASQUERADE** | 实现 NAT 的 Linux iptables 规则(改写源 IP) | +| **委托 LAN** | Droidspaces 在网关模式中创建的网桥网络——策略由网关容器而非 Droidspaces 掌管 | +| **网段** | 由 `--gateway-net` 标识的一个隔离 LAN——每个网段获得自己的网桥和在网关容器内的独立接口 | +| **延迟挂接** | 网关的 LAN 侧 veth 仅在第一个客户端容器启动时创建,而非网关容器启动时创建 | diff --git a/Documentation/zh-CN/community-supported-devices.md b/Documentation/zh-CN/community-supported-devices.md index 4aee25dd..768e08db 100644 --- a/Documentation/zh-CN/community-supported-devices.md +++ b/Documentation/zh-CN/community-supported-devices.md @@ -26,11 +26,12 @@ keywords: droidspaces, supported, devices, android, device, compatibility, kerne | 设备名称 | 型号 | Android / ROM | Baseband / Build | 内核版本 | Root 方式 | 内核源码 | 下载链接 | Droidspaces 模式 | GPU 加速 | 状态 | 维护者 | 附加说明 | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| **Galaxy S10(日版)** | SCV41 / SM-G973J | One UI 4.1 - Android 12 | `SCV41KDU1DWC1` | `4.14.190` | KernelSU-Next v3.1.0 | [Source](https://github.com/ravindu644/samsung_kernel_SCV41_droidspaces) | [Download](https://github.com/ravindu644/samsung_kernel_SCV41_droidspaces/releases/download/v2/Droidspaces-KSUN-Samsung-SCV41.tar) | Both | Turnip & Virgl | 正常运行 | [@ravindu644](https://github.com/ravindu644) | - | -| **Poco X2** | M1912G7Bx | BlissROM-v16.5 - Android 13 | - | `4.14.274` | KernelSU-Next v1.1.1 | [Source](https://github.com/ravindu644/phoenix-blissROM-A13-droidspaces) | [Download](https://github.com/ravindu644/phoenix-blissROM-A13-droidspaces/releases/download/v1/Droidspaces-KSUNv1.1.1-phoenix.zip) | Both | Turnip & Virgl | 正常运行 | [@ravindu644](https://github.com/ravindu644) | - | +| **Galaxy S10(日版)** | SCV41 / SM-G973J | One UI 4.1 - Android 12 | `SCV41KDU1DWC1` | `4.14.190` | KernelSU-Next v3.1.0 | [Source](https://github.com/ravindu644/samsung_kernel_SCV41_droidspaces) | [Download](https://github.com/ravindu644/samsung_kernel_SCV41_droidspaces/releases/download/v2/Droidspaces-KSUN-Samsung-SCV41.tar) | Both | Turnip & Virgl | 正常运行 | [@ravindu644](https://github.com/ravindu644) | - | +| **Poco X2** | M1912G7Bx | BlissROM-v16.5 - Android 13 | - | `4.14.274` | KernelSU-Next v1.1.1 | [Source](https://github.com/ravindu644/phoenix-blissROM-A13-droidspaces) | [Download](https://github.com/ravindu644/phoenix-blissROM-A13-droidspaces/releases/download/v1/Droidspaces-KSUNv1.1.1-phoenix.zip) | Both | Turnip & Virgl | 正常运行 | [@ravindu644](https://github.com/ravindu644) | - | | **Realme 6, 6i, 6s** | RMX2001L1 | RUI 2.0 - Android 11 | - | `4.14.336` | KSU-Next v3.2.0 / ReSukiSU v4.1.0 | [Source](https://github.com/Prime-TITAN-CameraMan/android_kernel_realme_nemo) | [Download](https://github.com/Prime-TITAN-CameraMan/android_kernel_realme_nemo/releases/tag/v1.1) | Both | Virgl | 正常运行 | [@Prime-TITAN-CameraMan](https://github.com/Prime-TITAN-CameraMan) | 仅限 RUI 2.0 且不支持防火墙(UFW/Fail2ban) | -| **Redmi K20 Pro(国行)** | M1903F11A | YAAP 16 - Banshee (Android 16) | `1.0.c3-0061-1017_2229_f7c3bc9ce` | `4.14.353` | KernelSU v3.2.4-gee75714a | [Source](https://github.com/Alhkxsj/CRom-KSU-Builder) | [Download](https://github.com/Alhkxsj/CRom-KSU-Builder/releases/download/raphael_YAAP-sixteen_KernelSU-by-xx_20260514_1438/raphael_YAAP-sixteen_KernelSU-by-xx_20260514_1438.zip) | Both | Turnip & Virgl | 正常运行 | [@Alhkxsj](https://github.com/Alhkxsj) | 内核源码为原版 YAAP 内核;通过自动化构建工作流应用 Droidspaces 配置 | -| **Oneplus 8, 8T, 8Pro, 9R** | IN2010 | Lineage OS 23.2 - Android 16 | `Q_V1_P14` | `4.19.325` | 无 | [Source](https://github.com/JackA1ltman/dreamworld_oneplus_sm8250) | [Download](https://github.com/JackA1ltman/dreamworld_oneplus_sm8250/releases/tag/v1.2) | Both | Turnip & Virgl | 正常运行 | [@JackA1ltman](https://github.com/JackA1ltman) | 仅限自定义 ROM。支持 NTSYNC。 | +| **Redmi K20 Pro(国行)** | M1903F11A | YAAP 16 - Banshee (Android 16) | `1.0.c3-0061-1017_2229_f7c3bc9ce` | `4.14.353` | KernelSU v3.2.4-gee75714a | [Source](https://github.com/Alhkxsj/CRom-KSU-Builder) | [Download](https://github.com/Alhkxsj/CRom-KSU-Builder/releases/latest/download/raphael_YAAP-sixteen_KernelSU-by-xx.zip) | Both | Turnip & Virgl | 正常运行 | [@Alhkxsj](https://github.com/Alhkxsj) | 内核源码为原版 YAAP 内核;通过自动化构建工作流应用 Droidspaces 配置 | +| **Oneplus 8, 8T, 8Pro, 9R** | IN2010 | Lineage OS 23.2 - Android 16 | `Q_V1_P14` | `4.19.325` | 无 | [Source](https://github.com/JackA1ltman/dreamworld_oneplus_sm8250) | [Download](https://github.com/JackA1ltman/dreamworld_oneplus_sm8250/releases/tag/v1.2) | Both | Turnip & Virgl | 正常运行 | [@JackA1ltman](https://github.com/JackA1ltman) | 仅限自定义 ROM。支持 NTSYNC。 | +| **OnePlus 7 Pro** | GM1911 | LineageOS 17.1 - Android 10 | `QQ3A.200805.001` | `4.14.117` | Magisk | [Source](https://github.com/charan-gn/guacamole-droidspaces-kernel) | [Download](https://github.com/charan-gn/guacamole-droidspaces-kernel/releases/tag/v1.0) | Both | Turnip & Virgl | 正常运行 | [@charan-gn](https://github.com/charan-gn) | AnyKernel3 可在任何 ROM 上刷入。 | ## GKI 设备(内核 5.4 及以上版本) @@ -38,114 +39,152 @@ keywords: droidspaces, supported, devices, android, device, compatibility, kerne | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | | **Galaxy A16 5G** | SM-A166P | One UI 6.1 - Android 14 | `5.15.148` | KernelSU-Next v3.1.0 | [Source](https://github.com/ravindu644/android_kernel_a166p) | [Download](https://github.com/ravindu644/android_kernel_a166p/releases/download/r20260402-c17abb78/KernelSU-SM-A166P-OneUI6-A166PXXS4AYC1-r20260402-c17abb78.tar.zip) | Both | 仅 Virgl | 正常运行 | [@ravindu644](https://github.com/ravindu644) | - | | **Galaxy A16 5G** | SM-A166P | One UI 7.0 - Android 15 | `5.15.167` | KernelSU-Next v1.1.1 | [Source](https://github.com/ravindu644/android_kernel_a166p) | [Download](https://github.com/ravindu644/android_kernel_a166p/releases/download/ubuntu-x-docker/Ubuntu-x-KernelSU-Next-SM-A166P-dev.zip) | Both | 仅 Virgl | 正常运行 | [@ravindu644](https://github.com/ravindu644) | - | -| **Galaxy M14 4G** | SM-M145F | One UI 7.0 - Android 15 | `5.15.167` | KernelSU-Next v1.1.1 | [Source](https://github.com/ravindu644/android_kernel_sm_m145f) | [Download](https://github.com/ravindu644/android_kernel_sm_m145f/releases/download/v1/SM-M145F-Droidspaces-KSUNv1.1.1-M145FXXS8DYH1.tar.zip) | Both | Turnip & Virgl | 正常运行 | [@ravindu644](https://github.com/ravindu644) | - | +| **Galaxy M14 4G** | SM-M145F | One UI 7.0 - Android 15 | `5.15.167` | KernelSU-Next v1.1.1 | [Source](https://github.com/ravindu644/android_kernel_sm_m145f) | [Download](https://github.com/ravindu644/android_kernel_sm_m145f/releases/download/v1/SM-M145F-Droidspaces-KSUNv1.1.1-M145FXXS8DYH1.tar.zip) | Both | Turnip & Virgl | 正常运行 | [@ravindu644](https://github.com/ravindu644) | - | | **Galaxy A25 5G** | SM-A253Z | One UI 8.0 - Android 16 | `5.15.180` | Magisk | [Source](https://github.com/yoshi3jp/android_kernel_samsung_a25ex_mt6835) | [Download](https://github.com/yoshi3jp/android_kernel_samsung_a25ex_mt6835/releases/download/r20260421-89a95a08/DroidSpaces-SM-A253Z-OneUI8-A253ZSCS1BZA2-r20260421-89a95a08.tar.zip) | Both | 无 | 正常运行 | [@yoshi3jp](https://github.com/yoshi3jp) | - | -| **OnePlus Pad 2 Pro(国行)** | OPD2413 | ColorOS 16.0 - Android 16 | `6.6.89` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.89_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | 适用于 OnePlus Pad 3 | -| **OnePlus 13(国行)** | PJZ110 | ColorOS 16.0 - Android 16 | `6.6.89` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.89_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | 适用于国际版 | -| **OnePlus 13(国行)** | PJZ110 | ColorOS 15.0 - Android 15 | `6.6.66` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.66_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | 适用于国际版 | -| **OnePlus Ace 5 Pro(国行)** | PKR110 | ColorOS 16.0 - Android 16 | `6.6.89` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.89_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | -| **OnePlus Ace 5 Pro(国行)** | PKR110 | ColorOS 15.0 - Android 15 | `6.6.66` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.66_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | -| **OnePlus 13T(国行)** | PKX110 | ColorOS 16.0 - Android 16 | `6.6.89` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.89_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | -| **OnePlus 13T(国行)** | PKX110 | ColorOS 15.0 - Android 15 | `6.6.66` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.66_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | -| **OnePlus Ace 6(国行)** | PLR110 | ColorOS 16.0 - Android 16 | `6.6.89` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.89_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | -| **OnePlus Ace 3 Pro(国行)** | PJX110 | ColorOS 16.0 - Android 16 | `6.1.141` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.141_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | -| **OnePlus Ace 3 Pro(国行)** | PJX110 | ColorOS 16.0 - Android 16 | `6.1.118` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.118_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | -| **OnePlus Ace 5(国行)** | PKG110 | ColorOS 16.0 - Android 16 | `6.1.141` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.141_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | 适用于 OnePlus 13R | -| **OnePlus Ace 5(国行)** | PKG110 | ColorOS 16.0 - Android 16 | `6.1.118` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.118_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | 适用于 OnePlus 13R | -| **OnePlus 12(国行)** | PJD110 | ColorOS 16.0 - Android 16 | `6.1.141` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.141_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | 适用于国际版 | -| **OnePlus 12(国行)** | PJD110 | ColorOS 16.0 - Android 16 | `6.1.118` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.118_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | 适用于国际版 | -| **OnePlus Pad Pro(国行)** | OPD2403 | ColorOS 16.0 - Android 16 | `6.1.141` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.141_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | 适用于 OnePlus Pad 2(国际版) | -| **OnePlus Pad Pro(国行)** | OPD2403 | ColorOS 16.0 - Android 16 | `6.1.118` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.118_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | 适用于 OnePlus Pad 2(国际版) | -| **Realme GT 5 Pro(国行)** | RMX3888 | RealmeUI 7.0 - Android 16 | `6.1.141` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.118_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | -| **OnePlus Ace 3(国行)** | PJE110 | ColorOS 16.0 - Android 16 | `5.15.180` | 无 | [Source](https://github.com/Numbersf/Action-Build) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_5.15.180_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | 适用于 OnePlus 12R | -| **OnePlus Ace 3(国行)** | PJE110 | ColorOS 15.0 - Android 15 | `5.15.167` | 无 | [Source](https://github.com/Numbersf/Action-Build) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_5.15.167_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | 适用于 OnePlus 12R | +| **OnePlus Pad 2 Pro(国行)** | OPD2413 | ColorOS 16.0 - Android 16 | `6.6.89` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.89_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | 适用于 OnePlus Pad 3 | +| **OnePlus 13(国行)** | PJZ110 | ColorOS 16.0 - Android 16 | `6.6.89` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.89_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | 适用于国际版 | +| **OnePlus 13(国行)** | PJZ110 | ColorOS 15.0 - Android 15 | `6.6.66` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.66_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | 适用于国际版 | +| **OnePlus Ace 5 Pro(国行)** | PKR110 | ColorOS 16.0 - Android 16 | `6.6.89` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.89_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | +| **OnePlus Ace 5 Pro(国行)** | PKR110 | ColorOS 15.0 - Android 15 | `6.6.66` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.66_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | +| **OnePlus 13T(国行)** | PKX110 | ColorOS 16.0 - Android 16 | `6.6.89` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.89_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | +| **OnePlus 13T(国行)** | PKX110 | ColorOS 15.0 - Android 15 | `6.6.66` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.66_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | +| **OnePlus Ace 6(国行)** | PLR110 | ColorOS 16.0 - Android 16 | `6.6.89` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8750) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.6.89_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | +| **OnePlus Ace 3 Pro(国行)** | PJX110 | ColorOS 16.0 - Android 16 | `6.1.141` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.141_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | +| **OnePlus Ace 3 Pro(国行)** | PJX110 | ColorOS 16.0 - Android 16 | `6.1.118` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.118_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | +| **OnePlus Ace 5(国行)** | PKG110 | ColorOS 16.0 - Android 16 | `6.1.141` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.141_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | 适用于 OnePlus 13R | +| **OnePlus Ace 5(国行)** | PKG110 | ColorOS 16.0 - Android 16 | `6.1.118` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.118_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | 适用于 OnePlus 13R | +| **OnePlus 12(国行)** | PJD110 | ColorOS 16.0 - Android 16 | `6.1.141` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.141_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | 适用于国际版 | +| **OnePlus 12(国行)** | PJD110 | ColorOS 16.0 - Android 16 | `6.1.118` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.118_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | 适用于国际版 | +| **OnePlus Pad Pro(国行)** | OPD2403 | ColorOS 16.0 - Android 16 | `6.1.141` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.141_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | 适用于 OnePlus Pad 2(国际版) | +| **OnePlus Pad Pro(国行)** | OPD2403 | ColorOS 16.0 - Android 16 | `6.1.118` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.118_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | 适用于 OnePlus Pad 2(国际版) | +| **Realme GT 5 Pro(国行)** | RMX3888 | RealmeUI 7.0 - Android 16 | `6.1.141` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8650) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_6.1.118_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | +| **OnePlus Ace 3(国行)** | PJE110 | ColorOS 16.0 - Android 16 | `5.15.180` | 无 | [Source](https://github.com/Numbersf/Action-Build) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_5.15.180_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | 适用于 OnePlus 12R | +| **OnePlus Ace 3(国行)** | PJE110 | ColorOS 15.0 - Android 15 | `5.15.167` | 无 | [Source](https://github.com/Numbersf/Action-Build) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_oki_5.15.167_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | 适用于 OnePlus 12R | | **OnePlus Ace 竞速版(国行)** | PGZ110 | ColorOS 15.0 - Android 15 | `5.10.167` | Resukisu | [Source](https://github.com/Numbersf/Action-Build) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/OKI%E5%86%85%E6%A0%B8/AnyKernel3_ReSukiSU_oki_5.10.226_Gold_bug_%E5%A4%A9%E7%8E%91%E7%89%B9%E4%BE%9B.zip) | Both | bridge & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | -| **OnePlus SM8650(所有 LineageOS OnePlus 6.1 设备)** | SM8650 | LineageOS 23.2 - Android 16 QPR 2 | `6.1.166` | 无 | [Source](https://github.com/LineageOS/android_kernel_oneplus_sm8650) | [Download](https://github.com/linx3141/CRom-KSU-Builder/releases/tag/sm8650_LineageOS-lineage-23.2_None_20260512_1647) | Both | Turnip & Virgl | 正常运行 | [@linx3141](https://github.com/linx3141) | - | -| **Xiaomi 17(国行)** | 25113PN0EC | HyperOS 3.0 - Android 16 | `6.12.23` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8850) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.12.23_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | 适用于国际版 | -| **Xiaomi 17 Pro(国行)** | 2509FPN0BC | HyperOS 3.0 - Android 16 | `6.12.23` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8850) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.12.23_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | -| **Xiaomi 17 Pro max(国行)** | 25113PN0EC | HyperOS 3.0 - Android 16 | `6.12.23` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8850) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.12.23_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | -| **红魔 11 Pro(国行)** | NX809J | RedMagicOS11 - Android 16 | `6.12.23` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8850) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.12.23_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | -| **HONOR Magic Pad 3 Pro 13.3(国行)** | YLP-W00 | MagicOS10 - Android 16 | `6.12.23` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8850) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.12.23_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | +| **OnePlus SM8650(所有 LineageOS OnePlus 6.1 设备)** | SM8650 | LineageOS 23.2 - Android 16 QPR 2 | `6.1.166` | 无 | [Source](https://github.com/LineageOS/android_kernel_oneplus_sm8650) | [Download](https://github.com/linx3141/CRom-KSU-Builder/releases/tag/sm8650_LineageOS-lineage-23.2_None_20260512_1647) | Both | Turnip & Virgl | 正常运行 | [@linx3141](https://github.com/linx3141) | - | +| **Xiaomi 17(国行)** | 25113PN0EC | HyperOS 3.0 - Android 16 | `6.12.23` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8850) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.12.23_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | 适用于国际版 | +| **Xiaomi 17 Pro(国行)** | 2509FPN0BC | HyperOS 3.0 - Android 16 | `6.12.23` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8850) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.12.23_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | +| **Xiaomi 17 Pro max(国行)** | 25113PN0EC | HyperOS 3.0 - Android 16 | `6.12.23` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8850) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.12.23_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | +| **红魔 11 Pro(国行)** | NX809J | RedMagicOS11 - Android 16 | `6.12.23` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8850) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.12.23_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | +| **HONOR Magic Pad 3 Pro 13.3(国行)** | YLP-W00 | MagicOS10 - Android 16 | `6.12.23` | 无 | [Source](https://github.com/cctv18/oppo_oplus_realme_sm8850) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.12.23_Gold_bug.zip) | Both | Turnip & Virgl | 正常运行 | [@Goldzxcbug](https://github.com/Goldzxcbug) | - | | **Xiaomi Pad7(国行)** | 2410CRP4CC | HyperOS 2.0 - Android 15 | `6.1.118` | Sukisu Ultra | [Source](https://github.com/HuangJunLin8/uke_GKI_KernelSU_SUSFS.git) | [Download](https://github.com/HuangJunLin8/uke_GKI_KernelSU_SUSFS/releases/tag/release) | Both | Turnip & Zink | 正常运行 | [HuangJunLin8](https://github.com/HuangJunLin8) | 支持 NTsync | -| **Xiaomi Pad 8 Pro(国行)** | 25091RP04C | HyperOS 3.0 - Android 16 | `6.6.77` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.6.77_NTsync_github%40404-GCross.zip) | Both | Turnip & Virgl | 正常运行 | [404-GCross](https://github.com/404-GCross) | 适用于国际版
支持 NTsync | -| **Xiaomi 15(国行)** | 24129PN74C | HyperOS 3.0 - Android 16 | `6.6.77` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.6.77_NTsync_github%40404-GCross.zip) | Both | Turnip & Virgl | 正常运行 | [404-GCross](https://github.com/404-GCross) | 适用于国际版
支持 NTsync | -| **Xiaomi 14(国行)** | 23127PN0CC | HyperOS 3.0 - Android 16 | `6.1.138` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_Resukisu_gki_6.1.138_coolapk%40%E7%88%B1%E4%BD%A0%E4%B9%88%E4%B9%88%E5%93%92qcjl%E5%93%9F.zip) | Both | Turnip & Virgl | 正常运行 | [爱你么么哒qcjl哟](https://www.coolapk.com/u/29859482) | 适用于国际版 | -| **红魔 9 Pro(国行)** | NX769J | RedMagicOS11 - Android 16 | `6.1.124` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_Resukisu_gki_6.1.124_KPM_BBG_RE-kernel_%E7%BA%A2%E9%AD%949pro_github%40sjszero.zip) | Both | Turnip & Virgl | 正常运行 | [@sjszero](https://github.com/sjszero) | - | -| **红魔 9 SPro+(国行)** | NX769J | RedMagicOS11 - Android 16 | `6.1.25` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_Resukisu_gki_6.1.25_KPM_BBG_RE-kernel_%E7%BA%A2%E9%AD%949spro%2B_github%40sjszero.zip) | Both | Turnip & Virgl | 正常运行 | [@sjszero](https://github.com/sjszero) | - | -| **Xiaomi Pad 6S Pro(国行)** | 24018RPACC | HyperOS 3.0 - Android 16 | `5.15.178` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/Anykernel3_Resukisu_gki_NTsync_5.15.178_github%40mlfc666.zip) | Both | Turnip & Virgl | 正常运行 | [@mlfc666](https://github.com/mlfc666) | 适用于国际版
支持 NTsync | -| **Redmi K70(国行)** | 23113RKC6C | HyperOS 3.0 - Android 16 | `5.15.178` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/Anykernel3_Resukisu_gki_NTsync_5.15.178_github%40mlfc666.zip) | Both | Turnip & Virgl | 正常运行 | [@mlfc666](https://github.com/mlfc666) | 支持 NTsync | -| **Xiaomi Pad 6 Max (yudi)** | 2307BRPDCC | HyperOS 3.0 - Android 15 | `5.10.252` | 无 | [Source](https://github.com/Star-ZER0/android_gki_kernel_5.10_common) | [Download](https://github.com/Star-ZER0/android_gki_kernel_5.10_common/releases/latest) | Both | Turnip & Virgl | 正常运行 | [@Star-ZER0](https://github.com/Star-ZER0) | 支持 NTsync | -| **Xiaomi Pad 6 Pro (liuqin)** | 2307BRPDCC | HyperOS 3.0 - Android 15 | `5.10.252` | 无 | [Source](https://github.com/Star-ZER0/android_gki_kernel_5.10_common) | [Download](https://github.com/Star-ZER0/android_gki_kernel_5.10_common/releases/latest) | Both | Turnip & Virgl | 正常运行 | [@Star-ZER0](https://github.com/Star-ZER0) | 支持 NTsync | -| **Redmi K60 / POCO F5 Pro (mondrian)** | 23013RK75C | HyperOS 3.0 - Android 15 | `5.10.252` | 无 | [Source](https://github.com/Star-ZER0/android_gki_kernel_5.10_common) | [Download](https://github.com/Star-ZER0/android_gki_kernel_5.10_common/releases/latest) | Both | Turnip & Virgl | 正常运行 | [@Star-ZER0](https://github.com/Star-ZER0) | 支持 NTsync \| 适用于国际版 | -| **Redmi K50 Ultra / Xiaomi 12T Pro (diting)** | 22081212C | HyperOS 3.0 - Android 15 | `5.10.252` | 无 | [Source](https://github.com/Star-ZER0/android_gki_kernel_5.10_common) | [Download](https://github.com/Star-ZER0/android_gki_kernel_5.10_common/releases/latest) | Both | Turnip & Virgl | 正常运行 | [@Star-ZER0](https://github.com/Star-ZER0) | 支持 NTsync \| 适用于国际版 | -| **Xiaomi 11T Pro (vili)** | 2107113 | DerpFest A16 | `5.4` | KernelSU-Next | [Source](https://github.com/loystonpais/android_kernel_qcom_sm8350) | [Download](https://github.com/loystonpais/android_kernel_qcom_sm8350/releases/latest) | Both | Turnip & Virgl | 正常运行 | [@loystonpais](https://github.com/loystonpais) | - | +| **Xiaomi Pad 8 Pro(国行)** | 25091RP04C | HyperOS 3.0 - Android 16 | `6.6.77` | Resukisu
&
无Root | [Source](https://github.com/404-GCross/Droidspaces_GKI_Buildin_Local) | [ReSukiSU](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_ReSukiSU-34924_6.6.77_NTsync_github@404-GCross.zip)
[无Root](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.6.77_NTsync_github@404-GCross.zip) | Both | Turnip & Virgl | 正常运行 | [404-GCross](https://github.com/404-GCross) | 适用于国际版
支持 NTsync
ReSukiSU版本为34924 | +| **Xiaomi 15(国行)** | 24129PN74C | HyperOS 3.0 - Android 16 | `6.6.77` | Resukisu
&
无Root | [Source](https://github.com/404-GCross/Droidspaces_GKI_Buildin_Local) | [ReSukiSU](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_ReSukiSU-34924_6.6.77_NTsync_github@404-GCross.zip)
[无Root](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_gki_6.6.77_NTsync_github@404-GCross.zip) | Both | Turnip & Virgl | 正常运行 | [404-GCross](https://github.com/404-GCross) | 适用于国际版
支持 NTsync
ReSukiSU版本为34924 | +| **Xiaomi 14(国行)** | 23127PN0CC | HyperOS 3.0 - Android 16 | `6.1.138` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_Resukisu_gki_6.1.138_coolapk%40%E7%88%B1%E4%BD%A0%E4%B9%88%E4%B9%88%E5%93%92qcjl%E5%93%9F.zip) | Both | Turnip & Virgl | 正常运行 | [爱你么么哒qcjl哟](https://www.coolapk.com/u/29859482) | 适用于国际版 | +| **红魔 9 Pro(国行)** | NX769J | RedMagicOS11 - Android 16 | `6.1.124` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_Resukisu_gki_6.1.124_KPM_BBG_RE-kernel_%E7%BA%A2%E9%AD%949pro_github%40sjszero.zip) | Both | Turnip & Virgl | 正常运行 | [@sjszero](https://github.com/sjszero) | - | +| **红魔 9 SPro+(国行)** | NX769J | RedMagicOS11 - Android 16 | `6.1.25` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/AnyKernel3_Resukisu_gki_6.1.25_KPM_BBG_RE-kernel_%E7%BA%A2%E9%AD%949spro%2B_github%40sjszero.zip) | Both | Turnip & Virgl | 正常运行 | [@sjszero](https://github.com/sjszero) | - | +| **Xiaomi Pad 6S Pro(国行)** | 24018RPACC | HyperOS 3.0 - Android 16 | `5.15.178` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/Anykernel3_Resukisu_gki_NTsync_5.15.178_github%40mlfc666.zip) | Both | Turnip & Virgl | 正常运行 | [@mlfc666](https://github.com/mlfc666) | 适用于国际版
支持 NTsync | +| **Redmi K70(国行)** | 23113RKC6C | HyperOS 3.0 - Android 16 | `5.15.178` | Resukisu | [Source](https://github.com/zzh20188/GKI_KernelSU_SUSFS) | [Download](https://raw.githubusercontent.com/Goldzxcbug/Droidspaces-kernel/refs/heads/main/GKI%E5%86%85%E6%A0%B8/Anykernel3_Resukisu_gki_NTsync_5.15.178_github%40mlfc666.zip) | Both | Turnip & Virgl | 正常运行 | [@mlfc666](https://github.com/mlfc666) | 支持 NTsync | +| **Xiaomi Pad 6 Max (yudi)** | 2307BRPDCC | HyperOS 3.0 - Android 15 | `5.10.252` | 无 | [Source](https://github.com/Star-ZER0/android_gki_kernel_5.10_common) | [Download](https://github.com/Star-ZER0/android_gki_kernel_5.10_common/releases/latest) | Both | Turnip & Virgl | 正常运行 | [@Star-ZER0](https://github.com/Star-ZER0) | 支持 NTsync | +| **Xiaomi Pad 6 Pro (liuqin)** | 2307BRPDCC | HyperOS 3.0 - Android 15 | `5.10.252` | 无 | [Source](https://github.com/Star-ZER0/android_gki_kernel_5.10_common) | [Download](https://github.com/Star-ZER0/android_gki_kernel_5.10_common/releases/latest) | Both | Turnip & Virgl | 正常运行 | [@Star-ZER0](https://github.com/Star-ZER0) | 支持 NTsync | +| **Redmi K60 / POCO F5 Pro (mondrian)** | 23013RK75C | HyperOS 3.0 - Android 15 | `5.10.252` | 无 | [Source](https://github.com/Star-ZER0/android_gki_kernel_5.10_common) | [Download](https://github.com/Star-ZER0/android_gki_kernel_5.10_common/releases/latest) | Both | Turnip & Virgl | 正常运行 | [@Star-ZER0](https://github.com/Star-ZER0) | 支持 NTsync \| 适用于国际版 | +| **Redmi K50 Ultra / Xiaomi 12T Pro (diting)** | 22081212C | HyperOS 3.0 - Android 15 | `5.10.252` | 无 | [Source](https://github.com/Star-ZER0/android_gki_kernel_5.10_common) | [Download](https://github.com/Star-ZER0/android_gki_kernel_5.10_common/releases/latest) | Both | Turnip & Virgl | 正常运行 | [@Star-ZER0](https://github.com/Star-ZER0) | 支持 NTsync \| 适用于国际版 | +| **Xiaomi 11T Pro (vili)** | 2107113 | DerpFest A16 | `5.4` | KernelSU-Next | [Source](https://github.com/loystonpais/android_kernel_qcom_sm8350) | [Download](https://github.com/loystonpais/android_kernel_qcom_sm8350/releases/latest) | Both | Turnip & Virgl | 正常运行 | [@loystonpais](https://github.com/loystonpais) | - | | **Redmi Note 13 4G / NFC** | 23124RA7EO | HyperOS 2.0 - Android 15 | `5.15.202` | Wild-KSU v3.1.2 | [Source](https://github.com/superuseryu/kernel_sapphire_SM6225) | [Download](https://github.com/superuseryu/kernel_sapphire_SM6225/releases/tag/v1.5.2%2B_R27) | Both | Turnip | 正常运行 | [@superuseryu](https://github.com/superuseryu) | 其他 KSU 变体可能因 SELinux 上下文重置而失败。 | -| **Nothing Phone (1)** | A063 | crDroid - Android 16 | `5.4.302` | KernelSU-Next | [Source](https://github.com/crdroidandroid/android_kernel_nothing_sm7325) | [Download](https://crdroid.net/Spacewar/12) | Both | Turnip & Virgl | 正常运行 | [@MySelly](https://github.com/MySelly) | - | +| **Nothing Phone (1)** | A063 | crDroid - Android 16 | `5.4.302` | KernelSU-Next | [Source](https://github.com/crdroidandroid/android_kernel_nothing_sm7325) | [Download](https://crdroid.net/Spacewar/12) | Both | Turnip & Virgl | 正常运行 | [@MySelly](https://github.com/MySelly) | - | +| **Motorola Edge 30** | XT2203 | Lineage OS 23.2 - Android 16 | `5.4.302` | KernelSU-Next | [Source](https://github.com/tuxdotrs/android_kernel_motorola_sm7325) | [Download](https://github.com/tuxdotrs/android_kernel_motorola_sm7325/releases/latest) | Both | Turnip & Virgl | Working | [@tuxdotrs](https://github.com/tuxdotrs) | - | | **Reno10 5G** | PHW110 | ColorOS15-16.1 - Android 15-16 | `5.4.254` | ReSukiSU | [Source](https://github.com/miaizhe/Kernel_oppo_sm7325_Reno10) | [Download](https://github.com/miaizhe/Kernel_oppo_sm7325_Reno10/releases) | Both | Turnip | 正常运行 | [@miaizhe](https://github.com/miaizhe) | - | | OnePlus 10 Pro | NE2210, NE2211, NE2213, NE2215, NE2217 | Android 14 (OOS/COS 14) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8450/tree/oneplus/sm8450_u_14.0.0_oneplus_10pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus 10 Pro | NE2210, NE2211, NE2213, NE2215, NE2217 | Android 15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8450/tree/210ba17c8f19beed0212eb4f209109e35ef35746) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus 10 Pro | NE2210, NE2211, NE2213, NE2215, NE2217 | Android 15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8450/tree/oneplus/sm8450_v_15.0.0_oneplus_10_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus 10 Pro | NE2210, NE2211, NE2213, NE2215, NE2217 | Android 16 (OOS/COS 16) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8450/tree/oneplus/sm8450_b_16.0_oneplus_10_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus 10R | CPH2411 | Android 14 (OOS/COS 14) | 5.10.168 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_u_14.0.0_10r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus 10R | CPH2411 | Android 15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_v_15.0.0_oneplus_10r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | -| OnePlus 10T | CPH2415, CPH2413, CPH2417, CPH2419 | Android 15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_v_15.0.0_oneplus_10t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus 10T | CPH2415, CPH2413, CPH2417, CPH2419 | Android 15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/bdebb03938ab5f6719abc50af454d76a1b12df67) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus 10T | CPH2415, CPH2413, CPH2417, CPH2419 | Android 15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/45235533105840dc875a80e9d5b4cd881e453472) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus 10T | CPH2415, CPH2413, CPH2417, CPH2419 | Android 15 (OOS/COS 15) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_v_15.0.0_oneplus_10t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus 11 | PHB110, CPH2449, CPH2447, CPH2451 | Android 14 (OOS/COS 14) | 5.15.123 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_u_14.0.0_oneplus11) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus 11 | PHB110, CPH2449, CPH2447, CPH2451 | Android 15 (OOS/COS 15) | 5.15.149 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/7d9f64c901ac11ce80bf78682d33fd336c825fd9) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus 11 | PHB110, CPH2449, CPH2447, CPH2451 | Android 15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_v_15.0.0_oneplus11) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | -| OnePlus 11 | PHB110, CPH2449, CPH2447, CPH2451 | Android 16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_oneplus_11) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus 11 | PHB110, CPH2449, CPH2447, CPH2451 | Android 16 (OOS/COS 16) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/bee3255be1f407e7cff7b0b8f1c26daaccbbb510) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus 11 | PHB110, CPH2449, CPH2447, CPH2451 | Android 16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_oneplus_11) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会变化。 | | OnePlus 11R | CPH2487 | Android 14 (OOS/COS 14) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_u_14.0.0_oneplus_11r_5g) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus 11R | CPH2487 | Android 15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/6821a3751c4fd016a7ee4da5aea635d42a8b3d43) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus 11R | CPH2487 | Android 15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_v_15.0.0_oneplus_11r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus 11R | CPH2487 | Android 16 (OOS/COS 16) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_b_16.0.0_oneplus_11r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus 12 | PJD110, CPH2573, CPH2581, CPH2583 | Android 14 (OOS/COS 14) | 6.1.57 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_u_14.0.0_oneplus12) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus 12 | PJD110, CPH2573, CPH2581, CPH2583 | Android 15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/c461b745bc361581d0b419effd284367d85f1e57) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus 12 | PJD110, CPH2573, CPH2581, CPH2583 | Android 15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_oneplus12) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus 12 | PJD110, CPH2573, CPH2581, CPH2583 | Android 16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/2936373a255da66646a05384e993d55a8dfecc80) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus 12 | PJD110, CPH2573, CPH2581, CPH2583 | Android 16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_oneplus12) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus 12R | CPH2609, CPH2585, CPH2611 | Android 15 (OOS/COS 15) | 5.15.149 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/86a89359565410962004fd838e3d2e3b42a047ca) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus 12R | CPH2609, CPH2585, CPH2611 | Android 15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_v_15.0.0_oneplus_12r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus 12R | CPH2609, CPH2585, CPH2611 | Android 16 (OOS/COS 16) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/717aea450444dfd00db7d60c99cff77fbcbf014e) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会变化。 | | OnePlus 12R | CPH2609, CPH2585, CPH2611 | Android 16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_oneplus_12r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus 13 | CPH2655, CPH2653, PJZ110, CPH2649 | Android 15 (OOS/COS 15) | 6.6.30 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/f9c1a38fe1bf6bf39f25e4167e8322813c7707b9) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus 13 | CPH2655, CPH2653, PJZ110, CPH2649 | Android 16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_oneplus_13) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus 13 CPH | CPH2655, CPH2653, CPH2649 | Android 15 (OOS 15) | 6.6.56 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/cec4a1056a32dfdd68c23767f6c88bb6c698f1bf) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus 13 CPH | CPH2655, CPH2653, CPH2649 | Android 15 (OOS 15) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.0_oneplus_13_global) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus 13 PJZ | PJZ110 | Android 15 (COS 15) | 6.6.66 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.0_oneplus_13) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus 13R | CPH2645, CPH2691, CPH2647 | Android 15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/d68f486cb00b246abf4b7b66375082dc8bcb884e) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus 13R | CPH2645, CPH2691, CPH2647 | Android 15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_oneplus_13r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | -| OnePlus 13R | CPH2645, CPH2691, CPH2647 | Android 16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_oneplus_13r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus 13R | CPH2645, CPH2691, CPH2647 | Android 16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/35f7c28cf36e19e3243a9db8496d5e70b94e34ce) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus 13R | CPH2645, CPH2691, CPH2647 | Android 16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_oneplus_13r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_oneplus_13r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus 13S | CPH2723 | Android 15 (OOS/COS 15) | 6.6.56 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.2_oneplus_13s) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus 13S | CPH2723 | Android 16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_oneplus_13s) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus 13T | PKX110 | Android 15 (OOS/COS 15) | 6.6.56 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/06be241a7b9eefa01db78447cc7877339b3685f3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus 13T | PKX110 | Android 15 (OOS/COS 15) | 6.6.66 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.2_oneplus_13t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus 13T | PKX110 | Android 16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_oneplus_13t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus 15 | CPH2747, CPH2745, PLK110, CPH2749 | Android 16 (OOS/COS 16) | 6.12.23 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8850/tree/oneplus/sm8850_b_16.0.0_oneplus_15) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus 15R | CPH2769, CPH2767 | Android 16 (OOS/COS 16) | 6.12.38 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8845/tree/oneplus/sm8845_b_16.0.0_oneplus_15r) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus 15T | PLZ110 | Android 16 (OOS/COS 16) | 6.12.38 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8850/tree/oneplus/sm8850_b_16.0_oneplus_15t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus ACE | PGKM10 | Android 14 (OOS/COS 14) | 5.10.168 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_u_14.0.0_ace) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus ACE | PGKM10 | Android 15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/e5f7c2a470fd9d8b43c5082aed8b58a6d5a2bf7c) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus ACE | PGKM10 | Android 15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_v_15.0.0_ace) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus ACE 2 | PHK110 | Android 15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/ef7ca029d0f6beddf38e60f6b9016935d0f1ac55) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus ACE 2 | PHK110 | Android 15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_v_15.0.0_ace_2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus ACE 2 | PHK110 | Android 16 (OOS/COS 16) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8475/tree/oneplus/sm8475_b_16.0.0_ace_2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus ACE 2 Pro | PJA110 | Android 15 (OOS/COS 15) | 5.15.149 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/95b9da8a7cd99053f65e7d4cebec310b5f5d5744) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus ACE 2 Pro | PJA110 | Android 15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_v_15.0.0_ace_2_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus ACE 2 Pro | PJA110 | Android 16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_ace_2_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus ACE 2V | PHP110 | Android 14 (OOS/COS 14) | 5.10.168 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus_mt6983_u_14.0.0_oneplus_ace_2v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus ACE 2V | PHP110 | Android 15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/8a49e1e297768839d78730f15e8f34e0d0b59f2b) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus ACE 2V | PHP110 | Android 15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus/mt6983_v_15.0.0_ace_2v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus ACE 2V | PHP110 | Android 16 (OOS/COS 16) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus/mt6983_b_16.0.0_ace_2v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus ACE 3 | PJE110 | Android 15 (OOS/COS 15) | 5.15.149 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/3a51ce558bfa3a1c7eeaaab69ee54d5e6bd03afb) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会变化。 | | OnePlus ACE 3 | PJE110 | Android 15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_v_15.0.0_ace_3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus ACE 3 | PJE110 | Android 16 (OOS/COS 16) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/3dc000f085497a728cdf21b28fab9d3ef632f5fd) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会变化。 | | OnePlus ACE 3 | PJE110 | Android 16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_ace_3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus ACE 3 Pro | PJX110 | Android 14 (OOS/COS 14) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_u_14.1.0_oneplus_ace3pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus ACE 3 Pro | PJX110 | Android 15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/1cee682fb60cc9ffd86a2342fa8051bcde77116f) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus ACE 3 Pro | PJX110 | Android 15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_ace_3_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus ACE 3 Pro | PJX110 | Android 16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_ace_3_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus ACE 3V | PJF110 | Android 15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/ac51a5e4c2b0f13f06e9234e415bfa68fb24a8b5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus ACE 3V | PJF110 | Android 15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/oneplus/sm7675_v_15.0.0_ace_3v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus ACE 3V | PJF110 | Android 16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/65c3035422d899d95bfde4e30dc426712ffe52f2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus ACE 3V | PJF110 | Android 16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/oneplus/sm7675_b_16.0.0_ace_3v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus ACE 5 | PKG110 | Android 15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/8c797fd3ff3f76248b44a619410ace1277692907) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus ACE 5 | PKG110 | Android 15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_oneplus_ace5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus ACE 5 | PKG110 | Android 16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/7499247fd6e0669a062ec44cce948ec1e9d4c75e) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus ACE 5 | PKG110 | Android 16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_ace_5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | -| OnePlus ACE 5 Pro | PKR110 | Android 15 (OOS/COS 15) | 6.6.66 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.0_oneplus_ace5_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus ACE 5 Pro | PKR110 | Android 15 (OOS/COS 15) | 6.6.30 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/94969654b1ac03071c42c4757cd9333535612415) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会变化。 | +| OnePlus ACE 5 Pro | PKR110 | Android 15 (OOS/COS 15) | 6.6.66 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.0_oneplus_ace5_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会变化。 | | OnePlus ACE 5 Pro | PKR110 | Android 16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_oneplus_ace5_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus ACE 5 Racing | PLF110 | Android 15 (OOS/COS 15) | 6.1.115 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6989/tree/oneplus/mt6989_v_15.0.2_ace5_race) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | -| OnePlus ACE 5 Racing | PLF110 | Android 16 (OOS/COS 16) | 6.1.134 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6989/tree/oneplus/mt6989_b_16.0.0_ace5_racehttps://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus ACE 5 Racing | PLF110 | Android 16 (OOS/COS 16) | 6.1.134 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6989/tree/oneplus/mt6989_b_16.0.0_ace5_race) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus ACE 5 Ultra | PLC110 | Android 15 (OOS/COS 15) | 6.6.50 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6991/tree/06b1dfbb8f777d329a25d6f932f58f0e45d87627) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus ACE 5 Ultra | PLC110 | Android 15 (OOS/COS 15) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6991/tree/oneplus/mt6991_v_15.0.2_ace5_ultra) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus ACE 5 Ultra | PLC110 | Android 16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6991/tree/oneplus/mt6991_b_16.0.0_oneplus_ace5_ultra) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus ACE 6 | PLQ110 | Android 16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_ace_6) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus ACE 6 Ultra | PMB110 | Android 16 (OOS/COS 16) | 6.12.58 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6993/tree/oneplus/mt6993_b_16.0_ace_6_ultra) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus ACE 6T | PLR110 | Android 16 (OOS/COS 16) | 6.12.38 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8845/tree/oneplus/sm8845_b_16.0.0_ace_6t) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus ACE Race | PGZ110 | Android 14 (OOS/COS 14) | 5.10.168 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_u_14.0.0_ace_race) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus ACE Race | PGZ110 | Android 15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/7a64b08a000cc7040d99543cc7093aaa13e0c786) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus ACE Race | PGZ110 | Android 15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/6409af87cfda3739caff4ee29eb28e77abe8a290) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus ACE Race | PGZ110 | Android 15 (OOS/COS 15) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6895/tree/oneplus/mt6895_v_15.0.0_ace_race) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Nord 3 | CPH2491, CPH2493 | Android 14 (OOS/COS 14) | 5.10.198 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus_mt6983_u_14.0.0_nord_3_5g) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus Nord 3 | CPH2491, CPH2493 | Android 15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/b9b40be7bbe4e2c201ba263ac72f2e1b10171998) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Nord 3 | CPH2491, CPH2493 | Android 15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus/mt6983_v_15.0.0_nord_3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus Nord 4 | CPH2663, CPH2661 | Android 15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/a3c5659c859e888102a144be0884fbf3731b170f) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Nord 4 | CPH2663, CPH2661 | Android 15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/oneplus/sm7675_v_15.0.0_nord_4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus Nord 4 | CPH2663, CPH2661 | Android 16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/975bcbc2e288c3b7c2d2799421c7f356f9c78ce4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Nord 4 | CPH2663, CPH2661 | Android 16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7675/tree/oneplus/sm7675_b_16.0.0_nord_4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus Nord 4 CE | CPH2613 | Android 15 (OOS/COS 15) | 5.15.149 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7550/tree/d8187f64c9e60fee0969a678a67ac77114a926d9) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus Nord 4 CE | CPH2613 | Android 15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7550/tree/0d28103245225a507d4f4b3ca657d3e27aae2f20) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Nord 4 CE | CPH2613 | Android 15 (OOS/COS 15) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7550/tree/oneplus/sm7550_v_15.0.0_nord_ce4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Nord 4 CE | CPH2613 | Android 16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7550/tree/oneplus/sm7550_b_16.0.0_nord_ce4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Nord 5 | CPH2709, CPH2707 | Android 15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8635/tree/oneplus/sm8635_v_15.0.2_nord5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus Nord 5 | CPH2709, CPH2707 | Android 16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8635/tree/fbeaa9ff25bef199618b9e47004f2eb8fe135f9f) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Nord 5 | CPH2709, CPH2707 | Android 16 (OOS/COS 16) | 6.1.141 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8635/tree/oneplus/sm8635_b_16.0.0_nord_5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Nord 6 | CPH2793, CPH2795 | Android 16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8735/tree/oneplus/sm8735_b_16.0.0_nord_6) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Nord CE4 Lite | CPH2619, CPH2621 | Android 15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm6375/tree/oneplus/sm6375_v_15.0.0_nord_ce4_lite_5g) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | @@ -153,16 +192,22 @@ keywords: droidspaces, supported, devices, android, device, compatibility, kerne | OnePlus Nord CE5 | CPH2719, CPH2717 | Android 15 (OOS/COS 15) | 6.1.115 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6897/tree/oneplus/mt6897_v_15.0.2_nord_ce5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Nord CE5 | CPH2719, CPH2717 | Android 16 (OOS/COS 16) | 6.1.134 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6897/tree/oneplus/mt6897_b_16.0.0_nord_ce5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Nord N30 SE | CPH2605 | Android 15 (OOS/COS 15) | 6.6.30 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6833/tree/oneplus/mt6833_v_15.0.0_nord_n30_se_5g) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus Open | CPH2551 | Android 15 (OOS/COS 15) | 5.15.149 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/2a845b8afd7afc3d72679ddbe5adb07c390966bc) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Open | CPH2551 | Android 15 (OOS/COS 15) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_v_15.0.0_oneplus_open) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus Open | CPH2551 | Android 16 (OOS/COS 16) | 5.15.167 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/1e88584a40b3b055e5ff3796f9a184655ef2d0b8) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Open | CPH2551 | Android 16 (OOS/COS 16) | 5.15.180 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8550/tree/oneplus/sm8550_b_16.0.0_oneplus_open) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Pad 2 MT6991 | OPD2508 | Android 16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6991/tree/oneplus/mt6991_b_16.0.0_pad_2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus Pad 2 Pro | OPD2413 | Android 15 (OOS/COS 15) | 6.6.30 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/68c46d941cc1818d49f8a8805c79e63fbee01eac) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Pad 2 Pro | OPD2413 | Android 15 (OOS/COS 15) | 6.6.57 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.1_pad_2_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Pad 2 Pro | OPD2413 | Android 16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_pad_2_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus Pad 2 SM8650 | OPD2403 | Android 15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/f5a170ce44c7657894247de3de41d23a89e248f7) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Pad 2 SM8650 | OPD2403 | Android 15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_pad2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Pad 2 SM8650 | OPD2403 | Android 16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_pad2) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus Pad 3 MT6897 | OPD2407 | Android 15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6897/tree/cbae97472bca105f69ffaef126065f9ab06e1c12) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会变化。 | | OnePlus Pad 3 MT6897 | OPD2407 | Android 15 (OOS/COS 15) | 6.1.128 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6897/tree/oneplus/mt6897_v_15.0.0_oneplus_pad) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Pad 3 MT6897 | OPD2407 | Android 16 (OOS/COS 16) | 6.1.134 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6897/tree/oneplus/mt6897_b_16.0.0_oneplus_pad) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Pad 3 Pro | OPD2513 | Android 16 (OOS/COS 16) | 6.12.58 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8850/tree/oneplus/sm8850_b_16.0_pad_3_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus Pad 3 SM8750 | OPD2415 | Android 15 (OOS/COS 15) | 6.6.30 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/35366be96d7e73d1d0d7093e1cf2fafd58f9c7d4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会变化。 | | OnePlus Pad 3 SM8750 | OPD2415 | Android 15 (OOS/COS 15) | 6.6.57 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_v_15.0.1_pad_3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Pad 3 SM8750 | OPD2415 | Android 16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8750/tree/oneplus/sm8750_b_16.0.0_pad_3) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Pad 4 | OPD2514 | Android 16 (OOS/COS 16) | 6.12.58 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8850/tree/oneplus/sm8850_b_16.0_pad_4) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | @@ -171,13 +216,16 @@ keywords: droidspaces, supported, devices, android, device, compatibility, kerne | OnePlus Pad Lite | OPD2480, OPD2481 | Android 15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6789/tree/oneplus/mt6789_v_15.0.1_pad_lite) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Pad Lite | OPD2480, OPD2481 | Android 16 (OOS/COS 16) | 6.12.23 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_oneplus_mt6789/tree/oneplus/mt6789_b_16.0_pad_lite) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Pad MT6983 | OPD2203 | Android 14 (OOS/COS 14) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus_mt6983_u_14.0.0_oneplus_pad) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus Pad MT6983 | OPD2203 | Android 15 (OOS/COS 15) | 5.10.209 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/4790201712d757f4f337cd55e5709255edc8bee5) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Pad MT6983 | OPD2203 | Android 15 (OOS/COS 15) | 5.10.226 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus/mt6983_v_15.0.0_oneplus_pad) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Pad MT6983 | OPD2203 | Android 16 (OOS/COS 16) | 5.10.236 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_5.10_oneplus_mt6983/tree/oneplus/mt6983_b_16.0.0_oneplus_pad) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Pad Pro | OPD2404 | Android 14 (OOS/COS 14) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_u_14.1.0_onepluspad_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| OnePlus Pad Pro | OPD2404 | Android 15 (OOS/COS 15) | 6.1.75 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/dcb47d3dcba84e13ae678f18b4629b22333e8898) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Pad Pro | OPD2404 | Android 15 (OOS/COS 15) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_v_15.0.0_pad_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Pad Pro | OPD2404 | Android 16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8650/tree/oneplus/sm8650_b_16.0.0_pad_pro) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Turbo 6 | PLU110 | Android 16 (OOS/COS 16) | 6.6.89 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm8735/tree/oneplus/sm8735_b_16.0.0_turbo_6) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | | OnePlus Turbo 6V | PLY110 | Android 16 (OOS/COS 16) | 6.1.118 | KernelSU Next + SusFS | [Source](https://github.com/OnePlusOSS/android_kernel_common_oneplus_sm7635/tree/oneplus/sm7635_b_16.0.0_turbo_6v) | [Download](https://github.com/WildKernels/OnePlus_KernelSU_SUSFS/releases) | Both | Turnip & Virgl | 正常运行 | [@fatalcoder524](https://github.com/fatalcoder524) | 请查看最新发布说明以获取更多信息和功能可用性。处于活跃 OTA 支持状态设备的系统内核版本可能会有变化。 | +| **Yoga Tab Plus(国际版)** | TB520FU | ZUI 17.5.10.096 (ROW) - Android 14 | `6.1.112` | SukiSU-Ultra v4.1.3 (40796) | [Source](https://github.com/da-ai-xian-zun/tb520fu-droidspaces-gki) | [Download](https://github.com/da-ai-xian-zun/tb520fu-droidspaces-gki/releases/download/v1.0.0/tb520fu-droidspaces-phase2-images.zip) | Both | Turnip | 部分可用 | [@da-ai-xian-zun](https://github.com/da-ai-xian-zun) | 维护者路径为**不解锁 BL**下的 9008 四镜像刷入(非 AnyKernel3),见 [MANUAL_FLASH.md](https://github.com/da-ai-xian-zun/tb520fu-droidspaces-gki/blob/main/docs/MANUAL_FLASH.md);仅在该 ZUI 小版本(ROW,`UKQ1.240826.001`)验证,Release 内 `init_boot`/`vbmeta` 绑定本机构建 AVB 链,其他小版本或国行勿直接套用;若已解锁 BL 可自行 fastboot 刷写并自备 `init_boot`/vbmeta,AVB 链限制较宽松;容器请用目录模式;App sparse 安装未解决;Virgl 未测试 | ## 贡献指南 diff --git a/Makefile b/Makefile index 72d151ce..a92194d3 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ OUT_DIR = output CC ?= cc # Get version from header -VERSION := $(shell grep "DS_VERSION" $(SRC_DIR)/droidspace.h | awk '{print $$3}' | tr -d '"') +VERSION := $(shell grep "DS_VERSION" $(SRC_DIR)/include/droidspace.h | awk '{print $$3}' | tr -d '"') # Parallel jobs - use all available CPU cores NPROC := $(shell nproc 2>/dev/null || sysctl -n hw.logicalcpu 2>/dev/null || echo 4) @@ -18,9 +18,9 @@ NPROC := $(shell nproc 2>/dev/null || sysctl -n hw.logicalcpu 2>/dev/null || ech # Verbose control - V=1 shows full commands, V=0 (default) shows kernel-style short logs V ?= 0 -# Optional private control bridge for the external C++ droidspaces-socketd. -# Keep this off by default so the stock static Droidspaces binary stays unchanged. -ENABLE_SOCKETD_BACKEND ?= 0 +# Optional private control bridge used by API-capable WebUI daemons. +# Enabled by default for WebUI-ready builds; set to 0 for minimal CLI-only builds. +ENABLE_SOCKETD_BACKEND ?= 1 ifeq ($(V),1) Q = @@ -37,11 +37,11 @@ endif # Source files SRCS = $(SRC_DIR)/main.c \ $(SRC_DIR)/utils.c \ - $(SRC_DIR)/android.c \ + $(SRC_DIR)/android/android.c \ $(SRC_DIR)/seccomp.c \ $(SRC_DIR)/mount.c \ $(SRC_DIR)/cgroup.c \ - $(SRC_DIR)/network.c \ + $(SRC_DIR)/net/network.c \ $(SRC_DIR)/terminal.c \ $(SRC_DIR)/console.c \ $(SRC_DIR)/pid.c \ @@ -52,18 +52,18 @@ SRCS = $(SRC_DIR)/main.c \ $(SRC_DIR)/environment.c \ $(SRC_DIR)/documentation.c \ $(SRC_DIR)/hardware.c \ - $(SRC_DIR)/ds_iptables.c \ - $(SRC_DIR)/ds_netlink.c \ - $(SRC_DIR)/ds_dhcp.c \ + $(SRC_DIR)/net/iptables.c \ + $(SRC_DIR)/net/netlink.c \ + $(SRC_DIR)/net/dhcp.c \ $(SRC_DIR)/daemon.c \ $(SRC_DIR)/check.c \ - $(SRC_DIR)/x11.c \ - $(SRC_DIR)/virgl-android.c \ - $(SRC_DIR)/pulseaudio-android.c \ + $(SRC_DIR)/android/x11.c \ + $(SRC_DIR)/android/virgl.c \ + $(SRC_DIR)/android/pulseaudio.c \ $(SRC_DIR)/virtualize.c # Compiler flags - hardened warning set, all warnings are errors -CFLAGS = -Wall -Wextra -Wpedantic -Werror -O2 -flto=auto -std=gnu99 -I$(SRC_DIR) -no-pie -pthread +CFLAGS = -Wall -Wextra -Wpedantic -Werror -O2 -flto=auto -std=gnu99 -I$(SRC_DIR)/include -no-pie -pthread CFLAGS += -Wformat=2 -Wformat-security -Wformat-overflow=2 -Wformat-truncation=2 CFLAGS += -Wnull-dereference -Wcast-qual -Wlogical-op -Wshadow -Wdouble-promotion -Wundef CFLAGS += -Wduplicated-cond -Wduplicated-branches -Wimplicit-fallthrough=3 @@ -100,7 +100,7 @@ find-cc = $(shell \ echo ""; \ fi) -.PHONY: all help clean native x86_64 aarch64 armhf x86 riscv64 all-build tarball all-tarball debug-hardened +.PHONY: all help clean native x86_64 aarch64 armhf x86 riscv64 all-build tarball all-tarball debug-hardened format all: help @@ -122,12 +122,13 @@ help: @echo "" @echo "Options:" @echo " V=1 - Show full compiler commands" - @echo " ENABLE_SOCKETD_BACKEND=1" - @echo " - Compile the private droidspaces-socketd backend bridge" + @echo " ENABLE_SOCKETD_BACKEND=0" + @echo " - Disable the private API backend bridge for minimal builds" @echo "" @echo "Other:" @echo " make clean - Remove build artifacts" @echo " make debug-hardened - Build with ASan/UBSan/LSan to find bugs" + @echo " make format - Run clang-format on all .c/.h/.cpp files" $(OUT_DIR): $(Q)mkdir -p $(OUT_DIR) @@ -138,6 +139,7 @@ $(OBJ_DIR): # Compile each source file to an object - runs in parallel via -j$(NPROC) $(OBJ_DIR)/%.o: $(SRC_DIR)/%.c | $(OBJ_DIR) $(msg_cc) + $(Q)mkdir -p $(dir $@) $(Q)$(CC) $(CFLAGS) -c $< -o $@ # Link step @@ -213,7 +215,7 @@ debug-hardened: $(OUT_DIR) @echo "[*] Building hardened debug binary..." @mkdir -p $(OUT_DIR)/.obj/debug $(Q)$(CC) $(SRCS) -o $(OUT_DIR)/$(BINARY_NAME)-hardened \ - -I$(SRC_DIR) -g3 -O1 -pthread -lutil \ + -I$(SRC_DIR)/include -I$(SRC_DIR) -g3 -O1 -pthread -lutil \ -fsanitize=address -fsanitize=undefined -fsanitize=leak \ -fstack-protector-strong -D_FORTIFY_SOURCE=2 \ -Wall -Wextra -Wno-unused-parameter @@ -257,8 +259,8 @@ tarball: echo "[+] Created: $$TARBALL ($$(du -h $$TARBALL | cut -f1))" all-tarball: all-build - @DATE=$$(date +%Y-%m-%d); \ - TARBALL="$(BINARY_NAME)-v$(VERSION)-$$DATE.tar.gz"; \ + @COMMIT_HASH=$$(git rev-parse --short HEAD 2>/dev/null || date +%Y-%m-%d); \ + TARBALL="$(BINARY_NAME)-v$(VERSION)-$$COMMIT_HASH.tar.gz"; \ ROOT_DIR="$(BINARY_NAME)-v$(VERSION)"; \ TEMP_DIR="/tmp/droidspaces-tarball-$$$$"; \ mkdir -p $$TEMP_DIR/$$ROOT_DIR; \ @@ -274,6 +276,11 @@ all-tarball: all-build $(MAKE) --no-print-directory sync-android; \ echo "[+] Created: $$TARBALL ($$(du -h $$TARBALL | cut -f1))" +format: + @command -v clang-format >/dev/null 2>&1 || { echo "Error: clang-format not found"; exit 1; } + @find $(SRC_DIR) -name '*.c' -o -name '*.h' -o -name '*.cpp' | xargs clang-format -i + @echo "[+] Formatted all source files" + clean: @rm -rf $(OUT_DIR) $(BINARY_NAME)-*.tar.gz @echo "[+] Cleaned build artifacts" diff --git a/README.md b/README.md index ede8a27f..adff5872 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ What makes Droidspaces unique is its **zero-dependency, native execution** on bo **Android** + **Linux Namespaces** = **Droidspaces**. Since Android is built on the Linux kernel, Droidspaces works seamlessly on Linux Desktop too. Both platforms are equally supported and maintained. > [!TIP] +> > Check out [Community-supported Android devices](./Documentation/community-supported-devices.md) for a growing list of phones known to run Droidspaces.
@@ -131,7 +132,6 @@ What makes Droidspaces unique is its **zero-dependency, native execution** on bo - [What is Droidspaces?](#what-is-droidspaces) - [Features](#features) -- [Security & Isolation Philosophy](#security-model) - [Droidspaces vs The Alternatives](#droidspaces-vs-the-alternatives) - [Requirements](#requirements) - [Android](#a-android-devices) @@ -142,10 +142,12 @@ What makes Droidspaces unique is its **zero-dependency, native execution** on bo - [GKI (Modern Kernels)](#GKI) - [Linux Desktop](#b-linux-desktop) - [Installation](#installation) -- [Community-supported Android devices](./Documentation/community-supported-devices.md) - [Usage](#usage) -- [Troubleshooting](./Documentation/Troubleshooting.md) +- [Display, Audio & Desktop](#display-audio-desktop) +- [Security & Isolation Philosophy](#security-model) - [Additional Documentation](#additional-documentation) +- [Troubleshooting](./Documentation/Troubleshooting.md) +- [Community-supported Android devices](./Documentation/community-supported-devices.md) - [Contributing](#contribution) - [Credits](#credits) @@ -174,9 +176,10 @@ The entire runtime is a **single static binary** under 400KB, compiled against m | **Init System Support** | Run systemd, OpenRC or any other init system as PID 1. Full service management and proper boot/shutdown/reboot sequences. | | **Deep Android Integration** | Supports two daemon modes: **Native init.rc** (lowest-level integration with auto-spawn/unkillable persistence) and **Userspace Daemon** (app-togglable, starts via `post-fs-data.sh`, no image modification required). **Both modes bypass root-domain seccomp blocks to ensure stable container lifecycles** [[init.rc Developer Guide](./init/README.md)]. | | **Namespace Isolation** | Complete isolation via PID, MNT, UTS, IPC, and Cgroup namespaces. Each container has its own process tree, mount table, hostname, IPC resources, and cgroup hierarchy. | -| **Network Isolation** | **3 Networking Modes (Host, NAT, None)**. Pure network isolation via `CLONE_NEWNET` (NAT/None modes) or shared host networking (Host mode). Works on both Android and Linux. | -| **Android GPU Acceleration** | Native hardware acceleration for Qualcomm Adreno GPUs via the Turnip driver. Use our [pre-built rootfs templates](https://github.com/ravindu644/Droidspaces-rootfs-builder/releases/latest) for an out-of-the-box experience. [[More info](./Documentation/GPU-Acceleration.md)] | -| **Linux GPU Acceleration** | Zero-configuration GPU acceleration for AMD and Intel GPUs on Linux desktop hosts. [[More info](./Documentation/GPU-Acceleration.md)] | +| **Network Isolation** | **4 Networking Modes (Host, NAT, None, Gateway)**. Pure network isolation via `CLONE_NEWNET` (NAT/None/Gateway modes) or shared host networking (Host mode). NAT auto-detects the active uplink (or pin it with `--upstream`); Gateway delegates LAN/DHCP/firewall to another container like OpenWRT. Works on both Android and Linux. | +| **Android Display & GPU** | Three acceleration modes: **llvmpipe** (software, all devices), **VirGL** (Mali/PowerVR), and **Turnip** (native Qualcomm/Adreno). As of v6.3.0, the X server and VirGL server launch automatically when the container starts - zero manual Termux commands required. Environment variables (`DISPLAY=:5`, `GALLIUM_DRIVER=virpipe`) are injected automatically. [[More info](./Documentation/Graphics-and-Audio.md)] | +| **Android Sound** | PulseAudio daemon runs on the host as the Termux user so Android's audio HAL grants it device access. The socket is bind-mounted into the container at `/tmp/.pulse-socket` and `PULSE_SERVER` is injected automatically - audio just works. [[More info](./Documentation/Graphics-and-Audio.md#pulseaudio)] | +| **Linux GPU Acceleration** | Zero-configuration GPU acceleration for AMD and Intel GPUs on Linux desktop hosts. [[More info](./Documentation/Graphics-and-Audio.md)] | | **Port Forwarding** | Forward host ports to the container in NAT mode (e.g., `--port 22:22`). Supports TCP and UDP, as well as ranges like `1-500:1-500`. | | **Volatile Mode** | Ephemeral containers using OverlayFS. All changes are stored in RAM and discarded on exit. Perfect for testing and development. | | **Custom Bind Mounts** | Map host directories into containers at arbitrary mount points. Supports both chained (`-B a:b -B c:d`) and comma-separated (`-B a:b,c:d`) syntax. | @@ -196,37 +199,6 @@ The entire runtime is a **single static binary** under 400KB, compiled against m --- - - -## Security & Isolation Philosophy - -> [!IMPORTANT] -> -> Droidspaces is a **privileged container runtime** built for **power users** who prioritize simplicity, performance, and native integration over complex, production-grade jailing. -> -> To provide full systemd support, native hardware acceleration (GPU), and complex mounts/networking on Android, the container root needs real privileges. Even though Droidspaces does not use the heavily restricted "unprivileged" (User Namespace) mode, it applies several security layers: -> - **Capability Dropping**: By default, Droidspaces drops high-risk capabilities (e.g., `CAP_SYS_MODULE`, `CAP_SYS_RAWIO`). -> - **Mount Hardening**: Critical host paths are masked or remounted as read-only. -> - **Seccomp Filters**: Common exploit vectors (like CVE-2026-31431 and malicious kernel module loading) are blocked by default. - -> [!WARNING] -> -> **A Container is not a Jail** -> If a process runs as **root** inside a Droidspaces container, it has significant power. -> -> A malicious root user can attempt to escape or manipulate the host. **Droidspaces is not a sandbox for untrusted code.** -> -> We focus on bringing a full Linux server experience to your pocket, not on building a production-grade fortress. - -> [!NOTE] -> -> **Our Security Advice:** -> 1. **Don't daily-drive root**: Just as you would on a standard Linux PC, create a normal user inside your container and use `sudo`. -> 2. **Be Careful with Modes**: Flags like `--privileged` and `--hw-access` intentionally relax security barriers. Use them only when necessary. -> 3. **Respect the Host**: If you compromise your container's root, you compromise your device. - ---- - ## Droidspaces vs The Alternatives @@ -246,7 +218,7 @@ The entire runtime is a **single static binary** under 400KB, compiled against m | **Run at Boot** | **Yes (native `init.rc` / `service.d`). Auto-starts containers even if the phone is locked, `/data` is encrypted, and before any user app has even started.** | No | No | No | No | | **Network Isolation on Android** | **First-in-class. Full NAT/Veth + internet works out of the box. No manual configuration needed.** | Internet works only in host-network mode (`lxc.net.0.type = none`). True network isolation (veth + NAT) often requires manual bridge, iptables, and ip_forward setup - and still breaks on most devices. | Requires `--network host` to get internet; actual network isolation with internet access often does not work reliably on Android. | None (no network namespace) | None (no network namespace) | | **Hardware & Native GPU Access** | **Full (single toggle). Adreno Turnip, USB, sensors, network interfaces, block devices. Full `systemd-udevd` support - behaves like a real Linux PC.** | Manual bind mounts, no udev | Manual bind mounts, no udev | Manual bind mounts, no udev | None | -| **Termux-X11 Support** | **Full (single toggle)** | Manual socket passthrough | Manual socket passthrough | Manual socket passthrough | Manual socket passthrough | +| **Termux-X11 Support** | **Full (single toggle). X server and VirGL server auto-launch on container start. Env vars (`DISPLAY=:5`, `GALLIUM_DRIVER=virpipe`) injected automatically.** | Manual socket passthrough | Manual socket passthrough | Manual socket passthrough | Manual socket passthrough | | **Privileged Mode** | **Full + customizable (`--nomask`, `--nocaps`, `--noseccomp`, etc.)** | Manual config | Yes (`--privileged`) | Full (no guardrails) | No | | **Nested Containers (Docker-in-DS)** | **Natively supported on all kernels** | Complex manual setup | Complex manual setup | No | No | | **Ephemeral / Volatile Containers** | **Yes (OverlayFS, RAM-backed, zero persistence on exit)** | No | Yes | No | No | @@ -366,6 +338,49 @@ sudo ./droidspaces check --- + + +## Display, Audio & Desktop + +As of v6.3.0, Droidspaces automatically launches the Termux:X11 X server, VirGL server, and PulseAudio daemon when the corresponding toggles are enabled in a container's configuration. The environment variables `DISPLAY=:5`, `GALLIUM_DRIVER=virpipe`, and `PULSE_SERVER` are injected into the container via `/run/droidspaces.env` before the init system finishes booting - displays, GPU acceleration, and audio all work without manual setup. + +Our official XFCE rootfs tarballs ship with **XFCE auto-boot** pre-wired: when Termux:X11 is enabled, XFCE launches automatically and appears in the Termux:X11 app - no terminal commands needed. Download from the [Rootfs Repository](./Documentation/Usage-Android-App.md#rootfs-repository) (search "XFCE") or from [Droidspaces Rootfs Builder Releases](https://github.com/Droidspaces/Droidspaces-rootfs-builder/releases/latest). + +For GPU acceleration methods, sound setup, DE auto-boot internals, and Linux desktop configuration, see the **[Display, Audio & Desktop Guide](./Documentation/Graphics-and-Audio.md)**. + +--- + + + +## Security & Isolation Philosophy + +> [!IMPORTANT] +> +> Droidspaces is a **privileged container runtime** built for **power users** who prioritize simplicity, performance, and native integration over complex, production-grade jailing. +> +> To provide full systemd support, native hardware acceleration (GPU), and complex mounts/networking on Android, the container root needs real privileges. Even though Droidspaces does not use the heavily restricted "unprivileged" (User Namespace) mode, it applies several security layers: +> - **Capability Dropping**: By default, Droidspaces drops high-risk capabilities (e.g., `CAP_SYS_MODULE`, `CAP_SYS_RAWIO`). +> - **Mount Hardening**: Critical host paths are masked or remounted as read-only. +> - **Seccomp Filters**: Common exploit vectors (like CVE-2026-31431 and malicious kernel module loading) are blocked by default. + +> [!WARNING] +> +> **A Container is not a Jail** +> If a process runs as **root** inside a Droidspaces container, it has significant power. +> +> A malicious root user can attempt to escape or manipulate the host. **Droidspaces is not a sandbox for untrusted code.** +> +> We focus on bringing a full Linux server experience to your pocket, not on building a production-grade fortress. + +> [!NOTE] +> +> **Our Security Advice:** +> 1. **Don't daily-drive root**: Just as you would on a standard Linux PC, create a normal user inside your container and use `sudo`. +> 2. **Be Careful with Modes**: Flags like `--privileged` and `--hw-access` intentionally relax security barriers. Use them only when necessary. +> 3. **Respect the Host**: If you compromise your container's root, you compromise your device. + +--- + ## Additional Documentation @@ -373,7 +388,9 @@ sudo ./droidspaces check | Document | Description | |----------|-------------| | [Feature Deep Dives](Documentation/Features.md) | Detailed explanation of each major feature. | -| [Cool Things You Can Do (Tailscale, Docker, etc.)](Documentation/Cool-things-you-can-do.md) | +| [Networking From Zero](Documentation/Networking-From-Zero.md) | Beginner-friendly guide to every networking concept behind Droidspaces - NAT, automatic uplink detection, `--upstream` pinning, and gateway mode with OpenWRT. | +| [Display, Audio & Desktop Guide](Documentation/Graphics-and-Audio.md) | GPU acceleration, PulseAudio sound, and desktop environment auto-boot on Android and Linux. | +| [Cool Things You Can Do (Tailscale, Docker, etc.)](Documentation/Cool-things-you-can-do.md) | Practical recipes for running Tailscale, Docker, and other tools inside containers. | | [Uninstallation Guide](Documentation/Uninstallation.md) | How to remove Droidspaces from your system. | --- diff --git a/README_CN.md b/README_CN.md index 6fb8893c..ce1e4527 100644 --- a/README_CN.md +++ b/README_CN.md @@ -131,8 +131,7 @@ Droidspaces 的独特之处在于其对 Android 和 Linux 本身**零依赖**、 - [什么是 Droidspaces?](#what-is-droidspaces) - [功能特性](#features) -- [安全与隔离的理念](#security-model) -- [Droidspaces与其他方案对比](#droidspaces-vs-the-alternatives) +- [Droidspaces 与其他方案对比](#droidspaces-vs-the-alternatives) - [系统需求](#requirements) - [Android](#a-android-devices) - [Root 环境要求](#rooting-requirements) @@ -142,10 +141,12 @@ Droidspaces 的独特之处在于其对 Android 和 Linux 本身**零依赖**、 - [GKI(现代内核)](#GKI) - [Linux 桌面端](#b-linux-desktop) - [安装指南](#installation) -- [社区支持的 Android 设备](./Documentation/zh-CN/community-supported-devices.md) - [使用指南](#usage) -- [疑难解答](./Documentation/zh-CN/Troubleshooting.md) +- [显示、音频与桌面](#display-audio-desktop) +- [安全与隔离的理念](#security-model) - [更多文档](#additional-documentation) +- [疑难解答](./Documentation/zh-CN/Troubleshooting.md) +- [社区支持的 Android 设备](./Documentation/zh-CN/community-supported-devices.md) - [参与贡献](#contribution) - [致谢](#credits) @@ -175,8 +176,9 @@ Droidspaces 的设计目标是原生运行在任何搭载 Linux 内核的设备 | **深度 Android 集成** | 支持两种守护进程模式:**原生 init.rc**(最底层的集成方式,自动生成/不可被终止的持久性)和**用户空间守护进程**(应用内可切换,通过 `post-fs-data.sh` 启动,无需修改镜像)。**两种模式均绕过 root 域 seccomp 限制,确保容器生命周期稳定** [[init.rc 开发者指南](./init/README_CN.md)]。 | | **命名空间隔离** | 通过 PID、MNT、UTS、IPC 以及 Cgroup 命名空间实现完全隔离。每个容器拥有自己的进程树、挂载表、主机名、IPC 资源和 cgroup 层级。 | | **网络隔离** | **3 种网络模式(Host、NAT、None)**。通过 `CLONE_NEWNET` 实现纯网络隔离(NAT/None 模式)或共享宿主机网络(Host 模式)。在 Android 和 Linux 版本上均可使用。 | -| **Android GPU 加速** | 通过 Turnip 驱动为 Qualcomm Adreno GPU 提供原生硬件加速。使用我们的[预构建 rootfs 模板](https://github.com/ravindu644/Droidspaces-rootfs-builder/releases/latest)获得开箱即用的体验。[[更多信息](./Documentation/zh-CN/GPU-Acceleration.md)] | -| **Linux GPU 加速** | 在 Linux 桌面端上无需额外配置 AMD 和 Intel GPU 硬件加速。[[更多信息](./Documentation/zh-CN/GPU-Acceleration.md)] | +| **Android 显示与 GPU** | 三种加速模式:**llvmpipe**(软件渲染,适用所有设备)、**VirGL**(Mali/PowerVR)和 **Turnip**(原生高通/Adreno)。自 v6.3.0 起,X 服务器和 VirGL 服务器在容器启动时自动拉起,无需任何 Termux 命令。环境变量(`DISPLAY=:5`、`GALLIUM_DRIVER=virpipe`)自动注入。[[更多信息](./Documentation/zh-CN/Graphics-and-Audio.md)] | +| **Android 音效** | PulseAudio 守护进程以 Termux 用户身份在宿主机上运行,使 Android 音频 HAL 授予其设备访问权限。套接字以绑定挂载方式挂载到容器的 `/tmp/.pulse-socket`,`PULSE_SERVER` 自动注入,音效开箱即用。[[更多信息](./Documentation/zh-CN/Graphics-and-Audio.md#pulseaudio)] | +| **Linux GPU 加速** | 在 Linux 桌面端上无需额外配置 AMD 和 Intel GPU 硬件加速。[[更多信息](./Documentation/zh-CN/Graphics-and-Audio.md)] | | **端口转发** | 在 NAT 模式下将宿主机端口转发到容器(例如 `--port 22:22`)。支持 TCP 和 UDP,以及端口范围如 `1-500:1-500`。 | | **易失模式** | 使用 OverlayFS 的临时容器。所有更改存储在内存中,退出后丢弃。非常适合用于测试和开发。 | | **自定义绑定挂载** | 将宿主机目录映射到容器中的任意挂载点。支持链式语法(`-B a:b -B c:d`)和逗号分隔语法(`-B a:b,c:d`)。 | @@ -196,37 +198,6 @@ Droidspaces 的设计目标是原生运行在任何搭载 Linux 内核的设备 --- - - -## 安全与隔离理念 - -> [!IMPORTANT] -> -> Droidspaces 是一个**特权容器运行环境**,专为追求简洁、性能和原生集成的**高级用户**打造,而非复杂的生产级沙箱。 -> -> 为了在 Android 上提供完整的 systemd 支持、原生硬件加速(GPU)以及复杂的挂载/网络功能,容器 root 需要真正的特权。尽管 Droidspaces 不使用严格受限的"非特权"(用户命名空间)模式,但它仍然应用了多层安全防护: -> - **权能裁剪**:默认情况下,Droidspaces 会剥离高风险权能(如 `CAP_SYS_MODULE`、`CAP_SYS_RAWIO`)。 -> - **挂载加固**:关键的宿主机路径被屏蔽或重新挂载为只读。 -> - **Seccomp 过滤**:默认阻止常见的攻击向量(如 CVE-2026-31431 和恶意内核模块加载)。 - -> [!WARNING] -> -> **容器不等于监狱** -> 如果进程在 Droidspaces 容器中以 **root** 身份运行,它将拥有相当大的权限。 -> -> 恶意的 root 用户可能试图逃逸或操纵宿主机。**Droidspaces 不是为不受信任代码设计的沙箱。** -> -> 我们专注于将完整的 Linux 服务器体验带到你的口袋中,而不是构建一个生产级的堡垒。 - -> [!NOTE] -> -> **我们的安全建议:** -> 1. **日常使用不要使用 root 用户**:就像在常规 Linux PC 上一样,在容器内创建普通用户并使用 `sudo`。 -> 2. **谨慎使用特殊模式**:`--privileged` 和 `--hw-access` 等标志会刻意放宽安全限制,仅在必要时使用。 -> 3. **尊重宿主机**:如果你容器的 root 权限被攻破,你的设备也将被攻破。 - ---- - ## Droidspaces 与其他方案对比 @@ -244,9 +215,9 @@ Droidspaces 的设计目标是原生运行在任何搭载 Linux 内核的设备 | **Android 端容器持久性** | **真正的不可终止。存活超过 15 天。完全免疫开发者选项中的"不保留活动"和"无后台进程"。** | 低(会被 Android LMK / 电池优化杀死) | 低(被 Android LMK / 电池优化杀死) | 低(会被 Android LMK / 电池优化杀死) | 低(被 Android LMK / 电池优化杀死) | | **数据持久性(应用卸载)** | **零数据丢失。所有容器、配置和数据保存在 `/data/local/Droidspaces`,完全独立于应用。二进制和守护进程在自己的进程会话中运行(`setsid`),与应用的进程组分离。卸载应用不会停止任何东西,也不会删除任何数据。** | 卸载 Termux 后一切消亡。LXC 配置和 rootfs 存储在 Termux 内部(`/data/data/com.termux`),卸载 Termux 会清除一切。 | 卸载 Termux 后一切消亡。`/data/docker` 中的容器数据保留,但若不重新安装整套工具链则无法访问。 | 若 rootfs 在 `/data/local/` 中则安全。若存储在 Termux 主目录内则不安全。 | 卸载 Termux 后一切消亡。PRoot rootfs 通常位于 `/data/data/com.termux`,卸载 Termux 时一并删除。 | | **开机自启** | **支持(原生 `init.rc` / `service.d`)。即使手机锁定、`/data` 加密,甚至在任何用户应用启动之前,即可自动启动容器。** | 不支持 | 不支持 | 不支持 | 不支持 | -| **Android 端网络隔离** | **同类首创。完整的 NAT/Veth + 互联网访问开箱即用。无需手动配置。** | 仅主机网络模式下互联网可用(`lxc.net.0.type = none`)。真正的网络隔离(veth + NAT)通常需要手动设置网桥、iptables 和 ip_forward — 且在大多数设备上仍然不可用。 | 需要 `--network host` 才能上网;真正的带互联网访问的网络隔离在 Android 上通常无法可靠工作。 | 无(没有网络命名空间) | 无(没有网络命名空间) | -| **硬件与原生 GPU 直通** | **完全(一键开关)。Adreno Turnip、USB、传感器、网络接口、块设备。完整的 `systemd-udevd` 支持 — 如同真正的 Linux PC。** | 手动绑定挂载,无 udev | 手动绑定挂载,无 udev | 手动绑定挂载,无 udev | 无 | -| **Termux-X11 支持** | **完全(一键开关)** | 手动套接字透传 | 手动套接字透传 | 手动套接字透传 | 手动套接字透传 | +| **Android 端网络隔离** | **同类首创。完整的 NAT/Veth + 互联网访问开箱即用。无需手动配置。** | 仅主机网络模式下互联网可用(`lxc.net.0.type = none`)。真正的网络隔离(veth + NAT)通常需要手动设置网桥、iptables 和 ip_forward,且在大多数设备上仍然不可用。 | 需要 `--network host` 才能上网;真正的带互联网访问的网络隔离在 Android 上通常无法可靠工作。 | 无(没有网络命名空间) | 无(没有网络命名空间) | +| **硬件与原生 GPU 直通** | **完全(一键开关)。Adreno Turnip、USB、传感器、网络接口、块设备。完整的 `systemd-udevd` 支持,如同真正的 Linux PC。** | 手动绑定挂载,无 udev | 手动绑定挂载,无 udev | 手动绑定挂载,无 udev | 无 | +| **Termux-X11 支持** | **完全(一键开关)。X 服务器和 VirGL 服务器在容器启动时自动拉起。环境变量(`DISPLAY=:5`、`GALLIUM_DRIVER=virpipe`)自动注入。** | 手动套接字透传 | 手动套接字透传 | 手动套接字透传 | 手动套接字透传 | | **特权模式** | **完全 + 可定制(`--nomask`、`--nocaps`、`--noseccomp` 等)** | 手动配置 | 支持(`--privileged`) | 完全(无任何防护) | 不支持 | | **嵌套容器(Docker-in-DS)** | **所有内核上原生支持** | 复杂的手动配置 | 复杂的手动配置 | 不支持 | 不支持 | | **临时/易失容器** | **支持(OverlayFS,RAM 后端存储,退出后零持久化)** | 不支持 | 支持 | 不支持 | 不支持 | @@ -366,6 +337,49 @@ sudo ./droidspaces check --- + + +## 显示、音频与桌面 + +自 v6.3.0 起,Droidspaces 在容器启动时会自动拉起 Termux:X11 X 服务器、VirGL 服务器和 PulseAudio 守护进程(对应开关已启用时)。环境变量 `DISPLAY=:5`、`GALLIUM_DRIVER=virpipe` 和 `PULSE_SERVER` 均通过 `/run/droidspaces.env` 在 init 系统完成启动前注入容器,显示、GPU 加速和音效无需手动配置即可正常使用。 + +我们的官方 XFCE rootfs 压缩包已预置 **XFCE 自动启动**配置:启用 Termux:X11 后启动容器,XFCE 会自动启动并出现在 Termux:X11 应用中,无需任何终端命令。从 [Rootfs 仓库](./Documentation/zh-CN/Usage-Android-App.md#rootfs-仓库)(搜索"XFCE")下载,或直接从 [Droidspaces Rootfs Builder Releases](https://github.com/Droidspaces/Droidspaces-rootfs-builder/releases/latest) 下载。 + +GPU 加速方式、音效配置、桌面环境自动启动原理以及 Linux 桌面配置,请参阅 **[显示、音频与桌面指南](./Documentation/zh-CN/Graphics-and-Audio.md)**。 + +--- + + + +## 安全与隔离理念 + +> [!IMPORTANT] +> +> Droidspaces 是一个**特权容器运行环境**,专为追求简洁、性能和原生集成的**高级用户**打造,而非复杂的生产级沙箱。 +> +> 为了在 Android 上提供完整的 systemd 支持、原生硬件加速(GPU)以及复杂的挂载/网络功能,容器 root 需要真正的特权。尽管 Droidspaces 不使用严格受限的"非特权"(用户命名空间)模式,但它仍然应用了多层安全防护: +> - **权能裁剪**:默认情况下,Droidspaces 会剥离高风险权能(如 `CAP_SYS_MODULE`、`CAP_SYS_RAWIO`)。 +> - **挂载加固**:关键的宿主机路径被屏蔽或重新挂载为只读。 +> - **Seccomp 过滤**:默认阻止常见的攻击向量(如 CVE-2026-31431 和恶意内核模块加载)。 + +> [!WARNING] +> +> **容器不等于监狱** +> 如果进程在 Droidspaces 容器中以 **root** 身份运行,它将拥有相当大的权限。 +> +> 恶意的 root 用户可能试图逃逸或操纵宿主机。**Droidspaces 不是为不受信任代码设计的沙箱。** +> +> 我们专注于将完整的 Linux 服务器体验带到你的口袋中,而不是构建一个生产级的堡垒。 + +> [!NOTE] +> +> **我们的安全建议:** +> 1. **日常使用不要使用 root 用户**:就像在常规 Linux PC 上一样,在容器内创建普通用户并使用 `sudo`。 +> 2. **谨慎使用特殊模式**:`--privileged` 和 `--hw-access` 等标志会刻意放宽安全限制,仅在必要时使用。 +> 3. **尊重宿主机**:如果你容器的 root 权限被攻破,你的设备也将被攻破。 + +--- + ## 更多文档 @@ -373,7 +387,8 @@ sudo ./droidspaces check | 文档 | 描述 | |----------|-------------| | [功能深度解析](./Documentation/zh-CN/Features.md) | 每个主要功能的详细说明。 | -| [你可以做的酷炫事情(Tailscale、Docker 等)](./Documentation/zh-CN/Cool-things-you-can-do.md) | +| [显示、音频与桌面指南](./Documentation/zh-CN/Graphics-and-Audio.md) | Android 和 Linux 上的 GPU 加速、PulseAudio 音效与桌面环境自动启动。 | +| [你可以做的酷炫事情(Tailscale、Docker 等)](./Documentation/zh-CN/Cool-things-you-can-do.md) | 在容器内运行 Tailscale、Docker 等工具的实用配置方案。 | | [卸载指南](./Documentation/zh-CN/Uninstallation.md) | 如何从系统中移除 Droidspaces。 | --- diff --git a/external/busybox b/external/busybox new file mode 160000 index 00000000..1745348a --- /dev/null +++ b/external/busybox @@ -0,0 +1 @@ +Subproject commit 1745348ad12c601c60de32a7c11c2f7a13508803 diff --git a/external/socketd b/external/socketd new file mode 160000 index 00000000..ec4d194f --- /dev/null +++ b/external/socketd @@ -0,0 +1 @@ +Subproject commit ec4d194f83a2bacdb17d9ddfe07f1d433b62fa18 diff --git a/external/www b/external/www new file mode 160000 index 00000000..7351ecd3 --- /dev/null +++ b/external/www @@ -0,0 +1 @@ +Subproject commit 7351ecd3a85fe6ef019eb52d8ce7b6575739aa5e diff --git a/init/android-service/vendor/bin/droidspaces_autoboot.sh b/init/android-service/vendor/bin/droidspaces_autoboot.sh index db5f8b36..16eabd86 100755 --- a/init/android-service/vendor/bin/droidspaces_autoboot.sh +++ b/init/android-service/vendor/bin/droidspaces_autoboot.sh @@ -63,34 +63,113 @@ fi # Breaking container's networking :) wait_for_network -# Scan and boot containers +# Scan and boot containers (priority-ordered, gateway-aware) +# +# Containers boot from the smallest run_at_boot_priority to the largest; missing +# or invalid priorities sort last (best-effort), then alphabetically. A +# net_mode=gateway client is skipped if its gateway_container did not come up, +# so we never boot a router-less client. Independent containers always boot, and +# a single failure never blocks the rest. log "Scanning for containers with run_at_boot=1..." success=0 failed=0 +skipped=0 + +TAB=$(printf '\t') +BOOT_TABLE="${DROIDSPACE_DIR}/.autoboot_table" +SORTED_FILE="${DROIDSPACE_DIR}/.autoboot_sorted" +STARTED_OK="${DROIDSPACE_DIR}/.autoboot_started" +: > "${BOOT_TABLE}" +: > "${STARTED_OK}" + +# Read a single key=value from a config file +get_cfg_val() { + grep "^$1=" "$2" 2>/dev/null | head -1 | sed 's/^[^=]*=//' | tr -d '\r\n' +} + +# Find the container.config whose name= matches $1 (echoes path, empty if none) +cfg_for_name() { + for c in $(find "${CONTAINERS_DIR}" -name "container.config" 2>/dev/null); do + n=$(get_cfg_val name "$c") + [ -n "$n" ] || n=$(basename "$(dirname "$c")") + if [ "$n" = "$1" ]; then + echo "$c" + return 0 + fi + done + return 1 +} + +# A gateway is available if it started during this run, or is already running +gateway_available() { + if grep -qxF "$1" "${STARTED_OK}" 2>/dev/null; then + return 0 + fi + gcfg=$(cfg_for_name "$1") + [ -n "${gcfg}" ] || return 1 + gpid=$("${DROIDSPACE_BINARY}" --config "${gcfg}" pid 2>/dev/null) + [ "${gpid}" != "NONE" ] && [ -n "${gpid}" ] +} + +# Start one container; returns 0 if it ends up with a live PID +boot_container() { + "${DROIDSPACE_BINARY}" --config "$1" start 2>&1 | strip_colors + bpid=$("${DROIDSPACE_BINARY}" --config "$1" pid 2>/dev/null) + [ "${bpid}" != "NONE" ] && [ -n "${bpid}" ] +} +# Build the boot table: PRIORITY \t NAME \t NET_MODE \t GATEWAY \t CFG for cfg in $(find "${CONTAINERS_DIR}" -name "container.config" 2>/dev/null); do [ -f "${cfg}" ] || continue + [ "$(get_cfg_val run_at_boot "${cfg}")" = "1" ] || continue - run_at_boot=$(grep "^run_at_boot=" "${cfg}" 2>/dev/null | \ - head -1 | sed 's/^[^=]*=//' | tr -d '\r\n') + name=$(get_cfg_val name "${cfg}") + [ -n "${name}" ] || name=$(basename "$(dirname "${cfg}")") - [ "${run_at_boot}" = "1" ] || continue + prio=$(get_cfg_val run_at_boot_priority "${cfg}") + case "${prio}" in + ''|*[!0-9]*) prio=99999999 ;; + esac + + # '-' placeholder for empty fields: IFS= collapses adjacent tabs + # (tab is IFS whitespace), so empty middle fields would shift the columns. + netmode=$(get_cfg_val net_mode "${cfg}") + [ -n "${netmode}" ] || netmode="-" + gw=$(get_cfg_val gateway_container "${cfg}") + [ -n "${gw}" ] || gw="-" + + printf '%s\t%s\t%s\t%s\t%s\n' "${prio}" "${name}" "${netmode}" "${gw}" "${cfg}" >> "${BOOT_TABLE}" +done - name=$(grep "^name=" "${cfg}" 2>/dev/null | \ - head -1 | sed 's/^[^=]*=//' | tr -d '\r\n') - display="${name:-$(basename "$(dirname "${cfg}")")}" +# Smallest priority first, then name. Gaps/holes and large sentinels sort cleanly. +sort -t"${TAB}" -k1,1n -k2,2 "${BOOT_TABLE}" > "${SORTED_FILE}" - log "Starting container: ${display}" - "${DROIDSPACE_BINARY}" --config "${cfg}" start 2>&1 | strip_colors +# Reading from a file (not a pipe) keeps the loop in this shell so the counters +# survive each iteration. +while IFS="${TAB}" read -r prio name netmode gw cfg; do + [ -n "${cfg}" ] || continue + [ "${gw}" = "-" ] && gw="" - PID=$("${DROIDSPACE_BINARY}" --config "${cfg}" pid 2>/dev/null) - if [ "${PID}" != "NONE" ] && [ -n "${PID}" ]; then - log "SUCCESS: ${display} (PID: ${PID})" + if [ "${netmode}" = "gateway" ] && [ -n "${gw}" ]; then + if ! gateway_available "${gw}"; then + log "SKIP: ${name} (gateway '${gw}' not running)" + skipped=$((skipped + 1)) + continue + fi + fi + + log "Starting container: ${name}" + if boot_container "${cfg}"; then + PID=$("${DROIDSPACE_BINARY}" --config "${cfg}" pid 2>/dev/null) + log "SUCCESS: ${name} (PID: ${PID})" + printf '%s\n' "${name}" >> "${STARTED_OK}" success=$((success + 1)) else - log "FAILED: ${display}" + log "FAILED: ${name}" failed=$((failed + 1)) fi -done +done < "${SORTED_FILE}" + +rm -f "${BOOT_TABLE}" "${SORTED_FILE}" "${STARTED_OK}" 2>/dev/null -log "Autoboot complete: ${success} started | ${failed} failed" +log "Autoboot complete: ${success} started | ${failed} failed | ${skipped} skipped" diff --git a/scripts/mkdeb.sh b/scripts/mkdeb.sh new file mode 100755 index 00000000..e95eecdb --- /dev/null +++ b/scripts/mkdeb.sh @@ -0,0 +1,153 @@ +#!/bin/bash +# mkdeb.sh - Debian package builder for Droidspaces +# Copyright (C) 2026 ravindu644 +# SPDX-License-Identifier: GPL-3.0-or-later + +set -e + +BINARY_NAME="droidspaces" +OUT_DIR="output" +PKG_RELEASE="1" + +usage() { + echo "Usage: $0 [arch ...]" + echo "" + echo "Architectures:" + echo " x86_64 (Debian: amd64)" + echo " aarch64 (Debian: arm64)" + echo " armhf (Debian: armhf)" + echo " x86 (Debian: i386)" + echo " riscv64 (Debian: riscv64)" + echo "" + echo "If no architecture is supplied, all built binaries in $OUT_DIR are packaged." + exit 1 +} + +deb_arch_for() { + case "$1" in + x86_64) echo "amd64" ;; + aarch64) echo "arm64" ;; + armhf) echo "armhf" ;; + x86) echo "i386" ;; + riscv64) echo "riscv64" ;; + *) return 1 ;; + esac +} + +detect_arch() { + local desc + desc=$(file "$1") + + case "$desc" in + *x86-64*) echo "x86_64" ;; + *ARM\ aarch64*) echo "aarch64" ;; + *RISC-V*) echo "riscv64" ;; + *Intel\ 80386*|*80386*|*i386*) echo "x86" ;; + *ARM*) echo "armhf" ;; + *) return 1 ;; + esac +} + +package_one() { + local arch="$1" + local binary="$2" + local deb_arch + local pkg_version + local pkg_name + local temp_dir + local pkg_root + local installed_size + + deb_arch=$(deb_arch_for "$arch") || { + echo "[-] Error: Unsupported architecture '$arch'" + exit 1 + } + + if [ ! -f "$binary" ]; then + echo "[-] Error: Binary not found: $binary" + exit 1 + fi + + pkg_version="${VERSION}-${PKG_RELEASE}" + pkg_name="${BINARY_NAME}_${pkg_version}_${deb_arch}.deb" + temp_dir=$(mktemp -d "/tmp/${BINARY_NAME}-deb-${arch}.XXXXXX") + pkg_root="$temp_dir/${BINARY_NAME}_${pkg_version}_${deb_arch}" + + mkdir -p "$pkg_root/DEBIAN" + install -Dm755 "$binary" "$pkg_root/usr/bin/$BINARY_NAME" + install -Dm644 "LICENSE" "$pkg_root/usr/share/doc/$BINARY_NAME/copyright" + install -Dm644 "README.md" "$pkg_root/usr/share/doc/$BINARY_NAME/README.md" + gzip -9n "$pkg_root/usr/share/doc/$BINARY_NAME/README.md" + + installed_size=$(du -sk "$pkg_root/usr" | awk '{print $1}') + + cat > "$pkg_root/DEBIAN/control" < +Installed-Size: $installed_size +Homepage: https://github.com/ravindu644/Droidspaces-OSS +Description: Lightweight Linux container runtime for Android and Linux + Droidspaces is a static, namespace-based container runtime with support for + full init systems such as systemd, OpenRC, runit, s6, and SysV init. +EOF_CONTROL + + chmod 0755 "$pkg_root/DEBIAN" + chmod 0644 "$pkg_root/DEBIAN/control" + + echo "[*] Creating $pkg_name..." + dpkg-deb --build --root-owner-group "$pkg_root" "$pkg_name" >/dev/null + rm -rf "$temp_dir" + echo "[+] Created: $pkg_name ($(du -h "$pkg_name" | cut -f1))" +} + +if ! command -v dpkg-deb >/dev/null 2>&1; then + echo "[-] Error: dpkg-deb not found. Please install dpkg." + exit 1 +fi + +VERSION=$(grep "DS_VERSION" "src/include/droidspace.h" | awk '{print $3}' | tr -d '"') +if [ -z "$VERSION" ]; then + echo "[-] Error: Could not determine Droidspaces version." + exit 1 +fi + +ARCHES=() +if [ "$#" -gt 0 ]; then + for arch in "$@"; do + case "$arch" in + -h|--help) usage ;; + *) ARCHES+=("$arch") ;; + esac + done +else + for arch in x86_64 aarch64 armhf x86 riscv64; do + if [ -f "$OUT_DIR/$BINARY_NAME-$arch" ]; then + ARCHES+=("$arch") + fi + done + + if [ "${#ARCHES[@]}" -eq 0 ] && [ -f "$OUT_DIR/$BINARY_NAME" ]; then + ARCHES+=("$(detect_arch "$OUT_DIR/$BINARY_NAME")") + fi +fi + +if [ "${#ARCHES[@]}" -eq 0 ]; then + echo "[-] Error: No built Droidspaces binaries found in $OUT_DIR." + echo "[!] Build first with: make native or make all-build" + exit 1 +fi + +for arch in "${ARCHES[@]}"; do + if [ -f "$OUT_DIR/$BINARY_NAME-$arch" ]; then + package_one "$arch" "$OUT_DIR/$BINARY_NAME-$arch" + elif [ "${#ARCHES[@]}" -eq 1 ] && [ -f "$OUT_DIR/$BINARY_NAME" ]; then + package_one "$arch" "$OUT_DIR/$BINARY_NAME" + else + echo "[-] Error: Binary not found: $OUT_DIR/$BINARY_NAME-$arch" + exit 1 + fi +done diff --git a/scripts/setup-termux.sh b/scripts/setup-termux.sh new file mode 100755 index 00000000..44d30a55 --- /dev/null +++ b/scripts/setup-termux.sh @@ -0,0 +1,87 @@ +#!/bin/bash + +# Copyright (C) 2026 ravindu644 +# SPDX-License-Identifier: GPL-3.0-or-later + +set -e + +DEFAULT_PA="$PREFIX/etc/pulse/default.pa" +AAUDIO_LINE="load-module module-aaudio-sink" +ALWAYS_LINE="load-module module-always-sink" +SLES_LINE="load-module module-sles-sink" +CK_LINE="load-module module-console-kit" +SIDLE_LINE="load-module module-suspend-on-idle" + +BOLD="\033[1m" +GREEN="\033[1;32m" +CYAN="\033[0;36m" +RESET="\033[0m" + +log() { + echo -e "${GREEN}===> ${BOLD}$*${RESET}\n" +} + +detail() { + echo -e " ${CYAN}->${RESET} $*" +} + +log "Setting up Droidspaces dependencies..." + +log "Updating repos and upgrading Termux..." +DEBIAN_FRONTEND=noninteractive pkg update -y -o Dpkg::Options::="--force-confold" +DEBIAN_FRONTEND=noninteractive pkg upgrade -y -o Dpkg::Options::="--force-confold" + +log "Installing x11-repo..." +DEBIAN_FRONTEND=noninteractive pkg install -y -o Dpkg::Options::="--force-confold" x11-repo + +log "Installing Termux:X11, VirGL and PulseAudio..." +DEBIAN_FRONTEND=noninteractive pkg install -y -o Dpkg::Options::="--force-confold" pulseaudio termux-x11 virglrenderer-android + +log "Installing libandroid-stub (OpenSL ES HAL fix)..." +DEBIAN_FRONTEND=noninteractive pkg install -y -o Dpkg::Options::="--force-confold" libandroid-stub + +log "Patching $DEFAULT_PA..." + +if [ ! -f "$DEFAULT_PA" ]; then + echo "Error: $DEFAULT_PA not found." >&2 + exit 1 +fi + +# Comment out module-sles-sink (missing Android HAL deps on most devices) +if grep -q "^${SLES_LINE}" "$DEFAULT_PA"; then + sed -i "s|^${SLES_LINE}|#${SLES_LINE}|" "$DEFAULT_PA" + detail "Commented out $SLES_LINE" +fi + +# Comment out module-console-kit (no D-Bus system bus on Android, causes futex deadlock) +if grep -q "^${CK_LINE}" "$DEFAULT_PA"; then + sed -i "s|^${CK_LINE}|#${CK_LINE}|" "$DEFAULT_PA" + detail "Commented out $CK_LINE" +fi + +# Comment out module-suspend-on-idle (causes PA futex deadlock on audio device changes) +if grep -q "^${SIDLE_LINE}" "$DEFAULT_PA"; then + sed -i "s|^${SIDLE_LINE}|#${SIDLE_LINE}|" "$DEFAULT_PA" + detail "Commented out $SIDLE_LINE" +fi + +# Check if already patched (aaudio appears before always-sink) +AAUDIO_LINE_NUM=$(grep -n "^${AAUDIO_LINE}$" "$DEFAULT_PA" | head -1 | cut -d: -f1) +ALWAYS_LINE_NUM=$(grep -n "^${ALWAYS_LINE}$" "$DEFAULT_PA" | head -1 | cut -d: -f1) + +if [ -n "$AAUDIO_LINE_NUM" ] && [ -n "$ALWAYS_LINE_NUM" ] && [ "$AAUDIO_LINE_NUM" -lt "$ALWAYS_LINE_NUM" ]; then + detail "default.pa already patched, skipping." +else + sed -i "s|^${ALWAYS_LINE}|${AAUDIO_LINE}\n${ALWAYS_LINE}|" "$DEFAULT_PA" + detail "Injected $AAUDIO_LINE before $ALWAYS_LINE" + + # Remove duplicate aaudio line at bottom if present + AAUDIO_COUNT=$(grep -c "^${AAUDIO_LINE}$" "$DEFAULT_PA" || true) + if [ "$AAUDIO_COUNT" -gt 1 ]; then + awk "BEGIN{found=0} /^${AAUDIO_LINE}$/{if(found){next}; found=1} {print}" "$DEFAULT_PA" > "$DEFAULT_PA.tmp" + mv "$DEFAULT_PA.tmp" "$DEFAULT_PA" + detail "Removed duplicate $AAUDIO_LINE" + fi +fi + +log "All done. Droidspaces audio/display dependencies are ready." diff --git a/src/android.c b/src/android/android.c similarity index 91% rename from src/android.c rename to src/android/android.c index 3e3cf7e8..fecd0b05 100644 --- a/src/android.c +++ b/src/android/android.c @@ -156,15 +156,15 @@ int android_setup_storage(const char *rootfs_path) { * Shared by x11.c and pulseaudio-android.c. Android-specific. * ---------------------------------------------------------------------------*/ -/* SELinux domains to try, newest first */ +/* SELinux domains to try -- untrusted_app_27 first (matches real Termux) */ static const char *const untrusted_domains[] = { - "u:r:untrusted_app", "u:r:untrusted_app_32", "u:r:untrusted_app_30", - "u:r:untrusted_app_29", "u:r:untrusted_app_27", "u:r:untrusted_app_25", + "u:r:untrusted_app_27", "u:r:untrusted_app_30", "u:r:untrusted_app_29", + "u:r:untrusted_app_25", "u:r:untrusted_app_32", "u:r:untrusted_app", }; /* * Extract MLS categories from a full SELinux context string. - * e.g. "u:object_r:app_data_file:s0:c78,c257,c512,c768" + * e.g. "u:object_r:app_data_file:s0:c80,c257,c512,c768" * ^ returned pointer */ const char *ds_extract_mls(const char *ctx) { @@ -177,12 +177,11 @@ const char *ds_extract_mls(const char *ctx) { /* * Transition the calling process into an untrusted_app SELinux domain - * with the given MLS categories. Tries newest API-level domain first. + * with the given MLS categories. Tries untrusted_app_27 first to match + * the real Termux process context. * Also clears /proc/self/attr/exec to prevent label leaking. - * Fills applied_ctx (if non-NULL) with the context string that succeeded. */ -void ds_selinux_dyntransition(const char *mls, char *applied_ctx, - size_t ctx_size) { +void ds_selinux_dyntransition(const char *mls) { char target[256] = ""; int fd = open("/proc/self/attr/current", O_WRONLY | O_CLOEXEC); if (fd >= 0) { @@ -202,9 +201,6 @@ void ds_selinux_dyntransition(const char *mls, char *applied_ctx, } close(fd); } - - if (applied_ctx && ctx_size > 0) - snprintf(applied_ctx, ctx_size, "%s", target); } /* diff --git a/src/pulseaudio-android.c b/src/android/pulseaudio.c similarity index 68% rename from src/pulseaudio-android.c rename to src/android/pulseaudio.c index 53dfe33d..247632f9 100644 --- a/src/pulseaudio-android.c +++ b/src/android/pulseaudio.c @@ -11,10 +11,6 @@ #define _GNU_SOURCE #include "droidspace.h" #include -#include -#include -#include -#include #include #include #include @@ -41,6 +37,41 @@ static int pa_resolve_termux_uid(void) { return uid; } +/* + * Probe for libskcodec.so (needed by libskandroidcodec.so on Samsung One UI + * 6.1+). lib64 takes priority; fall back to lib; skip if absent. + */ +#define SKCODEC_LIB64 "/system/lib64/libskcodec.so" +#define SKCODEC_LIB "/system/lib/libskcodec.so" + +static const char *find_skcodec(void) { + if (access(SKCODEC_LIB64, F_OK) == 0) + return SKCODEC_LIB64; + if (access(SKCODEC_LIB, F_OK) == 0) + return SKCODEC_LIB; + return NULL; +} + +/* + * Inject libskcodec.so into LD_PRELOAD so the OpenSL ES module can dlopen + * libskandroidcodec.so which has it as a hidden dependency on Samsung firmware. + */ +static void inject_skcodec_preload(void) { + const char *lib = find_skcodec(); + if (!lib) + return; + + const char *existing = getenv("LD_PRELOAD"); + if (existing && existing[0]) { + char buf[PATH_MAX * 2]; + snprintf(buf, sizeof(buf), "%s:%s", lib, existing); + setenv("LD_PRELOAD", buf, 1); + } else { + setenv("LD_PRELOAD", lib, 1); + } + ds_log("[PulseAudio] LD_PRELOAD += %s (Samsung OpenSL ES fix)", lib); +} + /* ---- daemon child ----------------------------------------------------- */ struct pulse_args { @@ -55,16 +86,7 @@ struct pulse_args { static void pulse_child_wrapper(int ready_fd, void *user_data) { struct pulse_args *args = (struct pulse_args *)user_data; - /* Ignore hangups, keyboard interrupts, and broken pipes to keep the daemon - * alive through terminal disconnects. SIGTERM is our shutdown signal. */ - signal(SIGHUP, SIG_IGN); - signal(SIGINT, SIG_IGN); - signal(SIGQUIT, SIG_IGN); - signal(SIGPIPE, SIG_IGN); - - /* Make PulseAudio unkillable by the OOM killer. - * Must be done while still root, before privilege drop. */ - ds_oom_protect(); + ds_daemon_child_preamble(); /* Set up the Termux environment before dropping privileges */ setenv("TMPDIR", TX11_PREFIX "/tmp", 1); @@ -75,6 +97,10 @@ static void pulse_child_wrapper(int ready_fd, void *user_data) { /* Ensure the tmp directory exists (root creates it before priv drop) */ mkdir_p(TX11_PREFIX "/tmp", 0755); + /* Stay in droidspacesd (permissive) -- no domain transition needed. + * untrusted_app_27 blocks execv of Termux binaries under enforcing. */ + ds_selinux_enter_domain(); + /* Drop root -> Termux UID. */ if (ds_drop_privileges(args->uid) < 0) { perror("[PulseAudio] privilege drop failed"); @@ -83,13 +109,16 @@ static void pulse_child_wrapper(int ready_fd, void *user_data) { _exit(1); } + /* Inject libskcodec.so if present -- fixes OpenSL ES on Samsung One UI 6.1+ + */ + inject_skcodec_preload(); + fprintf(stdout, "[PulseAudio] uid=%d socket=%s\n", (int)getuid(), TX11_PULSE_SOCKET); fflush(stdout); /* Launch PulseAudio in non-daemon foreground mode. - * - module-native-protocol-unix: UNIX socket with anonymous auth - * - module-aaudio-sink: Android AAudio low-latency audio output (default) + * - module-native-protocol-unix: UNIX socket at our custom path * - --exit-idle-time=-1: never exit on idle (we manage lifecycle ourselves) * - --daemonize=no: stay in foreground so our log relay captures all output */ @@ -97,10 +126,11 @@ static void pulse_child_wrapper(int ready_fd, void *user_data) { TX11_PULSE_BIN, "--load=module-native-protocol-unix socket=" TX11_PULSE_SOCKET " auth-anonymous=1", - "--load=module-aaudio-sink", "--exit-idle-time=-1", "--daemonize=no", "--log-target=stderr", + "--use-pid-file=false", + "--disallow-exit", NULL, }; @@ -131,10 +161,10 @@ static void run_pactl_set_default(int uid) { close(devnull); } - /* Run as the same Termux UID with the same supplementary groups */ - if (ds_drop_privileges(uid) < 0) { + /* Stay in droidspacesd (permissive) */ + ds_selinux_enter_domain(); + if (ds_drop_privileges(uid) < 0) _exit(1); - } setenv("PULSE_SERVER", "unix:" TX11_PULSE_SOCKET, 1); setenv("HOME", TX11_HOME, 1); @@ -148,6 +178,23 @@ static void run_pactl_set_default(int uid) { waitpid(p, NULL, 0); } +/* ---- stale config nuke ------------------------------------------------ */ + +#define PA_CONFIG_DIR TX11_HOME "/.config/pulse" + +/* + * Remove PulseAudio's stale runtime dir before each start. + * A leftover cookie/pid from a previous crash causes PA to refuse + * to bind its socket and exit silently with status=1. + */ +static void nuke_pulse_config(void) { + if (access(PA_CONFIG_DIR, F_OK) != 0) + return; + + ds_log("[PulseAudio] nuking stale config: %s", PA_CONFIG_DIR); + remove_recursive(PA_CONFIG_DIR); +} + /* ---- spawn ------------------------------------------------------------ */ static pid_t spawn_pulse(int uid) { @@ -180,6 +227,7 @@ int ds_pulse_daemon_start(struct ds_config *cfg) { /* Clean up stale socket from a previous crashed run */ unlink(TX11_PULSE_SOCKET); + nuke_pulse_config(); ds_log("[PulseAudio] launching daemon (uid=%d)", uid); pid_t child = spawn_pulse(uid); @@ -203,32 +251,10 @@ int ds_pulse_daemon_start(struct ds_config *cfg) { } void ds_pulse_daemon_stop(struct ds_config *cfg) { - if (!cfg || !is_android()) + if (!cfg) return; - - /* Keep the daemon alive if any other running container still needs it */ - if (check_pulse_needs() == 1) { - ds_log("[PulseAudio] keeping global daemon running for other active " - "containers"); - return; - } - - pid_t pid = - cfg->pulse_pid > 0 ? cfg->pulse_pid : ds_daemon_read_pid("pulse.ppid"); - if (pid > 0) { - ds_log("[PulseAudio] terminating daemon (PID %d)...", (int)pid); - kill(pid, SIGTERM); - for (int i = 0; i < 10 && kill(pid, 0) == 0; i++) - usleep(100000); - if (kill(pid, 0) == 0) { - kill(pid, SIGKILL); - waitpid(pid, NULL, 0); - } - cfg->pulse_pid = 0; - } - - ds_daemon_remove_pid("pulse.ppid"); - unlink(TX11_PULSE_SOCKET); + ds_global_daemon_stop(check_pulse_needs, cfg->pulse_pid, &cfg->pulse_pid, + "pulse.ppid", TX11_PULSE_SOCKET, "[PulseAudio]"); } /* ---- socket bridge ---------------------------------------------------- */ @@ -237,28 +263,9 @@ int ds_setup_pulse_socket(struct ds_config *cfg) { if (!is_android() || !cfg->pulseaudio) return 0; - /* Post-pivot_root: host filesystem is under /.old_root. - * TX11_PULSE_SOCKET lives in TX11_PREFIX/tmp, accessed via - * DS_TERMUX_TMP_OLDROOT, exactly like the VirGL socket. */ - char src[PATH_MAX]; - snprintf(src, sizeof(src), "%s/.pulse-socket", DS_TERMUX_TMP_OLDROOT); - - struct stat st; - if (stat(src, &st) != 0) { - ds_warn("PulseAudio: socket not found at %s - skipping socket bridge", src); - return 0; - } - - uid_t uid = st.st_uid; - - if (ds_bind_mount_socket(src, DS_PULSE_SOCKET, uid, "PulseAudio") < 0) - return 0; - - ds_log("PulseAudio: socket bind-mounted into container"); - - /* Inject PULSE_SERVER so all processes inside the container find the daemon - */ - setenv("PULSE_SERVER", "unix:" DS_PULSE_SOCKET, 1); - - return 0; + /* Post-pivot_root: the host socket lives under DS_TERMUX_TMP_OLDROOT. Bridge + * it in and export PULSE_SERVER for all container processes. */ + return ds_bridge_termux_socket(".pulse-socket", DS_PULSE_SOCKET, + "PULSE_SERVER", "unix:" DS_PULSE_SOCKET, + "PulseAudio"); } diff --git a/src/virgl-android.c b/src/android/virgl.c similarity index 65% rename from src/virgl-android.c rename to src/android/virgl.c index 5f9b7183..2b416969 100644 --- a/src/virgl-android.c +++ b/src/android/virgl.c @@ -31,16 +31,10 @@ struct virgl_args { static void virgl_child_wrapper(int ready_fd, void *user_data) { struct virgl_args *args = (struct virgl_args *)user_data; - /* Ignore hangups, keyboard interrupts, and broken pipes to make the server - * process robust and persistent (except for SIGTERM which we use to stop it). - */ - signal(SIGHUP, SIG_IGN); - signal(SIGINT, SIG_IGN); - signal(SIGQUIT, SIG_IGN); - signal(SIGPIPE, SIG_IGN); + ds_daemon_child_preamble(); - /* Make VirGL server unkillable */ - ds_oom_protect(); + /* Enter droidspacesd domain -- best-effort, fallback on pre-reboot */ + ds_selinux_enter_domain(); fprintf(stdout, "[VirGL] uid=%d starting server\n", (int)getuid()); fflush(stdout); @@ -131,32 +125,10 @@ int ds_virgl_daemon_start(struct ds_config *cfg) { } void ds_virgl_daemon_stop(struct ds_config *cfg) { - if (!cfg || !is_android()) + if (!cfg) return; - - /* Keep the server alive if any other running container still needs VirGL */ - if (check_virgl_needs() == 1) { - ds_log("[VirGL] keeping global VirGL server running for other active " - "containers"); - return; - } - - pid_t pid = - cfg->virgl_pid > 0 ? cfg->virgl_pid : ds_daemon_read_pid("virgl.vpid"); - if (pid > 0) { - ds_log("[VirGL] terminating VirGL server (PID %d)...", (int)pid); - kill(pid, SIGTERM); - for (int i = 0; i < 10 && kill(pid, 0) == 0; i++) - usleep(100000); - if (kill(pid, 0) == 0) { - kill(pid, SIGKILL); - waitpid(pid, NULL, 0); - } - cfg->virgl_pid = 0; - } - - ds_daemon_remove_pid("virgl.vpid"); - unlink(TX11_VIRGL_SOCKET); + ds_global_daemon_stop(check_virgl_needs, cfg->virgl_pid, &cfg->virgl_pid, + "virgl.vpid", TX11_VIRGL_SOCKET, "[VirGL]"); } /* ---- socket bridge ---------------------------------------------------- */ @@ -165,28 +137,8 @@ int ds_setup_virgl_socket(struct ds_config *cfg) { if (!is_android() || !cfg->virgl) return 0; - /* Post-pivot_root: host filesystem is accessible under /.old_root. - * Use the same DS_TERMUX_TMP_OLDROOT prefix that X11 socket bridging uses, - * otherwise stat() will always fail since the raw host path no longer - * resolves inside the container's mount namespace. */ - char src[PATH_MAX]; - snprintf(src, sizeof(src), "%s/.virgl_test", DS_TERMUX_TMP_OLDROOT); - - struct stat st; - if (stat(src, &st) != 0) { - ds_warn("VirGL: socket not found at %s - skipping socket bridge", src); - return 0; - } - - uid_t uid = st.st_uid; - - if (ds_bind_mount_socket(src, DS_VIRGL_SOCKET, uid, "VirGL") < 0) - return 0; - - ds_log("VirGL: socket bind-mounted into container"); - - /* Set GALLIUM_DRIVER so mesa uses the virpipe backend for HW acceleration */ - setenv("GALLIUM_DRIVER", "virpipe", 1); - - return 0; + /* Post-pivot_root: the host socket lives under DS_TERMUX_TMP_OLDROOT. Bridge + * it in and set GALLIUM_DRIVER so mesa uses the virpipe backend. */ + return ds_bridge_termux_socket(".virgl_test", DS_VIRGL_SOCKET, + "GALLIUM_DRIVER", "virpipe", "VirGL"); } diff --git a/src/x11.c b/src/android/x11.c similarity index 85% rename from src/x11.c rename to src/android/x11.c index 86475f8d..2b686ff3 100644 --- a/src/x11.c +++ b/src/android/x11.c @@ -56,18 +56,7 @@ struct xserver_args { static void xserver_child_wrapper(int ready_fd, void *user_data) { struct xserver_args *args = (struct xserver_args *)user_data; - /* Ignore hangups, keyboard interrupts, and broken pipes to make the server - * process robust and persistent (except for SIGTERM which we use to stop it). - */ - signal(SIGHUP, SIG_IGN); - signal(SIGINT, SIG_IGN); - signal(SIGQUIT, SIG_IGN); - signal(SIGPIPE, SIG_IGN); - - /* Make Termux-X11 server unkillable. - * This must be done while we are still running as root (before dropping - * privileges). */ - ds_oom_protect(); + ds_daemon_child_preamble(); char ctx[256]; if (get_selinux_context(TX11_DATA_DIR, ctx, sizeof(ctx)) < 0 && @@ -114,12 +103,10 @@ static void xserver_child_wrapper(int ready_fd, void *user_data) { _exit(1); } - /* SELinux dyntransition into untrusted_app */ - char target[256] = ""; - ds_selinux_dyntransition(mls, target, sizeof(target)); + /* SELinux dyntransition into untrusted_app_27 (Termux domain) */ + ds_selinux_dyntransition(mls); - fprintf(stdout, "[X11] ctx=%s uid=%d display=%s\n", target, (int)getuid(), - args->display); + fprintf(stdout, "[X11] uid=%d display=%s\n", (int)getuid(), args->display); fflush(stdout); char nice[256]; @@ -227,31 +214,11 @@ int ds_x11_daemon_start(struct ds_config *cfg) { } void ds_x11_daemon_stop(struct ds_config *cfg) { - if (!cfg || !is_android()) + if (!cfg) return; - - /* Keep the server alive if any other running container still needs X11 */ - if (check_x11_needs() == 1) { - ds_log( - "[X11] keeping global X11 server running for other active containers"); - return; - } - - pid_t pid = cfg->x11_pid > 0 ? cfg->x11_pid : ds_daemon_read_pid("x11.xpid"); - if (pid > 0) { - ds_log("[X11] terminating Termux-X11 server (PID %d)...", (int)pid); - kill(pid, SIGTERM); - for (int i = 0; i < 10 && kill(pid, 0) == 0; i++) - usleep(100000); - if (kill(pid, 0) == 0) { - kill(pid, SIGKILL); - waitpid(pid, NULL, 0); - } - cfg->x11_pid = 0; - } - - ds_daemon_remove_pid("x11.xpid"); - unlink(TX11_SOCK_DIR "/" TX11_DISPLAY_SOCK); + ds_global_daemon_stop(check_x11_needs, cfg->x11_pid, &cfg->x11_pid, + "x11.xpid", TX11_SOCK_DIR "/" TX11_DISPLAY_SOCK, + "[X11]"); } /* ---- socket bridge ---------------------------------------------------- */ diff --git a/src/boot.c b/src/boot.c index ab1b8748..8a3042b3 100644 --- a/src/boot.c +++ b/src/boot.c @@ -136,6 +136,8 @@ int internal_boot(struct ds_config *cfg) { if (nr != (ssize_t)sizeof(hs)) { ds_warn("[NET] Child: incomplete handshake (read %zd, expected %zu)", nr, sizeof(hs)); + } else if (cfg->net_mode == DS_NET_GATEWAY) { + ds_log("[NET] Child: handshake received (gateway mode)"); } else { ds_log("[NET] Child: handshake received: peer=%s ip=%s", hs.peer_name, hs.ip_str); @@ -143,7 +145,7 @@ int internal_boot(struct ds_config *cfg) { } /* Configure our side of the veth (or just loopback for DS_NET_NONE) */ - if (cfg->net_mode == DS_NET_NAT) { + if (cfg->net_mode == DS_NET_NAT || cfg->net_mode == DS_NET_GATEWAY) { setup_veth_child_side_named(cfg, hs.peer_name, hs.ip_str); } else { /* DS_NET_NONE: just bring up loopback */ @@ -213,6 +215,26 @@ int internal_boot(struct ds_config *cfg) { goto boot_fail; } + /* 4b. Force the image rootfs read-write - the single unified point covering + * fresh boot, restart, AND internal reboot. + * + * On shutdown/reboot OpenWRT remounts its root read-only, leaving our mount + * point under /mnt/Droidspaces/ read-only too. External and internal + * reboots never unmount the rootfs.img (to keep restarts fast), so the next + * boot reuses that still-read-only mount and pivot_root fails to create + * .old_root ("Read-only file system") - the container never boots. + * + * Skipped for directory rootfs (a fresh self-bind in this new namespace, its + * host backing fs already writable) and volatile mode (pivot_root targets the + * read-write overlay, not the read-only lower image). Safe: the guest synced + * the fs before flipping it read-only. A no-op when already read-write. */ + if (cfg->is_img_mount && !cfg->volatile_mode) { + if (mount(NULL, cfg->rootfs_path, NULL, + MS_REMOUNT | MS_NOATIME | MS_NODIRATIME, NULL) < 0) + ds_warn("Failed to remount rootfs %s read-write: %s", cfg->rootfs_path, + strerror(errno)); + } + /* 5. Set working directory to rootfs (required before pivot_root) */ if (chdir(cfg->rootfs_path) < 0) { ds_error("Failed to chdir to '%s': %s", cfg->rootfs_path, strerror(errno)); @@ -507,7 +529,7 @@ int internal_boot(struct ds_config *cfg) { * Apply security hardening (capabilities and seccomp) * This is done at the very end to ensure all setup tasks that might need * privileges (like chown/chmod or mknod) are finished. */ - ds_seccomp_apply_minimal(cfg->privileged_mask); + ds_seccomp_apply_minimal(cfg->privileged_mask, cfg->userns_allowed); android_seccomp_setup(is_systemd, cfg->block_nested_ns && !(cfg->privileged_mask & DS_PRIV_NOSEC), diff --git a/src/check.c b/src/check.c index 78348a8f..acd3f141 100644 --- a/src/check.c +++ b/src/check.c @@ -400,17 +400,11 @@ int check_requirements_detailed(void) { print_ds_check("Veth pair support", "Required for --net=nat; no fallback exists if absent", check_veth_support(), "OPT"); - - /* HARDENING */ - check_append("\n" C_BOLD "[HARDENING]" C_RESET - "\nThese checks are not required for Droidspaces to work, " - "but are recommended for hardened kernels:\n\n"); - - int has_user_ns = access("/proc/self/ns/user", F_OK) == 0; - print_ds_check("CONFIG_USER_NS disabled", - "Kernel exposes user namespace support, which Droidspaces " - "does not require and hardened kernels should disable", - !has_user_ns, "OPT"); + print_ds_check("User namespace", + "CONFIG_USER_NS; enable per container with --allow-userns. " + "Needed by Docker on some kernels, by sandboxed apps " + "(Flatpak, Bubblewrap, browsers) and by desktop environments", + access("/proc/self/ns/user", F_OK) == 0, "OPT"); /* FINAL SUMMARY */ check_append("\n" C_BOLD "Summary:" C_RESET "\n\n"); diff --git a/src/config.c b/src/config.c index 60af1bf4..1a5e507a 100644 --- a/src/config.c +++ b/src/config.c @@ -293,6 +293,8 @@ int ds_config_load(const char *config_path, struct ds_config *cfg) { cfg->pulseaudio = parse_bool(val); } else if (strcmp(key, "selinux_permissive") == 0) { cfg->selinux_permissive = parse_bool(val); + } else if (strcmp(key, "allow_userns") == 0) { + cfg->userns_allowed = parse_bool(val); } else if (strcmp(key, "volatile_mode") == 0) { cfg->volatile_mode = parse_bool(val); } else if (strcmp(key, "force_cgroupv1") == 0) { @@ -364,40 +366,41 @@ int ds_config_load(const char *config_path, struct ds_config *cfg) { cfg->net_mode = DS_NET_NONE; } else if (strcmp(val, "host") == 0) { cfg->net_mode = DS_NET_HOST; + } else if (strcmp(val, "gateway") == 0 || + strcmp(val, "delegated-gateway") == 0) { + cfg->net_mode = DS_NET_GATEWAY; } else { ds_warn( "Unknown network mode '%s' in config file. Defaulting to 'host'.", val); cfg->net_mode = DS_NET_HOST; } + } else if (strcmp(key, "gateway_container") == 0) { + if (validate_container_name(val)) + safe_strncpy(cfg->gateway_container, val, + sizeof(cfg->gateway_container)); + else + ds_warn("config: ignoring invalid gateway_container '%s'", val); + } else if (strcmp(key, "gateway_net") == 0) { + safe_strncpy(cfg->gateway_net, val, sizeof(cfg->gateway_net)); + } else if (strcmp(key, "gateway_bridge") == 0) { + if (strlen(val) < IFNAMSIZ) + safe_strncpy(cfg->gateway_bridge, val, sizeof(cfg->gateway_bridge)); + else + ds_warn("config: ignoring too-long gateway_bridge '%s'", val); + } else if (strcmp(key, "gateway_lan_ifname") == 0) { + if (strlen(val) < IFNAMSIZ) + safe_strncpy(cfg->gateway_lan_ifname, val, + sizeof(cfg->gateway_lan_ifname)); + else + ds_warn("config: ignoring too-long gateway_lan_ifname '%s'", val); } else if (strcmp(key, "upstream_interfaces") == 0) { - /* Comma-separated interface names, e.g. "wlan0,rmnet0,ccmni1" */ - char copy[1024]; - safe_strncpy(copy, val, sizeof(copy)); - char *up_saveptr; - char *up_tok = strtok_r(copy, ",", &up_saveptr); - while (up_tok && cfg->upstream_iface_count < DS_MAX_UPSTREAM_IFACES) { - while (*up_tok == ' ' || *up_tok == '\t') - up_tok++; - char *up_end = up_tok + strlen(up_tok) - 1; - while (up_end > up_tok && (*up_end == ' ' || *up_end == '\t')) - *up_end-- = '\0'; - if (up_tok[0] && strlen(up_tok) < IFNAMSIZ) { - int dup = 0; - for (int i = 0; i < cfg->upstream_iface_count; i++) { - if (strcmp(cfg->upstream_ifaces[i], up_tok) == 0) { - dup = 1; - break; - } - } - if (!dup) { - safe_strncpy(cfg->upstream_ifaces[cfg->upstream_iface_count++], - up_tok, IFNAMSIZ); - } - } - up_tok = strtok_r(NULL, ",", &up_saveptr); - } - if (up_tok) + /* Comma-separated interface names/wildcards, e.g. "wlan0,rmnet*". When + * present this pins NAT WAN to these interfaces in priority order and + * disables automatic uplink detection. */ + if (ds_parse_iface_csv(val, cfg->upstream_ifaces, + &cfg->upstream_iface_count, + DS_MAX_UPSTREAM_IFACES) > 0) ds_warn("config: too many upstream_interfaces (max %d) - extra entries " "ignored", DS_MAX_UPSTREAM_IFACES); @@ -444,6 +447,15 @@ int ds_config_load(const char *config_path, struct ds_config *cfg) { * Returns 1 on success, 0 on parse/range error. */ int valid = 1; + /* Only tcp/udp are valid protocols; the CLI --port path rejects + * anything else, so mirror that here instead of storing a bogus proto + * that would later be handed to iptables. */ + if (strcmp(pf->proto, "tcp") != 0 && strcmp(pf->proto, "udp") != 0) { + ds_warn("config: invalid protocol '%s' in port_forwards - skipping", + pf->proto); + valid = 0; + } + /* Host side */ { char *dash = strchr(host_side, '-'); @@ -600,6 +612,16 @@ void free_config_unknown_lines(struct ds_config *cfg) { cfg->unknown_head = cfg->unknown_tail = NULL; } +void ds_config_free(struct ds_config *cfg) { + free_config_binds(cfg); + free_config_env_vars(cfg); + free_config_unknown_lines(cfg); + free(cfg->tx11_extra_flags); + cfg->tx11_extra_flags = NULL; + free(cfg->virgl_extra_flags); + cfg->virgl_extra_flags = NULL; +} + static void ds_config_serialize_known(FILE *f, struct ds_config *cfg) { fprintf(f, "# Droidspaces Container Configuration\n"); fprintf(f, "# Generated automatically - Changes may be overwritten\n\n"); @@ -634,6 +656,7 @@ static void ds_config_serialize_known(FILE *f, struct ds_config *cfg) { fprintf(f, "enable_hw_access=%d\n", cfg->hw_access); fprintf(f, "enable_gpu_mode=%d\n", cfg->gpu_mode); fprintf(f, "selinux_permissive=%d\n", cfg->selinux_permissive); + fprintf(f, "allow_userns=%d\n", cfg->userns_allowed); fprintf(f, "volatile_mode=%d\n", cfg->volatile_mode); fprintf(f, "force_cgroupv1=%d\n", cfg->force_cgroupv1); fprintf(f, "block_nested_ns=%d\n", cfg->block_nested_ns); @@ -682,16 +705,28 @@ static void ds_config_serialize_known(FILE *f, struct ds_config *cfg) { fprintf(f, "net_mode=nat\n"); } else if (cfg->net_mode == DS_NET_NONE) { fprintf(f, "net_mode=none\n"); + } else if (cfg->net_mode == DS_NET_GATEWAY) { + fprintf(f, "net_mode=gateway\n"); } else { fprintf(f, "net_mode=host\n"); } + if (cfg->net_mode == DS_NET_GATEWAY) { + if (cfg->gateway_container[0]) + fprintf(f, "gateway_container=%s\n", cfg->gateway_container); + if (cfg->gateway_net[0]) + fprintf(f, "gateway_net=%s\n", cfg->gateway_net); + if (cfg->gateway_bridge[0]) + fprintf(f, "gateway_bridge=%s\n", cfg->gateway_bridge); + if (cfg->gateway_lan_ifname[0]) + fprintf(f, "gateway_lan_ifname=%s\n", cfg->gateway_lan_ifname); + } + if (cfg->net_mode == DS_NET_NAT && cfg->upstream_iface_count > 0) { fprintf(f, "upstream_interfaces="); - for (int i = 0; i < cfg->upstream_iface_count; i++) { + for (int i = 0; i < cfg->upstream_iface_count; i++) fprintf(f, "%s%s", cfg->upstream_ifaces[i], (i < cfg->upstream_iface_count - 1) ? "," : ""); - } fprintf(f, "\n"); } @@ -783,10 +818,7 @@ int ds_config_save(const char *config_path, struct ds_config *cfg) { } free(buf_cfg); free(buf_disk); - free_config_binds(&disk_cfg); - free_config_unknown_lines(&disk_cfg); - free(disk_cfg.tx11_extra_flags); - free(disk_cfg.virgl_extra_flags); + ds_config_free(&disk_cfg); if (is_equal) { if (!cfg->config_file_existed) { @@ -797,13 +829,26 @@ int ds_config_save(const char *config_path, struct ds_config *cfg) { } } + /* Step 2: Write all configurations to a temporary file. Use mkstemp (random + * name + O_EXCL) with an explicit 0644 mode: the daemon runs with umask 0, so + * a plain fopen would leave container.config world-writable, and a + * predictable ".tmp" could be pre-planted as a symlink. */ char temp_path[PATH_MAX]; - snprintf(temp_path, sizeof(temp_path), "%s.tmp", config_path); - - /* Step 2: Write all configurations to temporary file */ - FILE *f_out = fopen(temp_path, "we"); - if (!f_out) + int tn = snprintf(temp_path, sizeof(temp_path), "%s.XXXXXX", config_path); + if (tn < 0 || tn >= (int)sizeof(temp_path)) + return -1; + int tfd = mkstemp(temp_path); + if (tfd < 0) + return -1; + (void)fcntl(tfd, F_SETFD, FD_CLOEXEC); + if (fchmod(tfd, 0644) < 0) { /* best effort; config is world-readable 0644 */ + } + FILE *f_out = fdopen(tfd, "w"); + if (!f_out) { + close(tfd); + unlink(temp_path); return -1; + } ds_config_serialize_known(f_out, cfg); @@ -862,6 +907,15 @@ int ds_config_validate(struct ds_config *cfg) { } } + if (cfg->net_mode == DS_NET_GATEWAY) { + if (!cfg->gateway_container[0]) + errors++; + else if (!validate_container_name(cfg->gateway_container)) + errors++; + else if (strcmp(cfg->gateway_container, cfg->container_name) == 0) + errors++; + } + return (errors > 0) ? -1 : 0; } @@ -897,6 +951,23 @@ int ds_config_load_by_name(const char *name, struct ds_config *cfg) { snprintf(config_path, sizeof(config_path), "%s/Containers/%s/container.config", get_workspace_dir(), safe_name); + /* Single source of truth: if the container is running, load the immutable + * snapshot it actually booted with (/run/droidspaces/container.config inside + * its mount ns) so host-side edits to container.config while it runs never + * desync stop/cleanup/info. Keep config_file pointing at the workspace copy + * so later saves still target the host file. Fall back to the workspace copy + * when not running or the snapshot is unreadable. */ + pid_t pid = find_container_by_name(name); + if (pid > 0) { + char run_path[PATH_MAX]; + if (build_proc_root_path(pid, "/run/droidspaces/container.config", run_path, + sizeof(run_path)) == 0 && + access(run_path, F_OK) == 0 && ds_config_load(run_path, cfg) == 0) { + safe_strncpy(cfg->config_file, config_path, sizeof(cfg->config_file)); + return 0; + } + } + return ds_config_load(config_path, cfg); } diff --git a/src/container.c b/src/container.c index 2986825b..c117bcdf 100644 --- a/src/container.c +++ b/src/container.c @@ -264,8 +264,8 @@ void cleanup_container_resources(struct ds_config *cfg, pid_t pid, * if no external lock is active. */ } - /* Network cleanup: remove host veth and iptables rules */ - if (cfg->net_mode == DS_NET_NAT) { + /* Network cleanup: remove host veth and owned network state */ + if (cfg->net_mode == DS_NET_NAT || cfg->net_mode == DS_NET_GATEWAY) { ds_net_cleanup(cfg, pid > 0 ? pid : cfg->container_pid); } @@ -744,11 +744,9 @@ int start_rootfs(struct ds_config *cfg) { ds_error("Container failed to boot correctly."); /* If pid is still alive, we might want to kill it, but monitor usually * handles this. Let's just return error so parent doesn't report - * success. - */ - free_config_binds(cfg); - free_config_env_vars(cfg); - free_config_unknown_lines(cfg); + * success. Teardown (including ds_config_free) is owned by the single + * cleanup: block below - freeing here would leave cleanup reading a + * freed cfg and then double-free it. */ goto cleanup; } @@ -766,9 +764,7 @@ int start_rootfs(struct ds_config *cfg) { if (lock_acquired) release_external_lock(cfg->container_name); - free_config_binds(cfg); - free_config_env_vars(cfg); - free_config_unknown_lines(cfg); + ds_config_free(cfg); return 0; @@ -792,9 +788,7 @@ int start_rootfs(struct ds_config *cfg) { if (sync_pipe[1] >= 0) close(sync_pipe[1]); - free_config_binds(cfg); - free_config_env_vars(cfg); - free_config_unknown_lines(cfg); + ds_config_free(cfg); return -1; } @@ -881,20 +875,27 @@ int stop_rootfs_with_timeout(struct ds_config *cfg, int skip_unmount, .sleeptime = 3, }; - int fd = open(initctl, O_WRONLY | O_NONBLOCK | O_CLOEXEC); + int fd = open(initctl, O_WRONLY | O_NONBLOCK | O_NOFOLLOW | O_CLOEXEC); if (fd < 0) { /* Fallback: try /dev/initctl (historical path, used by Slackware) */ snprintf(initctl, sizeof(initctl), "/proc/%d/root/dev/initctl", pid); - fd = open(initctl, O_WRONLY | O_NONBLOCK | O_CLOEXEC); + fd = open(initctl, O_WRONLY | O_NONBLOCK | O_NOFOLLOW | O_CLOEXEC); } - if (fd >= 0) { + /* Only write into an actual FIFO. The container fully controls + * /run/initctl inside its own root: O_NOFOLLOW rejects a symlink at the + * final component, and the S_ISFIFO check rejects a regular file the + * container may have planted to capture the host-written init_request. */ + struct stat ictl_st; + if (fd >= 0 && fstat(fd, &ictl_st) == 0 && S_ISFIFO(ictl_st.st_mode)) { if (write(fd, &req, sizeof(req)) != (ssize_t)sizeof(req)) ds_warn("sysvinit: short write to initctl, falling back to SIGPWR"); close(fd); } else { - ds_warn("sysvinit: cannot open initctl (tried /run and /dev), falling " - "back to SIGPWR"); + if (fd >= 0) + close(fd); + ds_warn("sysvinit: cannot open initctl FIFO (tried /run and /dev), " + "falling back to SIGPWR"); kill(pid, SIGPWR); } break; @@ -998,7 +999,7 @@ int enter_namespace(pid_t pid, struct ds_config *cfg) { /* 1. Open all namespace descriptors first (CRITICAL: before any setns) */ for (int i = 0; i < 6; i++) { snprintf(path, sizeof(path), "/proc/%d/ns/%s", pid, ns_names[i]); - ns_fds[i] = open(path, O_RDONLY); + ns_fds[i] = open(path, O_RDONLY | O_CLOEXEC); if (ns_fds[i] < 0) { if (i == 0) { /* mnt is mandatory */ ds_error("Failed to open mount namespace at %s: %s", path, @@ -1073,7 +1074,7 @@ int enter_rootfs(struct ds_config *cfg, const char *user) { tty.master = tty.slave = -1; int sv[2]; - if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) < 0) { + if (socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, sv) < 0) { close(tty.master); close(tty.slave); free_config_env_vars(cfg); @@ -1126,7 +1127,7 @@ int enter_rootfs(struct ds_config *cfg, const char *user) { * inherited only via fork/exec from PID 1 - entering processes arrive via * setns() and are NOT children of init, so they inherit nothing. */ ds_log_silent = 1; - ds_seccomp_apply_minimal(cfg->privileged_mask); + ds_seccomp_apply_minimal(cfg->privileged_mask, cfg->userns_allowed); android_seccomp_setup( 0, cfg->block_nested_ns && !(cfg->privileged_mask & DS_PRIV_NOSEC), cfg->privileged_mask); @@ -1204,6 +1205,7 @@ int enter_rootfs(struct ds_config *cfg, const char *user) { char *shell_argv[] = {"su", "-l", (char *)(uintptr_t)user, NULL}; execve("/bin/su", shell_argv, environ); execve("/usr/bin/su", shell_argv, environ); + execve("/run/wrappers/bin/su", shell_argv, environ); /* Fallback: su not available - look up the shell from /etc/passwd */ char user_shell[PATH_MAX] = {0}; @@ -1278,6 +1280,31 @@ int enter_rootfs(struct ds_config *cfg, const char *user) { return 0; } +/* Append arg to buf as a single-quoted shell word, space-separated from any + * prior content unless first. Embedded single quotes use the '\'' idiom. + * Writes at most size-1 chars and always NUL-terminates; returns the new + * offset. Preserves argv word boundaries across su -c's shell. */ +static size_t shell_quote_append(char *buf, size_t off, size_t size, + const char *arg, int first) { + if (!first && off < size - 1) + buf[off++] = ' '; + if (off < size - 1) + buf[off++] = '\''; + for (const char *p = arg; *p; p++) { + if (*p == '\'') { + const char *esc = "'\\''"; /* close quote, escaped quote, reopen quote */ + for (const char *e = esc; *e && off < size - 1; e++) + buf[off++] = *e; + } else if (off < size - 1) { + buf[off++] = *p; + } + } + if (off < size - 1) + buf[off++] = '\''; + buf[off] = '\0'; + return off; +} + int run_in_rootfs(struct ds_config *cfg, int argc, char **argv, const char *as_user) { (void)argc; @@ -1318,7 +1345,7 @@ int run_in_rootfs(struct ds_config *cfg, int argc, char **argv, /* Apply identical security hardening as internal_boot() and enter_rootfs(). * Same reasoning: run processes are not children of container PID 1. */ ds_log_silent = 1; - ds_seccomp_apply_minimal(cfg->privileged_mask); + ds_seccomp_apply_minimal(cfg->privileged_mask, cfg->userns_allowed); android_seccomp_setup( 0, cfg->block_nested_ns && !(cfg->privileged_mask & DS_PRIV_NOSEC), cfg->privileged_mask); @@ -1357,24 +1384,23 @@ int run_in_rootfs(struct ds_config *cfg, int argc, char **argv, * Otherwise join all args into a single shell string. */ char cmd_buf[4096]; if (argv[1] == NULL) { + /* Single argument is treated as a shell command string as-is, so a + * caller can pass e.g. `-- "ls -la | grep foo"`. */ safe_strncpy(cmd_buf, argv[0], sizeof(cmd_buf)); } else { + /* Multiple args: shell-quote each so word boundaries (spaces, globs, + * metacharacters) survive su's shell -- e.g. `-- rm "a b"` stays two + * arguments instead of being resplit into three. */ size_t off = 0; - for (int k = 0; argv[k] && off < sizeof(cmd_buf) - 1; k++) { - if (k > 0 && off < sizeof(cmd_buf) - 2) - cmd_buf[off++] = ' '; - size_t al = strlen(argv[k]); - if (off + al >= sizeof(cmd_buf) - 1) - al = sizeof(cmd_buf) - 1 - off; - memcpy(cmd_buf + off, argv[k], al); - off += al; - } - cmd_buf[off] = '\0'; + for (int k = 0; argv[k]; k++) + off = shell_quote_append(cmd_buf, off, sizeof(cmd_buf), argv[k], + k == 0); } char *su_argv[] = {"su", "-", (char *)(uintptr_t)as_user, "-c", cmd_buf, NULL}; execvp("/bin/su", su_argv); execvp("/usr/bin/su", su_argv); + execvp("/run/wrappers/bin/su", su_argv); ds_error("Failed to exec su for user '%s': %s", as_user, strerror(errno)); _exit(EXIT_FAILURE); @@ -1556,6 +1582,9 @@ int show_info(struct ds_config *cfg, int trust_cfg_pid) { case DS_NET_NONE: net = "none"; break; + case DS_NET_GATEWAY: + net = "gateway"; + break; default: net = "host"; break; @@ -1570,12 +1599,19 @@ int show_info(struct ds_config *cfg, int trust_cfg_pid) { if (cfg->upstream_iface_count > 0) { printf("UPSTREAM_INTERFACES="); - for (int i = 0; i < cfg->upstream_iface_count; i++) { + for (int i = 0; i < cfg->upstream_iface_count; i++) printf("%s%s", cfg->upstream_ifaces[i], (i < cfg->upstream_iface_count - 1) ? "," : ""); - } printf("\n"); } + } else if (cfg->net_mode == DS_NET_GATEWAY) { + printf("GATEWAY_CONTAINER=%s\n", cfg->gateway_container); + printf("GATEWAY_NET=%s\n", + cfg->gateway_net[0] ? cfg->gateway_net : "lan"); + if (cfg->gateway_bridge[0]) + printf("GATEWAY_BRIDGE=%s\n", cfg->gateway_bridge); + printf("GATEWAY_IFACE=%s\n", + cfg->gateway_lan_ifname[0] ? cfg->gateway_lan_ifname : "eth1"); } printf("DISABLE_IPV6=%d\n", cfg->disable_ipv6); @@ -1611,6 +1647,7 @@ int show_info(struct ds_config *cfg, int trust_cfg_pid) { printf("VOLATILE_MODE=%d\n", cfg->volatile_mode); printf("FORCE_CGROUP_V1=%d\n", cfg->force_cgroupv1); printf("DEADLOCK_SHIELD=%d\n", cfg->block_nested_ns); + printf("USERNS_ALLOWED=%d\n", cfg->userns_allowed); printf("FOREGROUND_MODE=%d\n", cfg->foreground); printf("DNS_SERVERS=%s\n", cfg->dns_servers[0] ? cfg->dns_servers : ""); @@ -1706,6 +1743,9 @@ int show_info(struct ds_config *cfg, int trust_cfg_pid) { case DS_NET_NONE: net = "none"; break; + case DS_NET_GATEWAY: + net = "gateway"; + break; default: net = "host"; break; @@ -1713,7 +1753,13 @@ int show_info(struct ds_config *cfg, int trust_cfg_pid) { printf(" Networking: %s\n", net); feat_count++; - /* 2. NAT Configuration (IP, Upstream, Ports) */ + /* 2. NAT/Gateway Configuration */ + if (cfg->net_mode == DS_NET_GATEWAY) { + printf(" Gateway: %s (%s)\n", cfg->gateway_container, + cfg->gateway_net[0] ? cfg->gateway_net : "lan"); + feat_count++; + } + if (cfg->net_mode == DS_NET_NAT) { const char *ip = cfg->static_nat_ip[0] ? cfg->static_nat_ip : cfg->nat_container_ip; @@ -1723,11 +1769,10 @@ int show_info(struct ds_config *cfg, int trust_cfg_pid) { } if (cfg->upstream_iface_count > 0) { - printf(" Upstream ifaces: "); - for (int i = 0; i < cfg->upstream_iface_count; i++) { + printf(" Upstream (pinned): "); + for (int i = 0; i < cfg->upstream_iface_count; i++) printf("%s%s", cfg->upstream_ifaces[i], (i < cfg->upstream_iface_count - 1) ? ", " : ""); - } printf("\n"); feat_count++; } @@ -1824,7 +1869,13 @@ int show_info(struct ds_config *cfg, int trust_cfg_pid) { feat_count++; } - /* 14. Privileged Mode */ + /* 14. User namespaces */ + if (cfg->userns_allowed) { + printf(" " C_RED "User namespaces:" C_RESET " enabled\n"); + feat_count++; + } + + /* 15. Privileged Mode */ if (cfg->privileged_mask > 0) { printf(" " C_RED "Privileged mode:" C_RESET " "); if (cfg->privileged_mask == DS_PRIV_FULL) { @@ -1856,19 +1907,19 @@ int show_info(struct ds_config *cfg, int trust_cfg_pid) { feat_count++; } - /* 15. Bind Mounts */ + /* 16. Bind Mounts */ if (cfg->bind_count > 0) { printf(" Bind mounts: %d active\n", cfg->bind_count); feat_count++; } - /* 16. Custom Init */ + /* 17. Custom Init */ if (cfg->custom_init[0]) { printf(" " C_RED "Custom Init:" C_RESET " %s\n", cfg->custom_init); feat_count++; } - /* 17. Environment Variables */ + /* 18. Environment Variables */ if (cfg->env_var_count > 0) { printf(" Env variables: %d loaded\n", cfg->env_var_count); feat_count++; @@ -1936,6 +1987,14 @@ int restart_rootfs_with_timeout(struct ds_config *cfg, int timeout_seconds) { if (stop_rootfs_with_timeout(cfg, 1, timeout_seconds) < 0) { return -1; } + /* The stop above tore down using the booted snapshot (loaded while the + * container was alive). It is gone now, so reloading by name returns the + * workspace copy - reload it so host-side container.config edits made while + * it ran take effect on this restart. start_rootfs re-derives the preserved + * mount from the on-disk .mount sidecar, so losing the snapshot paths is + * fine. */ + free_config_binds(cfg); + ds_config_load_by_name(cfg->container_name, cfg); putchar('\n'); print_ds_banner(); return start_rootfs(cfg); diff --git a/src/daemon.c b/src/daemon.c index 5ad9352e..20522546 100644 --- a/src/daemon.c +++ b/src/daemon.c @@ -28,9 +28,9 @@ #include #include -#include #include #include +#include #include #include #include @@ -42,7 +42,11 @@ #define DS_MAX_ARGC 64 #define DS_MAX_ARG 8192 #define DS_IOBUF 8192 -#define DS_POLL_MS 100 +#define PTY_WBUF_MAX \ + (256 * 1024) /* absolute buffer cap for PTY master input \ + */ +#define PTY_WBUF_HIGH (192 * 1024) /* suspend conn EPOLLIN above this */ +#define PTY_WBUF_LOW (64 * 1024) /* resume conn EPOLLIN below this */ #define MSG_OUT ((uint8_t)0x01) #define MSG_ERR ((uint8_t)0x02) @@ -126,8 +130,6 @@ static void daemon_log_tee(const char *prefix, const char *fmt, ...) { daemon_log_tee("-", fmt, ##__VA_ARGS__); \ } while (0) -static volatile sig_atomic_t g_client_sigwinch = 0; - /* * g_self_path is populated once during daemon startup (after daemonize()). * Subsequent fork()+reexec() children inherit a copy of this path. @@ -413,6 +415,13 @@ static void handle_session(int conn, ds_req_t *r) { return; } + /* signalfd for zero-latency SIGCHLD: avoids 100ms poll-waitpid loop */ + sigset_t ss; + sigemptyset(&ss); + sigaddset(&ss, SIGCHLD); + sigprocmask(SIG_BLOCK, &ss, NULL); + int sfd = signalfd(-1, &ss, SFD_NONBLOCK | SFD_CLOEXEC); + struct epoll_event ev, events[8]; int active_reads = 0; @@ -441,6 +450,12 @@ static void handle_session(int conn, ds_req_t *r) { active_reads = 2; } + if (sfd >= 0) { + ev.events = EPOLLIN; + ev.data.fd = sfd; + epoll_ctl(epfd, EPOLL_CTL_ADD, sfd, &ev); + } + /* watch the connection for dead clients or pty input */ ev.events = EPOLLHUP | EPOLLERR; if (is_pty) @@ -451,15 +466,36 @@ static void handle_session(int conn, ds_req_t *r) { int exit_code = EXIT_PENDING; int child_done = 0; /* 0=running, 1=killed, 2=normal-exit-seen */ + /* Non-blocking write buffer for PTY master input. + * Prevents blocking the event loop when the PTY kernel buffer is full + * (e.g. large paste). Excess bytes are queued here and flushed via + * EPOLLOUT on master. */ + uint8_t *pty_wbuf = NULL; + size_t pty_wbuf_len = 0; + size_t pty_wbuf_cap = 0; + int conn_suspended = 0; /* 1 = conn EPOLLIN removed (wbuf high-water) */ + for (;;) { - int nfds = epoll_wait(epfd, events, 8, DS_POLL_MS); + int nfds = epoll_wait(epfd, events, 8, -1); if (nfds < 0 && errno != EINTR) break; for (int i = 0; i < nfds; i++) { int fd = events[i].data.fd; - if (fd == conn) { + if (sfd >= 0 && fd == sfd) { + /* drain all pending SIGCHLD notifications */ + struct signalfd_siginfo si; + while (read(sfd, &si, sizeof(si)) == (ssize_t)sizeof(si)) { + if (!child_done) { + int st; + if (waitpid(child, &st, WNOHANG) == child) { + exit_code = WIFEXITED(st) ? WEXITSTATUS(st) : 1; + child_done = is_pty ? 2 : 1; + } + } + } + } else if (fd == conn) { if (events[i].events & (EPOLLHUP | EPOLLERR)) { /* client died unexpectedly, kill the child */ kill(child, is_pty ? SIGHUP : SIGTERM); @@ -475,14 +511,69 @@ static void handle_session(int conn, ds_req_t *r) { goto session_end; } if (type == MSG_OUT && mlen > 0 && mlen <= (uint32_t)sizeof(buf)) { - if (read_exact(conn, buf, mlen) == 0) - write_all(master, buf, mlen); + if (read_exact(conn, buf, mlen) == 0) { + /* Non-blocking write to PTY master; queue overflow into wbuf. */ + size_t written = 0; + if (pty_wbuf_len == 0) { + /* Fast path: no pending data, try direct write first. */ + ssize_t w = write(master, buf, mlen); + if (w > 0) + written = (size_t)w; + } + size_t rem = mlen - written; + if (rem > 0) { + /* Buffer remainder; grow wbuf if needed (cap at PTY_WBUF_MAX). + */ + if (pty_wbuf_len + rem <= PTY_WBUF_MAX) { + if (pty_wbuf_len + rem > pty_wbuf_cap) { + /* Double capacity without overflow: clamp before shifting. + */ + size_t ncap = pty_wbuf_cap ? pty_wbuf_cap : DS_IOBUF; + while (ncap < pty_wbuf_len + rem) { + if (ncap > PTY_WBUF_MAX / 2) { + ncap = PTY_WBUF_MAX; + break; + } + ncap *= 2; + } + if (ncap > PTY_WBUF_MAX) + ncap = PTY_WBUF_MAX; + /* Preserve old pointer on realloc failure to avoid leak. */ + uint8_t *nb = realloc(pty_wbuf, ncap); + if (nb) { + pty_wbuf = nb; + pty_wbuf_cap = ncap; + } + /* If realloc failed, pty_wbuf unchanged; fall through to + * the capacity check below which will drop the frame. */ + } + if (pty_wbuf_len + rem <= pty_wbuf_cap) { + memcpy(pty_wbuf + pty_wbuf_len, buf + written, rem); + pty_wbuf_len += rem; + /* Arm EPOLLOUT on master to flush wbuf. */ + ev.events = EPOLLIN | EPOLLOUT | EPOLLHUP | EPOLLERR; + ev.data.fd = master; + epoll_ctl(epfd, EPOLL_CTL_MOD, master, &ev); + /* High-watermark: suspend conn input to apply backpressure. + * Prevents unbounded growth; conn resumes at low-watermark. + */ + if (!conn_suspended && pty_wbuf_len >= PTY_WBUF_HIGH) { + ev.events = EPOLLHUP | EPOLLERR; /* no EPOLLIN */ + ev.data.fd = conn; + epoll_ctl(epfd, EPOLL_CTL_MOD, conn, &ev); + conn_suspended = 1; + } + } + } + /* wbuf full and conn suspended: no data lost, client stalls. */ + } + } } else if (type == MSG_WINCH && mlen == 4) { uint16_t wd[2]; if (read_exact(conn, wd, 4) == 0) { struct winsize nws = {ntohs(wd[0]), ntohs(wd[1]), 0, 0}; ioctl(master, TIOCSWINSZ, &nws); - kill(-child, SIGWINCH); /* signal the whole process group */ + kill(child, SIGWINCH); } } else { /* drain unknown frames so we don't stall the pipe */ @@ -496,6 +587,50 @@ static void handle_session(int conn, ds_req_t *r) { } } } + } else if (is_pty && fd == master && (events[i].events & EPOLLOUT)) { + /* Flush pending wbuf to PTY master. */ + while (pty_wbuf_len > 0) { + ssize_t w = write(master, pty_wbuf, pty_wbuf_len); + if (w > 0) { + pty_wbuf_len -= (size_t)w; + if (pty_wbuf_len > 0) + memmove(pty_wbuf, pty_wbuf + w, pty_wbuf_len); + } else if (w < 0 && errno == EAGAIN) { + break; /* PTY buffer still full; wait for next EPOLLOUT */ + } else { + break; + } + } + if (pty_wbuf_len == 0) { + /* All flushed; stop watching for writability. */ + ev.events = EPOLLIN | EPOLLHUP | EPOLLERR; + ev.data.fd = master; + epoll_ctl(epfd, EPOLL_CTL_MOD, master, &ev); + } + /* Low-watermark: resume conn input once pressure drops. */ + if (conn_suspended && pty_wbuf_len < PTY_WBUF_LOW) { + ev.events = EPOLLIN | EPOLLHUP | EPOLLERR; + ev.data.fd = conn; + epoll_ctl(epfd, EPOLL_CTL_MOD, conn, &ev); + conn_suspended = 0; + } + /* Also service any pending output from master in this iteration. */ + if (events[i].events & EPOLLIN) { + for (;;) { + ssize_t n = read(master, buf, sizeof(buf)); + if (n > 0) { + if (send_frame(conn, MSG_OUT, buf, (uint32_t)n) < 0) { + kill(child, SIGHUP); + waitpid(child, NULL, 0); + goto session_end; + } + } else { + if (errno == EINTR) + continue; + break; + } + } + } } else { /* this is a read fd from the child */ if (events[i].events & (EPOLLIN | EPOLLHUP)) { @@ -510,7 +645,6 @@ static void handle_session(int conn, ds_req_t *r) { goto session_end; } } else if (n == 0) { - /* Truly EOF */ drained = 1; break; } else { @@ -518,10 +652,8 @@ static void handle_session(int conn, ds_req_t *r) { continue; if (errno == EAGAIN) break; - if (errno == EWOULDBLOCK) - break; drained = 1; - break; /* treat other errors as closure */ + break; } } @@ -541,15 +673,6 @@ static void handle_session(int conn, ds_req_t *r) { } } - if (!child_done) { - int st; - if (waitpid(child, &st, WNOHANG) == child) { - exit_code = WIFEXITED(st) ? WEXITSTATUS(st) : 1; - /* for pipes we wait for eof. for ptys, the child exiting is the end. */ - child_done = is_pty ? 2 : 1; - } - } - /* wait for eofs on pipes even if the child exited, because the monitor * might still be holding it */ if (child_done == 1) { @@ -572,6 +695,12 @@ static void handle_session(int conn, ds_req_t *r) { } session_end: + free(pty_wbuf); + if (sfd >= 0) { + epoll_ctl(epfd, EPOLL_CTL_DEL, sfd, NULL); + close(sfd); + } + sigprocmask(SIG_UNBLOCK, &ss, NULL); close(epfd); if (master >= 0) close(master); @@ -616,7 +745,7 @@ static void handle_conn(int conn) { /* log the request */ { - char cmdline[DS_MAX_ARG * 2] = {0}; + char cmdline[512]; size_t off = 0; for (int i = 0; i < req.argc && off < sizeof(cmdline) - 1; i++) { int n = snprintf(cmdline + off, sizeof(cmdline) - off, "%s%s", @@ -677,6 +806,15 @@ static void daemonize(int foreground) { if (chdir("/") < 0) { /* ignore */ } + /* Scrub dynamic-linker and field-splitting env vars before serving requests + * and re-exec'ing droidspaces as root, so a value inherited from the launcher + * cannot influence the privileged process (defense-in-depth; container init + * additionally receives a clearenv() in ds_env_boot_setup). */ + unsetenv("LD_PRELOAD"); + unsetenv("LD_LIBRARY_PATH"); + unsetenv("LD_AUDIT"); + unsetenv("IFS"); + if (!foreground) { /* redirect standard streams */ int dn = open("/dev/null", O_RDONLY); @@ -859,15 +997,26 @@ int ds_daemon_run(int foreground, char **argv) { strerror(errno)); #endif - /* Write PID file so the Android app can signal us */ + /* Write PID file so the Android app can signal us. The daemon runs with + * umask 0, so create it 0644 explicitly (not the fopen default 0666): a + * world-writable pidfile would let any local user overwrite it and redirect + * the app's signals (SIGUSR2/kill) to an arbitrary PID. O_NOFOLLOW guards + * against a symlink planted in the workspace dir; no O_EXCL so a restart can + * replace a stale pidfile. */ { char pid_path[PATH_MAX]; snprintf(pid_path, sizeof(pid_path), "%s/droidspacesd.pid", get_workspace_dir()); - FILE *pf = fopen(pid_path, "w"); - if (pf) { - fprintf(pf, "%d\n", getpid()); - fclose(pf); + int pfd = open(pid_path, + O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW | O_CLOEXEC, 0644); + if (pfd >= 0) { + FILE *pf = fdopen(pfd, "w"); + if (pf) { + fprintf(pf, "%d\n", getpid()); + fclose(pf); + } else { + close(pfd); + } } } @@ -921,45 +1070,18 @@ int ds_daemon_run(int foreground, char **argv) { } /* - * authenticate the peer: only root or members of the 'droidspaces' group - * may connect. abstract socket has no filesystem permissions, so we - * enforce this via SO_PEERCRED + getgrouplist() -- same model as Docker's + * Authenticate the peer via the shared authorizer: only root or members of + * the 'droidspaces' group, and only from our own PID namespace (the + * abstract socket has no filesystem permissions). Same model as Docker's * unix group. */ - { -#define DS_GROUP "droidspaces" - struct ucred cred; - socklen_t clen = sizeof(cred); - if (getsockopt(conn, SOL_SOCKET, SO_PEERCRED, &cred, &clen) < 0) { - close(conn); - continue; - } - - int allowed = (cred.uid == 0); - if (!allowed) { - struct group *gr = getgrnam(DS_GROUP); - struct passwd *pw = getpwuid(cred.uid); - if (gr && pw) { - int ngroups = 64; - gid_t groups[64]; - getgrouplist(pw->pw_name, pw->pw_gid, groups, &ngroups); - for (int i = 0; i < ngroups; i++) { - if (groups[i] == gr->gr_gid) { - allowed = 1; - break; - } - } - } - } - - if (!allowed) { - const char *msg = "permission denied: only root or '" DS_GROUP - "' group members may connect."; - send_frame(conn, MSG_ERR, msg, (uint32_t)strlen(msg)); - send_exit(conn, 1); - close(conn); - continue; - } + if (!ds_peer_authorized(conn, "droidspaces")) { + const char *msg = "permission denied: only root or 'droidspaces' group " + "members may connect."; + send_frame(conn, MSG_ERR, msg, (uint32_t)strlen(msg)); + send_exit(conn, 1); + close(conn); + continue; } pid_t h = fork(); @@ -1000,11 +1122,6 @@ int ds_daemon_probe(void) { /* connect to the daemon and relay our command */ -static void client_sigwinch_handler(int sig) { - (void)sig; - g_client_sigwinch = 1; -} - int ds_client_run(int argc, char **argv) { if (argc < 1) return -2; @@ -1107,6 +1224,7 @@ int ds_client_run(int argc, char **argv) { /* run the relay loop */ struct termios orig; int raw_tty_active = 0; + int winch_sfd = -1; if (interactive && has_tty && tcgetattr(STDIN_FILENO, &orig) == 0) { raw_tty_active = 1; @@ -1114,11 +1232,12 @@ int ds_client_run(int argc, char **argv) { cfmakeraw(&raw); tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw); - struct sigaction sa; - memset(&sa, 0, sizeof(sa)); - sa.sa_handler = client_sigwinch_handler; - sa.sa_flags = SA_RESTART; - sigaction(SIGWINCH, &sa, NULL); + /* signalfd for SIGWINCH: no handler, no 200ms timeout needed */ + sigset_t ws; + sigemptyset(&ws); + sigaddset(&ws, SIGWINCH); + sigprocmask(SIG_BLOCK, &ws, NULL); + winch_sfd = signalfd(-1, &ws, SFD_NONBLOCK | SFD_CLOEXEC); } int epfd = epoll_create1(EPOLL_CLOEXEC); @@ -1129,6 +1248,11 @@ int ds_client_run(int argc, char **argv) { ev.data.fd = STDIN_FILENO; epoll_ctl(epfd, EPOLL_CTL_ADD, STDIN_FILENO, &ev); } + if (winch_sfd >= 0) { + ev.events = EPOLLIN; + ev.data.fd = winch_sfd; + epoll_ctl(epfd, EPOLL_CTL_ADD, winch_sfd, &ev); + } ev.events = EPOLLIN | EPOLLHUP | EPOLLERR; ev.data.fd = sock; epoll_ctl(epfd, EPOLL_CTL_ADD, sock, &ev); @@ -1137,15 +1261,7 @@ int ds_client_run(int argc, char **argv) { char buf[DS_IOBUF]; for (;;) { - if (raw_tty_active && g_client_sigwinch) { - g_client_sigwinch = 0; - struct winsize nws = {24, 80, 0, 0}; - ioctl(STDIN_FILENO, TIOCGWINSZ, &nws); - uint16_t wd2[2] = {htons(nws.ws_row), htons(nws.ws_col)}; - send_frame(sock, MSG_WINCH, wd2, 4); - } - - int nfds = epoll_wait(epfd, events, 4, raw_tty_active ? 200 : -1); + int nfds = epoll_wait(epfd, events, 4, -1); if (nfds < 0 && errno != EINTR) break; @@ -1153,7 +1269,15 @@ int ds_client_run(int argc, char **argv) { for (int i = 0; i < nfds && !done; i++) { int fd = events[i].data.fd; - if (fd == STDIN_FILENO) { + if (winch_sfd >= 0 && fd == winch_sfd) { + struct signalfd_siginfo si; + while (read(winch_sfd, &si, sizeof(si)) == (ssize_t)sizeof(si)) { + struct winsize nws = {24, 80, 0, 0}; + ioctl(STDIN_FILENO, TIOCGWINSZ, &nws); + uint16_t wd2[2] = {htons(nws.ws_row), htons(nws.ws_col)}; + send_frame(sock, MSG_WINCH, wd2, 4); + } + } else if (fd == STDIN_FILENO) { ssize_t n = read(STDIN_FILENO, buf, sizeof(buf)); if (n > 0) { if (send_frame(sock, MSG_OUT, buf, (uint32_t)n) < 0) @@ -1162,21 +1286,11 @@ int ds_client_run(int argc, char **argv) { done = 1; } } else if (fd == sock) { - if (events[i].events & (EPOLLIN | EPOLLHUP)) { - /* Read as many frames as possible before checking HUP */ + if (events[i].events & EPOLLIN) { + /* drain all available frames without a redundant poll() probe */ for (;;) { uint8_t type; uint32_t mlen; - - /* Probe the socket for pending data */ - int pending = 0; - struct pollfd pfd = {sock, POLLIN, 0}; - if (poll(&pfd, 1, 0) > 0 && (pfd.revents & POLLIN)) - pending = 1; - - if (!pending) - break; - if (recv_frame_hdr(sock, &type, &mlen) < 0) { done = 1; break; @@ -1204,6 +1318,13 @@ int ds_client_run(int argc, char **argv) { rem -= c; } fflush(dest); + if (done) + break; + + /* check for more data without blocking */ + struct pollfd pfd = {sock, POLLIN, 0}; + if (poll(&pfd, 1, 0) <= 0 || !(pfd.revents & POLLIN)) + break; } } @@ -1217,8 +1338,16 @@ int ds_client_run(int argc, char **argv) { break; } - if (raw_tty_active) + if (raw_tty_active) { tcsetattr(STDIN_FILENO, TCSAFLUSH, &orig); + if (winch_sfd >= 0) { + sigset_t ws; + sigemptyset(&ws); + sigaddset(&ws, SIGWINCH); + sigprocmask(SIG_UNBLOCK, &ws, NULL); + close(winch_sfd); + } + } close(epfd); close(sock); return exit_code; diff --git a/src/documentation.c b/src/documentation.c index 4edd72ab..1295fc07 100644 --- a/src/documentation.c +++ b/src/documentation.c @@ -179,209 +179,244 @@ static void print_header(int page, int total_pages, const char *title) { printf("%s", RESET_TERMINAL); } -/* Print page content */ +/* Emit page content through the pager (p_printf), honouring the current scroll + * window / dry-run counting held in the g_* pager state. */ static void print_page(int page, const char *bin) { const char *bold = BOLD; const char *reset = RESET_TERMINAL; -#define printf p_printf - switch (page) { case 0: /* CLI Basics */ - printf("\n"); - printf("CLI BASICS & ARGUMENT FLEXIBILITY\n"); - printf("---------------------------------\n\n"); - printf("Droidspaces features a robust multi-pass argument parser.\n"); - printf("You can combine arguments like a \"salad\":\n\n"); - - printf("%sInterchangeable Flags:%s\n", bold, reset); - printf(" Short and long flags are identical:\n"); - printf(" %s -r /path/to/rootfs -n mycontainer start\n", bin); - printf(" %s --rootfs /path/to/rootfs --name mycontainer start\n", bin); - printf(" %s --rootfs=/path/to/rootfs --name=mycontainer start\n\n", bin); - printf(" (Run %s help to study all available short flags)\n\n", bin); - - printf("%sFlexible Order:%s\n", bold, reset); - printf(" Command and flags can be mixed in any order:\n"); - printf(" %s start --name mycontainer --rootfs /path\n", bin); - printf(" %s --rootfs /path/to/rootfs -n mycontainer start\n\n", bin); - - printf("%sMixing Styles:%s\n", bold, reset); - printf(" Feel free to mix short and long flags with or without '=':\n"); - printf(" %s -r /path/to/rootfs --name=mycontainer --hostname myserver " - "start\n", - bin); + p_printf("\n"); + p_printf("CLI BASICS & ARGUMENT FLEXIBILITY\n"); + p_printf("---------------------------------\n\n"); + p_printf("Droidspaces features a robust multi-pass argument parser.\n"); + p_printf("You can combine arguments like a \"salad\":\n\n"); + + p_printf("%sInterchangeable Flags:%s\n", bold, reset); + p_printf(" Short and long flags are identical:\n"); + p_printf(" %s -r /path/to/rootfs -n mycontainer start\n", bin); + p_printf(" %s --rootfs /path/to/rootfs --name mycontainer start\n", bin); + p_printf(" %s --rootfs=/path/to/rootfs --name=mycontainer start\n\n", bin); + p_printf(" (Run %s help to study all available short flags)\n\n", bin); + + p_printf("%sFlexible Order:%s\n", bold, reset); + p_printf(" Command and flags can be mixed in any order:\n"); + p_printf(" %s start --name mycontainer --rootfs /path\n", bin); + p_printf(" %s --rootfs /path/to/rootfs -n mycontainer start\n\n", bin); + + p_printf("%sMixing Styles:%s\n", bold, reset); + p_printf(" Feel free to mix short and long flags with or without '=':\n"); + p_printf(" %s -r /path/to/rootfs --name=mycontainer --hostname myserver " + "start\n", + bin); break; case 1: /* Quick Start */ - printf("\n"); - printf("QUICK START\n"); - printf("-----------\n\n"); - printf("%sStarting for the first time:%s\n", bold, reset); - printf(" (Define name and rootfs/img path. Flags are persisted to " - "config)\n"); - printf(" # From a rootfs directory\n"); - printf(" %s --name=mycontainer --rootfs=/path/to/rootfs start\n\n", bin); - printf(" # From an ext4 image\n"); - printf(" %s --name=mycontainer --rootfs-img=/path/to/rootfs.img start\n\n", - bin); - - printf("%sSubsequent starts:%s\n", bold, reset); - printf(" (Settings like rootfs path are loaded from the container's " - "config file)\n"); - printf(" %s --name=mycontainer start\n\n", bin); - - printf("%sEntering the container:%s\n", bold, reset); - printf(" %s --name=mycontainer enter\n", bin); - printf(" %s --name=mycontainer enter username\n\n", bin); - - printf("%sRunning Commands:%s\n", bold, reset); - printf(" %s --name=mycontainer run 'uname -a'\n", bin); - printf(" %s --name=mycontainer --user=myuser run whoami\n\n", bin); - - printf("%sStopping:%s\n", bold, reset); - printf(" %s --name=mycontainer stop\n", bin); - printf(" %s stop --name=c1,c2,c3 (Multi-stop)\n\n", bin); - - printf("%sUsing Configuration Files:%s\n", bold, reset); - printf(" %s --config=./container.config start\n", bin); - break; - - case 2: /* Management */ - printf("\n"); - printf("MANAGEMENT & LIFECYCLE\n"); - printf("----------------------\n\n"); + p_printf("\n"); + p_printf("QUICK START\n"); + p_printf("-----------\n\n"); + p_printf("%sStarting for the first time:%s\n", bold, reset); + p_printf(" (Define name and rootfs/img path. Flags are persisted to " + "config)\n"); + p_printf(" # From a rootfs directory\n"); + p_printf(" %s --name=mycontainer --rootfs=/path/to/rootfs start\n\n", bin); + p_printf(" # From an ext4 image\n"); + p_printf( + " %s --name=mycontainer --rootfs-img=/path/to/rootfs.img start\n\n", + bin); - printf("%sRestarting:%s\n", bold, reset); - printf(" %s --name=mycontainer restart\n", bin); - printf(" Note: You can also reboot by running `reboot` inside the\n"); - printf(" container shell or remotely (e.g. via SSH).\n\n"); + p_printf("%sSubsequent starts:%s\n", bold, reset); + p_printf(" (Settings like rootfs path are loaded from the container's " + "config file)\n"); + p_printf(" %s --name=mycontainer start\n\n", bin); - printf("%sListing Containers:%s\n", bold, reset); - printf(" %s show\n", bin); - printf(" (Displays name and PID inside a table)\n\n"); + p_printf("%sEntering the container:%s\n", bold, reset); + p_printf(" %s --name=mycontainer enter\n", bin); + p_printf(" %s --name=mycontainer enter username\n\n", bin); - printf("%sTechnical Information:%s\n", bold, reset); - printf(" %s --name=mycontainer info\n", bin); - printf(" (Shows current features, metadata, and container state)\n\n"); + p_printf("%sRunning Commands:%s\n", bold, reset); + p_printf(" %s --name=mycontainer run 'uname -a'\n", bin); + p_printf(" %s --name=mycontainer --user=myuser run whoami\n\n", bin); - printf("%sTechnical Information (machine-parseable):%s\n", bold, reset); - printf(" %s --name=mycontainer --format info\n\n", bin); + p_printf("%sStopping:%s\n", bold, reset); + p_printf(" %s --name=mycontainer stop\n", bin); + p_printf(" %s stop --name=c1,c2,c3 (Multi-stop)\n\n", bin); - printf("%sResource Usage:%s\n", bold, reset); - printf(" %s --name=mycontainer usage\n", bin); - printf(" (Shows uptime, CPU%%, and RAM usage)\n\n"); + p_printf("%sUsing Configuration Files:%s\n", bold, reset); + p_printf(" %s --config=./container.config start\n", bin); + break; - printf("%sMetadata Recovery:%s\n", bold, reset); - printf(" %s scan\n", bin); - printf(" (Detects containers even if host-side config/PIDs are lost)\n\n"); + case 2: /* Management */ + p_printf("\n"); + p_printf("MANAGEMENT & LIFECYCLE\n"); + p_printf("----------------------\n\n"); + + p_printf("%sRestarting:%s\n", bold, reset); + p_printf(" %s --name=mycontainer restart\n", bin); + p_printf(" Note: You can also reboot by running `reboot` inside the\n"); + p_printf(" container shell or remotely (e.g. via SSH).\n\n"); + + p_printf("%sListing Containers:%s\n", bold, reset); + p_printf(" %s show\n", bin); + p_printf(" (Displays name and PID inside a table)\n\n"); + + p_printf("%sTechnical Information:%s\n", bold, reset); + p_printf(" %s --name=mycontainer info\n", bin); + p_printf(" (Shows current features, metadata, and container state)\n\n"); + + p_printf("%sTechnical Information (machine-parseable):%s\n", bold, reset); + p_printf(" %s --name=mycontainer --format info\n\n", bin); + + p_printf("%sResource Usage:%s\n", bold, reset); + p_printf(" %s --name=mycontainer usage\n", bin); + p_printf(" (Shows uptime, CPU%%, and RAM usage)\n\n"); + + p_printf("%sMetadata Recovery:%s\n", bold, reset); + p_printf(" %s scan\n", bin); + p_printf( + " (Detects containers even if host-side config/PIDs are lost)\n\n"); break; case 3: /* Networking */ - printf("\n"); - printf("NETWORKING\n"); - printf("----------\n\n"); - - printf("%sIsolation Modes (--net):%s\n", bold, reset); - printf(" --net=host Shared with host (default)\n"); - printf(" --net=none No network access (air-gapped)\n"); - printf(" --net=nat Isolated namespace with internet access\n\n"); - - printf("%sNAT Mode Configuration:%s\n", bold, reset); - printf(" %s --name=mycontainer --rootfs=/path/to/rootfs --net=nat " - "--upstream=wlan0 " - "start\n", - bin); - printf(" %s --name=mycontainer --rootfs=/path/to/rootfs --net=nat " - "--upstream=rmnet*,wlan0 " - "start\n\n", - bin); - - printf("%sPort Forwarding (NAT only):%s\n", bold, reset); - printf(" --port=8080:80 Single port\n"); - printf(" --port=1000-2000:1000-2000 Symmetric range\n"); - printf(" --port=8080:80/udp Specific protocol\n\n"); - - printf("%sStatic IP assignment:%s\n", bold, reset); - printf(" --nat-ip=172.28.5.10\n\n"); - - printf("%sCustom DNS & IPv6:%s\n", bold, reset); - printf(" %s --name=mycontainer --dns=1.1.1.1,8.8.4.4 start\n", bin); - printf(" %s --name=mycontainer --disable-ipv6 start\n", bin); + p_printf("\n"); + p_printf("NETWORKING\n"); + p_printf("----------\n\n"); + + p_printf("%sIsolation Modes (--net):%s\n", bold, reset); + p_printf(" --net=host Shared with host (default)\n"); + p_printf(" --net=none No network access (air-gapped)\n"); + p_printf(" --net=nat Isolated namespace with internet access\n"); + p_printf(" --net=gateway LAN delegated to another container " + "(e.g. OpenWRT)\n\n"); + + p_printf("%sNAT Mode Configuration:%s\n", bold, reset); + p_printf(" %s --name=mycontainer --rootfs=/path/to/rootfs --net=nat " + "start\n", + bin); + p_printf(" The internet uplink is detected automatically and tracked " + "in real time\n"); + p_printf(" as the host switches networks (Wi-Fi <-> mobile data, " + "ethernet, etc.).\n\n"); + + p_printf("%sManual Uplink Pinning (--upstream, NAT only):%s\n", bold, + reset); + p_printf(" Pin WAN to specific interface(s) and disable auto-detect. " + "The list is\n"); + p_printf(" comma-separated, priority-ordered, and supports wildcards.\n"); + p_printf(" --upstream=wlan0 Always use Wi-Fi\n"); + p_printf(" --upstream=wlan0,rmnet* Prefer Wi-Fi, fall back to mobile " + "data\n"); + p_printf(" --upstream=tun0 Route only through a phone VPN " + "(killswitch)\n"); + p_printf(" --upstream=rmnet* Stay on mobile data while the phone " + "uses Wi-Fi\n"); + p_printf(" (Use rmnet* - the mobile-data interface number is not stable " + "across reconnects.)\n\n"); + + p_printf("%sGateway Mode Configuration:%s\n", bold, reset); + p_printf(" Delegate the LAN to another running container (e.g. OpenWRT), " + "which owns\n"); + p_printf(" DHCP, DNS, firewall and routing. Start the gateway first, " + "then the client.\n"); + p_printf(" %s --name=openwrt --rootfs=/path/to/openwrt --net=nat start\n", + bin); + p_printf(" %s --name=kali --rootfs=/path/to/kali --net=gateway " + "--gateway=openwrt start\n", + bin); + p_printf(" --gateway=NAME Gateway container (required)\n"); + p_printf(" --gateway-net=NAME LAN segment / bridge suffix " + "(default: lan)\n"); + p_printf(" --gateway-iface=IFACE Interface name inside the gateway " + "(default: eth1)\n"); + p_printf(" --gateway-bridge=BR Override host bridge name " + "(default: ds-NAME)\n\n"); + + p_printf("%sPort Forwarding (NAT only):%s\n", bold, reset); + p_printf(" --port=8080:80 Single port\n"); + p_printf(" --port=1000-2000:1000-2000 Symmetric range\n"); + p_printf(" --port=8080:80/udp Specific protocol\n\n"); + + p_printf("%sStatic IP assignment:%s\n", bold, reset); + p_printf(" --nat-ip=172.28.5.10\n\n"); + + p_printf("%sCustom DNS & IPv6:%s\n", bold, reset); + p_printf(" %s --name=mycontainer --dns=1.1.1.1,8.8.4.4 start\n", bin); + p_printf(" %s --name=mycontainer --disable-ipv6 start\n", bin); break; case 4: /* Hardware & Integration */ - printf("\n"); - printf("HARDWARE & INTEGRATION\n"); - printf("----------------------\n\n"); + p_printf("\n"); + p_printf("HARDWARE & INTEGRATION\n"); + p_printf("----------------------\n\n"); - printf("%sFull Hardware Access (--hw-access):%s\n", bold, reset); - printf( + p_printf("%sFull Hardware Access (--hw-access):%s\n", bold, reset); + p_printf( " %s --name=mycontainer --rootfs=/path/to/rootfs --hw-access start\n", bin); - printf(" (Exposes host /dev nodes, maps GPU groups, setups X11 in " - "Linux)\n\n"); - - printf("%sSecure GPU-only Mode (--gpu):%s\n", bold, reset); - printf(" %s --name=mycontainer --rootfs=/path/to/rootfs --gpu start\n", - bin); - printf(" (Maps ONLY GPU nodes into an isolated tmpfs /dev)\n\n"); - - printf("%sAndroid-specific Features:%s\n", bold, reset); - printf(" --enable-android-storage Mounts /storage/emulated/0\n"); - printf(" --termux-x11 Setups Termux:X11 socket\n\n"); - - printf("%sSystem Integration:%s\n", bold, reset); - printf(" --selinux-permissive Set host SELinux to permissive\n"); - printf(" --force-cgroupv1 Force legacy cgroup hierarchy\n"); - printf(" --block-nested-namespaces Shield against VFS deadlocks\n"); + p_printf(" (Exposes host /dev nodes, maps GPU groups, setups X11 in " + "Linux)\n\n"); + + p_printf("%sSecure GPU-only Mode (--gpu):%s\n", bold, reset); + p_printf(" %s --name=mycontainer --rootfs=/path/to/rootfs --gpu start\n", + bin); + p_printf(" (Maps ONLY GPU nodes into an isolated tmpfs /dev)\n\n"); + + p_printf("%sAndroid-specific Features:%s\n", bold, reset); + p_printf(" --enable-android-storage Mounts /storage/emulated/0\n"); + p_printf(" --termux-x11 Setups Termux:X11 socket\n\n"); + + p_printf("%sSystem Integration:%s\n", bold, reset); + p_printf(" --selinux-permissive Set host SELinux to permissive\n"); + p_printf(" --force-cgroupv1 Force legacy cgroup hierarchy\n"); + p_printf(" --block-nested-namespaces Shield against VFS deadlocks\n"); break; case 5: /* Advanced Features */ - printf("\n"); - printf("ADVANCED FEATURES\n"); - printf("-----------------\n\n"); - - printf("%sPrivileged Mode (--privileged):%s\n", bold, reset); - printf(" Relax security with comma-separated tags:\n"); - printf(" nomask Allow write access to /proc and /sys\n"); - printf(" nocaps Keep all Linux Capabilities (full root)\n"); - printf(" noseccomp Disable syscall filtering (useful for " - "flatpak,bwrap and other unprivileged sandboxes)\n"); - printf(" shared Enable MS_SHARED mount propagation\n"); - printf(" unfiltered-dev Bypass device filtering (all host /dev)\n"); - printf(" full Enable all above tags\n\n"); - - printf(" %s --name=mycontainer --rootfs=/path/to/rootfs --privileged=full " - "start\n\n", - bin); - - printf("%sEphemeral Mode (--volatile):%s\n", bold, reset); - printf( + p_printf("\n"); + p_printf("ADVANCED FEATURES\n"); + p_printf("-----------------\n\n"); + + p_printf("%sPrivileged Mode (--privileged):%s\n", bold, reset); + p_printf(" Relax security with comma-separated tags:\n"); + p_printf(" nomask Allow write access to /proc and /sys\n"); + p_printf(" nocaps Keep all Linux Capabilities (full root)\n"); + p_printf(" noseccomp Disable syscall filtering (useful for " + "flatpak,bwrap and other unprivileged sandboxes)\n"); + p_printf(" shared Enable MS_SHARED mount propagation\n"); + p_printf(" unfiltered-dev Bypass device filtering (all host /dev)\n"); + p_printf(" full Enable all above tags\n\n"); + + p_printf( + " %s --name=mycontainer --rootfs=/path/to/rootfs --privileged=full " + "start\n\n", + bin); + + p_printf("%sEphemeral Mode (--volatile):%s\n", bold, reset); + p_printf( " %s --name=mycontainer --rootfs=/path/to/rootfs --volatile start\n", bin); - printf(" (All changes are stored in RAM and lost on exit)\n\n"); - - printf("%sCustom Init (--init):%s\n", bold, reset); - printf(" Override the default init binary (/sbin/init).\n"); - printf(" Useful for distros with non-standard init paths, or for\n"); - printf(" running a shell directly as PID 1 (e.g. for debugging).\n\n"); - printf(" %s --name=mycontainer --rootfs=/path/to/rootfs " - "--init=/bin/bash start\n\n", - bin); - - printf("%sBind Mounts & Environment:%s\n", bold, reset); - printf(" --bind /host:/cont[:ro] Bind mount host path (append :ro for " - "read-only)\n"); - printf(" --env /path/to/env.list Load environment variables\n\n"); - - printf("%sConfig Management:%s\n", bold, reset); - printf(" --config=PATH Load specific config file\n"); - printf(" --reset Reset config to defaults\n"); - printf(" --help Show summary of all flags\n"); + p_printf(" (All changes are stored in RAM and lost on exit)\n\n"); + + p_printf("%sCustom Init (--init):%s\n", bold, reset); + p_printf(" Override the default init binary (/sbin/init).\n"); + p_printf(" Useful for distros with non-standard init paths, or for\n"); + p_printf(" running a shell directly as PID 1 (e.g. for debugging).\n\n"); + p_printf(" %s --name=mycontainer --rootfs=/path/to/rootfs " + "--init=/bin/bash start\n\n", + bin); + + p_printf("%sBind Mounts & Environment:%s\n", bold, reset); + p_printf(" --bind /host:/cont[:ro] Bind mount host path (append :ro for " + "read-only)\n"); + p_printf(" --env /path/to/env.list Load environment variables\n\n"); + + p_printf("%sConfig Management:%s\n", bold, reset); + p_printf(" --config=PATH Load specific config file\n"); + p_printf(" --reset Reset config to defaults\n"); + p_printf(" --help Show summary of all flags\n"); break; } -#undef printf } /* Read arrow key (escape sequence) */ @@ -426,7 +461,9 @@ static void handle_sigint(int sig) { (void)sig; if (g_old_tios_ptr) tcsetattr(STDIN_FILENO, TCSAFLUSH, g_old_tios_ptr); - printf("\033[?25h"); /* SHOW_CURSOR */ + /* Async-signal-safe: printf is not, so write the escape directly. */ + if (write(STDOUT_FILENO, "\033[?25h", 6) < 0) { /* SHOW_CURSOR; ignore */ + } if (write(STDOUT_FILENO, "\033[2J\033[H", 7) < 0) { /* ignore */ } @@ -530,7 +567,14 @@ void print_documentation(const char *argv0) { " [←/→] Prev/Next [↑/↓] Scroll (%d/%d) [q] Quit", g_scroll_offset + 1, g_total_lines); printf("%s", hint); - for (int i = strlen(hint) - 8; i < width; i++) + /* Pad to the terminal width. hint contains multi-byte arrow glyphs, so + * count display columns (bytes that are not UTF-8 continuation bytes) + * rather than strlen, which would over-count and need a magic fudge. */ + int hint_cols = 0; + for (const char *p = hint; *p; p++) + if (((unsigned char)*p & 0xC0) != 0x80) + hint_cols++; + for (int i = hint_cols; i < width; i++) printf(" "); printf("%s", RESET_TERMINAL); diff --git a/src/hardware.c b/src/hardware.c index 1b2e8420..259438bc 100644 --- a/src/hardware.c +++ b/src/hardware.c @@ -671,7 +671,9 @@ int setup_gpu_groups(void) { /* We'll rewrite the group file to a temporary location */ const char *group_path = "/etc/group"; - const char *tmp_path = "/etc/group.tmp"; + /* Randomized name + O_EXCL (mkstemp) so a symlink pre-planted at a + * predictable /etc/group.tmp cannot redirect this root-owned write. */ + char tmp_path[] = "/etc/group.XXXXXX"; FILE *fin = fopen(group_path, "re"); if (!fin) { @@ -679,9 +681,20 @@ int setup_gpu_groups(void) { return -1; } - FILE *fout = fopen(tmp_path, "we"); + int tfd = mkstemp(tmp_path); + if (tfd < 0) { + ds_warn("Cannot create temp group file: %s", strerror(errno)); + fclose(fin); + return -1; + } + (void)fcntl(tfd, F_SETFD, FD_CLOEXEC); + if (fchmod(tfd, 0644) < 0) { /* best effort; group files are 0644 */ + } + FILE *fout = fdopen(tfd, "w"); if (!fout) { - ds_warn("Cannot create /etc/group.tmp: %s", strerror(errno)); + ds_warn("Cannot create temp group file: %s", strerror(errno)); + close(tfd); + unlink(tmp_path); fclose(fin); return -1; } diff --git a/src/droidspace.h b/src/include/droidspace.h similarity index 85% rename from src/droidspace.h rename to src/include/droidspace.h index 8345307b..d15d176a 100644 --- a/src/droidspace.h +++ b/src/include/droidspace.h @@ -67,7 +67,7 @@ * ---------------------------------------------------------------------------*/ #define DS_PROJECT_NAME "Droidspaces" -#define DS_VERSION "6.2.5" +#define DS_VERSION "6.4.5" #define DS_MIN_KERNEL_MAJOR 3 #define DS_MIN_KERNEL_MINOR 10 #define DS_RECOMMENDED_KERNEL_MAJOR 4 @@ -204,6 +204,7 @@ enum ds_net_mode { DS_NET_HOST = 0, /* share host network namespace (default) */ DS_NET_NAT, /* isolated netns + bridge + MASQUERADE */ DS_NET_NONE, /* isolated netns with loopback only */ + DS_NET_GATEWAY, /* isolated netns attached to gateway LAN */ }; /* Opaque RTNETLINK context - defined in ds_netlink.c */ @@ -242,16 +243,37 @@ struct ds_net_handshake { * DS_RULE_PRIO_TO_SUBNET - "to 172.28.0.0/16 lookup main" * Guarantees reply traffic to the container always resolves correctly. * + * DS_RULE_PRIO_TETHER - "from 172.28.0.0/16 lookup local_network(97)" + * Returns container replies to hotspot/USB-tether clients out the tether + * interface. Reply packets of DNAT'd port-forward connections still carry + * the container source address at routing time (conntrack un-DNATs in + * POSTROUTING, after the route lookup), so without this rule they would + * match DS_RULE_PRIO_FROM_SUBNET and leak out the WAN uplink instead of + * reaching the tethered client. netd keeps the connected route of every + * downstream interface (swlan*, ap*, rndis*, ncm*, bt-pan) in the + * local_network table, which has no default route - so this rule only + * matches tether-bound traffic and falls through to FROM_SUBNET otherwise. + * Must sit between TO_SUBNET and FROM_SUBNET. + * * DS_RULE_PRIO_FROM_SUBNET - "from 172.28.0.0/16 lookup " - * Routes container-originated traffic through the active upstream table. + * Routes container-originated traffic through the active uplink table. */ #ifndef DS_RULE_PRIO_TO_SUBNET #define DS_RULE_PRIO_TO_SUBNET 6090 #endif +#ifndef DS_RULE_PRIO_TETHER +#define DS_RULE_PRIO_TETHER 6095 +#endif #ifndef DS_RULE_PRIO_FROM_SUBNET #define DS_RULE_PRIO_FROM_SUBNET 6100 #endif +/* netd's fixed local_network routing table + * (RouteController::ROUTE_TABLE_LOCAL_NETWORK, constant since Android 5.x). */ +#ifndef DS_ANDROID_TABLE_LOCAL_NETWORK +#define DS_ANDROID_TABLE_LOCAL_NETWORK 97 +#endif + /* Bind mount entry */ struct ds_bind_mount { char src[PATH_MAX]; @@ -318,14 +340,18 @@ typedef enum { struct ds_config { /* Paths */ - char rootfs_path[PATH_MAX]; /* --rootfs= */ - char rootfs_img_path[PATH_MAX]; /* --rootfs-img= */ - char pidfile[PATH_MAX]; /* --pidfile= or auto-resolved */ - char container_name[256]; /* --name= (mandatory) */ - char hostname[256]; /* --hostname= or container_name */ - char dns_servers[1024]; /* --dns= (comma/space separated) */ - enum ds_net_mode net_mode; /* --net=host|nat|none */ - char dns_server_content[1024]; /* In-memory DNS config for boot */ + char rootfs_path[PATH_MAX]; /* --rootfs= */ + char rootfs_img_path[PATH_MAX]; /* --rootfs-img= */ + char pidfile[PATH_MAX]; /* --pidfile= or auto-resolved */ + char container_name[256]; /* --name= (mandatory) */ + char hostname[256]; /* --hostname= or container_name */ + char dns_servers[1024]; /* --dns= (comma/space separated) */ + enum ds_net_mode net_mode; /* --net=host|nat|none|gateway */ + char dns_server_content[1024]; /* In-memory DNS config for boot */ + char gateway_container[256]; /* --gateway=NAME for gateway mode */ + char gateway_net[64]; /* --gateway-net=NAME (default: lan) */ + char gateway_bridge[IFNAMSIZ]; /* optional host bridge name */ + char gateway_lan_ifname[IFNAMSIZ]; /* ifname inside gateway (default: eth1) */ /* UUID for PID discovery */ char uuid[DS_UUID_LEN + 1]; @@ -343,6 +369,7 @@ struct ds_config { int disable_ipv6; /* --disable-ipv6 */ int android_storage; /* --enable-android-storage */ int selinux_permissive; /* --selinux-permissive */ + int userns_allowed; /* --allow-userns */ int net_bridgeless; /* Probe result: no CONFIG_BRIDGE, use PTP NAT */ int reboot_cycle; /* 1 if we are in a reboot loop */ int force_cgroupv1; /* --force-cgroupv1: use v1 even if v2 is available */ @@ -404,7 +431,10 @@ struct ds_config { * instead of re-deriving a PID-hash IP. Plain dotted-decimal, no CIDR. */ char static_nat_ip[INET_ADDRSTRLEN]; - /* Upstream interfaces for NAT routing (--upstream wlan0,rmnet0,...) */ + /* User-pinned upstream interfaces (--upstream wlan0,rmnet*,...). When set, + * NAT WAN is forced through this list (priority order, literals + wildcards) + * and automatic uplink detection is disabled entirely. Empty = auto-detect. + */ char upstream_ifaces[DS_MAX_UPSTREAM_IFACES][IFNAMSIZ]; int upstream_iface_count; @@ -424,6 +454,8 @@ struct ds_config { * ---------------------------------------------------------------------------*/ void safe_strncpy(char *dst, const char *src, size_t size); +int ds_parse_iface_csv(const char *val, char ifaces[][IFNAMSIZ], int *count, + int max); char *ds_resolve_path_arg(const char *path); void ds_resolve_argv_paths(int argc, char **argv); long ds_get_container_uptime(pid_t pid); @@ -456,7 +488,6 @@ void firmware_path_remove(const char *fw_path); int run_command(char *const argv[]); int run_command_quiet(char *const argv[]); int run_command_log(char *const argv[]); -int get_selinux_context(const char *path, char *buf, size_t size); int set_selinux_context(const char *path, const char *context); int ds_send_fd(int sock, int fd); int ds_recv_fd(int sock); @@ -494,13 +525,34 @@ typedef void (*ds_child_fn)(int ready_fd, void *user_data); pid_t ds_daemon_read_pid(const char *filename); void ds_daemon_write_pid(const char *filename, pid_t pid); void ds_daemon_remove_pid(const char *filename); +pid_t ds_resolve_daemon_pid(pid_t cached, const char *pidfile); +void ds_global_daemon_stop(int (*check_fn)(void), pid_t cached_pid, + pid_t *pid_out, const char *pidfile, + const char *sock_path, const char *tag); void ds_oom_protect(void); +/* Common preamble for a forked long-lived daemon child (audio/GPU/X11 helper): + * ignore terminal-disconnect signals so it outlives the launching session and + * protect it from the OOM killer. Must run while still root. */ +void ds_daemon_child_preamble(void); +/* Return 1 if peer_pid shares this process's PID namespace (or if it cannot be + * determined), 0 only on a positive mismatch. Used by the abstract-socket + * authorizers to reject host-net container peers. */ +int ds_peer_in_pidns(pid_t peer_pid); +/* Authorize a connected AF_UNIX peer: allow root, or a member of group_name, + * but only when the peer shares our PID namespace. Returns 1 if authorized. */ +int ds_peer_authorized(int fd, const char *group_name); void ds_spawn_log_relay(int pipe_read_fd, const char *log_file, const char *tag); pid_t ds_spawn_daemon(ds_child_fn child_fn, void *user_data, const char *log_file, const char *tag, const char *label); int ds_bind_mount_socket(const char *src, const char *dst, uid_t uid, const char *label); +/* Bridge a Termux host socket (DS_TERMUX_TMP_OLDROOT/) into the container + * at dst (owned by the host socket's uid), then export env_key=env_val. + * Best-effort: warns and skips if the source is absent. Always returns 0. */ +int ds_bridge_termux_socket(const char *leaf, const char *dst, + const char *env_key, const char *env_val, + const char *label); /* --------------------------------------------------------------------------- * config.c @@ -516,6 +568,7 @@ int ds_config_add_bind(struct ds_config *cfg, const char *src, const char *dest, void free_config_binds(struct ds_config *cfg); void free_config_env_vars(struct ds_config *cfg); void free_config_unknown_lines(struct ds_config *cfg); +void ds_config_free(struct ds_config *cfg); int ds_split_flags(const char *str, char ***out_argv, int *out_argc); void ds_free_split_flags(char **argv, int argc); char *ds_config_auto_path(const char *rootfs_path); @@ -535,12 +588,13 @@ void android_remount_data_suid(void); int android_setup_storage(const char *rootfs_path); int android_seccomp_setup(int is_systemd, int block_nested_ns, int privileged_mask); -int ds_seccomp_apply_minimal(int privileged_mask); +int ds_seccomp_apply_minimal(int privileged_mask, int userns_allowed); /* SELinux + Termux privilege helpers */ +int get_selinux_context(const char *path, char *buf, size_t size); const char *ds_extract_mls(const char *ctx); -void ds_selinux_dyntransition(const char *mls, char *applied_ctx, - size_t ctx_size); +void ds_selinux_dyntransition(const char *mls); +void ds_selinux_enter_domain(void); int ds_drop_privileges(int uid); int ds_resolve_termux_uid(void); @@ -624,7 +678,6 @@ int ds_setup_x11_socket(struct ds_config *cfg); int ds_virgl_daemon_start(struct ds_config *cfg); void ds_virgl_daemon_stop(struct ds_config *cfg); int ds_setup_virgl_socket(struct ds_config *cfg); -int check_virgl_needs(void); /* --------------------------------------------------------------------------- * pulseaudio-android.c @@ -633,7 +686,6 @@ int check_virgl_needs(void); int ds_pulse_daemon_start(struct ds_config *cfg); void ds_pulse_daemon_stop(struct ds_config *cfg); int ds_setup_pulse_socket(struct ds_config *cfg); -int check_pulse_needs(void); /* --------------------------------------------------------------------------- * network.c @@ -644,6 +696,10 @@ int fix_networking_rootfs(struct ds_config *cfg); /* NAT veth/bridge lifecycle */ int setup_veth_host_side(struct ds_config *cfg, pid_t child_pid); + +/* Gateway LAN lifecycle: bridge-only veth plumbing, no NAT/DHCP/firewall. */ +int setup_gateway_veth_side(struct ds_config *cfg, pid_t child_pid); + int setup_veth_child_side_named(struct ds_config *cfg, const char *peer_name, const char *ip_str); /* Populate a ds_net_handshake from a container init PID + resolved config. @@ -653,6 +709,19 @@ void ds_net_derive_handshake(pid_t init_pid, struct ds_config *cfg, struct ds_net_handshake *hs); void ds_net_cleanup(struct ds_config *cfg, pid_t container_pid); void ds_net_start_route_monitor(void); +/* Marks route_localnet as required; route monitor re-asserts it every cycle + * (same pattern as ip_forward). Called once port-forward rules with localhost + * DNAT are installed. Sticky for process lifetime - never cleared. */ +void ds_net_mark_local_forward_active(void); +/* Gateway self-heal: when the gateway container (re)boots, re-wire its LAN + * cable to every running client that delegates to it, with no client restart. + * Called from the gateway container's monitor on each boot cycle. */ +void ds_net_rewire_gateway_clients(const char *gateway_name, pid_t gateway_pid); +/* Gateway teardown: when a container that ACTS AS A GATEWAY stops, explicitly + * delete the gateway-side veth(s) it serves and reap any now-idle delegated + * bridge. The kernel does not auto-reap these (the host-side veth pins its + * orphan peer netns), so this prevents the leak. No-op for a non-gateway. */ +void ds_net_gateway_teardown(const char *gateway_name); int ds_net_disable_tx_checksum(const char *ifname); void parse_cidr(const char *cidr, uint32_t *ip_out, uint32_t *mask_out); @@ -674,18 +743,28 @@ int ds_nl_link_up(ds_nl_ctx_t *ctx, const char *ifname); int ds_nl_link_down(ds_nl_ctx_t *ctx, const char *ifname); int ds_nl_del_link(ds_nl_ctx_t *ctx, const char *ifname); int ds_nl_rename(ds_nl_ctx_t *ctx, const char *ifname, const char *newname); +int ds_nl_set_mac(ds_nl_ctx_t *ctx, const char *ifname, const uint8_t mac[6]); int ds_nl_add_addr4(ds_nl_ctx_t *ctx, const char *ifname, uint32_t ip_be, uint8_t prefix); int ds_nl_add_route4(ds_nl_ctx_t *ctx, uint32_t dst_be, uint8_t dst_len, uint32_t gw_be, int oif_idx); int ds_nl_move_to_netns(ds_nl_ctx_t *ctx, const char *ifname, int netns_fd); +int ds_nl_move_to_netns_named(ds_nl_ctx_t *ctx, const char *ifname, + int netns_fd, const char *newname); int ds_nl_get_iface_table(ds_nl_ctx_t *ctx, const char *ifname, int *table_out); +int ds_nl_get_table_default_oif(ds_nl_ctx_t *ctx, int table, char *ifname_out); +int ds_nl_get_android_default(ds_nl_ctx_t *ctx, char *ifname_out, + int *table_out); int ds_nl_add_rule4(ds_nl_ctx_t *ctx, uint32_t src_be, uint8_t src_len, uint32_t dst_be, uint8_t dst_len, int table, int priority); int ds_nl_del_rule4(ds_nl_ctx_t *ctx, uint32_t src_be, uint8_t src_len, uint32_t dst_be, uint8_t dst_len, int table, int priority); void ds_nl_flush_stale_veths(ds_nl_ctx_t *ctx, const char *prefix); int ds_nl_count_ifaces_with_prefix(ds_nl_ctx_t *ctx, const char *prefix); +/* Count links enslaved to `bridge` whose name starts with `prefix`. + * Returns 0 if the bridge does not exist. */ +int ds_nl_count_bridge_members_with_prefix(ds_nl_ctx_t *ctx, const char *bridge, + const char *prefix); int ds_nl_list_ifaces(ds_nl_ctx_t *ctx, char names[][IFNAMSIZ], int max); /* Kernel capability probe - call before any NAT setup */ int ds_nl_probe_nat_capability(char *reason, size_t rsz); @@ -787,6 +866,15 @@ int show_containers(struct ds_config *cfg); int scan_containers(void); int check_selinux_permissive_needs(void); int check_x11_needs(void); +int check_virgl_needs(void); +int check_pulse_needs(void); + +/* + * ds_feature_needs - generic feature-needs scanner. + * Pass offsetof(struct ds_config, ) for the boolean flag to check. + * Returns: 1=running, 0=installed-but-idle, -1=not-installed + */ +int ds_feature_needs(size_t cfg_flag_offset); void write_plain_env_file(const char *src, const char *dst); /* --------------------------------------------------------------------------- diff --git a/src/socketd_bridge.h b/src/include/socketd_bridge.h similarity index 100% rename from src/socketd_bridge.h rename to src/include/socketd_bridge.h diff --git a/src/socketd_protocol.h b/src/include/socketd_protocol.h similarity index 93% rename from src/socketd_protocol.h rename to src/include/socketd_protocol.h index 1b42aaa4..0a778a88 100644 --- a/src/socketd_protocol.h +++ b/src/include/socketd_protocol.h @@ -151,7 +151,7 @@ struct DS_SOCKETD_PACKED ds_socketd_bind_record { struct DS_SOCKETD_PACKED ds_socketd_inspect_container_record_v1 { uint16_t record_version_be; /* currently 1 */ uint16_t _header_pad; - uint32_t record_size_be; /* sizeof(ds_socketd_inspect_container_record_v1) */ + uint32_t record_size_be; /* sizeof(ds_socketd_inspect_container_record_v1) */ char name[DS_SOCKETD_RECORD_NAME_MAX]; char uuid[DS_UUID_LEN + 1]; @@ -162,7 +162,7 @@ struct DS_SOCKETD_PACKED ds_socketd_inspect_container_record_v1 { char custom_init[DS_SOCKETD_RECORD_PATH_MAX]; /* empty = /sbin/init */ char dns_servers[DS_SOCKETD_INSPECT_STRING_MAX]; - int32_t pid_be; /* host-view PID 1; 0 = stopped */ + int32_t pid_be; /* host-view PID 1; 0 = stopped */ int64_t started_at_be; /* CLOCK_REALTIME seconds; 0 if unknown */ int64_t memory_limit_be; /* bytes; 0 = unlimited */ @@ -184,12 +184,12 @@ struct DS_SOCKETD_PACKED ds_socketd_inspect_container_record_v1 { uint8_t block_nested_ns; uint8_t is_img_mount; - uint16_t env_count_be; /* entries serialized in env[] */ - uint16_t env_total_count_be; /* original cfg count before truncation */ - uint16_t bind_count_be; /* entries serialized in binds[] */ - uint16_t bind_total_count_be; /* original cfg count before truncation */ - uint16_t port_count_be; /* entries serialized in ports[] */ - uint16_t port_total_count_be; /* original cfg count before truncation */ + uint16_t env_count_be; /* entries serialized in env[] */ + uint16_t env_total_count_be; /* original cfg count before truncation */ + uint16_t bind_count_be; /* entries serialized in binds[] */ + uint16_t bind_total_count_be; /* original cfg count before truncation */ + uint16_t port_count_be; /* entries serialized in ports[] */ + uint16_t port_total_count_be; /* original cfg count before truncation */ struct ds_socketd_env_record env[DS_SOCKETD_INSPECT_ENV_MAX]; struct ds_socketd_bind_record binds[DS_SOCKETD_INSPECT_BINDS_MAX]; diff --git a/src/virtualize.h b/src/include/virtualize.h similarity index 100% rename from src/virtualize.h rename to src/include/virtualize.h diff --git a/src/main.c b/src/main.c index 18753dd1..ed10dd98 100644 --- a/src/main.c +++ b/src/main.c @@ -23,8 +23,10 @@ void print_usage(void) { printf("by " C_CYAN "%s" C_RESET "\n", DS_AUTHOR); printf("\n" C_BLUE "%s" C_RESET "\n", DS_REPO); printf(C_DIM "Built on: %s %s" C_RESET "\n\n", __DATE__, __TIME__); + printf("Usage: droidspaces [options] [args]\n\n"); + printf( - "Usage: droidspaces [options] [args]\n\n" C_BOLD + C_BOLD "Commands:" C_RESET "\n" " start Start a new container\n" " stop Stop one or more containers\n" @@ -41,32 +43,43 @@ void print_usage(void) { " help Show this help message\n" " version Show version information\n" " daemon Run daemon mode (use --foreground for " - "foreground execution)\n\n" + "foreground execution)\n\n"); - C_BOLD "Options (Container Setup):" C_RESET "\n" - " -r, --rootfs=PATH Path to rootfs directory\n" - " -i, --rootfs-img=PATH Path to rootfs image (.img)\n" - " -n, --name=NAME Container name (mandatory)\n" - " -h, --hostname=NAME Set container hostname\n" - " -C, --conf=PATH Load configuration from file\n\n" + printf(C_BOLD "Options (Container Setup):" C_RESET "\n" + " -r, --rootfs=PATH Path to rootfs directory\n" + " -i, --rootfs-img=PATH Path to rootfs image (.img)\n" + " -n, --name=NAME Container name (mandatory)\n" + " -h, --hostname=NAME Set container hostname\n" + " -C, --conf=PATH Load configuration from file\n\n"); - C_BOLD "Options (Networking):" C_RESET "\n" - " --net=MODE Modes: host (default), nat, none\n" + printf( + C_BOLD + "Options (Networking):" C_RESET "\n" + " --net=MODE Modes: host (default), nat, none, gateway\n" + " --gateway=NAME Gateway container for --net=gateway\n" + " --gateway-net=NAME Gateway LAN name/bridge suffix (default: " + "lan)\n" + " --gateway-iface=IFACE Interface name inside gateway (default: " + "eth1)\n" + " --gateway-bridge=BR Host bridge for gateway LAN (default: " + "ds-NAME)\n" " --nat-ip=IP Assign a fixed IP in 172.28.*.* range (nat " "mode)\n" - " --upstream IFACE Upstream interface(s) (supports wildcards, " - "e.g. rmnet*)\n" - " e.g. --upstream wlan0 or --upstream " - "wlan0,rmnet*\n" + " --upstream IFACE Pin NAT WAN to interface(s); disables " + "auto-detect\n" + " comma-separated, wildcards ok, e.g. " + "--upstream wlan0 or wlan0,rmnet*\n" " --port [H:]C[/P] Forward ports (supports ranges and " "symmetric ports)\n" " e.g. --port 22, 80:80/tcp, " "1000-2000:1000-2000/udp\n" " -d, --dns=SERVERS Set custom DNS servers (comma separated)\n" " e.g. --dns 1.1.1.1,8.8.8.8\n" - " -I, --disable-ipv6 Disable IPv6 inside the container\n\n" + " -I, --disable-ipv6 Disable IPv6 inside the container\n\n"); - C_BOLD "Options (Integration & Hardware):" C_RESET "\n" + printf( + C_BOLD + "Options (Integration & Hardware):" C_RESET "\n" " -S, --enable-android-storage\n" " Mount Android internal storage (/sdcard)\n" " -H, --hw-access Enable direct hardware access (/dev nodes)\n" @@ -77,9 +90,11 @@ void print_usage(void) { " --virgl-flags=\"FLAGS\" Extra flags passed to " "virgl_test_server_android\n" " --pulse-audio Configure PulseAudio sound server " - "support\n\n" + "support\n\n"); - C_BOLD "Options (Security & Boot):" C_RESET "\n" + printf( + C_BOLD + "Options (Security & Boot):" C_RESET "\n" " -P, --selinux-permissive Set host SELinux to permissive mode\n" " -V, --volatile Discard changes on exit (OverlayFS)\n" " --force-cgroupv1 Force legacy cgroup v1 hierarchy\n" @@ -90,9 +105,12 @@ void print_usage(void) { " --cpus=COUNT CPU limit (e.g. 1.5, 2)\n" " --pids-limit=N Max number of PIDs\n" " --privileged=TAGS Relax security: nomask, nocaps, noseccomp, " - "shared, unfiltered-dev, full\n\n" + "shared, unfiltered-dev, full\n" + " --allow-userns Allow user namespaces\n\n"); - C_BOLD "Options (Advanced):" C_RESET "\n" + printf( + C_BOLD + "Options (Advanced):" C_RESET "\n" " -f, --foreground Run in foreground (attach console)\n" " --init=PATH Custom init binary (default: /sbin/init)\n" " -u, --user=USER Run command as USER (for 'run' command " @@ -106,12 +124,13 @@ void print_usage(void) { " --reset Reset config to defaults (keeps " "name/rootfs)\n" " --format Machine-parseable output (KEY=VALUE)\n" - " --help Show this help message\n\n" + " --help Show this help message\n\n"); - C_BOLD "Examples:" C_RESET "\n" - " droidspaces --name=mycontainer --rootfs=/path/to/rootfs start\n" - " droidspaces --name=mycontainer enter\n" - " droidspaces --name=mycontainer stop\n\n"); + printf(C_BOLD + "Examples:" C_RESET "\n" + " droidspaces --name=mycontainer --rootfs=/path/to/rootfs start\n" + " droidspaces --name=mycontainer enter\n" + " droidspaces --name=mycontainer stop\n\n"); } /* --------------------------------------------------------------------------- @@ -201,6 +220,30 @@ static int validate_configuration_cli(struct ds_config *cfg) { } } + if (cfg->net_mode == DS_NET_GATEWAY) { + if (!cfg->gateway_container[0]) { + ds_error("--net=gateway requires --gateway=."); + errors++; + } else if (reject_container_name(cfg->gateway_container) < 0) { + errors++; + } else if (strcmp(cfg->gateway_container, cfg->container_name) == 0) { + ds_error("A container cannot use itself as --gateway."); + errors++; + } + + if (cfg->gateway_bridge[0] && strlen(cfg->gateway_bridge) >= IFNAMSIZ) { + ds_error("--gateway-bridge interface name is too long: %s", + cfg->gateway_bridge); + errors++; + } + if (cfg->gateway_lan_ifname[0] && + strlen(cfg->gateway_lan_ifname) >= IFNAMSIZ) { + ds_error("--gateway-iface interface name is too long: %s", + cfg->gateway_lan_ifname); + errors++; + } + } + return (errors > 0) ? -1 : 0; } @@ -264,21 +307,20 @@ static void enforce_nat_safety(struct ds_config *cfg, int argc, char **argv) { "IPv6 is already inactive in NAT mode - --disable-ipv6 has no effect."); } - if (cfg->net_mode == DS_NET_NAT || cfg->net_mode == DS_NET_NONE) { + if (cfg->net_mode == DS_NET_NAT || cfg->net_mode == DS_NET_NONE || + cfg->net_mode == DS_NET_GATEWAY) { if (!check_ns(CLONE_NEWNET, "net")) { printf("\n" C_RED C_BOLD "[ FATAL: NETWORK NAMESPACE UNSUPPORTED ]" C_RESET "\n\n"); ds_error("Kernel does not support CLONE_NEWNET (network namespaces)."); - ds_log("Cannot use --net=nat or --net=none."); + ds_log("Cannot use --net=nat, --net=none, or --net=gateway."); ds_log("Tip: Use --net=host (default) for shared host networking."); exit(EXIT_FAILURE); } } - if (cfg->net_mode != DS_NET_NAT) - return; - - /* --upstream and --port are only meaningful with --net=nat */ + /* --upstream and --port are only meaningful with --net=nat. --upstream is an + * optional override (auto-detection is the default) - never mandatory. */ if (cfg->upstream_iface_count > 0 && cfg->net_mode != DS_NET_NAT) { ds_warn("--upstream is only valid with --net=nat - ignoring"); cfg->upstream_iface_count = 0; @@ -288,21 +330,25 @@ static void enforce_nat_safety(struct ds_config *cfg, int argc, char **argv) { cfg->port_forward_count = 0; } - /* --upstream is mandatory when using --net=nat */ - if (cfg->upstream_iface_count == 0) { - printf("\n" C_RED C_BOLD "[ FATAL: --upstream REQUIRED ]" C_RESET "\n\n"); - ds_error("--net=nat requires --upstream \n" - "\n" - " Specify the host interface(s) that provide internet access.\n" - " The monitor will track whichever is currently active.\n" - "\n" - " Examples:\n" - " --upstream wlan0\n" - " --upstream wlan0,rmnet0\n" - " --upstream wlan0,rmnet0,ccmni1,v4-ccmni1"); - exit(EXIT_FAILURE); + if (cfg->net_mode == DS_NET_GATEWAY) { + char reason[512]; + int probe = ds_nl_probe_nat_capability(reason, sizeof(reason)); + if (probe < 0 || probe == 1) { + printf("\n" C_RED C_BOLD + "[ FATAL: GATEWAY NETWORKING UNSUPPORTED ]" C_RESET "\n\n"); + ds_error("--net=gateway requires NET_NS + VETH + BRIDGE support:\n %s", + reason); + ds_log("Tip: use --net=nat/host, or rebuild your kernel with " + "CONFIG_VETH=y and CONFIG_BRIDGE=y."); + exit(1); + } + ds_log("[NET] Kernel capability probe passed for --net=gateway."); + return; } + if (cfg->net_mode != DS_NET_NAT) + return; + char reason[512]; int probe = ds_nl_probe_nat_capability(reason, sizeof(reason)); if (probe < 0) { @@ -350,6 +396,7 @@ int main(int argc, char **argv) { {"disable-ipv6", no_argument, 0, 'I'}, {"enable-android-storage", no_argument, 0, 'S'}, {"selinux-permissive", no_argument, 0, 'P'}, + {"allow-userns", no_argument, 0, 279}, {"volatile", no_argument, 0, 'V'}, {"bind-mount", required_argument, 0, 'B'}, {"bind", required_argument, 0, 'B'}, @@ -368,6 +415,11 @@ int main(int argc, char **argv) { {"virgl", no_argument, 0, 270}, {"virgl-flags", required_argument, 0, 272}, {"pulse-audio", no_argument, 0, 273}, + {"gateway", required_argument, 0, 274}, + {"gateway-container", required_argument, 0, 274}, + {"gateway-net", required_argument, 0, 275}, + {"gateway-iface", required_argument, 0, 276}, + {"gateway-bridge", required_argument, 0, 277}, {"reset", no_argument, 0, 256}, {"format", no_argument, 0, 265}, {"memory", required_argument, 0, 266}, @@ -440,8 +492,12 @@ int main(int argc, char **argv) { cfg.net_mode = DS_NET_NONE; else if (strcmp(optarg, "host") == 0) cfg.net_mode = DS_NET_HOST; + else if (strcmp(optarg, "gateway") == 0 || + strcmp(optarg, "delegated-gateway") == 0) + cfg.net_mode = DS_NET_GATEWAY; else { - ds_error("Unknown network mode: '%s'. Valid options: host, nat, none", + ds_error("Unknown network mode: '%s'. Valid options: host, nat, none, " + "gateway", optarg); ret = 1; goto cleanup; @@ -461,13 +517,14 @@ int main(int argc, char **argv) { * Commands that do not require root access (docs, help, version) or * must be run locally to avoid recursive loops (mode) are never proxied. */ - int is_stateless_cmd = + int is_no_root_cmd = (discovered_cmd && (strcmp(discovered_cmd, "docs") == 0 || strcmp(discovered_cmd, "help") == 0 || strcmp(discovered_cmd, "version") == 0 || - strcmp(discovered_cmd, "mode") == 0)); + strcmp(discovered_cmd, "mode") == 0 || + strcmp(discovered_cmd, "check") == 0)); - if (!is_daemon_cmd && !is_stateless_cmd && getenv("DS_NO_PROXY") == NULL) { + if (!is_daemon_cmd && !is_no_root_cmd && getenv("DS_NO_PROXY") == NULL) { int proxy_ret = ds_client_run(argc - 1, argv + 1); if (proxy_ret != -2) { ret = proxy_ret; @@ -475,6 +532,14 @@ int main(int argc, char **argv) { } } + /* Unified root gate: block all non-exempt commands before any work begins */ + if (!is_no_root_cmd && getuid() != 0) { + ds_error("Root privileges required for '%s'", + discovered_cmd ? discovered_cmd : "(unknown)"); + ret = 1; + goto cleanup; + } + /* * Unified Configuration Discovery and Loading * 1. Try to load from explicitly provided config file. @@ -614,6 +679,20 @@ int main(int argc, char **argv) { case 273: cfg.pulseaudio = 1; break; + case 274: + safe_strncpy(cfg.gateway_container, optarg, + sizeof(cfg.gateway_container)); + break; + case 275: + safe_strncpy(cfg.gateway_net, optarg, sizeof(cfg.gateway_net)); + break; + case 276: + safe_strncpy(cfg.gateway_lan_ifname, optarg, + sizeof(cfg.gateway_lan_ifname)); + break; + case 277: + safe_strncpy(cfg.gateway_bridge, optarg, sizeof(cfg.gateway_bridge)); + break; case 'I': cfg.disable_ipv6 = 1; break; @@ -623,6 +702,9 @@ int main(int argc, char **argv) { case 'P': cfg.selinux_permissive = 1; break; + case 279: + cfg.userns_allowed = 1; + break; case 'V': cfg.volatile_mode = 1; break; @@ -681,8 +763,12 @@ int main(int argc, char **argv) { cli_net_mode = DS_NET_NONE; else if (strcmp(optarg, "host") == 0) cli_net_mode = DS_NET_HOST; + else if (strcmp(optarg, "gateway") == 0 || + strcmp(optarg, "delegated-gateway") == 0) + cli_net_mode = DS_NET_GATEWAY; else { - ds_error("Unknown network mode: '%s'. Valid options: host, nat, none", + ds_error("Unknown network mode: '%s'. Valid options: host, nat, none, " + "gateway", optarg); ret = 1; goto cleanup; @@ -873,51 +959,19 @@ int main(int argc, char **argv) { } case 259: { - /* --upstream wlan0,rmnet0,ccmni1 (comma-separated list) */ - char tmp[256]; - strncpy(tmp, optarg, sizeof(tmp) - 1); - tmp[sizeof(tmp) - 1] = '\0'; - char *saveptr2; - char *tok2 = strtok_r(tmp, ",", &saveptr2); - while (tok2) { - /* Trim leading/trailing whitespace */ - while (*tok2 == ' ' || *tok2 == '\t') - tok2++; - char *end2 = tok2 + strlen(tok2) - 1; - while (end2 > tok2 && (*end2 == ' ' || *end2 == '\t')) - *end2-- = '\0'; - - if (tok2[0] == '\0') { - tok2 = strtok_r(NULL, ",", &saveptr2); - continue; - } - if (cfg.upstream_iface_count >= DS_MAX_UPSTREAM_IFACES) { - ds_error("Too many --upstream interfaces (max %d)", - DS_MAX_UPSTREAM_IFACES); - ret = 1; - goto cleanup; - } - if (strlen(tok2) >= IFNAMSIZ) { - ds_error("Interface name too long: '%s' (max %d chars)", tok2, - IFNAMSIZ - 1); - ret = 1; - goto cleanup; - } - int dup = 0; - for (int i = 0; i < cfg.upstream_iface_count; i++) { - if (strcmp(cfg.upstream_ifaces[i], tok2) == 0) { - dup = 1; - break; - } - } - if (!dup) { - safe_strncpy(cfg.upstream_ifaces[cfg.upstream_iface_count++], tok2, - IFNAMSIZ); - } - tok2 = strtok_r(NULL, ",", &saveptr2); + /* --upstream wlan0,rmnet* (comma-separated, wildcards allowed). Pins + * NAT WAN to this list in priority order and disables auto-detection. */ + if (ds_parse_iface_csv(optarg, cfg.upstream_ifaces, + &cfg.upstream_iface_count, + DS_MAX_UPSTREAM_IFACES) > 0) { + ds_error("Too many --upstream interfaces (max %d)", + DS_MAX_UPSTREAM_IFACES); + ret = 1; + goto cleanup; } break; } + case 260: /* --force-cgroupv1: escape hatch to legacy hierarchy */ cfg.force_cgroupv1 = 1; @@ -1057,12 +1111,6 @@ int main(int argc, char **argv) { goto cleanup; } - /* Root required commands */ - if (getuid() != 0) { - ds_error("Root privileges required for '%s'", cmd); - ret = 1; - goto cleanup; - } ensure_workspace(); if (strcmp(cmd, "show") == 0) { @@ -1184,11 +1232,6 @@ int main(int argc, char **argv) { } if (strcmp(cmd, "daemon") == 0) { - if (getuid() != 0) { - ds_error("Root privileges required for daemon mode"); - ret = 1; - goto cleanup; - } ret = ds_daemon_run(cfg.foreground, argv); goto cleanup; } @@ -1199,10 +1242,6 @@ int main(int argc, char **argv) { ret = 1; cleanup: - free_config_unknown_lines(&cfg); - free_config_env_vars(&cfg); - free_config_binds(&cfg); - free(cfg.tx11_extra_flags); - free(cfg.virgl_extra_flags); + ds_config_free(&cfg); return ret; } diff --git a/src/monitor.c b/src/monitor.c index f84f7abe..4bffd9c3 100644 --- a/src/monitor.c +++ b/src/monitor.c @@ -7,6 +7,50 @@ #include "droidspace.h" +/* --------------------------------------------------------------------------- + * ds_console_drain - Empty the container's console PTY master. + * + * In BACKGROUND mode the monitor is the only process holding the console PTY + * master, and nothing reads it (foreground mode drives it from the parent via + * console_monitor_loop). If it is never read, a chatty container init that + * writes past the ~64 KiB PTY buffer blocks forever in n_tty_write and the + * whole container wedges. So the monitor must keep this master drained for the + * container's entire lifetime. + * + * Drained bytes are appended to the per-container console log when one is open + * (log_fd >= 0), which also makes background boot/console output visible for + * debugging. Draining continues even if the log write fails: keeping the PTY + * buffer empty is the load-bearing part; logging is best-effort. + * + * master_fd must be O_NONBLOCK so read() returns EAGAIN once drained. + * ---------------------------------------------------------------------------*/ +static void ds_console_drain(int master_fd, int log_fd, size_t *logged) { + char buf[4096]; + ssize_t n; + + while ((n = read(master_fd, buf, sizeof(buf))) > 0) { + if (log_fd < 0) + continue; + + /* Bound the console log: truncate at 2 MiB. The fd was opened O_APPEND, + * so subsequent writes still land correctly after the truncation. */ + if (logged && *logged >= 2u * 1024 * 1024) { + if (ftruncate(log_fd, 0) == 0) + *logged = 0; + } + + size_t off = 0; + while (off < (size_t)n) { + ssize_t w = write(log_fd, buf + off, (size_t)n - off); + if (w <= 0) + break; + off += (size_t)w; + } + if (logged) + *logged += off; + } +} + /* --------------------------------------------------------------------------- * ds_monitor_run - Supervisor process for a single container instance. * @@ -41,6 +85,11 @@ void ds_monitor_run(struct ds_config *cfg, int sync_pipe_write) { /* Make monitor unkillable */ ds_oom_protect(); + /* Enter droidspacesd domain. Best-effort: if policy not yet loaded + * (user hasn't rebooted after module install), we stay in the inherited + * domain -- still functional since droidspacesd is typepermissive. */ + ds_selinux_enter_domain(); + prctl(PR_SET_NAME, "[ds-monitor]", 0, 0, 0); /* Unshare namespaces - Monitor enters new UTS, IPC, and optionally Cgroup @@ -145,6 +194,33 @@ void ds_monitor_run(struct ds_config *cfg, int sync_pipe_write) { int stdio_redirected = 0; + /* Background console-drain setup (see ds_console_drain). + * Runs once, before the reboot loop, so a single log fd survives internal + * reboot cycles. In background mode the monitor is the only reader of the + * console PTY master: make it non-blocking and open a per-container console + * log so the heartbeat loop below keeps the PTY buffer drained. Foreground + * mode leaves the master untouched - the parent's console_monitor_loop + * drives it. */ + int console_log_fd = -1; + size_t console_logged = 0; + if (!cfg->foreground && cfg->console.master >= 0) { + int fl = fcntl(cfg->console.master, F_GETFL, 0); + if (fl >= 0) + (void)fcntl(cfg->console.master, F_SETFL, fl | O_NONBLOCK); + + char safe_name[256]; + char log_dir[PATH_MAX]; + char log_path[PATH_MAX]; + sanitize_container_name(cfg->container_name, safe_name, sizeof(safe_name)); + snprintf(log_dir, sizeof(log_dir), "%.2048s/" DS_LOGS_SUBDIR "/%.256s", + get_workspace_dir(), safe_name); + mkdir_p(log_dir, 0755); + snprintf(log_path, sizeof(log_path), "%.4080s/console", log_dir); + rotate_log(log_path, 2 * 1024 * 1024); + console_log_fd = + open(log_path, O_WRONLY | O_CREAT | O_APPEND | O_CLOEXEC, 0644); + } + /* Reboot-aware boot loop * Each iteration forks an intermediate child that creates a fresh PID * namespace (unshare(CLONE_NEWPID)) and then forks the container init. @@ -389,13 +465,38 @@ reboot_loop:; * switches */ ds_net_start_route_monitor(); } + } else if (cfg->net_mode == DS_NET_GATEWAY) { + if (setup_gateway_veth_side(cfg, netns_pid) < 0) { + ds_warn("[NET] Monitor: setup_gateway_veth_side failed - " + "container will remain isolated"); + } } + /* Gateway self-heal: if any running client delegates to THIS container as + * its gateway, (re)plug their LAN cable into our (possibly just-rebooted) + * netns now. Runs on every boot cycle - a cheap no-op when nobody routes + * through us - so a gateway reboot re-wires its clients with no client + * restart. + * + * This MUST run BEFORE the DONE handshake below. The DONE write is what + * unblocks init to proceed into pivot_root + exec of the real init + * (procd/netifd). A client's own eth0 is likewise wired before its DONE + * (setup_gateway_veth_side above); plugging the gateway's LAN cable(s) + * (eth1 ...) after DONE would hot-plug them into a gateway whose netifd + * is already booting, racing its LAN bring-up. Wire first, then unblock, + * so the gateway execs its init with every client cable already present. + */ + ds_net_rewire_gateway_clients(cfg->container_name, netns_pid); + /* Send handshake to init */ struct ds_net_handshake hs; ds_net_derive_handshake(netns_pid, cfg, &hs); - ds_log("[NET] Monitor: sending DONE: peer=%s ip=%s", hs.peer_name, - hs.ip_str); + if (cfg->net_mode == DS_NET_GATEWAY) + ds_log("[NET] Monitor: sending DONE (gateway mode: eth0 is wired " + "host-side, IP comes from the gateway's DHCP)"); + else + ds_log("[NET] Monitor: sending DONE: peer=%s ip=%s", hs.peer_name, + hs.ip_str); if (write(cfg->net_done_pipe[1], &hs, sizeof(hs)) != (ssize_t)sizeof(hs)) ds_warn("[NET] Monitor: failed to write handshake to init"); close(cfg->net_done_pipe[1]); @@ -470,14 +571,35 @@ reboot_loop:; ds_virtualize_update(cfg); + /* Poll the signalfd and, in background mode, the console PTY master. + * poll() wakes immediately when the master becomes readable, so draining + * is effectively real-time - a container init that fills the PTY buffer + * is unblocked at once instead of wedging forever in n_tty_write. */ + struct pollfd pfds[2]; + int npfd = 0; + int sfd_slot = -1; + int con_slot = -1; if (sfd >= 0) { - struct pollfd pfd = {.fd = sfd, .events = POLLIN}; - poll(&pfd, 1, 500); - if (pfd.revents & POLLIN) { + pfds[npfd].fd = sfd; + pfds[npfd].events = POLLIN; + sfd_slot = npfd++; + } + if (!cfg->foreground && cfg->console.master >= 0) { + pfds[npfd].fd = cfg->console.master; + pfds[npfd].events = POLLIN; + con_slot = npfd++; + } + if (npfd > 0) { + poll(pfds, (nfds_t)npfd, 500); + if (sfd_slot >= 0 && (pfds[sfd_slot].revents & POLLIN)) { struct signalfd_siginfo si; while (read(sfd, &si, sizeof(si)) == (ssize_t)sizeof(si)) ; /* drain */ } + if (con_slot >= 0 && + (pfds[con_slot].revents & (POLLIN | POLLHUP | POLLERR))) + ds_console_drain(cfg->console.master, console_log_fd, + &console_logged); } else { usleep(500000); } @@ -643,9 +765,13 @@ reboot_loop:; cleanup_container_resources(cfg, 0, 0, 0); monitor_cleanup_and_exit: + /* Capture any final console output, then close the background console log. */ + if (!cfg->foreground && cfg->console.master >= 0) + ds_console_drain(cfg->console.master, console_log_fd, &console_logged); + if (console_log_fd >= 0) + close(console_log_fd); + /* Free dynamically allocated configuration members before exit */ - free_config_binds(cfg); - free_config_env_vars(cfg); - free_config_unknown_lines(cfg); + ds_config_free(cfg); _exit(WIFEXITED(status) ? WEXITSTATUS(status) : 0); } diff --git a/src/mount.c b/src/mount.c index 4c1d200c..87236dec 100644 --- a/src/mount.c +++ b/src/mount.c @@ -113,7 +113,7 @@ int domount_silent(const char *src, const char *tgt, const char *fstype, if (mount(src, tgt, fstype, flags, data) < 0) { if (errno != EBUSY) { ds_log("[DEBUG] mount %s -> %s failed: %s", src ? src : "none", - strerror(errno)); + tgt ? tgt : "none", strerror(errno)); return -1; } } @@ -151,15 +151,28 @@ int bind_mount(const char *src, const char *tgt) { return -1; } + /* Create the target if it does not exist yet. Use O_NOFOLLOW | O_EXCL for + * the file case so a symlink planted at the final component (e.g. by a + * malicious rootfs) is never followed or silently reused. */ struct stat st_tgt; if (lstat(tgt, &st_tgt) < 0) { if (S_ISDIR(st_src.st_mode)) { - mkdir(tgt, st_src.st_mode & 07777); + if (mkdir(tgt, st_src.st_mode & 07777) < 0 && errno != EEXIST) { + close(src_fd); + return -1; + } if (chown(tgt, st_src.st_uid, st_src.st_gid) < 0) { /* ignore chown failure, not critical for bind mount setup */ } } else { - write_file(tgt, ""); + int tf = + open(tgt, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0644); + if (tf < 0 && errno != EEXIST) { + close(src_fd); + return -1; + } + if (tf >= 0) + close(tf); } } else if (S_ISLNK(st_tgt.st_mode)) { ds_error("Security Violation: Bind target %s is a symlink!", tgt); @@ -168,11 +181,28 @@ int bind_mount(const char *src, const char *tgt) { return -1; } - char proc_path[64]; - snprintf(proc_path, sizeof(proc_path), "/proc/self/fd/%d", src_fd); + /* Re-open the target through O_PATH | O_NOFOLLOW and mount onto the resulting + * descriptor via /proc/self/fd. This pins the exact inode we validated, so + * the classic lstat()->mount() swap race (target replaced by a symlink in + * between) cannot redirect the bind outside the rootfs. O_NOFOLLOW also + * rejects a symlink at the final component here. (A symlink in an + * intermediate component is still resolved at open() time; fully closing + * that needs openat2(RESOLVE_NO_SYMLINKS), which is unavailable on the older + * kernels Droidspaces supports. Custom binds get an additional is_subpath() + * post-check in setup_custom_binds().) */ + int tgt_fd = open(tgt, O_PATH | O_NOFOLLOW | O_CLOEXEC); + if (tgt_fd < 0) { + close(src_fd); + return -1; + } + + char src_proc[64], tgt_proc[64]; + snprintf(src_proc, sizeof(src_proc), "/proc/self/fd/%d", src_fd); + snprintf(tgt_proc, sizeof(tgt_proc), "/proc/self/fd/%d", tgt_fd); - int res = domount(proc_path, tgt, NULL, MS_BIND | MS_REC, NULL); + int res = domount(src_proc, tgt_proc, NULL, MS_BIND | MS_REC, NULL); close(src_fd); + close(tgt_fd); return res; } @@ -1018,10 +1048,14 @@ int mount_rootfs_img(const char *img_path, char *mount_point, size_t mp_size, return -1; } - /* e2fsck: only for ext images */ + /* e2fsck: only for ext images. No -f: e2fsck consults the superblock and + * exits in milliseconds when the fs was unmounted cleanly, replays the + * journal when it was not, and runs a full repair only when the kernel + * flagged an error (errors=remount-ro) or the fs is otherwise marked unclean + * - so a clean start pays nothing instead of a forced full scan every boot. + */ if (strcmp(fstype, "ext4") == 0) { - char *e2fsck_argv[] = {"e2fsck", "-f", "-y", (char *)(uintptr_t)img_path, - NULL}; + char *e2fsck_argv[] = {"e2fsck", "-y", (char *)(uintptr_t)img_path, NULL}; if (run_command_quiet(e2fsck_argv) == 0) ds_log("Image checked and repaired successfully."); } @@ -1165,7 +1199,3 @@ int ds_fix_host_ptys(void) { ds_log("Host devpts mounted successfully (Recovery fix)."); return 0; } - -/* --------------------------------------------------------------------------- - * Container introspection helpers (used by info/show) - * ---------------------------------------------------------------------------*/ diff --git a/src/ds_dhcp.c b/src/net/dhcp.c similarity index 97% rename from src/ds_dhcp.c rename to src/net/dhcp.c index f101b555..a01febe1 100644 --- a/src/ds_dhcp.c +++ b/src/net/dhcp.c @@ -333,7 +333,7 @@ static void *dhcp_server_loop(void *arg) { uint8_t rx_buf[2048]; /* 0. Create the AF_PACKET socket. */ - packet_sock = socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL)); + packet_sock = socket(AF_PACKET, SOCK_RAW | SOCK_CLOEXEC, htons(ETH_P_ALL)); if (packet_sock < 0) { ds_warn("[DHCP] packet socket: %s", strerror(errno)); pthread_mutex_lock(&g_dhcp_lock); @@ -555,21 +555,24 @@ void ds_dhcp_server_start(struct ds_config *cfg, const char *veth_host, g_dhcp.offer_ip_be = offer_ip_be; g_dhcp.gw_ip_be = gw_ip_be; - /* Fetch Bridge MAC */ - /* We need the bridge MAC to spoof the source in DHCP replies. - * If we use 00:00:00... or a random MAC, the container's ARP will break. */ - int s = socket(AF_INET, SOCK_DGRAM, 0); + /* Source MAC for DHCP replies = the MAC of the interface we transmit on + * (veth_host, the bind interface). That device egresses the reply frame, + * and in bridgeless mode it also owns the gateway IP, so the container's ARP + * for the gateway resolves to a real MAC. Looking up the bind interface + * works in both bridged and bridgeless modes - no hardcoded bridge + * dependency. Falling back to all-zeros would break the container's ARP. */ + int s = socket(AF_INET, SOCK_DGRAM | SOCK_CLOEXEC, 0); if (s >= 0) { struct ifreq ifr; memset(&ifr, 0, sizeof(ifr)); - safe_strncpy(ifr.ifr_name, DS_NAT_BRIDGE, IFNAMSIZ); + safe_strncpy(ifr.ifr_name, veth_host, IFNAMSIZ); if (ioctl(s, SIOCGIFHWADDR, &ifr) == 0) { memcpy(g_dhcp.server_mac, ifr.ifr_hwaddr.sa_data, 6); } else { /* Fallback to all-zeros if SIOCGIFHWADDR fails (unlikely) */ memset(g_dhcp.server_mac, 0, 6); ds_warn("[DHCP] Failed to get MAC for %s: %s. Using all-zeros.", - DS_NAT_BRIDGE, strerror(errno)); + veth_host, strerror(errno)); } close(s); } else { diff --git a/src/ds_iptables.c b/src/net/iptables.c similarity index 73% rename from src/ds_iptables.c rename to src/net/iptables.c index 4736d5d2..b746dae8 100644 --- a/src/ds_iptables.c +++ b/src/net/iptables.c @@ -178,6 +178,17 @@ static int target_is_accept(const struct xt_entry_target *t) { return 0; } +/* Human-readable target name for logging. Standard targets (ACCEPT and the + * other built-in verdicts) carry an empty user.name - the verdict is encoded + * numerically - so decode the common ACCEPT case instead of printing ''. */ +static const char *target_label(const struct xt_entry_target *t) { + if (t->u.user.name[0]) + return t->u.user.name; + if (target_is_accept(t)) + return "ACCEPT"; + return "standard"; +} + /* --------------------------------------------------------------------------- * Internal: walk the blob to find an existing rule matching our fingerprint. * @@ -271,6 +282,58 @@ static void fixup_jump_targets(unsigned char *blob, unsigned int blob_sz, } } +/* --------------------------------------------------------------------------- + * Internal: fixup_jump_targets_removed + * + * Removal twin of fixup_jump_targets. After rules are deleted the surviving + * entries shift to lower offsets, but every xt_standard_target with a + * non-negative verdict (= absolute byte offset = chain jump) still holds its + * OLD offset. Re-base each by the number of bytes removed before that offset: + * old_offsets[k] gives an entry's old start, removed_before[k] the bytes + * removed before it. Without this the kernel's mark_source_chains() validator + * can no longer resolve the shifted jump (xt_find_jump_offset fails) and + * rejects the whole table replace with ELOOP. + * ---------------------------------------------------------------------------*/ + +static void fixup_jump_targets_removed(unsigned char *blob, + unsigned int blob_sz, + const unsigned int *old_offsets, + const unsigned int *removed_before, + unsigned int nents) { + unsigned int off = 0; + + while (off < blob_sz) { + struct ipt_entry *e = (struct ipt_entry *)(blob + off); + + if (e->next_offset < sizeof(*e) || off + e->next_offset > blob_sz) + break; + + if (e->target_offset + sizeof(struct xt_standard_target) <= + e->next_offset) { + struct xt_entry_target *t = + (struct xt_entry_target *)((uint8_t *)e + e->target_offset); + + if (t->u.user.name[0] == '\0' && + t->u.target_size == + (uint16_t)XT_ALIGN(sizeof(struct xt_standard_target))) { + struct xt_standard_target *st = (struct xt_standard_target *)t; + if (st->verdict >= 0) { + /* Jump target: find the tracked entry that started at this old + * offset and subtract the bytes removed before it. */ + for (unsigned int k = 0; k < nents; k++) { + if (old_offsets[k] == (unsigned int)st->verdict) { + st->verdict -= (int)removed_before[k]; + break; + } + } + } + } + } + + off += e->next_offset; + } +} + /* --------------------------------------------------------------------------- * Internal: insert_rule_at_hook * @@ -457,8 +520,13 @@ static int remove_matching_rules(int fd, const char *table_name, while (max_retries-- > 0) { unsigned char *new_blob = malloc(cur_info.size); - if (!new_blob) + if (!new_blob) { + /* On an EAGAIN refetch iteration cur_base owns a get_table() blob; free + * it before bailing so the early return does not leak it. */ + if (cur_base) + free(cur_base); return -ENOMEM; + } /* Allocate per-entry tracking arrays */ unsigned int *old_offsets = @@ -543,7 +611,8 @@ static int remove_matching_rules(int fd, const char *table_name, ei++; continue; } - ds_log("[IPT] remove: dropping '%s' rule at offset %u", tname, offset); + ds_log("[IPT] remove: matched '%s' rule at offset %u", target_label(t), + offset); cumulative_gone += e->next_offset; removed_count++; } else { @@ -565,6 +634,12 @@ static int remove_matching_rules(int fd, const char *table_name, break; } + /* Re-base surviving jump verdicts for the bytes just removed; without this + * the kernel rejects the table replace with ELOOP (see + * fixup_jump_targets_removed). */ + fixup_jump_targets_removed(new_blob, new_sz, old_offsets, removed_before, + ei + 1); + /* Build ipt_replace */ size_t replace_sz = sizeof(struct ipt_replace) + new_sz; struct ipt_replace *repl = calloc(1, replace_sz); @@ -659,6 +734,10 @@ static int remove_matching_rules(int fd, const char *table_name, continue; } + ds_warn( + "[IPT] remove: table replace on '%s' failed (%s) - caller will fall " + "back to the iptables binary", + table_name, strerror(err)); break; } @@ -695,7 +774,7 @@ static int should_use_raw_api(void) { static int open_raw_socket(void) { probe_iptables_modules(); - int fd = socket(AF_INET, SOCK_RAW, IPPROTO_RAW); + int fd = socket(AF_INET, SOCK_RAW | SOCK_CLOEXEC, IPPROTO_RAW); if (fd < 0) ds_log("[IPT] Failed to open raw socket: %s", strerror(errno)); return fd; @@ -803,6 +882,85 @@ int ds_ipt_ensure_masquerade(const char *src_cidr) { return run_command_quiet(argv); } +/* --------------------------------------------------------------------------- + * Internal: insert_iface_accept + * + * Insert "-I 1 [-i|-o] -j ACCEPT" into the filter table, + * idempotently, via the raw socket API with a per-rule binary fallback. + * is_out selects the egress (outiface) match; otherwise it matches ingress + * (iniface). Returns 0 when the rule is present (inserted, already there, or + * added via the binary fallback), or a negative errno when the table itself + * could not be read (the caller decides whether that warrants a full binary + * fallback). + * ---------------------------------------------------------------------------*/ + +static int insert_iface_accept(int fd, unsigned int hook, const char *chain, + const char *iface, int is_out) { + struct ipt_getinfo info; + unsigned char *base = NULL; + int ret = get_table(fd, "filter", &info, &base); + if (ret < 0) + return ret; + + const char *want_in = is_out ? NULL : iface; + const char *want_out = is_out ? iface : NULL; + + if (rule_exists_in_hook(&info, ENTRIES_BLOB(base), hook, want_in, want_out, 0, + 0, "ACCEPT")) { + ds_log("[IPT] %s -%c %s ACCEPT already present", chain, is_out ? 'o' : 'i', + iface); + free(base); + return 0; + } + + unsigned char rule_buf[XT_ALIGN(sizeof(struct ipt_entry)) + + XT_ALIGN(sizeof(struct xt_standard_target))]; + memset(rule_buf, 0, sizeof(rule_buf)); + + struct ipt_entry *re = (struct ipt_entry *)rule_buf; + struct xt_standard_target *st = + (struct xt_standard_target *)(rule_buf + + XT_ALIGN(sizeof(struct ipt_entry))); + + if (is_out) { + safe_strncpy(re->ip.outiface, iface, IFNAMSIZ); + memset(re->ip.outiface_mask, 0xff, strlen(iface) + 1); + } else { + safe_strncpy(re->ip.iniface, iface, IFNAMSIZ); + memset(re->ip.iniface_mask, 0xff, strlen(iface) + 1); + } + re->target_offset = (__u16)XT_ALIGN(sizeof(struct ipt_entry)); + re->next_offset = (__u16)sizeof(rule_buf); + + st->target.u.target_size = (__u16)XT_ALIGN(sizeof(struct xt_standard_target)); + st->target.u.user.name[0] = '\0'; + st->target.u.user.revision = 0; + st->verdict = -NF_ACCEPT - 1; + + ret = insert_rule_at_hook(fd, "filter", &info, ENTRIES_BLOB(base), hook, + rule_buf, sizeof(rule_buf)); + free(base); + + if (ret == 0) { + ds_log("[IPT] %s -%c %s ACCEPT inserted", chain, is_out ? 'o' : 'i', iface); + return 0; + } + + ds_log("[IPT] Raw insert %s -%c %s failed (ret=%d), using binary", chain, + is_out ? 'o' : 'i', iface, ret); + char *a[] = {"iptables", + "-I", + (char *)(uintptr_t)chain, + "1", + is_out ? "-o" : "-i", + (char *)(uintptr_t)iface, + "-j", + "ACCEPT", + NULL}; + run_command_quiet(a); + return 0; +} + /* --------------------------------------------------------------------------- * Public API: ds_ipt_ensure_forward_accept * @@ -820,119 +978,25 @@ int ds_ipt_ensure_forward_accept(const char *iface) { if (!should_use_raw_api()) goto binary_fallback_fwd; - int fd = open_raw_socket(); - if (fd < 0) - goto binary_fallback_fwd; - - /* -i iface */ { - struct ipt_getinfo info; - unsigned char *base = NULL; - int ret = get_table(fd, "filter", &info, &base); - if (ret < 0) { - close(fd); - if (ret == -ENOENT || ret == -ENOPROTOOPT || ret == -EACCES || - ret == -EOPNOTSUPP) - goto binary_fallback_fwd; - return ret; - } + int fd = open_raw_socket(); + if (fd < 0) + goto binary_fallback_fwd; - if (!rule_exists_in_hook(&info, ENTRIES_BLOB(base), NF_INET_FORWARD, iface, - NULL, 0, 0, "ACCEPT")) { - unsigned char rule_buf[XT_ALIGN(sizeof(struct ipt_entry)) + - XT_ALIGN(sizeof(struct xt_standard_target))]; - memset(rule_buf, 0, sizeof(rule_buf)); - - struct ipt_entry *re = (struct ipt_entry *)rule_buf; - struct xt_standard_target *st = - (struct xt_standard_target *)(rule_buf + - XT_ALIGN(sizeof(struct ipt_entry))); - - safe_strncpy(re->ip.iniface, iface, IFNAMSIZ); - memset(re->ip.iniface_mask, 0xff, strlen(iface) + 1); - re->target_offset = (__u16)XT_ALIGN(sizeof(struct ipt_entry)); - re->next_offset = (__u16)sizeof(rule_buf); - - st->target.u.target_size = - (__u16)XT_ALIGN(sizeof(struct xt_standard_target)); - st->target.u.user.name[0] = '\0'; - st->target.u.user.revision = 0; - st->verdict = -NF_ACCEPT - 1; - - ret = insert_rule_at_hook(fd, "filter", &info, ENTRIES_BLOB(base), - NF_INET_FORWARD, rule_buf, sizeof(rule_buf)); - if (ret == 0) { - ds_log("[IPT] FORWARD -i %s ACCEPT inserted", iface); - } else { - ds_log("[IPT] Raw insert -i %s failed (ret=%d), using binary", iface, - ret); - char *a[] = {"iptables", "-I", "FORWARD", - "1", "-i", (char *)(uintptr_t)iface, - "-j", "ACCEPT", NULL}; - run_command_quiet(a); - } - } else { - ds_log("[IPT] FORWARD -i %s ACCEPT already present", iface); - } - free(base); - } + int ret = insert_iface_accept(fd, NF_INET_FORWARD, "FORWARD", iface, 0); + if (ret == 0) + ret = insert_iface_accept(fd, NF_INET_FORWARD, "FORWARD", iface, 1); + close(fd); - /* Re-read table for the second insert */ - { - struct ipt_getinfo info; - unsigned char *base = NULL; - int ret = get_table(fd, "filter", &info, &base); if (ret < 0) { - close(fd); if (ret == -ENOENT || ret == -ENOPROTOOPT || ret == -EACCES || ret == -EOPNOTSUPP) goto binary_fallback_fwd; return ret; } - - if (!rule_exists_in_hook(&info, ENTRIES_BLOB(base), NF_INET_FORWARD, NULL, - iface, 0, 0, "ACCEPT")) { - unsigned char rule_buf[XT_ALIGN(sizeof(struct ipt_entry)) + - XT_ALIGN(sizeof(struct xt_standard_target))]; - memset(rule_buf, 0, sizeof(rule_buf)); - - struct ipt_entry *re = (struct ipt_entry *)rule_buf; - struct xt_standard_target *st = - (struct xt_standard_target *)(rule_buf + - XT_ALIGN(sizeof(struct ipt_entry))); - - safe_strncpy(re->ip.outiface, iface, IFNAMSIZ); - memset(re->ip.outiface_mask, 0xff, strlen(iface) + 1); - re->target_offset = (__u16)XT_ALIGN(sizeof(struct ipt_entry)); - re->next_offset = (__u16)sizeof(rule_buf); - - st->target.u.target_size = - (__u16)XT_ALIGN(sizeof(struct xt_standard_target)); - st->target.u.user.name[0] = '\0'; - st->target.u.user.revision = 0; - st->verdict = -NF_ACCEPT - 1; - - ret = insert_rule_at_hook(fd, "filter", &info, ENTRIES_BLOB(base), - NF_INET_FORWARD, rule_buf, sizeof(rule_buf)); - if (ret == 0) { - ds_log("[IPT] FORWARD -o %s ACCEPT inserted", iface); - } else { - ds_log("[IPT] Raw insert -o %s failed (ret=%d), using binary", iface, - ret); - char *a[] = {"iptables", "-I", "FORWARD", - "1", "-o", (char *)(uintptr_t)iface, - "-j", "ACCEPT", NULL}; - run_command_quiet(a); - } - } else { - ds_log("[IPT] FORWARD -o %s ACCEPT already present", iface); - } - free(base); + return 0; } - close(fd); - return 0; - binary_fallback_fwd: ds_log("[IPT] FORWARD ACCEPT binary fallback for iface=%s", iface); { @@ -961,62 +1025,23 @@ int ds_ipt_ensure_input_accept(const char *iface) { if (!should_use_raw_api()) goto binary_fallback_inp; - int fd = open_raw_socket(); - if (fd < 0) - goto binary_fallback_inp; - - struct ipt_getinfo info; - unsigned char *base = NULL; - int ret = get_table(fd, "filter", &info, &base); - if (ret < 0) { - close(fd); - if (ret == -ENOENT || ret == -ENOPROTOOPT || ret == -EACCES || - ret == -EOPNOTSUPP) + { + int fd = open_raw_socket(); + if (fd < 0) goto binary_fallback_inp; - return ret; - } - - if (!rule_exists_in_hook(&info, ENTRIES_BLOB(base), NF_INET_LOCAL_IN, iface, - NULL, 0, 0, "ACCEPT")) { - unsigned char rule_buf[XT_ALIGN(sizeof(struct ipt_entry)) + - XT_ALIGN(sizeof(struct xt_standard_target))]; - memset(rule_buf, 0, sizeof(rule_buf)); - struct ipt_entry *re = (struct ipt_entry *)rule_buf; - struct xt_standard_target *st = - (struct xt_standard_target *)(rule_buf + - XT_ALIGN(sizeof(struct ipt_entry))); - - safe_strncpy(re->ip.iniface, iface, IFNAMSIZ); - memset(re->ip.iniface_mask, 0xff, strlen(iface) + 1); - re->target_offset = (__u16)XT_ALIGN(sizeof(struct ipt_entry)); - re->next_offset = (__u16)sizeof(rule_buf); - - st->target.u.target_size = - (__u16)XT_ALIGN(sizeof(struct xt_standard_target)); - st->target.u.user.name[0] = '\0'; - st->target.u.user.revision = 0; - st->verdict = -NF_ACCEPT - 1; + int ret = insert_iface_accept(fd, NF_INET_LOCAL_IN, "INPUT", iface, 0); + close(fd); - ret = insert_rule_at_hook(fd, "filter", &info, ENTRIES_BLOB(base), - NF_INET_LOCAL_IN, rule_buf, sizeof(rule_buf)); - if (ret == 0) { - ds_log("[IPT] INPUT -i %s ACCEPT inserted", iface); - } else { - ds_log("[IPT] Raw insert INPUT failed (ret=%d), binary fallback", ret); - char *a[] = {"iptables", "-I", "INPUT", - "1", "-i", (char *)(uintptr_t)iface, - "-j", "ACCEPT", NULL}; - run_command_quiet(a); + if (ret < 0) { + if (ret == -ENOENT || ret == -ENOPROTOOPT || ret == -EACCES || + ret == -EOPNOTSUPP) + goto binary_fallback_inp; + return ret; } - } else { - ds_log("[IPT] INPUT -i %s ACCEPT already present", iface); + return 0; } - free(base); - close(fd); - return 0; - binary_fallback_inp: { char *a[] = {"iptables", "-I", "INPUT", "1", "-i", (char *)(uintptr_t)iface, @@ -1137,69 +1162,75 @@ int ds_ipt_remove_ds_rules(void) { uint32_t ds_src, ds_mask; parse_cidr(DS_DEFAULT_SUBNET, &ds_src, &ds_mask); - /* 1. NAT table: remove our MASQUERADE rule */ - if (fd >= 0) { - struct ipt_getinfo info; - unsigned char *base = NULL; - if (get_table(fd, "nat", &info, &base) == 0) { - remove_matching_rules(fd, "nat", &info, ENTRIES_BLOB(base), - NF_INET_POST_ROUTING, ds_src, ds_mask, NULL, 0); - free(base); - } - - /* 2. Filter table: remove our bridge FORWARD -i rule */ - base = NULL; - if (get_table(fd, "filter", &info, &base) == 0) { - remove_matching_rules(fd, "filter", &info, ENTRIES_BLOB(base), - NF_INET_FORWARD, 0, 0, DS_NAT_BRIDGE, - DS_IPT_MATCH_IN); - free(base); + /* Binary `iptables -D` forms. Used when there is no raw socket, and as a + * fallback whenever a raw-socket table replace fails to commit: the binary + * deletes reliably (taking the xtables lock), so the rule is actually removed + * rather than merely logged as removed. */ + char *del_masq[] = {"iptables", + "-t", + "nat", + "-D", + "POSTROUTING", + "-s", + DS_DEFAULT_SUBNET, + "!", + "-d", + DS_DEFAULT_SUBNET, + "-j", + "MASQUERADE", + NULL}; + char *del_fwd_in[] = {"iptables", "-D", "FORWARD", "-i", + DS_NAT_BRIDGE, "-j", "ACCEPT", NULL}; + char *del_fwd_out[] = {"iptables", "-D", "FORWARD", "-o", + DS_NAT_BRIDGE, "-j", "ACCEPT", NULL}; + char *del_inp[] = {"iptables", "-D", "INPUT", "-i", + DS_NAT_BRIDGE, "-j", "ACCEPT", NULL}; + + struct ds_ipt_del_target { + const char *table; + unsigned int hook; + uint32_t src; + uint32_t mask; + const char *iface; + unsigned int iface_flags; + char **binary_del; + } targets[] = { + {"nat", NF_INET_POST_ROUTING, ds_src, ds_mask, NULL, 0, del_masq}, + {"filter", NF_INET_FORWARD, 0, 0, DS_NAT_BRIDGE, DS_IPT_MATCH_IN, + del_fwd_in}, + {"filter", NF_INET_FORWARD, 0, 0, DS_NAT_BRIDGE, DS_IPT_MATCH_OUT, + del_fwd_out}, + {"filter", NF_INET_LOCAL_IN, 0, 0, DS_NAT_BRIDGE, DS_IPT_MATCH_IN, + del_inp}, + }; + + for (size_t i = 0; i < sizeof(targets) / sizeof(targets[0]); i++) { + /* rc < 0 means "not removed via the raw path" (no socket, or the kernel + * rejected the table replace) -> use the binary. rc == 0 means the raw + * path committed the delete, or nothing matched. */ + int rc = -1; + if (fd >= 0) { + struct ipt_getinfo info; + unsigned char *base = NULL; + if (get_table(fd, targets[i].table, &info, &base) == 0) { + rc = remove_matching_rules(fd, targets[i].table, &info, + ENTRIES_BLOB(base), targets[i].hook, + targets[i].src, targets[i].mask, + targets[i].iface, targets[i].iface_flags); + free(base); + } } - - /* 3. Filter table: remove our bridge FORWARD -o rule */ - base = NULL; - if (get_table(fd, "filter", &info, &base) == 0) { - remove_matching_rules(fd, "filter", &info, ENTRIES_BLOB(base), - NF_INET_FORWARD, 0, 0, DS_NAT_BRIDGE, - DS_IPT_MATCH_OUT); - free(base); + if (rc < 0) { + if (fd >= 0) + ds_log("[IPT] remove: raw delete failed for %s/hook%u - using the " + "iptables binary", + targets[i].table, targets[i].hook); + run_command_quiet(targets[i].binary_del); } + } - /* 4. Filter table: remove our bridge INPUT rule */ - base = NULL; - if (get_table(fd, "filter", &info, &base) == 0) { - remove_matching_rules(fd, "filter", &info, ENTRIES_BLOB(base), - NF_INET_LOCAL_IN, 0, 0, DS_NAT_BRIDGE, - DS_IPT_MATCH_IN); - free(base); - } + if (fd >= 0) close(fd); - } else { - /* Binary fallback for cleanup */ - char *del_masq[] = {"iptables", - "-t", - "nat", - "-D", - "POSTROUTING", - "-s", - DS_DEFAULT_SUBNET, - "!", - "-d", - DS_DEFAULT_SUBNET, - "-j", - "MASQUERADE", - NULL}; - run_command_quiet(del_masq); - char *del_fwd_in[] = {"iptables", "-D", "FORWARD", "-i", - DS_NAT_BRIDGE, "-j", "ACCEPT", NULL}; - run_command_quiet(del_fwd_in); - char *del_fwd_out[] = {"iptables", "-D", "FORWARD", "-o", - DS_NAT_BRIDGE, "-j", "ACCEPT", NULL}; - run_command_quiet(del_fwd_out); - char *del_inp[] = {"iptables", "-D", "INPUT", "-i", - DS_NAT_BRIDGE, "-j", "ACCEPT", NULL}; - run_command_quiet(del_inp); - } /* 3. MSS clamp: binary only */ { @@ -1298,7 +1329,7 @@ static int pf_state_remove(const char *container_ip) { to_dest, cont_port_str) != 5) continue; - /* Delete PREROUTING DNAT - mirror the variant that was inserted */ + /* Delete PREROUTING DNAT + FORWARD ACCEPT - mirror the variant inserted */ if (strcmp(variant, "addrtype") == 0) { char *del[] = {"iptables", "-t", "nat", "-D", "PREROUTING", "-p", proto, "-m", @@ -1306,21 +1337,47 @@ static int pf_state_remove(const char *container_ip) { host_port_str, "-j", "DNAT", "--to-destination", to_dest, NULL}; run_command_quiet(del); - } else { + + char cont_ip_buf[INET_ADDRSTRLEN]; + safe_strncpy(cont_ip_buf, container_ip, sizeof(cont_ip_buf)); + char *del_fwd[] = {"iptables", "-D", "FORWARD", "-p", + proto, "-d", cont_ip_buf, "--dport", + cont_port_str, "-j", "ACCEPT", NULL}; + run_command_quiet(del_fwd); + } else if (strcmp(variant, "basic") == 0) { char *del[] = {"iptables", "-t", "nat", "-D", "PREROUTING", "-p", proto, "--dport", host_port_str, "-j", "DNAT", "--to-destination", to_dest, NULL}; run_command_quiet(del); - } - /* Delete FORWARD ACCEPT */ - char cont_ip_buf[INET_ADDRSTRLEN]; - safe_strncpy(cont_ip_buf, container_ip, sizeof(cont_ip_buf)); - char *del_fwd[] = {"iptables", "-D", "FORWARD", "-p", - proto, "-d", cont_ip_buf, "--dport", - cont_port_str, "-j", "ACCEPT", NULL}; - run_command_quiet(del_fwd); + char cont_ip_buf[INET_ADDRSTRLEN]; + safe_strncpy(cont_ip_buf, container_ip, sizeof(cont_ip_buf)); + char *del_fwd[] = {"iptables", "-D", "FORWARD", "-p", + proto, "-d", cont_ip_buf, "--dport", + cont_port_str, "-j", "ACCEPT", NULL}; + run_command_quiet(del_fwd); + } else if (strcmp(variant, "local_out") == 0) { + /* Localhost OUTPUT DNAT (127.0.0.1:host_port -> to_dest) */ + char *del[] = {"iptables", "-t", + "nat", "-D", + "OUTPUT", "-p", + proto, "-o", + "lo", "--dport", + host_port_str, "-j", + "DNAT", "--to-destination", + to_dest, NULL}; + run_command_quiet(del); + } else if (strcmp(variant, "local_post") == 0) { + /* Localhost POSTROUTING MASQUERADE for return traffic */ + char cont_ip_buf[INET_ADDRSTRLEN]; + safe_strncpy(cont_ip_buf, container_ip, sizeof(cont_ip_buf)); + char *del[] = {"iptables", "-t", "nat", "-D", + "POSTROUTING", "-p", proto, "-d", + cont_ip_buf, "--dport", cont_port_str, "-j", + "MASQUERADE", NULL}; + run_command_quiet(del); + } } fclose(f); @@ -1328,6 +1385,25 @@ static int pf_state_remove(const char *container_ip) { return 1; } +/* Format the three iptables strings for one port-forward entry. Range entries + * (host_port_end set) use START:END for --dport and START-END for + * --to-destination; single ports use the plain number. */ +static void pf_fmt_ports(const struct ds_port_forward *pf, + const char *container_ip, char host_port_str[16], + char cont_port_str[16], char to_dest[80]) { + if (pf->host_port_end) { + snprintf(host_port_str, 16, "%u:%u", pf->host_port, pf->host_port_end); + snprintf(cont_port_str, 16, "%u:%u", pf->container_port, + pf->container_port_end); + snprintf(to_dest, 80, "%s:%u-%u", container_ip, pf->container_port, + pf->container_port_end); + } else { + snprintf(host_port_str, 16, "%u", pf->host_port); + snprintf(cont_port_str, 16, "%u", pf->container_port); + snprintf(to_dest, 80, "%s:%u", container_ip, pf->container_port); + } +} + /* --------------------------------------------------------------------------- * Public API: ds_ipt_add_portforwards * @@ -1362,24 +1438,19 @@ int ds_ipt_add_portforwards(struct ds_config *cfg, const char *container_ip) { * for every port forward entry. */ int use_addrtype = addrtype_available(); + /* Every port-forward rule below also gets a matching localhost DNAT + * (OUTPUT chain, -o lo) so 127.0.0.1:HOST reaches the container the same + * way LAN peers do. That requires route_localnet=1 (kernel drops loopback + * destined/sourced packets otherwise). Enable once here; the route monitor + * re-asserts it for the process lifetime (Android/netd resets it). */ + write_file("/proc/sys/net/ipv4/conf/all/route_localnet", "1"); + ds_net_mark_local_forward_active(); + for (int i = 0; i < cfg->port_forward_count; i++) { struct ds_port_forward *pf = &cfg->port_forwards[i]; char host_port_str[16], cont_port_str[16], to_dest[80]; - if (pf->host_port_end) { - /* Range syntax: START:END for --dport, START-END for --to-destination */ - snprintf(host_port_str, sizeof(host_port_str), "%u:%u", pf->host_port, - pf->host_port_end); - snprintf(cont_port_str, sizeof(cont_port_str), "%u:%u", - pf->container_port, pf->container_port_end); - snprintf(to_dest, sizeof(to_dest), "%s:%u-%u", container_ip, - pf->container_port, pf->container_port_end); - } else { - snprintf(host_port_str, sizeof(host_port_str), "%u", pf->host_port); - snprintf(cont_port_str, sizeof(cont_port_str), "%u", pf->container_port); - snprintf(to_dest, sizeof(to_dest), "%s:%u", container_ip, - pf->container_port); - } + pf_fmt_ports(pf, container_ip, host_port_str, cont_port_str, to_dest); ds_log("portforward: %s %s -> %s", pf->proto, host_port_str, to_dest); @@ -1455,6 +1526,48 @@ int ds_ipt_add_portforwards(struct ds_config *cfg, const char *container_ip) { if (inserted_variant) pf_state_append(state_f, inserted_variant, pf->proto, host_port_str, to_dest, cont_port_str); + + /* Localhost forwarding: 127.0.0.1: -> : + * Same proto/single-port/range coverage as the LAN-facing rules above - + * host_port_str/cont_port_str/to_dest are already formatted for ranges + * (START:END / START-END) by pf_fmt_ports, so this works identically for + * tcp, udp, single ports, and symmetric/asymmetric-width ranges. + * + * OUTPUT DNAT rewrites packets a local process sends to lo:host_port so + * they target the container. POSTROUTING MASQUERADE rewrites the source + * back to the container's gateway IP so return traffic routes correctly + * (without it the container sees the real 127.0.0.1 source and replies + * are dropped/misrouted). */ + char *lo_dnat[] = { + "iptables", "-t", "nat", "-I", "OUTPUT", + "1", "-p", pf->proto, "-o", "lo", + "--dport", host_port_str, "-j", "DNAT", "--to-destination", + to_dest, NULL}; + int lo_ok = (run_command_log(lo_dnat) == 0); + if (!lo_ok) + ds_warn("portforward: localhost OUTPUT DNAT failed for port %s", + host_port_str); + + char *lo_masq[] = {"iptables", "-t", + "nat", "-I", + "POSTROUTING", "1", + "-p", pf->proto, + "-d", (char *)(uintptr_t)container_ip, + "--dport", cont_port_str, + "-j", "MASQUERADE", + NULL}; + int masq_ok = (run_command_quiet(lo_masq) == 0); + if (!masq_ok) + ds_warn("portforward: localhost POSTROUTING MASQUERADE failed for " + "port %s", + cont_port_str); + + if (lo_ok) + pf_state_append(state_f, "local_out", pf->proto, host_port_str, to_dest, + cont_port_str); + if (masq_ok) + pf_state_append(state_f, "local_post", pf->proto, host_port_str, to_dest, + cont_port_str); } if (state_f) @@ -1511,19 +1624,7 @@ int ds_ipt_remove_portforwards(struct ds_config *cfg) { struct ds_port_forward *pf = &cfg->port_forwards[i]; char host_port_str[16], cont_port_str[16], to_dest[80]; - if (pf->host_port_end) { - snprintf(host_port_str, sizeof(host_port_str), "%u:%u", pf->host_port, - pf->host_port_end); - snprintf(cont_port_str, sizeof(cont_port_str), "%u:%u", - pf->container_port, pf->container_port_end); - snprintf(to_dest, sizeof(to_dest), "%s:%u-%u", container_ip, - pf->container_port, pf->container_port_end); - } else { - snprintf(host_port_str, sizeof(host_port_str), "%u", pf->host_port); - snprintf(cont_port_str, sizeof(cont_port_str), "%u", pf->container_port); - snprintf(to_dest, sizeof(to_dest), "%s:%u", container_ip, - pf->container_port); - } + pf_fmt_ports(pf, container_ip, host_port_str, cont_port_str, to_dest); /* addrtype variant */ char *del_at[] = { @@ -1555,6 +1656,26 @@ int ds_ipt_remove_portforwards(struct ds_config *cfg) { "ACCEPT", NULL}; run_command_quiet(del_fwd); + + /* Localhost OUTPUT DNAT */ + char *del_lo[] = {"iptables", "-t", + "nat", "-D", + "OUTPUT", "-p", + pf->proto, "-o", + "lo", "--dport", + host_port_str, "-j", + "DNAT", "--to-destination", + to_dest, NULL}; + run_command_quiet(del_lo); + + /* Localhost POSTROUTING MASQUERADE */ + char *del_masq[] = { + "iptables", "-t", "nat", + "-D", "POSTROUTING", "-p", + pf->proto, "-d", (char *)(uintptr_t)container_ip, + "--dport", cont_port_str, "-j", + "MASQUERADE", NULL}; + run_command_quiet(del_masq); } /* Pass 3: iptables-save shell sweep (fallback) @@ -1564,7 +1685,18 @@ int ds_ipt_remove_portforwards(struct ds_config *cfg) { * process crashed before the file could be written. * We intentionally avoid this path in the normal case: parsing iptables-save * output through a shell is slower and depends on the host having sh(1). */ - if (!had_state) { + /* Defense-in-depth: container_ip is already validated to 172.28.x.x at every + * ingest point, but Pass 3 is the only path that interpolates it into a shell + * command. Re-validate it as a plain IPv4 literal and skip the sweep + * otherwise, so no shell metacharacter can ever reach sh -c. */ + struct in_addr ip_check; + int ip_ok = (inet_pton(AF_INET, container_ip, &ip_check) == 1); + if (!had_state && !ip_ok) + ds_warn("Skipping iptables-save shell sweep: container IP '%s' is not a " + "valid IPv4 literal", + container_ip); + + if (!had_state && ip_ok) { char cmd[512]; /* Remove PREROUTING DNAT rules whose --to-destination targets this IP */ @@ -1586,6 +1718,29 @@ int ds_ipt_remove_portforwards(struct ds_config *cfg) { container_ip); char *sh_fwd[] = {"sh", "-c", cmd, NULL}; run_command_quiet(sh_fwd); + + /* Remove localhost OUTPUT DNAT rules whose --to-destination targets + * this IP (127.0.0.1:HOST -> container_ip:PORT). */ + snprintf(cmd, sizeof(cmd), + "iptables-save -t nat | grep ' -A OUTPUT ' | " + "grep -- '-o lo ' | grep -- '--to-destination %s:' | " + "sed 's/ -A / -D /' | " + "while IFS= read -r rule; do iptables -t nat $rule; done", + container_ip); + char *sh_lo[] = {"sh", "-c", cmd, NULL}; + run_command_quiet(sh_lo); + + /* Remove localhost POSTROUTING MASQUERADE rules whose -d + --dport + * target this IP. Filtered on --dport too so the general uplink + * MASQUERADE rule (whole subnet, no -d/--dport) is never touched. */ + snprintf(cmd, sizeof(cmd), + "iptables-save -t nat | grep ' -A POSTROUTING ' | " + "grep -- ' -d %s/32 ' | grep -- '--dport' | " + "grep ' -j MASQUERADE' | sed 's/ -A / -D /' | " + "while IFS= read -r rule; do iptables -t nat $rule; done", + container_ip); + char *sh_masq[] = {"sh", "-c", cmd, NULL}; + run_command_quiet(sh_masq); } return 0; diff --git a/src/ds_netlink.c b/src/net/netlink.c similarity index 67% rename from src/ds_netlink.c rename to src/net/netlink.c index 0b2d1390..0a2af65d 100644 --- a/src/ds_netlink.c +++ b/src/net/netlink.c @@ -23,10 +23,18 @@ * Netlink rule attributes (linux/fib_rules.h - not always in Android sysroot) * ---------------------------------------------------------------------------*/ #ifndef FRA_DST -#define FRA_DST 1 /* destination address */ -#define FRA_SRC 2 /* source address */ -#define FRA_PRIORITY 6 /* rule priority / preference */ -#define FRA_TABLE 15 /* extended table id */ +#define FRA_DST 1 /* destination address */ +#define FRA_SRC 2 /* source address */ +#define FRA_IIFNAME 3 /* input interface name */ +#define FRA_PRIORITY 6 /* rule priority / preference */ +#define FRA_FWMARK 10 /* fwmark value */ +#define FRA_TABLE 15 /* extended table id */ +#define FRA_FWMASK 16 /* fwmark mask */ +#define FRA_UID_RANGE 20 /* uid range (kernel 4.10+; Android per-app rules) */ +#endif + +#ifndef FR_ACT_TO_TBL +#define FR_ACT_TO_TBL 1 /* rule action: look up the referenced table */ #endif /* --------------------------------------------------------------------------- @@ -65,9 +73,28 @@ static struct rtattr *nl_nest_begin(struct nlmsghdr *n, int maxlen, int type) { /* Fix up the length of a nested rtattr opened by nl_nest_begin */ static void nl_nest_end(struct nlmsghdr *n, struct rtattr *nest) { + /* nl_nest_begin() returns NULL if the buffer was exhausted; guard here so + * every caller is covered without a NULL write. The message is then built + * without the nest and the kernel rejects it (EINVAL) rather than crashing. + */ + if (!nest) + return; nest->rta_len = (unsigned short)((uint8_t *)NLMSG_TAIL(n) - (uint8_t *)nest); } +/* Read a 4-byte netlink attribute payload safely. RTA_OK() bounds only the + * declared attribute length, not that the payload is a full 4 bytes, so a + * truncated/malformed attribute would otherwise be read out of bounds. + * Returns 0 for a short attribute. (Defensive: the source is the host kernel, + * which always emits full-width attributes.) memcpy also avoids any unaligned + * access. */ +static uint32_t nl_rta_u32(struct rtattr *rta) { + uint32_t v = 0; + if (RTA_PAYLOAD(rta) >= (int)sizeof(v)) + memcpy(&v, RTA_DATA(rta), sizeof(v)); + return v; +} + /* --------------------------------------------------------------------------- * Context lifecycle * ---------------------------------------------------------------------------*/ @@ -161,7 +188,11 @@ static int ds_nl_talk(ds_nl_ctx_t *ctx, struct nlmsghdr *req) { continue; /* more fragments coming */ return 0; } - break; + /* No message matching our sequence was in this datagram. This happens when + * a prior request left a trailing ACK in the socket buffer, or for a + * multipart reply that spans datagrams. recv() again for our actual reply + * rather than (wrongly) reporting success - the bug that made + * ds_nl_link_exists() return false positives. */ } return 0; } @@ -268,7 +299,11 @@ int ds_nl_link_exists(ds_nl_ctx_t *ctx, const char *ifname) { memset(&req, 0, sizeof(req)); req.n.nlmsg_len = NLMSG_LENGTH(sizeof(struct ifinfomsg)); req.n.nlmsg_type = RTM_GETLINK; - req.n.nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK; + /* No NLM_F_ACK: a successful GETLINK already replies with RTM_NEWLINK; adding + * ACK makes the kernel send a *second* (NLMSG_ERROR err=0) message, whose + * leftover in the socket buffer used to desync the next request's reply. + * A missing link still returns NLMSG_ERROR(-ENODEV) regardless of ACK. */ + req.n.nlmsg_flags = NLM_F_REQUEST; req.i.ifi_family = AF_UNSPEC; nl_addattr(&req.n, (int)sizeof(req), IFLA_IFNAME, ifname, (int)strlen(ifname) + 1); @@ -407,7 +442,8 @@ int ds_nl_set_master(ds_nl_ctx_t *ctx, const char *ifname, const char *master) { * Bring link UP / DOWN * ---------------------------------------------------------------------------*/ -int ds_nl_link_up(ds_nl_ctx_t *ctx, const char *ifname) { +/* Set or clear IFF_UP on an interface (shared by link_up / link_down). */ +static int ds_nl_link_set_up(ds_nl_ctx_t *ctx, const char *ifname, int up) { int idx = ds_nl_get_ifindex(ctx, ifname); if (idx <= 0) return -ENODEV; @@ -422,29 +458,17 @@ int ds_nl_link_up(ds_nl_ctx_t *ctx, const char *ifname) { req.n.nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK; req.i.ifi_family = AF_UNSPEC; req.i.ifi_index = idx; - req.i.ifi_flags = IFF_UP; + req.i.ifi_flags = up ? IFF_UP : 0; req.i.ifi_change = IFF_UP; return ds_nl_talk(ctx, &req.n); } -int ds_nl_link_down(ds_nl_ctx_t *ctx, const char *ifname) { - int idx = ds_nl_get_ifindex(ctx, ifname); - if (idx <= 0) - return -ENODEV; +int ds_nl_link_up(ds_nl_ctx_t *ctx, const char *ifname) { + return ds_nl_link_set_up(ctx, ifname, 1); +} - struct { - struct nlmsghdr n; - struct ifinfomsg i; - } req; - memset(&req, 0, sizeof(req)); - req.n.nlmsg_len = NLMSG_LENGTH(sizeof(struct ifinfomsg)); - req.n.nlmsg_type = RTM_NEWLINK; - req.n.nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK; - req.i.ifi_family = AF_UNSPEC; - req.i.ifi_index = idx; - req.i.ifi_flags = 0; - req.i.ifi_change = IFF_UP; - return ds_nl_talk(ctx, &req.n); +int ds_nl_link_down(ds_nl_ctx_t *ctx, const char *ifname) { + return ds_nl_link_set_up(ctx, ifname, 0); } /* --------------------------------------------------------------------------- @@ -499,6 +523,33 @@ int ds_nl_rename(ds_nl_ctx_t *ctx, const char *ifname, const char *newname) { return ds_nl_talk(ctx, &req.n); } +/* --------------------------------------------------------------------------- + * Set an interface's L2 hardware address (IFLA_ADDRESS). + * mac points to 6 bytes. The link should be DOWN to avoid EBUSY on some + * drivers; veth tolerates it either way, but callers set it before bringing + * the interface up. + * ---------------------------------------------------------------------------*/ + +int ds_nl_set_mac(ds_nl_ctx_t *ctx, const char *ifname, const uint8_t mac[6]) { + int idx = ds_nl_get_ifindex(ctx, ifname); + if (idx <= 0) + return -ENODEV; + + struct { + struct nlmsghdr n; + struct ifinfomsg i; + char buf[256]; + } req; + memset(&req, 0, sizeof(req)); + req.n.nlmsg_len = NLMSG_LENGTH(sizeof(struct ifinfomsg)); + req.n.nlmsg_type = RTM_NEWLINK; + req.n.nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK; + req.i.ifi_family = AF_UNSPEC; + req.i.ifi_index = idx; + nl_addattr(&req.n, (int)sizeof(req), IFLA_ADDRESS, mac, 6); + return ds_nl_talk(ctx, &req.n); +} + /* --------------------------------------------------------------------------- * Add an IPv4 address to an interface * ip_be and bcast_be are in network byte order. @@ -597,6 +648,51 @@ int ds_nl_move_to_netns(ds_nl_ctx_t *ctx, const char *ifname, int netns_fd) { return ds_nl_talk(ctx, &req.n); } +/* --------------------------------------------------------------------------- + * Move an interface into another network namespace AND rename it in the same + * RTM_NEWLINK request (IFLA_NET_NS_FD + IFLA_IFNAME). The kernel applies both + * in a single op, so the device appears in the target netns already carrying + * its final name - there is no transient "raw name then rename" window for a + * daemon (e.g. OpenWrt netifd) in that netns to race against. + * + * The link is brought DOWN first: renaming a running device returns EBUSY on + * some drivers, and a freshly created veth peer is down anyway. The device + * arrives DOWN in the target netns; the caller brings it up there. + * + * Returns 0 on success. On failure (e.g. newname already exists in the target + * netns) nothing is guaranteed to have moved; callers should fall back to a + * plain move + in-netns rename. + * ---------------------------------------------------------------------------*/ +int ds_nl_move_to_netns_named(ds_nl_ctx_t *ctx, const char *ifname, + int netns_fd, const char *newname) { + if (!newname || !newname[0]) + return ds_nl_move_to_netns(ctx, ifname, netns_fd); + + ds_nl_link_down(ctx, ifname); /* allow rename during the move */ + + int idx = ds_nl_get_ifindex(ctx, ifname); + if (idx <= 0) + return -ENODEV; + + struct { + struct nlmsghdr n; + struct ifinfomsg i; + char buf[256]; + } req; + memset(&req, 0, sizeof(req)); + req.n.nlmsg_len = NLMSG_LENGTH(sizeof(struct ifinfomsg)); + req.n.nlmsg_type = RTM_NEWLINK; + req.n.nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK; + req.i.ifi_family = AF_UNSPEC; + req.i.ifi_index = idx; + + nl_addattr(&req.n, (int)sizeof(req), IFLA_NET_NS_FD, &netns_fd, + (int)sizeof(int)); + nl_addattr(&req.n, (int)sizeof(req), IFLA_IFNAME, newname, + (int)strlen(newname) + 1); + return ds_nl_talk(ctx, &req.n); +} + /* --------------------------------------------------------------------------- * Flush stale Droidspaces veth interfaces via RTM_GETLINK dump * @@ -668,8 +764,8 @@ void ds_nl_flush_stale_veths(ds_nl_ctx_t *ctx, const char *prefix) { * * Fills `names` with up to `max` interface name strings. * Returns the number of interfaces found. - * Used by find_active_upstream() in network.c for wildcard pattern matching - * against entries like "*rmnet_data*" or "v4-rmnet_data*". + * Used by the uplink whitelist scan in network.c for pattern matching + * against interface families like "rmnet*" or "*ccmni*". * ---------------------------------------------------------------------------*/ int ds_nl_list_ifaces(ds_nl_ctx_t *ctx, char names[][IFNAMSIZ], int max) { struct { @@ -774,6 +870,69 @@ int ds_nl_count_ifaces_with_prefix(ds_nl_ctx_t *ctx, const char *prefix) { return count; } +/* --------------------------------------------------------------------------- + * Count links enslaved to `bridge` whose name starts with `prefix`. + * + * Used by gateway-mode cleanup to refcount clients on a specific delegated + * bridge (the global ds-v* count cannot tell ds-br0 from ds-lan clients). + * Returns 0 if the bridge does not exist. + * ---------------------------------------------------------------------------*/ +int ds_nl_count_bridge_members_with_prefix(ds_nl_ctx_t *ctx, const char *bridge, + const char *prefix) { + unsigned int br_idx = if_nametoindex(bridge); + if (br_idx == 0) + return 0; + + struct { + struct nlmsghdr n; + struct ifinfomsg i; + } req; + memset(&req, 0, sizeof(req)); + req.n.nlmsg_len = NLMSG_LENGTH(sizeof(struct ifinfomsg)); + req.n.nlmsg_type = RTM_GETLINK; + req.n.nlmsg_flags = NLM_F_REQUEST | NLM_F_DUMP; + req.i.ifi_family = AF_UNSPEC; + req.n.nlmsg_seq = ++ctx->seq; + req.n.nlmsg_pid = (uint32_t)ctx->pid; + + if (send(ctx->fd, &req, req.n.nlmsg_len, 0) < 0) + return 0; + + int count = 0; + size_t prefix_len = strlen(prefix); + uint8_t buf[NL_BUFSIZE]; + + for (;;) { + ssize_t n = recv(ctx->fd, buf, sizeof(buf), 0); + if (n <= 0) + break; + struct nlmsghdr *h = (struct nlmsghdr *)buf; + for (; NLMSG_OK(h, (uint32_t)n); h = NLMSG_NEXT(h, n)) { + if (h->nlmsg_type == NLMSG_DONE) + goto member_done; + if (h->nlmsg_type != RTM_NEWLINK) + continue; + struct ifinfomsg *ifi = NLMSG_DATA(h); + struct rtattr *rta = IFLA_RTA(ifi); + int rlen = (int)IFLA_PAYLOAD(h); + char ifname[IFNAMSIZ] = {0}; + int master = 0; + for (; RTA_OK(rta, rlen); rta = RTA_NEXT(rta, rlen)) { + if (rta->rta_type == IFLA_IFNAME) + safe_strncpy(ifname, RTA_DATA(rta), IFNAMSIZ); + else if (rta->rta_type == IFLA_MASTER) + master = (int)nl_rta_u32(rta); + } + if (master == (int)br_idx && ifname[0] && + strncmp(ifname, prefix, prefix_len) == 0) + count++; + } + } + +member_done: + return count; +} + /* --------------------------------------------------------------------------- * Find the default-route table used for internet connectivity * @@ -793,7 +952,7 @@ int ds_nl_count_ifaces_with_prefix(ds_nl_ctx_t *ctx, const char *prefix) { * Per-interface route table lookup * * Finds the routing table that holds the IPv4 default route for a specific - * named interface. This is the core primitive used by the upstream monitor: + * named interface. This is the core primitive used by the uplink monitor: * rather than guessing the active internet table from all routes (which is * ambiguous on Android where multiple interfaces can have simultaneous * default routes in separate per-interface tables), we ask directly: @@ -851,9 +1010,9 @@ int ds_nl_get_iface_table(ds_nl_ctx_t *ctx, const char *ifname, int rlen = (int)RTM_PAYLOAD(h); for (; RTA_OK(rta, rlen); rta = RTA_NEXT(rta, rlen)) { if (rta->rta_type == RTA_TABLE) - r_table = *(int *)RTA_DATA(rta); + r_table = (int)nl_rta_u32(rta); if (rta->rta_type == RTA_OIF) - r_oif = *(int *)RTA_DATA(rta); + r_oif = (int)nl_rta_u32(rta); } if ((unsigned int)r_oif == target_idx) { @@ -871,6 +1030,233 @@ int ds_nl_get_iface_table(ds_nl_ctx_t *ctx, const char *ifname, return 0; } +/* --------------------------------------------------------------------------- + * Default-route OIF lookup for a specific routing table + * + * Dumps IPv4 routes and returns the interface owning the default route in + * `table`. When several default routes coexist in the table (multi-homed + * hosts), the lowest metric (RTA_PRIORITY) wins - the same tie-break the + * kernel itself applies. + * + * On Android this transparently handles 464xlat: on IPv6-only mobile + * networks the IPv4 default route inside the default network's table points + * at the CLAT interface (v4-rmnet_dataX), which is exactly the interface + * IPv4 forwarding needs. + * + * Returns 0 and fills ifname_out (IFNAMSIZ) on success. + * Returns -ENOENT if the table has no IPv4 default route. + * ---------------------------------------------------------------------------*/ +int ds_nl_get_table_default_oif(ds_nl_ctx_t *ctx, int table, char *ifname_out) { + struct { + struct nlmsghdr n; + struct rtmsg r; + } req; + memset(&req, 0, sizeof(req)); + req.n.nlmsg_len = NLMSG_LENGTH(sizeof(struct rtmsg)); + req.n.nlmsg_type = RTM_GETROUTE; + req.n.nlmsg_flags = NLM_F_REQUEST | NLM_F_DUMP; + req.r.rtm_family = AF_INET; + req.n.nlmsg_seq = ++ctx->seq; + req.n.nlmsg_pid = (uint32_t)ctx->pid; + + if (send(ctx->fd, &req, req.n.nlmsg_len, 0) < 0) + return -errno; + + uint8_t buf[NL_BUFSIZE]; + int best_oif = 0; + uint32_t best_metric = 0; + int found = 0; + + for (;;) { + ssize_t n = recv(ctx->fd, buf, sizeof(buf), 0); + if (n <= 0) + break; + + struct nlmsghdr *h = (struct nlmsghdr *)buf; + for (; NLMSG_OK(h, (uint32_t)n); h = NLMSG_NEXT(h, n)) { + if (h->nlmsg_type == NLMSG_DONE) + goto table_oif_done; + if (h->nlmsg_type != RTM_NEWROUTE) + continue; + + struct rtmsg *r = NLMSG_DATA(h); + /* Only IPv4 unicast default routes */ + if (r->rtm_family != AF_INET || r->rtm_dst_len != 0) + continue; + if (r->rtm_type != RTN_UNICAST) + continue; + + int r_table = r->rtm_table; + int r_oif = 0; + uint32_t r_metric = 0; + + struct rtattr *rta = RTM_RTA(r); + int rlen = (int)RTM_PAYLOAD(h); + for (; RTA_OK(rta, rlen); rta = RTA_NEXT(rta, rlen)) { + if (rta->rta_type == RTA_TABLE) + r_table = (int)nl_rta_u32(rta); + if (rta->rta_type == RTA_OIF) + r_oif = (int)nl_rta_u32(rta); + if (rta->rta_type == RTA_PRIORITY) + r_metric = nl_rta_u32(rta); + } + + if (r_table != table || r_oif <= 0) + continue; + if (!found || r_metric < best_metric) { + best_oif = r_oif; + best_metric = r_metric; + found = 1; + } + } + } + +table_oif_done: + if (!found) + return -ENOENT; + if (ifname_out) { + ifname_out[0] = '\0'; + if (!if_indextoname((unsigned int)best_oif, ifname_out) || !ifname_out[0]) + return -ENODEV; + } + return 0; +} + +/* --------------------------------------------------------------------------- + * Android default-network detection via the kernel FIB rule table + * + * Android's netd installs exactly one IPv4 rule of the form: + * ": from all fwmark 0x0/0xffff iif lo lookup " + * for the active default internet network. It is swapped atomically when + * the default network changes (wifi <-> mobile data handoffs), making it + * the kernel's ground truth. IMS/MMS-only interfaces never appear here - + * they use explicit fwmarks (0xd0064, 0xd0066 etc). + * + * This is the same source of truth AOSP's own tethering (RouteController) + * consumes; we read it directly over RTM_GETRULE instead of shelling out + * to `ip rule show`, so it works even where no `ip` binary exists. + * + * If several matching rules exist, the lowest FRA_PRIORITY (= highest + * precedence) wins, matching kernel rule evaluation order. + * + * Returns 0 and fills ifname_out (IFNAMSIZ) / table_out on success. + * Returns -ENOENT when no such rule exists (non-Android, airplane mode). + * ---------------------------------------------------------------------------*/ +int ds_nl_get_android_default(ds_nl_ctx_t *ctx, char *ifname_out, + int *table_out) { + struct { + struct nlmsghdr n; + struct rtmsg r; + } req; + memset(&req, 0, sizeof(req)); + req.n.nlmsg_len = NLMSG_LENGTH(sizeof(struct rtmsg)); + req.n.nlmsg_type = RTM_GETRULE; + req.n.nlmsg_flags = NLM_F_REQUEST | NLM_F_DUMP; + req.r.rtm_family = AF_INET; + req.n.nlmsg_seq = ++ctx->seq; + req.n.nlmsg_pid = (uint32_t)ctx->pid; + + if (send(ctx->fd, &req, req.n.nlmsg_len, 0) < 0) + return -errno; + + uint8_t buf[NL_BUFSIZE]; + int best_table = 0; + uint32_t best_prio = 0; + int found = 0; + + for (;;) { + ssize_t n = recv(ctx->fd, buf, sizeof(buf), 0); + if (n <= 0) + break; + + struct nlmsghdr *h = (struct nlmsghdr *)buf; + for (; NLMSG_OK(h, (uint32_t)n); h = NLMSG_NEXT(h, n)) { + if (h->nlmsg_type == NLMSG_DONE) + goto rule_dump_done; + if (h->nlmsg_type != RTM_NEWRULE) + continue; + + struct rtmsg *r = NLMSG_DATA(h); + if (r->rtm_family != AF_INET) + continue; + /* Only table-lookup actions - skips prohibit/unreachable variants */ + if (r->rtm_type != FR_ACT_TO_TBL) + continue; + /* "from all" only - src/dst selectors mean a different kind of rule */ + if (r->rtm_src_len != 0 || r->rtm_dst_len != 0) + continue; + + int r_table = r->rtm_table; + uint32_t r_prio = 0; + uint32_t fwmark = 0, fwmask = 0; + int have_mark = 0, have_mask = 0, have_uidrange = 0; + char iifname[IFNAMSIZ] = {0}; + + struct rtattr *rta = RTM_RTA(r); + int rlen = (int)RTM_PAYLOAD(h); + for (; RTA_OK(rta, rlen); rta = RTA_NEXT(rta, rlen)) { + switch (rta->rta_type) { + case FRA_TABLE: + r_table = (int)nl_rta_u32(rta); + break; + case FRA_PRIORITY: + r_prio = nl_rta_u32(rta); + break; + case FRA_FWMARK: + fwmark = nl_rta_u32(rta); + have_mark = 1; + break; + case FRA_FWMASK: + fwmask = nl_rta_u32(rta); + have_mask = 1; + break; + case FRA_IIFNAME: + safe_strncpy(iifname, RTA_DATA(rta), IFNAMSIZ); + break; + case FRA_UID_RANGE: + have_uidrange = 1; + break; + } + } + + /* The netd default-network signature: fwmark 0x0/0xffff iif lo. + * + * Kernel quirk: fib_nl_fill_rule() only emits FRA_FWMARK when the + * mark is non-zero, so for this rule (mark 0x0) the attribute is + * absent from the dump and only FRA_FWMASK (0xffff) is present. + * An absent FRA_FWMARK therefore means mark == 0 - exactly what + * `ip rule show` assumes when it prints "fwmark 0x0/0xffff". */ + if (!have_mask || fwmask != 0xffff) + continue; + if (have_mark && fwmark != 0) + continue; + if (strcmp(iifname, "lo") != 0) + continue; + /* Skip uid-scoped rules (Android 12+ per-app / work-profile network + * preference). They share the default-network signature at higher + * precedence but only apply to specific uids - we want the global + * default network, which is the rule without a uid range. */ + if (have_uidrange) + continue; + if (r_table <= 0) + continue; + + if (!found || r_prio < best_prio) { + best_table = r_table; + best_prio = r_prio; + found = 1; + } + } + } + +rule_dump_done: + if (!found) + return -ENOENT; + if (table_out) + *table_out = best_table; + return ds_nl_get_table_default_oif(ctx, best_table, ifname_out); +} + /* --------------------------------------------------------------------------- * IPv4 policy rule management (RTM_NEWRULE / RTM_DELRULE) * ---------------------------------------------------------------------------*/ diff --git a/src/net/network.c b/src/net/network.c new file mode 100644 index 00000000..523919eb --- /dev/null +++ b/src/net/network.c @@ -0,0 +1,2301 @@ +/* + * Droidspaces v6 - High-performance Container Runtime + * + * Network configuration: DNS, host-side setup, rootfs-side setup, + * veth pair management, and network cleanup. + * + * All link/addr/route management uses the pure-C RTNETLINK API + * (ds_netlink.c). All iptables management uses the raw socket API + * (ds_iptables.c). No external binary dependencies for core networking. + * + * Copyright (C) 2026 ravindu644 + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +#include "droidspace.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* --------------------------------------------------------------------------- + * Internal helpers + * ---------------------------------------------------------------------------*/ + +/* Host-side veth prefix for an application container. NAT containers use + * "ds-v"; gateway clients use "ds-c" so the NAT-mode "last container" refcount + * (which scans for "ds-v") never miscounts a gateway client as a live NAT + * container and so keeps shared NAT iptables/route rules alive forever. */ +static const char *app_veth_host_prefix(const struct ds_config *cfg) { + return (cfg && cfg->net_mode == DS_NET_GATEWAY) ? "ds-c" : "ds-v"; +} + +/* Derive the host-side veth name from a container init PID (mode-aware). */ +static void veth_host_name(const struct ds_config *cfg, pid_t pid, char *buf, + size_t sz) { + snprintf(buf, sz, "%s%d", app_veth_host_prefix(cfg), (int)pid); +} + +/* Derive the peer (container-side) veth name from a container init PID. + * Gateway clients use "ds-q" to match the distinct host-side prefix. */ +static void veth_peer_name(const struct ds_config *cfg, pid_t pid, char *buf, + size_t sz) { + const char *p = (cfg && cfg->net_mode == DS_NET_GATEWAY) ? "ds-q" : "ds-p"; + snprintf(buf, sz, "%s%d", p, (int)pid); +} + +/* Derive a deterministic IP from a PID (avoids sequential collisions) */ +static void veth_peer_ip(pid_t pid, char *buf, size_t sz) { + /* Multiplicative hash to spread sequential PIDs across the /16 subnet. + * + * The /16 space gives us 256 third-octets (172.28.x.y) each with 254 + * usable host addresses, for 65534 total. + * + * octet3: 0–255, but we skip 0 (network row) → range 1–254 (254 rows) + * octet4: 0–255, but we skip 0 (net) and 255 (bcast) → range 1–254 + * + * We also reserve 172.28.0.x entirely for gateway/infrastructure: + * octet3 starts at 1 so the first container gets 172.28.1.x, keeping + * 172.28.0.1 (DS_NAT_GW_IP) unambiguously the gateway in every row. */ + uint32_t hash = (uint32_t)pid; + hash = ((hash >> 16) ^ hash) * 0x45d9f3b; + int octet3 = (int)(((hash >> 8) % 254) + 1); + int octet4 = (int)((hash % 254) + 1); + snprintf(buf, sz, "172.28.%d.%d/%d", octet3, octet4, DS_NAT_PREFIX); +} + +static uint32_t ds_net_hash_string(const char *s) { + uint32_t h = 5381; + if (!s) + return h; + while (*s) + h = ((h << 5) + h) ^ (unsigned char)*s++; + return h; +} + +/* Derive a stable, locally-administered unicast MAC from a key string, salted + * with a domain prefix so the two callers never collide on the same key. + * + * "ds-mac:" - the container's own eth0. A gateway (e.g. + * OpenWrt) then sees one persistent host + * across restarts - one DHCP lease / LuCI + * entry - instead of a fresh random MAC. + * "ds-gwmac:" - a gateway LAN-side veth (becomes e.g. eth1 + * inside OpenWrt). Keeps the same MAC across + * every (re)plug so netifd sees one + * persistent device, not a new one each time. + */ +static void ds_derive_mac(const char *key, const char *salt_prefix, + uint8_t mac[6]) { + uint32_t h1 = ds_net_hash_string(key); + char salted[512]; + snprintf(salted, sizeof(salted), "%s%s", salt_prefix, key ? key : ""); + uint32_t h2 = ds_net_hash_string(salted); + mac[0] = 0x02; /* locally administered (bit1), unicast (bit0 clear) */ + mac[1] = (uint8_t)(h1 >> 24); + mac[2] = (uint8_t)(h1 >> 16); + mac[3] = (uint8_t)(h1 >> 8); + mac[4] = (uint8_t)(h1); + mac[5] = (uint8_t)(h2); +} + +static void gateway_hash_key(struct ds_config *cfg, char *buf, size_t sz) { + const char *gw = + (cfg && cfg->gateway_container[0]) ? cfg->gateway_container : "gateway"; + const char *net = (cfg && cfg->gateway_net[0]) ? cfg->gateway_net : "lan"; + snprintf(buf, sz, "%s:%s", gw, net); +} + +static void gateway_veth_names(struct ds_config *cfg, char *host, size_t hsz, + char *peer, size_t psz) { + char key[384]; + gateway_hash_key(cfg, key, sizeof(key)); + uint32_t h = ds_net_hash_string(key); + snprintf(host, hsz, "ds-g%08x", h); + snprintf(peer, psz, "ds-h%08x", h); +} + +static int gateway_ifname_component_ok(const char *s) { + if (!s || !s[0]) + return 0; + for (const unsigned char *p = (const unsigned char *)s; *p; p++) { + if (!(isalnum(*p) || *p == '_' || *p == '-')) + return 0; + } + return 1; +} + +static void gateway_bridge_name(struct ds_config *cfg, char *buf, size_t sz) { + if (cfg && cfg->gateway_bridge[0]) { + safe_strncpy(buf, cfg->gateway_bridge, sz); + return; + } + + const char *net = (cfg && cfg->gateway_net[0]) ? cfg->gateway_net : "lan"; + char clean[10] = {0}; + size_t j = 0; + for (size_t i = 0; net[i] && j < sizeof(clean) - 1; i++) { + unsigned char c = (unsigned char)net[i]; + if (isalnum(c) || c == '_' || c == '-') + clean[j++] = (char)c; + } + if (j == 0) + safe_strncpy(clean, "lan", sizeof(clean)); + snprintf(buf, sz, "ds-%s", clean); +} + +static const char *gateway_lan_ifname(struct ds_config *cfg) { + if (cfg && cfg->gateway_lan_ifname[0]) + return cfg->gateway_lan_ifname; + return "eth1"; +} + +static int ds_netns_rename_up(const char *netns_path, const char *old_name, + const char *new_name) { + int self_fd = open("/proc/self/ns/net", O_RDONLY | O_CLOEXEC); + if (self_fd < 0) + return -errno; + + int target_fd = open(netns_path, O_RDONLY | O_CLOEXEC); + if (target_fd < 0) { + int e = -errno; + close(self_fd); + return e; + } + + int ret = 0; + if (setns(target_fd, CLONE_NEWNET) < 0) { + ret = -errno; + goto out_restore; + } + + ds_nl_ctx_t *ctx = ds_nl_open(); + if (!ctx) { + ret = -errno; + goto out_restore; + } + + if (new_name && new_name[0] && ds_nl_link_exists(ctx, new_name)) { + /* Already present under its final name inside the gateway - the common, + * healthy case: the atomic move+rename created it, or this is an idempotent + * re-entry. Just make sure it is up; the caller logs "uplink ready". */ + ds_nl_link_up(ctx, new_name); + } else if (old_name && old_name[0] && new_name && new_name[0] && + strcmp(old_name, new_name) != 0) { + if (ds_nl_rename(ctx, old_name, new_name) < 0) { + ds_warn("[NET] Gateway: failed to rename %s to %s", old_name, new_name); + ret = -1; + } else { + ds_nl_link_up(ctx, new_name); + } + } else if (old_name && old_name[0]) { + ds_nl_link_up(ctx, old_name); + } + + ds_nl_close(ctx); + +out_restore: + if (setns(self_fd, CLONE_NEWNET) < 0 && ret == 0) + ret = -errno; + close(target_fd); + close(self_fd); + return ret; +} + +/* Return 1 if interface `ifname` exists inside the netns at `netns_path`, else + * 0. Used to tell a healthy gateway cable (its peer really lives inside the + * CURRENT gateway netns) apart from a stale host-side veth whose peer is + * stranded in a zombie netns - one kept alive past container stop by a leftover + * process (e.g. tailscaled). On any error it returns 0, i.e. "not present", so + * the caller rebuilds the cable: the safe default. */ +static int netns_has_link(const char *netns_path, const char *ifname) { + if (!netns_path || !ifname || !ifname[0]) + return 0; + + int self_fd = open("/proc/self/ns/net", O_RDONLY | O_CLOEXEC); + if (self_fd < 0) + return 0; + int target_fd = open(netns_path, O_RDONLY | O_CLOEXEC); + if (target_fd < 0) { + close(self_fd); + return 0; + } + + int present = 0; + if (setns(target_fd, CLONE_NEWNET) == 0) { + ds_nl_ctx_t *ctx = ds_nl_open(); + if (ctx) { + present = ds_nl_link_exists(ctx, ifname) ? 1 : 0; + ds_nl_close(ctx); + } + if (setns(self_fd, CLONE_NEWNET) < 0) + ds_warn("[NET] Gateway: failed to restore netns after liveness check: %s", + strerror(errno)); + } + + close(target_fd); + close(self_fd); + return present; +} + +/* --------------------------------------------------------------------------- + * Uplink routing globals - shared by android routing setup and monitor + * ---------------------------------------------------------------------------*/ + +static int g_current_gw_table = 0; +static pthread_mutex_t g_gw_mutex = PTHREAD_MUTEX_INITIALIZER; +static int g_route_monitor_sock = -1; +static volatile sig_atomic_t g_stop_monitor = 0; +static pthread_t g_route_monitor_tid; +static int g_route_monitor_started = 0; /* loop active; guarded by g_gw_mutex */ +static int g_route_monitor_needs_join = 0; /* thread created, not yet joined; + guarded by g_gw_mutex */ +/* Set once any container installs localhost port-forward DNAT rules. + * Sticky - never cleared, since route_localnet=1 is harmless to leave on + * and other containers may still depend on it. */ +static volatile sig_atomic_t g_local_forward_active = 0; + +/* User-pinned upstream interfaces (--upstream). When non-empty, the uplink is + * resolved ONLY from this list (priority order, literals + wildcards) and all + * automatic detection is disabled - an explicit manual override. Copied from + * cfg in setup_veth_host_side(), before routing setup and the monitor read it. + */ +static char g_upstream_ifaces[DS_MAX_UPSTREAM_IFACES][IFNAMSIZ]; +static int g_upstream_count = 0; + +/* Returns 1 if ifname exists and is both UP and RUNNING. + * On Android, the active data interface has IFF_RUNNING set; an interface + * that is physically present but not carrying data loses IFF_RUNNING. */ +static int iface_is_running(const char *ifname) { + int fd = socket(AF_INET, SOCK_DGRAM | SOCK_CLOEXEC, 0); + if (fd < 0) + return 0; + struct ifreq ifr; + memset(&ifr, 0, sizeof(ifr)); + safe_strncpy(ifr.ifr_name, ifname, IFNAMSIZ); + int ret = 0; + if (ioctl(fd, SIOCGIFFLAGS, &ifr) == 0) + ret = (ifr.ifr_flags & (IFF_UP | IFF_RUNNING)) == (IFF_UP | IFF_RUNNING); + close(fd); + return ret; +} + +/* --------------------------------------------------------------------------- + * Built-in uplink classification (no user configuration) + * + * k_uplink_patterns: the only interface families that terminate internet + * access on Android, highest precedence first - Wi-Fi STA, ethernet + * adapters, CLAT (464xlat IPv4-over-IPv6), Qualcomm mobile data, MTK + * mobile data. + * + * k_uplink_excludes: interfaces that must NEVER be picked as an uplink. + * swlan, ap, usb, rndis and ncm devices are created by Android to SHARE + * its connectivity downstream (hotspot / USB tethering - Android acts as + * the router, these face clients, not the internet). tun/ppp are VPN + * tunnels which container traffic intentionally bypasses (see the + * DS_RULE_PRIO_* rationale in droidspace.h). The rest are loopback, + * placeholders, and our own bridge/veth devices. + * ---------------------------------------------------------------------------*/ + +static const char *const k_uplink_patterns[] = { + "wlan*", "eth*", "v4-*", "rmnet*", "*ccmni*", +}; + +static const char *const k_uplink_excludes[] = { + "ds-*", "lo", "dummy*", "swlan*", "ap*", "usb*", + "rndis*", "ncm*", "p2p*", "tun*", "ppp*", "bt-pan", +}; + +static int uplink_name_excluded(const char *ifname) { + for (size_t i = 0; + i < sizeof(k_uplink_excludes) / sizeof(k_uplink_excludes[0]); i++) { + if (fnmatch(k_uplink_excludes[i], ifname, 0) == 0) + return 1; + } + return 0; +} + +/* --------------------------------------------------------------------------- + * Public helper: populate a ds_net_handshake from a container init PID + * ---------------------------------------------------------------------------*/ + +void ds_net_derive_handshake(pid_t init_pid, struct ds_config *cfg, + struct ds_net_handshake *hs) { + veth_peer_name(cfg, init_pid, hs->peer_name, sizeof(hs->peer_name)); + /* Use the already-resolved static IP - not the PID-hash fallback. + * ip_str is informational on the child side (voided in + * setup_veth_child_side_named) but the boot.c log line prints it, + * so it should reflect the actual IP the DHCP server will offer. */ + if (cfg && cfg->net_mode == DS_NET_NAT && cfg->static_nat_ip[0]) + safe_strncpy(hs->ip_str, cfg->static_nat_ip, sizeof(hs->ip_str)); + else if (cfg && cfg->net_mode == DS_NET_NAT) + veth_peer_ip(init_pid, hs->ip_str, + sizeof(hs->ip_str)); /* last-resort fallback */ + else + hs->ip_str[0] = '\0'; +} + +/* --------------------------------------------------------------------------- + * Host-side networking setup (before container boot) + * ---------------------------------------------------------------------------*/ + +int ds_get_dns_servers(const char *custom_dns, char *out, size_t size) { + out[0] = '\0'; + int count = 0; + + /* 0. Try custom DNS if provided */ + if (custom_dns && custom_dns[0]) { + char buf[1024]; + safe_strncpy(buf, custom_dns, sizeof(buf)); + char *saveptr; + char *token = strtok_r(buf, ", ", &saveptr); + while (token && (size_t)strlen(out) < size - 32) { + char line[128]; + snprintf(line, sizeof(line), "nameserver %s\n", token); + size_t current_len = strlen(out); + snprintf(out + current_len, size - current_len, "%s", line); + count++; + token = strtok_r(NULL, ", ", &saveptr); + } + } + + /* 1. Global stable fallbacks (defined in droidspace.h) */ + if (count == 0) { + int n = snprintf(out, size, "nameserver %s\nnameserver %s\n", + DS_DNS_DEFAULT_1, DS_DNS_DEFAULT_2); + if (n > 0 && (size_t)n < size) + count = 2; + } + + return count; +} + +/* --------------------------------------------------------------------------- + * Static NAT IP: validation, collision check, and resolution + * ---------------------------------------------------------------------------*/ + +int ds_net_validate_static_ip(const char *ip_str, char *errbuf, + size_t errsize) { + if (!ip_str || !ip_str[0]) { + snprintf(errbuf, errsize, "empty IP string"); + return -1; + } + + /* Reject CIDR notation - we store plain dotted-decimal */ + if (strchr(ip_str, '/')) { + snprintf(errbuf, errsize, + "pass plain IP without prefix length " + "(e.g. 172.28.5.10, not 172.28.5.10/16)"); + return -1; + } + + struct in_addr addr; + if (inet_pton(AF_INET, ip_str, &addr) != 1) { + snprintf(errbuf, errsize, "not a valid IPv4 address"); + return -1; + } + + int o1, o2, o3, o4; + if (sscanf(ip_str, "%d.%d.%d.%d", &o1, &o2, &o3, &o4) != 4) { + snprintf(errbuf, errsize, "malformed IPv4 address"); + return -1; + } + + /* Must be inside 172.28.0.0/16 */ + if (o1 != 172 || o2 != 28) { + snprintf(errbuf, errsize, + "must be inside the NAT subnet " DS_DEFAULT_SUBNET " (got %s)", + ip_str); + return -1; + } + + /* Octet3: reserve row 0 entirely for gateway/infrastructure (172.28.0.x) */ + if (o3 < 1 || o3 > 254) { + snprintf(errbuf, errsize, + "third octet %d out of range - must be 1-254 " + "(172.28.0.x is reserved for the gateway)", + o3); + return -1; + } + + /* Octet4: exclude network address (0) and broadcast (255) */ + if (o4 < 1 || o4 > 254) { + snprintf(errbuf, errsize, "fourth octet %d out of range - must be 1-254", + o4); + return -1; + } + + return 0; +} + +int ds_net_check_ip_collision(const char *ip_str, const char *exclude_name) { + char containers_dir[PATH_MAX]; + snprintf(containers_dir, sizeof(containers_dir), "%s/Containers", + get_workspace_dir()); + + /* The directory entries are sanitized names (written by + * ds_config_save_by_name via sanitize_container_name). Sanitize exclude_name + * the same way so the self-skip comparison is always apples-to-apples. */ + char safe_exclude[256] = {0}; + if (exclude_name && exclude_name[0]) + sanitize_container_name(exclude_name, safe_exclude, sizeof(safe_exclude)); + + DIR *d = opendir(containers_dir); + if (!d) + return 0; /* Can't scan → assume unique */ + + struct dirent *ent; + int collision = 0; + + while ((ent = readdir(d)) != NULL && !collision) { + if (ent->d_name[0] == '.') + continue; + /* Skip the container being configured so it can keep its own IP on restart + */ + if (safe_exclude[0] && strcmp(ent->d_name, safe_exclude) == 0) + continue; + + /* config_path must hold: containers_dir (PATH_MAX-1) + '/' + + * ent->d_name (NAME_MAX = 255) + "/container.config" (18) + NUL. + * A plain PATH_MAX buffer overflows that worst case - use an explicit + * worst-case size so -Werror=format-truncation is satisfied. */ + char config_path[PATH_MAX + NAME_MAX + 32]; + snprintf(config_path, sizeof(config_path), "%s/%s/container.config", + containers_dir, ent->d_name); + + struct ds_config other; + memset(&other, 0, sizeof(other)); + other.net_ready_pipe[0] = other.net_ready_pipe[1] = -1; + other.net_done_pipe[0] = other.net_done_pipe[1] = -1; + + if (ds_config_load(config_path, &other) == 0) { + if (other.static_nat_ip[0] && strcmp(other.static_nat_ip, ip_str) == 0) + collision = 1; + ds_config_free(&other); + } + } + + closedir(d); + return collision; +} + +/* Internal: derive a unique IP via djb2(container_name), walking forward on + * collision. Deterministic on first boot → same row every time the same + * container name is used, spreading containers across the /16. */ +static void ds_net_auto_assign_ip(struct ds_config *cfg) { + uint32_t hash = ds_net_hash_string(cfg->container_name); + + int o3 = (int)((hash >> 8) % 254) + 1; /* 1-254 */ + int o4 = (int)(hash % 254) + 1; /* 1-254 */ + + /* Walk up to a full /16 to find an unoccupied slot */ + for (int attempts = 0; attempts < 254 * 254; attempts++) { + char candidate[32]; + snprintf(candidate, sizeof(candidate), "172.28.%d.%d", o3, o4); + + if (ds_net_check_ip_collision(candidate, cfg->container_name) == 0) { + safe_strncpy(cfg->static_nat_ip, candidate, sizeof(cfg->static_nat_ip)); + return; + } + + if (++o4 > 254) { + o4 = 1; + if (++o3 > 254) + o3 = 1; + } + } + + /* Subnet is completely exhausted - reuse hash address as last resort */ + char fallback[32]; + snprintf(fallback, sizeof(fallback), "172.28.%d.%d", o3, o4); + safe_strncpy(cfg->static_nat_ip, fallback, sizeof(cfg->static_nat_ip)); + ds_warn("[NET] NAT subnet appears fully allocated - reusing %s as fallback", + cfg->static_nat_ip); +} + +void ds_net_resolve_static_ip(struct ds_config *cfg) { + char errbuf[256]; + + if (cfg->static_nat_ip[0]) { + /* IP already set - from --nat-ip flag or loaded from a previous boot. + * Re-validate in case the config was hand-edited since last boot. */ + if (ds_net_validate_static_ip(cfg->static_nat_ip, errbuf, sizeof(errbuf)) != + 0) { + ds_warn("[NET] static_nat_ip '%s' failed validation: %s " + "- auto-assigning a new IP", + cfg->static_nat_ip, errbuf); + cfg->static_nat_ip[0] = '\0'; + + } else if (ds_net_check_ip_collision(cfg->static_nat_ip, + cfg->container_name)) { + ds_warn("[NET] static_nat_ip '%s' is already assigned to another " + "container - auto-assigning a new IP", + cfg->static_nat_ip); + cfg->static_nat_ip[0] = '\0'; + } + } + + if (!cfg->static_nat_ip[0]) + ds_net_auto_assign_ip(cfg); + + ds_log("[NET] Container '%s' → static NAT IP: %s (persisted to config)", + cfg->container_name, cfg->static_nat_ip); +} + +int fix_networking_host(struct ds_config *cfg) { + ds_log("Configuring host-side networking for %s...", cfg->container_name); + + /* Enable IPv4 forwarding */ + write_file("/proc/sys/net/ipv4/ip_forward", "1"); + + /* Re-enable IPv6 globally only in host mode if not disabled */ + if (cfg->net_mode == DS_NET_HOST && !cfg->disable_ipv6) { + write_file("/proc/sys/net/ipv6/conf/all/disable_ipv6", "0"); + write_file("/proc/sys/net/ipv6/conf/default/disable_ipv6", "0"); + } + + /* Get DNS and store it in the config struct to be used after pivot_root */ + cfg->dns_server_content[0] = '\0'; + int count = ds_get_dns_servers(cfg->dns_servers, cfg->dns_server_content, + sizeof(cfg->dns_server_content)); + + if (cfg->dns_servers[0]) + ds_log("Setting up %d custom DNS servers...", count); + + return 0; +} + +/* --------------------------------------------------------------------------- + * Android-specific policy routing + * + * Detects the active uplink (the routing table netd designates as the + * default internet network), then injects low-priority ip rules to direct + * container traffic through that table. Fully automatic - the route + * monitor keeps the rule in sync across wifi <-> mobile-data handoffs. + * ---------------------------------------------------------------------------*/ + +/* Forward declaration - defined later in this file after route monitor globals + */ +static int find_active_uplink(ds_nl_ctx_t *ctx, char *iface_out, + int *table_out); + +static void ds_net_setup_android_routing(ds_nl_ctx_t *ctx) { + char active_iface[IFNAMSIZ] = {0}; + int gw_table = 0; + find_active_uplink(ctx, active_iface, &gw_table); + + uint32_t subnet_be, mask_be; + parse_cidr(DS_DEFAULT_SUBNET, &subnet_be, &mask_be); + uint8_t prefix = DS_NAT_PREFIX; + + /* DS_RULE_PRIO_TO_SUBNET (6090): inbound traffic to our subnet always + * resolves via main table. Install this even if no uplink is active + * yet - the monitor will handle the FROM rule once an interface comes up. + * + * Priority 6090 is: + * • above Android's VPN rule range (10000–22000) -> checked FIRST, so + * reply-to-container traffic is never hijacked by a VPN's catch-all rule + * • above OEM reserved low-priority rules (typically < 1000) */ + int ret = ds_nl_add_rule4(ctx, 0, 0, subnet_be, prefix, RT_TABLE_MAIN, + DS_RULE_PRIO_TO_SUBNET); + if (ret < 0) + ds_warn("[NET] Android routing: failed to add 'to subnet' rule (%d)", + DS_RULE_PRIO_TO_SUBNET); + + /* DS_RULE_PRIO_TETHER (6095): replies from our subnet to hotspot/USB-tether + * clients must consult netd's local_network table (which holds every + * downstream interface's connected route and no default route) before the + * uplink table grabs them. Installed regardless of uplink state - tether + * clients can reach forwarded ports even with no WAN. */ + ret = ds_nl_add_rule4(ctx, subnet_be, prefix, 0, 0, + DS_ANDROID_TABLE_LOCAL_NETWORK, DS_RULE_PRIO_TETHER); + if (ret < 0) + ds_warn("[NET] Android routing: failed to add tether-return rule (%d)", + DS_RULE_PRIO_TETHER); + + /* No uplink yet: find_active_uplink() already logged the single "no WAN" + * line. The TO_SUBNET/tether rules above are in place; the route monitor + * installs the FROM rule once an uplink appears. */ + if (!active_iface[0]) + return; + + ds_log("[NET] Android routing: active uplink %s → table %d", active_iface, + gw_table); + + /* DS_RULE_PRIO_FROM_SUBNET (6100): traffic from our subnet → uplink + * internet table. Also above Android's VPN range so container-originated + * traffic always routes through the physical uplink, not through any VPN + * tunnel (the container has its own isolation layer). */ + ret = ds_nl_add_rule4(ctx, subnet_be, prefix, 0, 0, gw_table, + DS_RULE_PRIO_FROM_SUBNET); + if (ret == 0) { + ds_log("[NET] Android routing: rule from %s lookup table %d (prio %d)", + DS_DEFAULT_SUBNET, gw_table, DS_RULE_PRIO_FROM_SUBNET); + /* Seed the monitor's current table so it knows the baseline */ + pthread_mutex_lock(&g_gw_mutex); + g_current_gw_table = gw_table; + pthread_mutex_unlock(&g_gw_mutex); + } else { + ds_warn("[NET] Android routing: ds_nl_add_rule4 failed (ret=%d)", ret); + } +} + +/* --------------------------------------------------------------------------- + * TX checksum disable (Samsung/MTK kernel workaround) + * ---------------------------------------------------------------------------*/ + +int ds_net_disable_tx_checksum(const char *ifname) { + int fd = socket(AF_INET, SOCK_DGRAM | SOCK_CLOEXEC, 0); + if (fd < 0) + return -errno; + + struct ifreq ifr; + memset(&ifr, 0, sizeof(ifr)); + safe_strncpy(ifr.ifr_name, ifname, IFNAMSIZ); + + struct ethtool_value eval; + eval.cmd = ETHTOOL_STXCSUM; + eval.data = 0; /* Disable */ + ifr.ifr_data = (caddr_t)&eval; + + int ret = ioctl(fd, SIOCETHTOOL, &ifr); + close(fd); + return (ret < 0) ? -errno : 0; +} + +/* --------------------------------------------------------------------------- + * setup_veth_host_side + * + * Called from the Monitor process AFTER receiving the "ready" signal from the + * container init (via net_ready_pipe). + * + * Steps: + * 1. Create or reuse bridge ds-br0 with IP 172.28.0.1/16 + * 2. iptables: MASQUERADE + FORWARD ACCEPT + INPUT ACCEPT + MSS clamp + * 3. Create veth pair (ds-vXXXXX / ds-pXXXXX) + * 4. Disable TX checksum on host veth (Samsung/MTK workaround) + * 5. Attach host veth to bridge, bring up + * 6. Move peer veth into container's network namespace + * 7. Android policy routing + * ---------------------------------------------------------------------------*/ + +int setup_veth_host_side(struct ds_config *cfg, pid_t child_pid) { + char veth_host[IFNAMSIZ], veth_peer[IFNAMSIZ]; + veth_host_name(cfg, child_pid, veth_host, sizeof(veth_host)); + veth_peer_name(cfg, child_pid, veth_peer, sizeof(veth_peer)); + + ds_log("Setting up host-side NAT networking for %s (PID %d)...", + cfg->container_name, (int)child_pid); + + ds_nl_ctx_t *ctx = ds_nl_open(); + if (!ctx) { + ds_warn("[NET] Failed to open RTNETLINK socket"); + return -1; + } + + /* Clean up stale interfaces from previous runs */ + ds_log("[DEBUG] Cleaning up any stale interfaces: %s, %s", veth_host, + veth_peer); + ds_nl_del_link(ctx, veth_host); + + /* 1. Ensure bridge exists (SKIP for bridgeless fallback) */ + if (!cfg->net_bridgeless) { + if (!ds_nl_link_exists(ctx, DS_NAT_BRIDGE)) { + ds_log("[DEBUG] Creating bridge %s...", DS_NAT_BRIDGE); + if (ds_nl_create_bridge(ctx, DS_NAT_BRIDGE) < 0) + ds_warn("[DEBUG] Failed to create bridge %s", DS_NAT_BRIDGE); + } + + /* Always assert bridge IP/UP/Hardening even if it already exists. + * This ensures everything is correct after host-side networking changes or + * crashes. */ + int err = ds_nl_add_addr4(ctx, DS_NAT_BRIDGE, inet_addr(DS_NAT_GW_IP), + DS_NAT_PREFIX); + if (err < 0 && err != -EEXIST && err != -ENETDOWN) { + ds_warn("[DEBUG] Failed to add IP to %s: %d", DS_NAT_BRIDGE, err); + } + + if (ds_nl_link_up(ctx, DS_NAT_BRIDGE) < 0) + ds_warn("[DEBUG] Failed to bring up %s", DS_NAT_BRIDGE); + + /* Disable ICMP redirects on the bridge. */ + write_file("/proc/sys/net/ipv4/conf/" DS_NAT_BRIDGE "/accept_redirects", + "0"); + write_file("/proc/sys/net/ipv4/conf/" DS_NAT_BRIDGE "/send_redirects", "0"); + write_file("/proc/sys/net/ipv4/conf/" DS_NAT_BRIDGE "/rp_filter", "0"); + } else { + ds_log("[NET] Bridgeless Fallback: skipping bridge creation."); + } + + /* Late-stage hardening: sysctl for bridge */ + if (cfg->net_mode == DS_NET_NAT) { + ds_log("[DEBUG] Applying late-stage hardening for Android NAT..."); + if (!cfg->net_bridgeless) { + if (access("/proc/sys/net/bridge", F_OK) == 0) { + write_file("/proc/sys/net/bridge/bridge-nf-call-iptables", "0"); + write_file("/proc/sys/net/bridge/bridge-nf-call-ip6tables", "0"); + } + ds_ipt_ensure_input_accept(DS_NAT_BRIDGE); + } else { + write_file("/proc/sys/net/ipv4/conf/all/rp_filter", "0"); + write_file("/proc/sys/net/ipv4/conf/default/rp_filter", "0"); + /* In bridgeless mode, we must accept input from the veth itself */ + ds_ipt_ensure_input_accept(veth_host); + } + } + + /* 2. iptables rules */ + if (ds_ipt_ensure_masquerade(DS_DEFAULT_SUBNET) < 0) + ds_warn("[NET] MASQUERADE rule failed"); + if (!cfg->net_bridgeless) { + if (ds_ipt_ensure_forward_accept(DS_NAT_BRIDGE) < 0) + ds_warn("[NET] FORWARD ACCEPT failed"); + } else { + if (ds_ipt_ensure_forward_accept(veth_host) < 0) + ds_warn("[NET] FORWARD ACCEPT failed"); + } + ds_ipt_ensure_mss_clamp(); + + /* 3. Create veth pair */ + ds_log("[DEBUG] Creating veth pair %s <-> %s...", veth_host, veth_peer); + if (ds_nl_create_veth(ctx, veth_host, veth_peer) < 0) { + ds_warn("[NET] Failed to create veth pair (%s, %s)", veth_host, veth_peer); + ds_nl_close(ctx); + return -1; + } + + /* 4. Disable TX checksum on host veth */ + ds_net_disable_tx_checksum(veth_host); + + /* 5. Set master or assign IP directly for PTP */ + if (!cfg->net_bridgeless) { + if (ds_nl_set_master(ctx, veth_host, DS_NAT_BRIDGE) < 0) + ds_warn("[NET] Failed to attach %s to %s", veth_host, DS_NAT_BRIDGE); + } else { + /* Bridgeless Fallback: Assign GW IP to veth_host directly */ + if (ds_nl_add_addr4(ctx, veth_host, inet_addr(DS_NAT_GW_IP), 32) < 0) + ds_warn("[NET] Bridgeless: Failed to add IP to %s", veth_host); + + /* Interface must be UP before routes can be added on some kernels */ + if (ds_nl_link_up(ctx, veth_host) < 0) + ds_warn("[NET] Failed to bring up %s", veth_host); + + /* Add host route for the container's static IP to this veth. + * cfg->static_nat_ip is already resolved and persisted before fork. */ + struct in_addr peer_in; + if (inet_pton(AF_INET, cfg->static_nat_ip, &peer_in) == 1) { + if (ds_nl_add_route4(ctx, peer_in.s_addr, 32, 0, + ds_nl_get_ifindex(ctx, veth_host)) < 0) + ds_warn("[NET] Bridgeless: Failed to add route for %s", + cfg->static_nat_ip); + } else { + ds_warn("[NET] Bridgeless: static_nat_ip '%s' is not parseable - " + "no host route installed", + cfg->static_nat_ip); + } + } + + /* Ensure veth_host is UP (redundant if bridgeless but safe) */ + if (ds_nl_link_up(ctx, veth_host) < 0) + ds_warn("[NET] Failed to bring up %s", veth_host); + + /* Disable ICMP redirects on the host veth. */ + { + char sysctl_path[128]; + snprintf(sysctl_path, sizeof(sysctl_path), + "/proc/sys/net/ipv4/conf/%s/accept_redirects", veth_host); + write_file(sysctl_path, "0"); + } + + /* 6. Move peer veth into container's network namespace */ + char netns_path[PATH_MAX]; + snprintf(netns_path, sizeof(netns_path), "/proc/%d/ns/net", child_pid); + + /* No retry loop needed; init has already signaled readiness */ + int netns_fd = open(netns_path, O_RDONLY | O_CLOEXEC); + if (netns_fd < 0) { + ds_warn("[NET] Failed to open container netns %s: %s", netns_path, + strerror(errno)); + ds_nl_close(ctx); + return -1; + } + + ds_log("[DEBUG] Moving %s into netns of PID %d using FD %d...", veth_peer, + (int)child_pid, netns_fd); + int r = ds_nl_move_to_netns(ctx, veth_peer, netns_fd); + close(netns_fd); + + if (r < 0) { + ds_warn("[NET] Failed to move %s into container netns (ret=%d)", veth_peer, + r); + ds_nl_close(ctx); + return -1; + } + ds_log("[DEBUG] Successfully moved %s to PID %d", veth_peer, (int)child_pid); + + /* Cache the user-pinned upstream list (if any) into the globals that the + * routing setup and the route monitor read. Empty list = auto-detect. This + * runs before ds_net_setup_android_routing() and + * ds_net_start_route_monitor(), which both consult g_upstream_*. The count + * is already capped at DS_MAX_UPSTREAM_IFACES by the parsers. */ + g_upstream_count = cfg->upstream_iface_count; + for (int _i = 0; _i < g_upstream_count; _i++) + safe_strncpy(g_upstream_ifaces[_i], cfg->upstream_ifaces[_i], IFNAMSIZ); + + /* 7. Android policy routing - uplink is auto-detected, or pinned via + * --upstream. */ + if (is_android()) + ds_net_setup_android_routing(ctx); + + ds_nl_close(ctx); + + /* 8. Start embedded DHCP server so the container's DHCP client acquires + * the static IP persisted in cfg->static_nat_ip. Using a stable IP here + * means every reboot the container gets the same address - no PREROUTING + * rule churn, no "wrong IP" on the first DHCP renew. + * + * Binding interface depends on topology: + * Bridge mode - bind to ds-br0. veth_host is a bridge slave; the + * kernel delivers frames from the container upward to + * the bridge interface, not the slave. A socket bound + * to the slave would never see the DHCP DISCOVERs. + * Bridgeless mode - bind to veth_host directly (point-to-point veth, + * no bridge in the path). */ + { + struct in_addr offer_in; + uint32_t offer_ip = 0; + if (inet_pton(AF_INET, cfg->static_nat_ip, &offer_in) == 1) { + offer_ip = offer_in.s_addr; + } else { + ds_warn("[NET] DHCP: static_nat_ip '%s' unparseable - " + "DHCP server will offer 0.0.0.0 (container boot will fail)", + cfg->static_nat_ip); + } + + /* Bind to veth_host. In bridge mode the kernel also floods L2 broadcasts + * to sibling veth ports; isolation is enforced by peer_mac filter in + * the DHCP server loop, not by the socket bind alone. */ + const char *dhcp_iface = veth_host; + ds_dhcp_server_start(cfg, dhcp_iface, offer_ip, inet_addr(DS_NAT_GW_IP)); + + /* Store the container IP string (plain dotted-decimal) for port-forward + * cleanup later. static_nat_ip is already in that exact format. */ + safe_strncpy(cfg->nat_container_ip, cfg->static_nat_ip, + sizeof(cfg->nat_container_ip)); + + /* Install DNAT + FORWARD rules for any --port mappings */ + if (cfg->port_forward_count > 0) + ds_ipt_add_portforwards(cfg, cfg->nat_container_ip); + } + + return 0; +} + +/* --------------------------------------------------------------------------- + * Gateway segment lock + * + * One advisory file lock per delegated LAN segment (keyed on the bridge name). + * Serialises (re)creation of the single shared gateway-side veth + bridge + * across the independent monitor processes that touch a segment - concurrent + * client starts, and the gateway re-wiring its clients on (re)boot - so they + * cannot race each other into EEXIST half-states. + * ---------------------------------------------------------------------------*/ + +static int gateway_segment_lock(const char *bridge) { + char path[PATH_MAX]; + snprintf(path, sizeof(path), "%s/gw_%s.lock", get_net_dir(), bridge); + int fd = open(path, O_CREAT | O_RDWR | O_CLOEXEC, 0600); + if (fd < 0) { + ds_warn("[NET] Gateway: could not open segment lock %s: %s", path, + strerror(errno)); + return -1; + } + if (flock(fd, LOCK_EX) < 0) { + ds_warn("[NET] Gateway: flock failed on %s: %s", path, strerror(errno)); + close(fd); + return -1; + } + return fd; +} + +static void gateway_segment_unlock(int fd) { + if (fd < 0) + return; + flock(fd, LOCK_UN); + close(fd); +} + +/* --------------------------------------------------------------------------- + * Gateway liveness + * ---------------------------------------------------------------------------*/ + +/* Resolve the gateway container's init pid, or 0 if it is not running. */ +static pid_t gateway_pid_of(const char *name) { + struct ds_config c; + memset(&c, 0, sizeof(c)); + c.net_ready_pipe[0] = c.net_ready_pipe[1] = -1; + c.net_done_pipe[0] = c.net_done_pipe[1] = -1; + safe_strncpy(c.container_name, name, sizeof(c.container_name)); + (void)ds_config_load_by_name(name, &c); + pid_t p = 0; + if (!is_container_running(&c, &p)) + p = 0; + ds_config_free(&c); + return p > 0 ? p : 0; +} + +/* --------------------------------------------------------------------------- + * gateway_ensure_lan_uplink_locked + * + * Ensure the shared half of a delegated LAN segment is wired into the running + * gateway container. The caller holds the segment lock and has already + * confirmed the gateway is up, passing its netns pid: + * - create/reuse the IP-less, policy-neutral bridge + * - ensure the gateway-side veth (ds-g) exists with its peer living in + * the gateway netns as gw_if (e.g. eth1) + * + * Idempotent: a repeat call is a cheap reattach when the cable is genuinely + * healthy (its peer lives inside the CURRENT gateway netns). When the cable is + * absent - or present but stale (peer stranded in a zombie netns that outlived + * the previous gateway) - we plug a fresh one into the gateway's (possibly + * just-rebooted) netns. This is what heals clients after a gateway restart, + * with no client restart. + * + * Returns 0 when the gateway-side cable is up, -1 on a netlink failure. + * ---------------------------------------------------------------------------*/ + +static int gateway_ensure_lan_uplink_locked(struct ds_config *cfg, + pid_t gw_pid) { + if (!cfg || !cfg->gateway_container[0] || gw_pid <= 0) + return -1; + + const char *gw_if = gateway_lan_ifname(cfg); + if (strlen(gw_if) >= IFNAMSIZ || !gateway_ifname_component_ok(gw_if)) { + ds_warn("[NET] Gateway: invalid gateway interface name '%s'", gw_if); + return -1; + } + + char bridge[IFNAMSIZ], gw_host[IFNAMSIZ], gw_peer[IFNAMSIZ]; + gateway_bridge_name(cfg, bridge, sizeof(bridge)); + gateway_veth_names(cfg, gw_host, sizeof(gw_host), gw_peer, sizeof(gw_peer)); + + ds_nl_ctx_t *ctx = ds_nl_open(); + if (!ctx) { + ds_warn("[NET] Gateway: failed to open RTNETLINK socket"); + return -1; + } + + /* Bridge first, unconditionally: an IP-less, policy-neutral switch. Disable + * bridge netfilter calls so OpenWrt's own firewalling stays the only policy + * authority on the delegated network. */ + if (!ds_nl_link_exists(ctx, bridge)) { + ds_log("[NET] Gateway: creating delegated LAN bridge %s", bridge); + int br = ds_nl_create_bridge(ctx, bridge); + if (br < 0) { + /* Bail before any veth/netns work. This should be unreachable - the + * startup capability probe (enforce_nat_safety) refuses gateway mode on + * a kernel without CONFIG_BRIDGE - but recognise EOPNOTSUPP explicitly + * so we never cascade into further unsupported operations. */ + if (br == -EOPNOTSUPP) + ds_warn("[NET] Gateway: kernel lacks CONFIG_BRIDGE - cannot wire " + "delegated LAN (gateway mode requires bridge support)"); + else + ds_warn("[NET] Gateway: failed to create bridge %s", bridge); + ds_nl_close(ctx); + return -1; + } + } + if (ds_nl_link_up(ctx, bridge) < 0) + ds_warn("[NET] Gateway: failed to bring up bridge %s", bridge); + write_file("/proc/sys/net/bridge/bridge-nf-call-iptables", "0"); + write_file("/proc/sys/net/bridge/bridge-nf-call-ip6tables", "0"); + + char gw_netns[PATH_MAX]; + snprintf(gw_netns, sizeof(gw_netns), "/proc/%d/ns/net", (int)gw_pid); + + /* Host-side cable present. This does NOT prove the peer is inside the + * CURRENT gateway netns: if a process kept the previous gateway's netns alive + * past `stop` (e.g. tailscaled), the veth pair survived and its peer is + * stranded there - so a stale ds-g can outlive the gateway it was built + * for. Verify gw_if actually exists inside this gateway before trusting the + * cable. + * - peer live in this netns → idempotent no-op: re-assert master + up. + * - peer absent → stale cable: delete it (which also reaps the + * stranded peer, veth pairs die together) and + * fall through to build a fresh one into this netns. */ + if (ds_nl_link_exists(ctx, gw_host)) { + if (netns_has_link(gw_netns, gw_if)) { + if (ds_nl_set_master(ctx, gw_host, bridge) < 0) + ds_warn("[NET] Gateway: failed to reattach %s to %s", gw_host, bridge); + ds_nl_link_up(ctx, gw_host); + ds_nl_close(ctx); + return 0; + } + ds_warn("[NET] Gateway: stale cable %s (peer not in gateway netns) - " + "rebuilding", + gw_host); + ds_nl_del_link(ctx, gw_host); + } + + ds_log("[NET] Gateway: creating gateway veth %s <-> %s", gw_host, gw_peer); + if (ds_nl_create_veth(ctx, gw_host, gw_peer) < 0) { + ds_warn("[NET] Gateway: failed to create gateway veth pair"); + ds_nl_close(ctx); + return -1; + } + + /* Pin a stable, segment-derived MAC on the gateway-facing peer (becomes gw_if + * inside the gateway) so netifd sees one persistent device across every + * re-plug. Set while down, before the move. */ + { + char key[384]; + uint8_t mac[6]; + gateway_hash_key(cfg, key, sizeof(key)); + ds_derive_mac(key, "ds-gwmac:", mac); + if (ds_nl_set_mac(ctx, gw_peer, mac) < 0) + ds_warn("[NET] Gateway: failed to pin MAC on %s", gw_peer); + } + + ds_net_disable_tx_checksum(gw_host); + if (ds_nl_set_master(ctx, gw_host, bridge) < 0) + ds_warn("[NET] Gateway: failed to attach %s to %s", gw_host, bridge); + if (ds_nl_link_up(ctx, gw_host) < 0) + ds_warn("[NET] Gateway: failed to bring up %s", gw_host); + + int gw_netns_fd = open(gw_netns, O_RDONLY | O_CLOEXEC); + if (gw_netns_fd < 0) { + ds_warn("[NET] Gateway: failed to open %s: %s", gw_netns, strerror(errno)); + ds_nl_del_link(ctx, gw_host); /* drop the half-built pair */ + ds_nl_close(ctx); + return -1; + } + + /* Atomic move+rename: the peer appears inside the gateway already named + * gw_if, so there is no transient raw-name device for netifd to race against + * ("device initialization failed"). Fall back to a plain move on failure. */ + if (ds_nl_move_to_netns_named(ctx, gw_peer, gw_netns_fd, gw_if) != 0) { + ds_warn("[NET] Gateway: atomic move+rename of %s failed - falling back", + gw_peer); + if (ds_nl_move_to_netns(ctx, gw_peer, gw_netns_fd) < 0) { + ds_warn("[NET] Gateway: fallback move of %s into gateway netns failed", + gw_peer); + close(gw_netns_fd); + ds_nl_close(ctx); + return -1; + } + } + close(gw_netns_fd); + ds_nl_close(ctx); + + /* Bring it up inside the gateway under its final name (the atomic path + * already renamed it; the fallback path renames here). */ + if (ds_netns_rename_up(gw_netns, gw_peer, gw_if) < 0) + ds_warn("[NET] Gateway: moved %s but could not bring it up as %s", gw_peer, + gw_if); + + ds_log("[NET] Gateway: LAN uplink ready on %s -> %s (%s)", bridge, + cfg->gateway_container, gw_if); + return 0; +} + +/* --------------------------------------------------------------------------- + * gateway_wire_client + * + * Fully wire ONE gateway-mode client into its delegated LAN, entirely from the + * host side. Because the host owns every step - including renaming the peer to + * eth0 and bringing it up *inside* the client netns - this works identically + * whether the client is just starting (its netns fresh, child blocked on the + * handshake) or already running (the gateway came up later and is re-wiring + * it). The gateway-mode child only brings up lo; it never touches eth0. + * + * Under the segment lock: ensure bridge + gateway uplink, create the app veth, + * pin the client's stable MAC, attach the host end to the bridge, then + * move+rename the peer into the client netns as eth0 (up). Returns 0 on + * success. Caller passes the client's init pid and the confirmed-running + * gateway's init pid. + * ---------------------------------------------------------------------------*/ + +static int gateway_wire_client(struct ds_config *cfg, pid_t client_pid, + pid_t gateway_pid) { + if (!cfg || client_pid <= 0 || gateway_pid <= 0) + return -1; + + char bridge[IFNAMSIZ], app_host[IFNAMSIZ], app_peer[IFNAMSIZ]; + gateway_bridge_name(cfg, bridge, sizeof(bridge)); + veth_host_name(cfg, client_pid, app_host, sizeof(app_host)); + veth_peer_name(cfg, client_pid, app_peer, sizeof(app_peer)); + + /* One lock spans the shared uplink AND this client's app-veth attach, so a + * concurrent client's cleanup cannot reap the bridge between them. */ + int lock = gateway_segment_lock(bridge); + int ret = -1; + + if (gateway_ensure_lan_uplink_locked(cfg, gateway_pid) < 0) { + ds_warn("[NET] Gateway: uplink for %s not ready - cannot wire '%s'", bridge, + cfg->container_name); + goto out; + } + + ds_nl_ctx_t *ctx = ds_nl_open(); + if (!ctx) { + ds_warn("[NET] Gateway: failed to open RTNETLINK socket"); + goto out; + } + + ds_nl_del_link(ctx, app_host); /* drop any stale half from a prior wiring */ + ds_log("[NET] Gateway: creating app veth %s <-> %s", app_host, app_peer); + if (ds_nl_create_veth(ctx, app_host, app_peer) < 0) { + ds_warn("[NET] Gateway: failed to create app veth pair"); + ds_nl_close(ctx); + goto out; + } + + /* Pin the client's stable, name-derived MAC on the peer (becomes eth0) before + * the move, so the gateway's DHCP leases see one host across reboots. */ + { + uint8_t mac[6]; + ds_derive_mac(cfg->container_name, "ds-mac:", mac); + if (ds_nl_set_mac(ctx, app_peer, mac) < 0) + ds_warn("[NET] Gateway: failed to pin MAC on %s", app_peer); + } + + ds_net_disable_tx_checksum(app_host); + if (ds_nl_set_master(ctx, app_host, bridge) < 0) + ds_warn("[NET] Gateway: failed to attach %s to %s", app_host, bridge); + if (ds_nl_link_up(ctx, app_host) < 0) + ds_warn("[NET] Gateway: failed to bring up %s", app_host); + + char netns[PATH_MAX]; + snprintf(netns, sizeof(netns), "/proc/%d/ns/net", (int)client_pid); + int netns_fd = open(netns, O_RDONLY | O_CLOEXEC); + if (netns_fd < 0) { + ds_warn("[NET] Gateway: failed to open client netns %s: %s", netns, + strerror(errno)); + ds_nl_del_link(ctx, app_host); + ds_nl_close(ctx); + goto out; + } + + /* Atomic move+rename into the client as eth0 (no transient raw-name device + * for the container's own DHCP/networkd to race), with a plain-move fallback. + */ + if (ds_nl_move_to_netns_named(ctx, app_peer, netns_fd, "eth0") != 0) { + ds_warn("[NET] Gateway: atomic move+rename of %s failed - falling back", + app_peer); + if (ds_nl_move_to_netns(ctx, app_peer, netns_fd) < 0) { + ds_warn("[NET] Gateway: move of %s into client netns failed", app_peer); + close(netns_fd); + ds_nl_del_link(ctx, app_host); + ds_nl_close(ctx); + goto out; + } + } + close(netns_fd); + ds_nl_close(ctx); + + if (ds_netns_rename_up(netns, app_peer, "eth0") < 0) + ds_warn("[NET] Gateway: wired '%s' but could not bring up its eth0", + cfg->container_name); + + ret = 0; + ds_log("Gateway: wiring complete for '%s': %s -> %s", cfg->container_name, + bridge, cfg->gateway_container); + +out: + gateway_segment_unlock(lock); + return ret; +} + +/* --------------------------------------------------------------------------- + * setup_gateway_veth_side + * + * Called at client start. OpenWrt gateway mode: Droidspaces owns only the L2 + * plumbing (bridge + veths), never NAT/DHCP/DNS/firewall - the gateway + * container runs that policy. + * + * If the gateway is already running we wire this client now. If it is NOT + * running we skip ALL networking and return: the gateway wires every running + * client itself when it (re)boots (ds_net_rewire_gateway_clients). Deferring + * instead of half-wiring also closes a race - a client started before its + * gateway must not bake in gateway/LAN settings the user may still edit before + * the gateway comes up. + * ---------------------------------------------------------------------------*/ + +int setup_gateway_veth_side(struct ds_config *cfg, pid_t child_pid) { + if (!cfg || !cfg->gateway_container[0]) { + ds_warn("[NET] Gateway: no gateway container configured"); + return -1; + } + + if (strcmp(cfg->gateway_container, cfg->container_name) == 0) { + ds_warn("[NET] Gateway: refusing to attach container to itself"); + return -1; + } + + pid_t gw_pid = gateway_pid_of(cfg->gateway_container); + if (gw_pid <= 0) { + ds_warn("Gateway: '%s' not running - deferring all networking for " + "'%s'; the gateway will wire it when it starts", + cfg->gateway_container, cfg->container_name); + return 0; + } + + ds_log("[NET] Gateway: wiring '%s' to gateway '%s'", cfg->container_name, + cfg->gateway_container); + return gateway_wire_client(cfg, child_pid, gw_pid); +} + +/* --------------------------------------------------------------------------- + * ds_net_rewire_gateway_clients + * + * Gateway self-heal, driven by the gateway itself. On every boot the gateway + * container's monitor calls this: it scans the running containers for the ones + * that delegate to this gateway and (re)wires each into the gateway's current + * netns via gateway_wire_client. That one function also idempotently ensures + * the shared LAN uplink, so a single pass covers the gateway-side cable and + * every client's app veth. + * + * This restores clients after the gateway (re)boots - its old netns died and + * took the LAN cable with it - and wires clients that were started while the + * gateway was down. One actor, no client restart. + * ---------------------------------------------------------------------------*/ +void ds_net_rewire_gateway_clients(const char *gateway_name, + pid_t gateway_pid) { + if (!gateway_name || !gateway_name[0] || gateway_pid <= 0) + return; + + char containers_dir[PATH_MAX]; + snprintf(containers_dir, sizeof(containers_dir), "%s/Containers", + get_workspace_dir()); + DIR *d = opendir(containers_dir); + if (!d) + return; + + struct dirent *ent; + while ((ent = readdir(d)) != NULL) { + if (ent->d_name[0] == '.') + continue; + + struct ds_config c = {0}; + if (ds_config_load_by_name(ent->d_name, &c) != 0) + continue; + + pid_t p = 0; + if (c.net_mode == DS_NET_GATEWAY && c.gateway_container[0] && + strcmp(c.gateway_container, gateway_name) == 0 && + is_container_running(&c, &p) && p > 0) + gateway_wire_client(&c, p, gateway_pid); + + ds_config_free(&c); + } + closedir(d); +} + +/* --------------------------------------------------------------------------- + * ds_net_gateway_teardown + * + * Called when a container that ACTS AS A GATEWAY stops. The gateway-side veth + * ds-g lives in the host netns; its peer is the gateway's eth1. When the + * gateway stops the kernel does NOT auto-reap ds-g: the host-side veth itself + * pins its now-process-less peer netns (a veth end holds a reference to its + * peer's namespace), and that netns can only be freed by a cleanup_net that + * cannot run while ds-g pins it - a self-sustaining orphan. So we delete ds-g + * explicitly, exactly as NAT mode deletes ds-v. + * + * We do not track our own segments (clients choose --gateway-net), so scan the + * client configs that delegate to us, derive each segment's bridge + gateway + * veth, delete the veth (reaping the peer and freeing the netns), and reap the + * bridge once no client veths remain on it. Per-segment work runs under the + * same advisory lock client setup/cleanup use, and bridges are de-duplicated + * since many clients can share one segment. A no-op for a container that is + * nobody's gateway. + * ---------------------------------------------------------------------------*/ +void ds_net_gateway_teardown(const char *gateway_name) { + if (!gateway_name || !gateway_name[0]) + return; + + char containers_dir[PATH_MAX]; + snprintf(containers_dir, sizeof(containers_dir), "%s/Containers", + get_workspace_dir()); + DIR *d = opendir(containers_dir); + if (!d) + return; + + /* De-dupe segments: many clients can share one --gateway-net (one bridge). */ + char seen[32][IFNAMSIZ]; + int seen_count = 0; + + struct dirent *ent; + while ((ent = readdir(d)) != NULL) { + if (ent->d_name[0] == '.') + continue; + + struct ds_config c = {0}; + if (ds_config_load_by_name(ent->d_name, &c) != 0) + continue; + + if (!(c.net_mode == DS_NET_GATEWAY && c.gateway_container[0] && + strcmp(c.gateway_container, gateway_name) == 0)) { + ds_config_free(&c); + continue; + } + + char bridge[IFNAMSIZ], gw_host[IFNAMSIZ], gw_peer[IFNAMSIZ]; + gateway_bridge_name(&c, bridge, sizeof(bridge)); + gateway_veth_names(&c, gw_host, sizeof(gw_host), gw_peer, sizeof(gw_peer)); + + int dup = 0; + for (int i = 0; i < seen_count; i++) + if (strcmp(seen[i], bridge) == 0) { + dup = 1; + break; + } + if (dup) { + ds_config_free(&c); + continue; + } + if (seen_count < (int)(sizeof(seen) / sizeof(seen[0]))) + safe_strncpy(seen[seen_count++], bridge, IFNAMSIZ); + + ds_nl_ctx_t *ctx = ds_nl_open(); + if (!ctx) { + ds_config_free(&c); + continue; + } + + /* Same lock client setup/cleanup take, so we cannot race a concurrent + * client start/wire or the gateway's own rewire on the segment. */ + int lock = gateway_segment_lock(bridge); + + ds_nl_del_link(ctx, gw_host); + ds_log("[NET] Gateway teardown: removed gateway veth %s (segment %s)", + gw_host, bridge); + + int clients = ds_nl_count_bridge_members_with_prefix( + ctx, bridge, app_veth_host_prefix(&c)); + if (clients > 0) { + ds_log("[NET] Gateway teardown: %d client(s) still on %s - keeping " + "bridge", + clients, bridge); + } else { + ds_nl_del_link(ctx, bridge); + ds_log("[NET] Gateway teardown: reaped idle delegated LAN bridge %s", + bridge); + } + + gateway_segment_unlock(lock); + ds_nl_close(ctx); + ds_config_free(&c); + } + closedir(d); +} + +/* --------------------------------------------------------------------------- + * setup_veth_child_side_named + * + * Called from internal_boot() INSIDE the container's new network namespace. + * ---------------------------------------------------------------------------*/ + +int setup_veth_child_side_named(struct ds_config *cfg, const char *peer_name, + const char *ip_str) { + (void)ip_str; /* IP is now assigned by the container's own DHCP client */ + + ds_nl_ctx_t *ctx = ds_nl_open(); + if (!ctx) { + ds_warn("[DEBUG] Child: Failed to open netlink socket"); + return -1; + } + + /* Gateway mode: the host monitor owns the entire app-veth wiring - it renames + * the peer to eth0, pins the MAC, and brings it up *inside* this netns - so + * the child only needs loopback. This lets the gateway wire us identically + * whether we are starting now or already running when it (re)boots, and means + * a client started before its gateway simply has no eth0 until the gateway + * comes up and wires it. */ + if (cfg && cfg->net_mode == DS_NET_GATEWAY) { + ds_nl_link_up(ctx, "lo"); + ds_nl_close(ctx); + ds_log("[NET] Child: gateway mode - lo up; gateway container owns " + "eth0/DHCP/routing"); + return 0; + } + + /* NAT mode: the monitor moved the veth peer into this netns under its raw + * name; we rename it to eth0, pin the MAC, and bring it up. */ + ds_log("[DEBUG] Child: configuring container veth (peer %s -> eth0, local " + "PID %d)", + peer_name ? peer_name : "(null)", (int)getpid()); + + /* 0. Rename interface to eth0 */ + if (peer_name && peer_name[0] && strcmp(peer_name, "eth0") != 0) { + ds_log("[DEBUG] Renaming %s to eth0...", peer_name); + if (ds_nl_rename(ctx, peer_name, "eth0") < 0) + ds_warn("[DEBUG] Failed to rename %s to eth0.", peer_name); + } + + /* 0b. Pin eth0 to a deterministic MAC derived from the container name, so + * an upstream gateway sees one stable host across restarts (no LuCI/lease + * churn) instead of a new random MAC each boot. Set while down, pre-up. */ + if (cfg && cfg->container_name[0]) { + uint8_t mac[6]; + ds_derive_mac(cfg->container_name, "ds-mac:", mac); + if (ds_nl_set_mac(ctx, "eth0", mac) < 0) + ds_warn("[NET] Child: failed to set deterministic MAC on eth0"); + else + ds_log("[NET] Child: eth0 MAC pinned to %02x:%02x:%02x:%02x:%02x:%02x", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + } + + /* 1. Loopback */ + ds_nl_link_up(ctx, "lo"); + + /* 2. Bring eth0 UP - the container's DHCP client configures IP and route */ + ds_nl_link_up(ctx, "eth0"); + + ds_nl_close(ctx); + ds_log("[NET] Child: eth0 UP - awaiting DHCP lease from monitor"); + return 0; +} + +/* Compatibility wrapper */ + +/* --------------------------------------------------------------------------- + * /etc/resolv.conf wiring (inside container, after pivot_root) + * + * Single source of truth for the container's resolver. Two cases: + * + * 1. gateway mode + no custom --dns + systemd container: + * DNS is owned by the gateway's DHCP/DNS (e.g. OpenWrt). systemd-resolved + * (fed by the lease) publishes it at /run/systemd/resolve/resolv.conf, so + * we only point the symlink there - resolved owns the file. (A non-systemd + * gateway has no resolved, so it falls through to case 2 with default + * DNS.) + * + * 2. everything else (nat/host/none, or any mode with custom --dns): + * Droidspaces owns DNS. Write our content (custom or default) to + * /run/droidspaces/resolv.conf and symlink it. + * + * This replaces the old /run/resolvconf duct-tape, which clobbered the distro's + * resolver and left a dangling symlink in gateway mode. + * ---------------------------------------------------------------------------*/ +static void setup_resolv_conf(struct ds_config *cfg) { + const char *target; + + /* Gateway mode with no explicit --dns: DNS belongs to the gateway (OpenWrt + * dnsmasq), advertised in the DHCP lease. Droidspaces must NOT write a + * static resolv.conf or it would bypass the gateway's DNS filtering/caching. + */ + if (cfg->net_mode == DS_NET_GATEWAY && !cfg->dns_servers[0]) { + if (is_systemd_rootfs("/")) { + /* systemd-resolved consumes the lease and publishes the real resolver. */ + target = "/run/systemd/resolve/resolv.conf"; + } else { + /* Non-systemd: leave /etc/resolv.conf to the container's own DHCP client, + * which writes the gateway-supplied nameserver from the lease. Writing a + * hardcoded 1.1.1.1/8.8.8.8 here would silently defeat the gateway's DNS + * (adblock, split-horizon, etc.). Pass --dns to override. */ + ds_log("[NET] Gateway: leaving /etc/resolv.conf to the container's DHCP " + "client (gateway owns DNS)"); + return; + } + } else { + mkdir("/run/droidspaces", 0755); + write_file("/run/droidspaces/resolv.conf", cfg->dns_server_content); + target = "/run/droidspaces/resolv.conf"; + } + + unlink("/etc/resolv.conf"); + if (symlink(target, "/etc/resolv.conf") < 0) + ds_warn("Failed to link /etc/resolv.conf -> %s: %s", target, + strerror(errno)); +} + +/* --------------------------------------------------------------------------- + * Rootfs-side networking setup (inside container, after pivot_root) + * ---------------------------------------------------------------------------*/ + +int fix_networking_rootfs(struct ds_config *cfg) { + /* 1. Hostname */ + if (cfg->hostname[0]) { + if (sethostname(cfg->hostname, strlen(cfg->hostname)) < 0) { + ds_warn("Failed to set hostname to %s: %s", cfg->hostname, + strerror(errno)); + } + /* Persist to /etc/hostname */ + char hn_buf[256 + 2]; + snprintf(hn_buf, sizeof(hn_buf), "%.256s\n", cfg->hostname); + write_file("/etc/hostname", hn_buf); + } + + /* 2. /etc/hosts */ + char hosts_content[1024]; + const char *hostname = (cfg->hostname[0]) ? cfg->hostname : "localhost"; + + /* IPv6 is enabled in host mode and gateway mode unless explicitly disabled. + * Gateway mode is policy-owned by OpenWrt, so IPv6 RA/DHCPv6 should be able + * to operate inside the application container netns. */ + int ipv6_enabled = + ((cfg->net_mode == DS_NET_HOST || cfg->net_mode == DS_NET_GATEWAY) && + !cfg->disable_ipv6); + if (ipv6_enabled) { + snprintf(hosts_content, sizeof(hosts_content), + "127.0.0.1\tlocalhost\n" + "127.0.1.1\t%s\n" + "::1\t\tlocalhost ip6-localhost ip6-loopback\n" + "ff02::1\t\tip6-allnodes\n" + "ff02::2\t\tip6-allrouters\n", + hostname); + } else { + snprintf(hosts_content, sizeof(hosts_content), + "127.0.0.1\tlocalhost\n" + "127.0.1.1\t%s\n", + hostname); + } + + write_file("/etc/hosts", hosts_content); + + /* 3. resolv.conf (unified resolver wiring - see setup_resolv_conf). */ + setup_resolv_conf(cfg); + + if (!ipv6_enabled) { + if (cfg->net_mode == DS_NET_HOST) { + /* In host mode, disabling IPv6 affects the host's netns. Warn and apply. + */ + ds_warn("--disable-ipv6 in host mode disables IPv6 on the host " + "network namespace."); + } + write_file("/proc/sys/net/ipv6/conf/all/disable_ipv6", "1"); + write_file("/proc/sys/net/ipv6/conf/default/disable_ipv6", "1"); + } + + /* 5. unprivileged ICMP sockets: new network namespaces reset + * ping_group_range to "1 0". Allow all GIDs so ping works without + * CAP_NET_RAW. */ + write_file("/proc/sys/net/ipv4/ping_group_range", "0 2147483647"); + + return 0; +} + +/* --------------------------------------------------------------------------- + * Runtime introspection + * ---------------------------------------------------------------------------*/ + +int detect_ipv6_in_container(pid_t pid) { + char path[PATH_MAX]; + build_proc_root_path(pid, "/proc/sys/net/ipv6/conf/all/disable_ipv6", path, + sizeof(path)); + + char buf[16]; + if (read_file(path, buf, sizeof(buf)) < 0) + return -1; + + /* 0 means enabled, 1 means disabled */ + return (buf[0] == '0') ? 1 : 0; +} + +/* --------------------------------------------------------------------------- + * Uplink Route Monitor + * + * Watches FIB rule, route, link, and IPv4 address changes on the host. + * When a relevant change is detected it re-probes which uplink is currently + * active and atomically updates the container policy rule. Fully automatic + * - no user-declared interface list. + * + * Event triggers: + * RTM_NEWRULE / RTM_DELRULE - netd swaps the default-network rule + * (this IS the wifi <-> mobile handoff) + * RTM_NEWROUTE / RTM_DELROUTE - default route moved between tables + * RTM_NEWLINK / RTM_DELLINK - interface state change (UP/RUNNING/DOWN) + * RTM_NEWADDR / RTM_DELADDR - IPv4 address assigned or removed + * + * A 1.5s heartbeat covers devices with broken netlink notifications and + * re-asserts ip_forward, which Android periodically resets. + * ---------------------------------------------------------------------------*/ + +/* Last detected uplink interface. Suppresses the "[NET] active uplink:" + * log line on every heartbeat - only log when the result changes. */ +static char g_last_uplink_iface[IFNAMSIZ]; + +/* Set to 1 when we've already warned that automatic detection found no + * uplink. Cleared when a probe succeeds again, so the next failure after + * a working period logs once more. */ +static int g_uplink_fail_warned; + +/* Basic sanity for a probe result: a real, carrier-up interface that is + * not loopback, not ours, and not a downstream/tether/VPN device. */ +static int uplink_candidate_ok(const char *ifname) { + if (!ifname[0]) + return 0; + if (uplink_name_excluded(ifname)) + return 0; + return iface_is_running(ifname); +} + +static void log_uplink_change(const char *ifname, int table, + const char *method) { + if (strcmp(g_last_uplink_iface, ifname) != 0) { + ds_log("[NET] Active uplink: %s (table %d) [%s]", ifname, table, method); + safe_strncpy(g_last_uplink_iface, ifname, sizeof(g_last_uplink_iface)); + } + g_uplink_fail_warned = 0; /* reset so next failure logs once */ +} + +/* Single "no uplink" outcome for both modes: warn once (until the next success) + * and clear the last-seen cache. This is the one place the condition is + * reported - callers must not log it again. */ +static void log_no_uplink(void) { + if (!g_uplink_fail_warned) { + if (g_upstream_count > 0) + ds_warn("[NET] Uplink: no pinned --upstream interface is up - container " + "has no WAN; the route monitor will " + "wire it when one appears"); + else + ds_warn( + "[NET] Uplink: no active internet interface found - container has " + "no WAN; the route monitor will wire it when one appears"); + g_uplink_fail_warned = 1; + } + g_last_uplink_iface[0] = '\0'; +} + +/* Tier 3 (last resort): scan all interfaces against the built-in uplink + * whitelist in priority order; return the first that is RUNNING and has + * an IPv4 default route in some table. */ +static int scan_uplink_whitelist(ds_nl_ctx_t *ctx, char *iface_out, + int *table_out) { + char all_ifaces[64][IFNAMSIZ]; + int all_count = ds_nl_list_ifaces(ctx, all_ifaces, 64); + + for (size_t p = 0; + p < sizeof(k_uplink_patterns) / sizeof(k_uplink_patterns[0]); p++) { + for (int j = 0; j < all_count; j++) { + if (fnmatch(k_uplink_patterns[p], all_ifaces[j], 0) != 0) + continue; + if (!uplink_candidate_ok(all_ifaces[j])) + continue; + int tbl = 0; + if (ds_nl_get_iface_table(ctx, all_ifaces[j], &tbl) != 0) + continue; + if (iface_out) + safe_strncpy(iface_out, all_ifaces[j], IFNAMSIZ); + if (table_out) + *table_out = tbl; + return 0; + } + } + return -ENOENT; +} + +/* Manual override: resolve the user-pinned --upstream list in priority order. + * The first entry that is RUNNING and has an IPv4 default route wins. Literal + * entries are checked directly; wildcard entries (containing * or ?) are + * matched with fnmatch() against the live interface list (handles dynamic names + * like rmnet_dataX or v4-rmnet_dataX whose number changes across reconnects). + * No exclude heuristics apply here - the user picked the interface + * deliberately. Returns 0 + fills iface/table, or -ENOENT when none are + * currently available. */ +static int resolve_pinned_uplink(ds_nl_ctx_t *ctx, char *iface_out, + int *table_out) { + char all_ifaces[64][IFNAMSIZ]; + int all_count = -1; /* enumerated lazily, only if a wildcard needs it */ + + for (int i = 0; i < g_upstream_count; i++) { + const char *pat = g_upstream_ifaces[i]; + int is_wild = (strchr(pat, '*') != NULL || strchr(pat, '?') != NULL); + + if (!is_wild) { + int tbl = 0; + if (iface_is_running(pat) && ds_nl_get_iface_table(ctx, pat, &tbl) == 0) { + if (iface_out) + safe_strncpy(iface_out, pat, IFNAMSIZ); + if (table_out) + *table_out = tbl; + return 0; + } + continue; + } + + if (all_count < 0) + all_count = ds_nl_list_ifaces(ctx, all_ifaces, 64); + for (int j = 0; j < all_count; j++) { + if (fnmatch(pat, all_ifaces[j], 0) != 0) + continue; + int tbl = 0; + if (!iface_is_running(all_ifaces[j]) || + ds_nl_get_iface_table(ctx, all_ifaces[j], &tbl) != 0) + continue; + if (iface_out) + safe_strncpy(iface_out, all_ifaces[j], IFNAMSIZ); + if (table_out) + *table_out = tbl; + return 0; + } + } + return -ENOENT; +} + +/* Find the interface/table currently providing internet access. + * + * If --upstream is set this is a pure manual override: resolve ONLY from that + * list (resolve_pinned_uplink) and disable all auto-detection - the WAN never + * hops to whatever netd marks active. Otherwise it is fully automatic, three + * tiers, first hit wins: + * + * 1. Android netd default-network FIB rule (fwmark 0x0/0xffff iif lo). + * The kernel's ground truth - swapped atomically on every handoff, + * and never points at IMS/MMS-only interfaces. Also the only + * interface Qualcomm IPA / MTK CCCI has hardware reply sessions + * for, which NAT'd forwarded traffic requires. + * 2. The main routing table's IPv4 default route - the standard Linux + * case (what LXC/Docker implicitly rely on), and ROMs/chroots that + * populate the main table. + * 3. Built-in whitelist scan of known uplink interface families. + * + * Returns 0 and fills iface_out (IFNAMSIZ) / table_out on success. */ +static int find_active_uplink(ds_nl_ctx_t *ctx, char *iface_out, + int *table_out) { + char name[IFNAMSIZ] = {0}; + int tbl = 0; + + /* Manual override: pinned --upstream list only, no fallback to auto-detect. + */ + if (g_upstream_count > 0) { + if (resolve_pinned_uplink(ctx, name, &tbl) == 0) { + log_uplink_change(name, tbl, "pinned"); + if (iface_out) + safe_strncpy(iface_out, name, IFNAMSIZ); + if (table_out) + *table_out = tbl; + return 0; + } + log_no_uplink(); + return -ENOENT; + } + + /* Tier 1: netd default-network rule (Android only - the rule simply + * does not exist elsewhere, but skip the dump cost off-Android). */ + if (is_android() && ds_nl_get_android_default(ctx, name, &tbl) == 0 && + uplink_candidate_ok(name)) { + log_uplink_change(name, tbl, "netd rule"); + if (iface_out) + safe_strncpy(iface_out, name, IFNAMSIZ); + if (table_out) + *table_out = tbl; + return 0; + } + + /* Tier 2: main-table default route (standard Linux semantics). */ + name[0] = '\0'; + if (ds_nl_get_table_default_oif(ctx, RT_TABLE_MAIN, name) == 0 && + uplink_candidate_ok(name)) { + log_uplink_change(name, RT_TABLE_MAIN, "main table"); + if (iface_out) + safe_strncpy(iface_out, name, IFNAMSIZ); + if (table_out) + *table_out = RT_TABLE_MAIN; + return 0; + } + + /* Tier 3: built-in whitelist scan. */ + name[0] = '\0'; + tbl = 0; + if (scan_uplink_whitelist(ctx, name, &tbl) == 0) { + log_uplink_change(name, tbl, "whitelist scan"); + if (iface_out) + safe_strncpy(iface_out, name, IFNAMSIZ); + if (table_out) + *table_out = tbl; + return 0; + } + + log_no_uplink(); + return -ENOENT; +} + +/* Re-probe which uplink is active and update the ip rule if needed. */ +static void do_uplink_reprobe(void) { + ds_nl_ctx_t *ctx = ds_nl_open(); + if (!ctx) + return; + + char new_iface[IFNAMSIZ] = {0}; + int new_table = 0; + + if (find_active_uplink(ctx, new_iface, &new_table) != 0) { + /* No uplink available. */ + if (g_upstream_count > 0) { + /* Pinned mode: the forced interface is gone. Tear the FROM rule down so + * container traffic is NOT silently re-routed onto a table the kernel may + * recycle for a different network (no hopping / no leak). When the + * pinned interface returns, a later reprobe reinstalls the rule. */ + pthread_mutex_lock(&g_gw_mutex); + int old_table = g_current_gw_table; + pthread_mutex_unlock(&g_gw_mutex); + if (old_table > 0) { + uint32_t subnet_be, mask_be; + parse_cidr(DS_DEFAULT_SUBNET, &subnet_be, &mask_be); + (void)mask_be; + ds_nl_del_rule4(ctx, subnet_be, DS_NAT_PREFIX, 0, 0, old_table, + DS_RULE_PRIO_FROM_SUBNET); + pthread_mutex_lock(&g_gw_mutex); + g_current_gw_table = 0; + pthread_mutex_unlock(&g_gw_mutex); + ds_log("[NET] Route monitor: pinned uplink gone - removed FROM rule " + "(container WAN is down until it returns)"); + } + } + /* Auto mode leaves the current rule in place (avoid flapping on + * transients); pinned mode has already torn it down above. */ + ds_nl_close(ctx); + return; + } + + pthread_mutex_lock(&g_gw_mutex); + int old_table = g_current_gw_table; + pthread_mutex_unlock(&g_gw_mutex); + + if (new_table == old_table) { + ds_nl_close(ctx); + return; + } + + ds_log("[NET] Route monitor: uplink switch table %d → %d (%s)", old_table, + new_table, new_iface); + + uint32_t subnet_be, mask_be; + parse_cidr(DS_DEFAULT_SUBNET, &subnet_be, &mask_be); + (void)mask_be; + + if (old_table > 0) + ds_nl_del_rule4(ctx, subnet_be, DS_NAT_PREFIX, 0, 0, old_table, + DS_RULE_PRIO_FROM_SUBNET); + + if (ds_nl_add_rule4(ctx, subnet_be, DS_NAT_PREFIX, 0, 0, new_table, + DS_RULE_PRIO_FROM_SUBNET) == 0) { + pthread_mutex_lock(&g_gw_mutex); + g_current_gw_table = new_table; + pthread_mutex_unlock(&g_gw_mutex); + ds_log("[NET] Route monitor: rule updated → from %s lookup %d (prio %d)", + DS_DEFAULT_SUBNET, new_table, DS_RULE_PRIO_FROM_SUBNET); + + if (is_android()) + write_file("/proc/sys/net/ipv4/ip_forward", "1"); + } else { + ds_warn("[NET] Route monitor: failed to install new rule for table %d", + new_table); + } + + ds_nl_close(ctx); +} + +static void *route_monitor_loop(void *arg) { + (void)arg; + + ds_log("[NET] Uplink route monitor started (automatic detection)"); + + int sock = socket(AF_NETLINK, SOCK_RAW | SOCK_CLOEXEC, NETLINK_ROUTE); + if (sock < 0) { + ds_warn("[NET] Route monitor: failed to open netlink socket: %s", + strerror(errno)); + return NULL; + } + + struct sockaddr_nl sa; + memset(&sa, 0, sizeof(sa)); + sa.nl_family = AF_NETLINK; + /* RTMGRP_LINK - interface state changes (IFF_RUNNING, up/down) + * RTMGRP_IPV4_IFADDR - IPv4 address add/remove + * RTMGRP_IPV4_ROUTE - default route moved between tables + * RTNLGRP_IPV4_RULE - netd swapping the default-network FIB rule: + * this IS the wifi <-> mobile handoff signal. + * No legacy RTMGRP_ bitmask macro exists for rule + * groups; the bit is 1 << (RTNLGRP_IPV4_RULE - 1). */ + sa.nl_groups = RTMGRP_LINK | RTMGRP_IPV4_IFADDR | RTMGRP_IPV4_ROUTE | + (1u << (RTNLGRP_IPV4_RULE - 1)); + + if (bind(sock, (struct sockaddr *)&sa, sizeof(sa)) < 0) { + ds_warn("[NET] Route monitor: failed to bind netlink socket: %s", + strerror(errno)); + close(sock); + return NULL; + } + + pthread_mutex_lock(&g_gw_mutex); + g_route_monitor_sock = sock; + pthread_mutex_unlock(&g_gw_mutex); + + uint8_t buf[8192]; + struct pollfd pfd = {.fd = sock, .events = POLLIN}; + + while (!g_stop_monitor) { + /* Enforce IPv4 forwarding in real-time. If ip_forward ever flips to 0 + * the NAT'd container loses all WAN traffic, so re-assert it on every + * cycle regardless of platform - Android's netd is the usual culprit, + * but a desktop firewall/sysctl reload or another tool can clear it too. + * The kernel does not broadcast POLLERR/inotify events for /proc/sys/ + * memory variables, so we must poll; reading a 1-byte procfs flag takes + * < 1 microsecond, costing 0% CPU. */ + if (g_current_gw_table > 0) { + char val[4] = {0}; + if (read_file("/proc/sys/net/ipv4/ip_forward", val, sizeof(val)) > 0 && + val[0] == '0') { + ds_log("[NET] Route monitor: ip_forward was disabled - re-enabling..."); + write_file("/proc/sys/net/ipv4/ip_forward", "1\n"); + } + } + + /* Same treatment for route_localnet - required for localhost port-forward + * DNAT (127.0.0.1:HOST -> container). Android/netd resets this the same + * way it resets ip_forward. */ + if (g_local_forward_active) { + char val[4] = {0}; + if (read_file("/proc/sys/net/ipv4/conf/all/route_localnet", val, + sizeof(val)) > 0 && + val[0] == '0') { + ds_log("[NET] Route monitor: route_localnet was disabled - " + "re-enabling..."); + write_file("/proc/sys/net/ipv4/conf/all/route_localnet", "1\n"); + } + } + + /* 1.5-second heartbeat: aggressively re-asserts ip_forward and covers + * devices with broken netlink notifications. */ + int pr = poll(&pfd, 1, 1500); + if (pr < 0) { + if (g_stop_monitor) + break; + if (errno == EINTR) + continue; + ds_warn("[NET] Route monitor: poll failed (%s) - stopping", + strerror(errno)); + break; + } + + if (pr == 0) { + do_uplink_reprobe(); + continue; + } + + ssize_t len = recv(sock, buf, sizeof(buf), 0); + if (len < 0) { + if (g_stop_monitor) + break; + if (errno == EINTR || errno == EAGAIN) + continue; + if (errno == ENOBUFS) { + /* The netlink multicast receive buffer overflowed during an event + * burst (e.g. a container reboot tearing down and recreating veths). + * The kernel dropped notifications but the socket is still valid: + * resync by reprobing the uplink and keep monitoring, rather than + * treating a recoverable condition as fatal and killing the thread. */ + do_uplink_reprobe(); + continue; + } + ds_warn("[NET] Route monitor: recv failed (%s) - stopping", + strerror(errno)); + break; + } + if (len == 0) { + /* Only our own shutdown() during stop yields a 0-length read; keep + * monitoring otherwise instead of exiting. */ + if (g_stop_monitor) + break; + continue; + } + + int should_reprobe = 0; + struct nlmsghdr *h = (struct nlmsghdr *)buf; + + for (; NLMSG_OK(h, (uint32_t)len); h = NLMSG_NEXT(h, len)) { + if (h->nlmsg_type == NLMSG_DONE || h->nlmsg_type == NLMSG_ERROR) + break; + + if (h->nlmsg_type == RTM_NEWRULE || h->nlmsg_type == RTM_DELRULE) { + /* FIB rule change - on Android this is netd switching the default + * network. Always reprobe; the reprobe is idempotent so the echo + * of our own rule updates settles in one extra no-op pass. */ + should_reprobe = 1; + } else if (h->nlmsg_type == RTM_NEWLINK || h->nlmsg_type == RTM_DELLINK) { + /* Any real interface changing state may affect the uplink - e.g. + * a new rmnet_dataX popping up mid-session. Only our own veths + * and loopback are noise. */ + struct ifinfomsg *ifi = NLMSG_DATA(h); + char evname[IFNAMSIZ] = {0}; + if_indextoname((unsigned int)ifi->ifi_index, evname); + if (evname[0] && strncmp(evname, "ds-", 3) != 0 && + strcmp(evname, "lo") != 0) + should_reprobe = 1; + } else if (h->nlmsg_type == RTM_NEWADDR || h->nlmsg_type == RTM_DELADDR) { + struct ifaddrmsg *ifa = NLMSG_DATA(h); + if (ifa->ifa_family == AF_INET) { + char evname[IFNAMSIZ] = {0}; + if_indextoname((unsigned int)ifa->ifa_index, evname); + if (evname[0] && strncmp(evname, "ds-", 3) != 0 && + strcmp(evname, "lo") != 0) + should_reprobe = 1; + } + } else if (h->nlmsg_type == RTM_NEWROUTE || + h->nlmsg_type == RTM_DELROUTE) { + /* Default route added/removed in some table. Filter our own + * subnet/bridge routes by output interface. */ + struct rtmsg *rtm = NLMSG_DATA(h); + if (rtm->rtm_family == AF_INET) { + int oif = 0; + struct rtattr *rta = RTM_RTA(rtm); + int rlen = (int)RTM_PAYLOAD(h); + for (; RTA_OK(rta, rlen); rta = RTA_NEXT(rta, rlen)) { + /* RTA_OK bounds only the declared length; require a full 4-byte + * payload before dereferencing (defensive; kernel-sourced). */ + if (rta->rta_type == RTA_OIF && + RTA_PAYLOAD(rta) >= (int)sizeof(int)) + oif = *(int *)RTA_DATA(rta); + } + char evname[IFNAMSIZ] = {0}; + if (oif > 0) + if_indextoname((unsigned int)oif, evname); + if (!evname[0] || + (strncmp(evname, "ds-", 3) != 0 && strcmp(evname, "lo") != 0)) + should_reprobe = 1; + } + } + + if (should_reprobe) + break; + } + + if (should_reprobe) { + /* Handoffs emit a burst of rule/route/addr events back-to-back. + * Drain the socket for a short window so the burst collapses into + * a single reprobe instead of one per event. */ + struct pollfd dp = {.fd = sock, .events = POLLIN}; + while (!g_stop_monitor && poll(&dp, 1, 200) > 0) { + if (recv(sock, buf, sizeof(buf), 0) <= 0) + break; + } + do_uplink_reprobe(); + } + } + + pthread_mutex_lock(&g_gw_mutex); + close(sock); + g_route_monitor_sock = -1; + /* Mark the loop inactive so a later ds_net_start_route_monitor() (e.g. the + * next reboot cycle) spawns a fresh thread instead of no-op'ing on a stale + * flag. needs_join stays set: this thread has exited but is still joinable, + * and start/stop will reap it. */ + g_route_monitor_started = 0; + pthread_mutex_unlock(&g_gw_mutex); + + ds_log("[NET] Uplink route monitor stopped"); + return NULL; +} + +void ds_net_stop_route_monitor(void) { + pthread_mutex_lock(&g_gw_mutex); + /* Gate the join on needs_join, not started: a thread that self-exited on a + * fatal error has started==0 but is still joinable and must be reaped. */ + int needs_join = g_route_monitor_needs_join; + pthread_t tid = g_route_monitor_tid; + g_stop_monitor = 1; + if (g_route_monitor_sock >= 0) + shutdown(g_route_monitor_sock, SHUT_RDWR); + g_route_monitor_needs_join = 0; + pthread_mutex_unlock(&g_gw_mutex); + + /* Join so the monitor is fully stopped before cleanup removes the shared + * MASQUERADE / FIB policy rules - otherwise an in-flight do_uplink_reprobe() + * could re-add a rule we just deleted, or rewrite ip_forward after teardown. + */ + if (needs_join) { + pthread_join(tid, NULL); + pthread_mutex_lock(&g_gw_mutex); + g_route_monitor_started = 0; + pthread_mutex_unlock(&g_gw_mutex); + } +} + +void ds_net_mark_local_forward_active(void) { g_local_forward_active = 1; } + +void ds_net_start_route_monitor(void) { + if (!is_android()) + return; + + pthread_mutex_lock(&g_gw_mutex); + /* Idempotent: exactly one monitor thread per process. setup_veth_host_side + * calls this on every boot cycle (including reboots); the monitor tracks the + * host uplink, which is independent of the container PID, so a single live + * instance rightly persists across the container's reboots. + */ + if (g_route_monitor_started) { + pthread_mutex_unlock(&g_gw_mutex); + return; + } + /* Not running. A previous thread may have self-exited on a fatal error + * without being joined (needs_join==1, started==0); reap it before spawning + * a replacement so its resources are freed and g_route_monitor_tid is not + * overwritten while still joinable. Join outside the mutex (the pattern stop + * uses) - the exiting thread takes g_gw_mutex on its way out. */ + int reap = g_route_monitor_needs_join; + pthread_t old_tid = g_route_monitor_tid; + g_route_monitor_needs_join = 0; + pthread_mutex_unlock(&g_gw_mutex); + + if (reap) + pthread_join(old_tid, NULL); + + pthread_mutex_lock(&g_gw_mutex); + g_stop_monitor = 0; + if (pthread_create(&g_route_monitor_tid, NULL, route_monitor_loop, NULL) != + 0) { + ds_warn("[NET] Failed to start route monitor thread: %s", strerror(errno)); + } else { + g_route_monitor_started = 1; + g_route_monitor_needs_join = 1; + } + pthread_mutex_unlock(&g_gw_mutex); +} + +/* --------------------------------------------------------------------------- + * Network cleanup (called on container stop) + * ---------------------------------------------------------------------------*/ + +void ds_net_cleanup(struct ds_config *cfg, pid_t container_pid) { + /* If this container is a gateway for others, explicitly tear down the + * gateway-side veth(s) it serves: the kernel will not auto-reap them (the + * host-side veth pins its orphan peer netns). No-op when nobody delegates + * to us, so it is safe to run for every stopping container. */ + ds_net_gateway_teardown(cfg->container_name); + + if (cfg->net_mode == DS_NET_GATEWAY) { + ds_nl_ctx_t *ctx = ds_nl_open(); + if (!ctx) + return; + + char veth_host[IFNAMSIZ] = {0}; + pid_t effective_pid = + container_pid > 0 ? container_pid : cfg->container_pid; + if (effective_pid > 0) { + veth_host_name(cfg, effective_pid, veth_host, sizeof(veth_host)); + + char bridge[IFNAMSIZ]; + gateway_bridge_name(cfg, bridge, sizeof(bridge)); + + /* Hold the segment lock across our veth removal + the client count + + * reap, so a concurrent client's setup (which holds the same lock while + * attaching its veth) cannot slip in between our count and our reap. */ + int lock = gateway_segment_lock(bridge); + + ds_nl_del_link(ctx, veth_host); + ds_log("[NET] Gateway cleanup: removed %s", veth_host); + + int clients = ds_nl_count_bridge_members_with_prefix( + ctx, bridge, app_veth_host_prefix(cfg)); + if (clients > 0) { + ds_log("[NET] Gateway cleanup: %d client(s) still on %s - keeping " + "delegated LAN", + clients, bridge); + } else if (gateway_pid_of(cfg->gateway_container) > 0) { + /* No clients left, but the gateway is still up. Do NOT reap the + * bridge: deleting it flaps the gateway's live LAN iface (eth1) + * carrier, and netifd occasionally fails to re-init it ("device + * initialization failed"). The IP-less, policy-free bridge is harmless + * idle and the next client reuses it; the gateway veth stays put. */ + ds_log("[NET] Gateway cleanup: no clients on %s but gateway '%s' is up " + "- keeping bridge to avoid flapping its LAN iface", + bridge, cfg->gateway_container); + } else { + /* No clients and the gateway is gone. The gateway's own stop already + * ran ds_net_gateway_teardown(), which explicitly deleted the gateway + * veth (the kernel does not auto-reap it), so the bridge is now idle + * and safe to reap. */ + ds_nl_del_link(ctx, bridge); + ds_log("[NET] Gateway cleanup: reaped idle delegated LAN bridge %s", + bridge); + } + + gateway_segment_unlock(lock); + } else { + ds_warn("[NET] Gateway cleanup: cannot derive veth name - no valid PID"); + } + + ds_nl_close(ctx); + return; + } + + if (cfg->net_mode != DS_NET_NAT) + return; + + ds_net_stop_route_monitor(); + + ds_nl_ctx_t *ctx = ds_nl_open(); + if (!ctx) + return; + + /* 1. Delete host-side veth - peer in dead netns is already gone */ + char veth_host[IFNAMSIZ] = {0}; + pid_t effective_pid = container_pid > 0 ? container_pid : cfg->container_pid; + if (effective_pid <= 0) { + ds_warn("[NET] cleanup: cannot derive veth name - no valid PID"); + /* still proceed with iptables cleanup */ + } else { + veth_host_name(cfg, effective_pid, veth_host, sizeof(veth_host)); + ds_nl_del_link(ctx, veth_host); + } + + /* Check how many ds-v* veths remain AFTER deleting ours. + * Shared resources (DHCP server, MASQUERADE, FORWARD, Android + * policy rules) must only be torn down when we are the last container. + * Stopping them while others are running kills their networking. */ + int surviving = ds_nl_count_ifaces_with_prefix(ctx, "ds-v"); + if (surviving > 0) { + ds_log("[NET] cleanup: %d other container(s) still running - " + "keeping shared iptables and routing rules", + surviving); + ds_ipt_remove_portforwards(cfg); + if (cfg->net_bridgeless && veth_host[0] != '\0') + ds_ipt_remove_iface_rules(veth_host); + ds_nl_close(ctx); + return; + } + + /* Last container - safe to stop shared services and remove shared rules */ + ds_dhcp_server_stop(); + + /* 2. Remove Android policy rules (last container - safe to clean up) */ + if (is_android()) { + uint32_t subnet, mask; + parse_cidr(DS_DEFAULT_SUBNET, &subnet, &mask); + + /* Remove DS policy rules at both current and legacy priority values so + * an upgrade from an older build that used hardcoded 90/100/200/201 still + * cleans up completely. del_rule4 is idempotent (ENOENT → 0). */ + int prios[] = { + DS_RULE_PRIO_TO_SUBNET, /* 6090 - current */ + DS_RULE_PRIO_TETHER, /* 6095 - current */ + DS_RULE_PRIO_FROM_SUBNET, /* 6100 - current */ + 90, + 100, + 200, + 201 /* legacy - pre-VPN-fix builds */ + }; + for (size_t i = 0; i < sizeof(prios) / sizeof(prios[0]); i++) { + ds_nl_del_rule4(ctx, 0, 0, subnet, DS_NAT_PREFIX, 0, prios[i]); + ds_nl_del_rule4(ctx, subnet, DS_NAT_PREFIX, 0, 0, 0, prios[i]); + } + } + + ds_nl_close(ctx); + + /* 3. Remove iptables rules */ + if (cfg->net_bridgeless && veth_host[0] != '\0') { + ds_ipt_remove_iface_rules(veth_host); + } + ds_ipt_remove_portforwards(cfg); + ds_ipt_remove_ds_rules(); +} diff --git a/src/network.c b/src/network.c deleted file mode 100644 index 7d4a55d9..00000000 --- a/src/network.c +++ /dev/null @@ -1,1302 +0,0 @@ -/* - * Droidspaces v6 - High-performance Container Runtime - * - * Network configuration: DNS, host-side setup, rootfs-side setup, - * veth pair management, and network cleanup. - * - * All link/addr/route management uses the pure-C RTNETLINK API - * (ds_netlink.c). All iptables management uses the raw socket API - * (ds_iptables.c). No external binary dependencies for core networking. - * - * Copyright (C) 2026 ravindu644 - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -#include "droidspace.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include - -/* --------------------------------------------------------------------------- - * Internal helpers - * ---------------------------------------------------------------------------*/ - -/* Derive the host-side veth name from a container init PID */ -static void veth_host_name(pid_t pid, char *buf, size_t sz) { - snprintf(buf, sz, "ds-v%d", (int)pid); -} - -/* Derive the peer (container-side) veth name from a container init PID */ -static void veth_peer_name(pid_t pid, char *buf, size_t sz) { - snprintf(buf, sz, "ds-p%d", (int)pid); -} - -/* Derive a deterministic IP from a PID (avoids sequential collisions) */ -static void veth_peer_ip(pid_t pid, char *buf, size_t sz) { - /* Multiplicative hash to spread sequential PIDs across the /16 subnet. - * - * The /16 space gives us 256 third-octets (172.28.x.y) each with 254 - * usable host addresses, for 65534 total. - * - * octet3: 0–255, but we skip 0 (network row) → range 1–254 (254 rows) - * octet4: 0–255, but we skip 0 (net) and 255 (bcast) → range 1–254 - * - * We also reserve 172.28.0.x entirely for gateway/infrastructure: - * octet3 starts at 1 so the first container gets 172.28.1.x, keeping - * 172.28.0.1 (DS_NAT_GW_IP) unambiguously the gateway in every row. */ - uint32_t hash = (uint32_t)pid; - hash = ((hash >> 16) ^ hash) * 0x45d9f3b; - int octet3 = (int)(((hash >> 8) % 254) + 1); - int octet4 = (int)((hash % 254) + 1); - snprintf(buf, sz, "172.28.%d.%d/%d", octet3, octet4, DS_NAT_PREFIX); -} - -/* --------------------------------------------------------------------------- - * Upstream routing globals - shared by android routing setup and monitor - * ---------------------------------------------------------------------------*/ - -static char g_upstream_ifaces[DS_MAX_UPSTREAM_IFACES][IFNAMSIZ]; -static int g_upstream_count = 0; -static int g_current_gw_table = 0; -static pthread_mutex_t g_gw_mutex = PTHREAD_MUTEX_INITIALIZER; -static int g_route_monitor_sock = -1; -static volatile sig_atomic_t g_stop_monitor = 0; - -/* Returns 1 if ifname exists and is both UP and RUNNING. - * On Android, the active data interface has IFF_RUNNING set; an interface - * that is physically present but not carrying data loses IFF_RUNNING. */ -static int iface_is_running(const char *ifname) { - int fd = socket(AF_INET, SOCK_DGRAM, 0); - if (fd < 0) - return 0; - struct ifreq ifr; - memset(&ifr, 0, sizeof(ifr)); - safe_strncpy(ifr.ifr_name, ifname, IFNAMSIZ); - int ret = 0; - if (ioctl(fd, SIOCGIFFLAGS, &ifr) == 0) - ret = (ifr.ifr_flags & (IFF_UP | IFF_RUNNING)) == (IFF_UP | IFF_RUNNING); - close(fd); - return ret; -} - -/* Returns 1 if `pattern` contains a glob wildcard character ('*' or '?'). - * Used to decide whether fnmatch() or strcmp() is appropriate. */ -static int is_wildcard_pattern(const char *pattern) { - return strchr(pattern, '*') != NULL || strchr(pattern, '?') != NULL; -} - -/* Returns 1 if `iface` matches `pattern`. - * If pattern is a plain name, falls back to strcmp for speed. */ -static int iface_matches_pattern(const char *pattern, const char *iface) { - if (is_wildcard_pattern(pattern)) - return fnmatch(pattern, iface, 0) == 0; - return strcmp(pattern, iface) == 0; -} - -/* --------------------------------------------------------------------------- - * Android default network detection via kernel ip rules - * - * Android's netd inserts exactly one rule of the form: - * ": from all fwmark 0x0/0xffff iif lo lookup " - * for the active default internet network. This rule is the kernel's - * ground truth - it's set atomically when the default network changes - * (wifi ↔ mobile data ↔ handoff). - * - * IMS/MMS-only interfaces (rmnet_data1, rmnet_data6 etc) NEVER appear - * in this rule - they use explicit fwmarks (0xd0064, 0xd0066 etc). - * Only the true internet interface that Qualcomm IPA/MTK CCCI has - * hardware reply sessions for will appear here. - * - * Returns 1 and fills iface_out (IFNAMSIZ) on success, 0 on failure. - * ---------------------------------------------------------------------------*/ -static int ds_net_get_android_default_iface_from_rules(char *iface_out) { - FILE *fp = popen("ip rule show 2>/dev/null", "r"); - if (!fp) - return 0; - - char line[512]; - int found = 0; - - while (fgets(line, sizeof(line), fp) && !found) { - /* Match lines containing both "fwmark 0x0/0xffff" and "iif lo" */ - if (!strstr(line, "fwmark 0x0/0xffff")) - continue; - if (!strstr(line, "iif lo")) - continue; - - char *lookup = strstr(line, "lookup "); - if (!lookup) - continue; - lookup += 7; - - /* Copy interface name until whitespace/newline */ - size_t i = 0; - while (lookup[i] && lookup[i] != ' ' && lookup[i] != '\n' && - lookup[i] != '\r' && lookup[i] != '\t' && i < (size_t)(IFNAMSIZ - 1)) - i++; - if (i == 0) - continue; - - char iface[IFNAMSIZ]; - memcpy(iface, lookup, i); - iface[i] = '\0'; - - /* Skip non-real interfaces */ - if (strcmp(iface, "dummy0") == 0 || strcmp(iface, "lo") == 0 || - strncmp(iface, "ds-", 3) == 0) - continue; - - safe_strncpy(iface_out, iface, IFNAMSIZ); - found = 1; - } - - pclose(fp); - return found; -} - -/* --------------------------------------------------------------------------- - * Public helper: populate a ds_net_handshake from a container init PID - * ---------------------------------------------------------------------------*/ - -void ds_net_derive_handshake(pid_t init_pid, struct ds_config *cfg, - struct ds_net_handshake *hs) { - veth_peer_name(init_pid, hs->peer_name, sizeof(hs->peer_name)); - /* Use the already-resolved static IP - not the PID-hash fallback. - * ip_str is informational on the child side (voided in - * setup_veth_child_side_named) but the boot.c log line prints it, - * so it should reflect the actual IP the DHCP server will offer. */ - if (cfg && cfg->static_nat_ip[0]) - safe_strncpy(hs->ip_str, cfg->static_nat_ip, sizeof(hs->ip_str)); - else - veth_peer_ip(init_pid, hs->ip_str, - sizeof(hs->ip_str)); /* last-resort fallback */ -} - -/* --------------------------------------------------------------------------- - * Host-side networking setup (before container boot) - * ---------------------------------------------------------------------------*/ - -int ds_get_dns_servers(const char *custom_dns, char *out, size_t size) { - out[0] = '\0'; - int count = 0; - - /* 0. Try custom DNS if provided */ - if (custom_dns && custom_dns[0]) { - char buf[1024]; - safe_strncpy(buf, custom_dns, sizeof(buf)); - char *saveptr; - char *token = strtok_r(buf, ", ", &saveptr); - while (token && (size_t)strlen(out) < size - 32) { - char line[128]; - snprintf(line, sizeof(line), "nameserver %s\n", token); - size_t current_len = strlen(out); - snprintf(out + current_len, size - current_len, "%s", line); - count++; - token = strtok_r(NULL, ", ", &saveptr); - } - } - - /* 1. Global stable fallbacks (defined in droidspace.h) */ - if (count == 0) { - int n = snprintf(out, size, "nameserver %s\nnameserver %s\n", - DS_DNS_DEFAULT_1, DS_DNS_DEFAULT_2); - if (n > 0 && (size_t)n < size) - count = 2; - } - - return count; -} - -/* --------------------------------------------------------------------------- - * Static NAT IP: validation, collision check, and resolution - * ---------------------------------------------------------------------------*/ - -int ds_net_validate_static_ip(const char *ip_str, char *errbuf, - size_t errsize) { - if (!ip_str || !ip_str[0]) { - snprintf(errbuf, errsize, "empty IP string"); - return -1; - } - - /* Reject CIDR notation - we store plain dotted-decimal */ - if (strchr(ip_str, '/')) { - snprintf(errbuf, errsize, - "pass plain IP without prefix length " - "(e.g. 172.28.5.10, not 172.28.5.10/16)"); - return -1; - } - - struct in_addr addr; - if (inet_pton(AF_INET, ip_str, &addr) != 1) { - snprintf(errbuf, errsize, "not a valid IPv4 address"); - return -1; - } - - int o1, o2, o3, o4; - if (sscanf(ip_str, "%d.%d.%d.%d", &o1, &o2, &o3, &o4) != 4) { - snprintf(errbuf, errsize, "malformed IPv4 address"); - return -1; - } - - /* Must be inside 172.28.0.0/16 */ - if (o1 != 172 || o2 != 28) { - snprintf(errbuf, errsize, - "must be inside the NAT subnet " DS_DEFAULT_SUBNET " (got %s)", - ip_str); - return -1; - } - - /* Octet3: reserve row 0 entirely for gateway/infrastructure (172.28.0.x) */ - if (o3 < 1 || o3 > 254) { - snprintf(errbuf, errsize, - "third octet %d out of range - must be 1-254 " - "(172.28.0.x is reserved for the gateway)", - o3); - return -1; - } - - /* Octet4: exclude network address (0) and broadcast (255) */ - if (o4 < 1 || o4 > 254) { - snprintf(errbuf, errsize, "fourth octet %d out of range - must be 1-254", - o4); - return -1; - } - - return 0; -} - -int ds_net_check_ip_collision(const char *ip_str, const char *exclude_name) { - char containers_dir[PATH_MAX]; - snprintf(containers_dir, sizeof(containers_dir), "%s/Containers", - get_workspace_dir()); - - /* The directory entries are sanitized names (written by - * ds_config_save_by_name via sanitize_container_name). Sanitize exclude_name - * the same way so the self-skip comparison is always apples-to-apples. */ - char safe_exclude[256] = {0}; - if (exclude_name && exclude_name[0]) - sanitize_container_name(exclude_name, safe_exclude, sizeof(safe_exclude)); - - DIR *d = opendir(containers_dir); - if (!d) - return 0; /* Can't scan → assume unique */ - - struct dirent *ent; - int collision = 0; - - while ((ent = readdir(d)) != NULL && !collision) { - if (ent->d_name[0] == '.') - continue; - /* Skip the container being configured so it can keep its own IP on restart - */ - if (safe_exclude[0] && strcmp(ent->d_name, safe_exclude) == 0) - continue; - - /* config_path must hold: containers_dir (PATH_MAX-1) + '/' + - * ent->d_name (NAME_MAX = 255) + "/container.config" (18) + NUL. - * A plain PATH_MAX buffer overflows that worst case - use an explicit - * worst-case size so -Werror=format-truncation is satisfied. */ - char config_path[PATH_MAX + NAME_MAX + 32]; - snprintf(config_path, sizeof(config_path), "%s/%s/container.config", - containers_dir, ent->d_name); - - struct ds_config other; - memset(&other, 0, sizeof(other)); - other.net_ready_pipe[0] = other.net_ready_pipe[1] = -1; - other.net_done_pipe[0] = other.net_done_pipe[1] = -1; - - if (ds_config_load(config_path, &other) == 0) { - if (other.static_nat_ip[0] && strcmp(other.static_nat_ip, ip_str) == 0) - collision = 1; - free_config_binds(&other); - free_config_env_vars(&other); - free_config_unknown_lines(&other); - } - } - - closedir(d); - return collision; -} - -/* Internal: derive a unique IP via djb2(container_name), walking forward on - * collision. Deterministic on first boot → same row every time the same - * container name is used, spreading containers across the /16. */ -static void ds_net_auto_assign_ip(struct ds_config *cfg) { - uint32_t hash = 5381; - for (const char *p = cfg->container_name; *p; p++) - hash = ((hash << 5) + hash) ^ (unsigned char)*p; - - int o3 = (int)((hash >> 8) % 254) + 1; /* 1-254 */ - int o4 = (int)(hash % 254) + 1; /* 1-254 */ - - /* Walk up to a full /16 to find an unoccupied slot */ - for (int attempts = 0; attempts < 254 * 254; attempts++) { - char candidate[32]; - snprintf(candidate, sizeof(candidate), "172.28.%d.%d", o3, o4); - - if (ds_net_check_ip_collision(candidate, cfg->container_name) == 0) { - safe_strncpy(cfg->static_nat_ip, candidate, sizeof(cfg->static_nat_ip)); - return; - } - - if (++o4 > 254) { - o4 = 1; - if (++o3 > 254) - o3 = 1; - } - } - - /* Subnet is completely exhausted - reuse hash address as last resort */ - char fallback[32]; - snprintf(fallback, sizeof(fallback), "172.28.%d.%d", o3, o4); - safe_strncpy(cfg->static_nat_ip, fallback, sizeof(cfg->static_nat_ip)); - ds_warn("[NET] NAT subnet appears fully allocated - reusing %s as fallback", - cfg->static_nat_ip); -} - -void ds_net_resolve_static_ip(struct ds_config *cfg) { - char errbuf[256]; - - if (cfg->static_nat_ip[0]) { - /* IP already set - from --nat-ip flag or loaded from a previous boot. - * Re-validate in case the config was hand-edited since last boot. */ - if (ds_net_validate_static_ip(cfg->static_nat_ip, errbuf, sizeof(errbuf)) != - 0) { - ds_warn("[NET] static_nat_ip '%s' failed validation: %s " - "- auto-assigning a new IP", - cfg->static_nat_ip, errbuf); - cfg->static_nat_ip[0] = '\0'; - - } else if (ds_net_check_ip_collision(cfg->static_nat_ip, - cfg->container_name)) { - ds_warn("[NET] static_nat_ip '%s' is already assigned to another " - "container - auto-assigning a new IP", - cfg->static_nat_ip); - cfg->static_nat_ip[0] = '\0'; - } - } - - if (!cfg->static_nat_ip[0]) - ds_net_auto_assign_ip(cfg); - - ds_log("[NET] Container '%s' → static NAT IP: %s (persisted to config)", - cfg->container_name, cfg->static_nat_ip); -} - -int fix_networking_host(struct ds_config *cfg) { - ds_log("Configuring host-side networking for %s...", cfg->container_name); - - /* Enable IPv4 forwarding */ - write_file("/proc/sys/net/ipv4/ip_forward", "1"); - - /* Re-enable IPv6 globally only in host mode if not disabled */ - if (cfg->net_mode == DS_NET_HOST && !cfg->disable_ipv6) { - write_file("/proc/sys/net/ipv6/conf/all/disable_ipv6", "0"); - write_file("/proc/sys/net/ipv6/conf/default/disable_ipv6", "0"); - } - - /* Get DNS and store it in the config struct to be used after pivot_root */ - cfg->dns_server_content[0] = '\0'; - int count = ds_get_dns_servers(cfg->dns_servers, cfg->dns_server_content, - sizeof(cfg->dns_server_content)); - - if (cfg->dns_servers[0]) - ds_log("Setting up %d custom DNS servers...", count); - - return 0; -} - -/* --------------------------------------------------------------------------- - * Android-specific policy routing - * - * Iterates the user-declared upstream interfaces in priority order, finds the - * first one that is RUNNING and has an IPv4 default route, then injects - * low-priority ip rules to direct container traffic through that table. - * - * This replaces the old auto-detection approach which was unreliable on - * Android MTK/Qualcomm because both wlan0 and mobile-data interfaces can - * have simultaneous default routes in per-interface tables; only the policy - * rules distinguish which is active, and parsing those rules was fragile. - * ---------------------------------------------------------------------------*/ - -/* Forward declaration - defined later in this file after route monitor globals - */ -static int find_active_upstream(ds_nl_ctx_t *ctx, char *iface_out, - int *table_out); - -static void ds_net_setup_android_routing(ds_nl_ctx_t *ctx, - const char ifaces[][IFNAMSIZ], - int iface_count) { - /* Temporarily populate the globals so find_active_upstream() can use them. - * setup_veth_host_side() copies these right after this call anyway. */ - for (int _i = 0; _i < iface_count && _i < DS_MAX_UPSTREAM_IFACES; _i++) - safe_strncpy(g_upstream_ifaces[_i], ifaces[_i], IFNAMSIZ); - g_upstream_count = iface_count; - - char active_iface[IFNAMSIZ] = {0}; - int gw_table = 0; - find_active_upstream(ctx, active_iface, &gw_table); - - uint32_t subnet_be, mask_be; - parse_cidr(DS_DEFAULT_SUBNET, &subnet_be, &mask_be); - uint8_t prefix = DS_NAT_PREFIX; - - /* DS_RULE_PRIO_TO_SUBNET (6090): inbound traffic to our subnet always - * resolves via main table. Install this even if no upstream is active - * yet - the monitor will handle the FROM rule once an interface comes up. - * - * Priority 6090 is: - * • above Android's VPN rule range (10000–22000) -> checked FIRST, so - * reply-to-container traffic is never hijacked by a VPN's catch-all rule - * • above OEM reserved low-priority rules (typically < 1000) */ - int ret = ds_nl_add_rule4(ctx, 0, 0, subnet_be, prefix, RT_TABLE_MAIN, - DS_RULE_PRIO_TO_SUBNET); - if (ret < 0) - ds_warn("[NET] Android routing: failed to add 'to subnet' rule (%d)", - DS_RULE_PRIO_TO_SUBNET); - - if (!active_iface[0]) { - ds_warn("[NET] Android routing: no upstream interface is active yet - " - "route monitor will install rule when one comes up"); - return; - } - - ds_log("[NET] Android routing: active upstream %s → table %d", active_iface, - gw_table); - - /* DS_RULE_PRIO_FROM_SUBNET (6100): traffic from our subnet → upstream - * internet table. Also above Android's VPN range so container-originated - * traffic always routes through the physical uplink, not through any VPN - * tunnel (the container has its own isolation layer). */ - ret = ds_nl_add_rule4(ctx, subnet_be, prefix, 0, 0, gw_table, - DS_RULE_PRIO_FROM_SUBNET); - if (ret == 0) { - ds_log("[NET] Android routing: rule from %s lookup table %d (prio %d)", - DS_DEFAULT_SUBNET, gw_table, DS_RULE_PRIO_FROM_SUBNET); - /* Seed the monitor's current table so it knows the baseline */ - pthread_mutex_lock(&g_gw_mutex); - g_current_gw_table = gw_table; - pthread_mutex_unlock(&g_gw_mutex); - } else { - ds_warn("[NET] Android routing: ds_nl_add_rule4 failed (ret=%d)", ret); - } -} - -/* --------------------------------------------------------------------------- - * TX checksum disable (Samsung/MTK kernel workaround) - * ---------------------------------------------------------------------------*/ - -int ds_net_disable_tx_checksum(const char *ifname) { - int fd = socket(AF_INET, SOCK_DGRAM, 0); - if (fd < 0) - return -errno; - - struct ifreq ifr; - memset(&ifr, 0, sizeof(ifr)); - safe_strncpy(ifr.ifr_name, ifname, IFNAMSIZ); - - struct ethtool_value eval; - eval.cmd = ETHTOOL_STXCSUM; - eval.data = 0; /* Disable */ - ifr.ifr_data = (caddr_t)&eval; - - int ret = ioctl(fd, SIOCETHTOOL, &ifr); - close(fd); - return (ret < 0) ? -errno : 0; -} - -/* --------------------------------------------------------------------------- - * setup_veth_host_side - * - * Called from the Monitor process AFTER receiving the "ready" signal from the - * container init (via net_ready_pipe). - * - * Steps: - * 1. Create or reuse bridge ds-br0 with IP 172.28.0.1/16 - * 2. iptables: MASQUERADE + FORWARD ACCEPT + INPUT ACCEPT + MSS clamp - * 3. Create veth pair (ds-vXXXXX / ds-pXXXXX) - * 4. Disable TX checksum on host veth (Samsung/MTK workaround) - * 5. Attach host veth to bridge, bring up - * 6. Move peer veth into container's network namespace - * 7. Android policy routing - * ---------------------------------------------------------------------------*/ - -int setup_veth_host_side(struct ds_config *cfg, pid_t child_pid) { - char veth_host[IFNAMSIZ], veth_peer[IFNAMSIZ]; - veth_host_name(child_pid, veth_host, sizeof(veth_host)); - veth_peer_name(child_pid, veth_peer, sizeof(veth_peer)); - - ds_log("Setting up host-side NAT networking for %s (PID %d)...", - cfg->container_name, (int)child_pid); - - ds_nl_ctx_t *ctx = ds_nl_open(); - if (!ctx) { - ds_warn("[NET] Failed to open RTNETLINK socket"); - return -1; - } - - /* Clean up stale interfaces from previous runs */ - ds_log("[DEBUG] Cleaning up any stale interfaces: %s, %s", veth_host, - veth_peer); - ds_nl_del_link(ctx, veth_host); - - /* 1. Ensure bridge exists (SKIP for bridgeless fallback) */ - if (!cfg->net_bridgeless) { - if (!ds_nl_link_exists(ctx, DS_NAT_BRIDGE)) { - ds_log("[DEBUG] Creating bridge %s...", DS_NAT_BRIDGE); - if (ds_nl_create_bridge(ctx, DS_NAT_BRIDGE) < 0) - ds_warn("[DEBUG] Failed to create bridge %s", DS_NAT_BRIDGE); - } - - /* Always assert bridge IP/UP/Hardening even if it already exists. - * This ensures everything is correct after host-side networking changes or - * crashes. */ - int err = ds_nl_add_addr4(ctx, DS_NAT_BRIDGE, inet_addr(DS_NAT_GW_IP), - DS_NAT_PREFIX); - if (err < 0 && err != -EEXIST && err != -ENETDOWN) { - ds_warn("[DEBUG] Failed to add IP to %s: %d", DS_NAT_BRIDGE, err); - } - - if (ds_nl_link_up(ctx, DS_NAT_BRIDGE) < 0) - ds_warn("[DEBUG] Failed to bring up %s", DS_NAT_BRIDGE); - - /* Disable ICMP redirects on the bridge. */ - write_file("/proc/sys/net/ipv4/conf/" DS_NAT_BRIDGE "/accept_redirects", - "0"); - write_file("/proc/sys/net/ipv4/conf/" DS_NAT_BRIDGE "/send_redirects", "0"); - write_file("/proc/sys/net/ipv4/conf/" DS_NAT_BRIDGE "/rp_filter", "0"); - } else { - ds_log("[NET] Bridgeless Fallback: skipping bridge creation."); - } - - /* Late-stage hardening: sysctl for bridge */ - if (cfg->net_mode == DS_NET_NAT) { - ds_log("[DEBUG] Applying late-stage hardening for Android NAT..."); - if (!cfg->net_bridgeless) { - if (access("/proc/sys/net/bridge", F_OK) == 0) { - write_file("/proc/sys/net/bridge/bridge-nf-call-iptables", "0"); - write_file("/proc/sys/net/bridge/bridge-nf-call-ip6tables", "0"); - } - ds_ipt_ensure_input_accept(DS_NAT_BRIDGE); - } else { - write_file("/proc/sys/net/ipv4/conf/all/rp_filter", "0"); - write_file("/proc/sys/net/ipv4/conf/default/rp_filter", "0"); - /* In bridgeless mode, we must accept input from the veth itself */ - ds_ipt_ensure_input_accept(veth_host); - } - } - - /* 2. iptables rules */ - if (ds_ipt_ensure_masquerade(DS_DEFAULT_SUBNET) < 0) - ds_warn("[NET] MASQUERADE rule failed"); - if (!cfg->net_bridgeless) { - if (ds_ipt_ensure_forward_accept(DS_NAT_BRIDGE) < 0) - ds_warn("[NET] FORWARD ACCEPT failed"); - } else { - if (ds_ipt_ensure_forward_accept(veth_host) < 0) - ds_warn("[NET] FORWARD ACCEPT failed"); - } - ds_ipt_ensure_mss_clamp(); - - /* 3. Create veth pair */ - ds_log("[DEBUG] Creating veth pair %s <-> %s...", veth_host, veth_peer); - if (ds_nl_create_veth(ctx, veth_host, veth_peer) < 0) { - ds_warn("[NET] Failed to create veth pair (%s, %s)", veth_host, veth_peer); - ds_nl_close(ctx); - return -1; - } - - /* 4. Disable TX checksum on host veth */ - ds_net_disable_tx_checksum(veth_host); - - /* 5. Set master or assign IP directly for PTP */ - if (!cfg->net_bridgeless) { - if (ds_nl_set_master(ctx, veth_host, DS_NAT_BRIDGE) < 0) - ds_warn("[NET] Failed to attach %s to %s", veth_host, DS_NAT_BRIDGE); - } else { - /* Bridgeless Fallback: Assign GW IP to veth_host directly */ - if (ds_nl_add_addr4(ctx, veth_host, inet_addr(DS_NAT_GW_IP), 32) < 0) - ds_warn("[NET] Bridgeless: Failed to add IP to %s", veth_host); - - /* Interface must be UP before routes can be added on some kernels */ - if (ds_nl_link_up(ctx, veth_host) < 0) - ds_warn("[NET] Failed to bring up %s", veth_host); - - /* Add host route for the container's static IP to this veth. - * cfg->static_nat_ip is already resolved and persisted before fork. */ - struct in_addr peer_in; - if (inet_pton(AF_INET, cfg->static_nat_ip, &peer_in) == 1) { - if (ds_nl_add_route4(ctx, peer_in.s_addr, 32, 0, - ds_nl_get_ifindex(ctx, veth_host)) < 0) - ds_warn("[NET] Bridgeless: Failed to add route for %s", - cfg->static_nat_ip); - } else { - ds_warn("[NET] Bridgeless: static_nat_ip '%s' is not parseable - " - "no host route installed", - cfg->static_nat_ip); - } - } - - /* Ensure veth_host is UP (redundant if bridgeless but safe) */ - if (ds_nl_link_up(ctx, veth_host) < 0) - ds_warn("[NET] Failed to bring up %s", veth_host); - - /* Disable ICMP redirects on the host veth. */ - { - char sysctl_path[128]; - snprintf(sysctl_path, sizeof(sysctl_path), - "/proc/sys/net/ipv4/conf/%s/accept_redirects", veth_host); - write_file(sysctl_path, "0"); - } - - /* 6. Move peer veth into container's network namespace */ - char netns_path[PATH_MAX]; - snprintf(netns_path, sizeof(netns_path), "/proc/%d/ns/net", child_pid); - - /* No retry loop needed; init has already signaled readiness */ - int netns_fd = open(netns_path, O_RDONLY | O_CLOEXEC); - if (netns_fd < 0) { - ds_warn("[NET] Failed to open container netns %s: %s", netns_path, - strerror(errno)); - ds_nl_close(ctx); - return -1; - } - - ds_log("[DEBUG] Moving %s into netns of PID %d using FD %d...", veth_peer, - (int)child_pid, netns_fd); - int r = ds_nl_move_to_netns(ctx, veth_peer, netns_fd); - close(netns_fd); - - if (r < 0) { - ds_warn("[NET] Failed to move %s into container netns (ret=%d)", veth_peer, - r); - ds_nl_close(ctx); - return -1; - } - ds_log("[DEBUG] Successfully moved %s to PID %d", veth_peer, (int)child_pid); - - /* 7. Android policy routing - uses the user-declared upstream interfaces */ - if (is_android()) { - ds_net_setup_android_routing(ctx, - (const char (*)[IFNAMSIZ])cfg->upstream_ifaces, - cfg->upstream_iface_count); - } - - /* Cache the upstream list so ds_net_start_route_monitor() can access it. - * setup_veth_host_side() always runs before ds_net_start_route_monitor(). */ - for (int _i = 0; - _i < cfg->upstream_iface_count && _i < DS_MAX_UPSTREAM_IFACES; _i++) - safe_strncpy(g_upstream_ifaces[_i], cfg->upstream_ifaces[_i], IFNAMSIZ); - g_upstream_count = cfg->upstream_iface_count; - - ds_nl_close(ctx); - - /* 8. Start embedded DHCP server so the container's DHCP client acquires - * the static IP persisted in cfg->static_nat_ip. Using a stable IP here - * means every reboot the container gets the same address - no PREROUTING - * rule churn, no "wrong IP" on the first DHCP renew. - * - * Binding interface depends on topology: - * Bridge mode - bind to ds-br0. veth_host is a bridge slave; the - * kernel delivers frames from the container upward to - * the bridge interface, not the slave. A socket bound - * to the slave would never see the DHCP DISCOVERs. - * Bridgeless mode - bind to veth_host directly (point-to-point veth, - * no bridge in the path). */ - { - struct in_addr offer_in; - uint32_t offer_ip = 0; - if (inet_pton(AF_INET, cfg->static_nat_ip, &offer_in) == 1) { - offer_ip = offer_in.s_addr; - } else { - ds_warn("[NET] DHCP: static_nat_ip '%s' unparseable - " - "DHCP server will offer 0.0.0.0 (container boot will fail)", - cfg->static_nat_ip); - } - - /* Bind to veth_host. In bridge mode the kernel also floods L2 broadcasts - * to sibling veth ports; isolation is enforced by peer_mac filter in - * the DHCP server loop, not by the socket bind alone. */ - const char *dhcp_iface = veth_host; - ds_dhcp_server_start(cfg, dhcp_iface, offer_ip, inet_addr(DS_NAT_GW_IP)); - - /* Store the container IP string (plain dotted-decimal) for port-forward - * cleanup later. static_nat_ip is already in that exact format. */ - safe_strncpy(cfg->nat_container_ip, cfg->static_nat_ip, - sizeof(cfg->nat_container_ip)); - - /* Install DNAT + FORWARD rules for any --port mappings */ - if (cfg->port_forward_count > 0) - ds_ipt_add_portforwards(cfg, cfg->nat_container_ip); - } - - return 0; -} - -/* --------------------------------------------------------------------------- - * setup_veth_child_side_named - * - * Called from internal_boot() INSIDE the container's new network namespace. - * ---------------------------------------------------------------------------*/ - -int setup_veth_child_side_named(struct ds_config *cfg, const char *peer_name, - const char *ip_str) { - (void)cfg; - (void)ip_str; /* IP is now assigned by the container's own DHCP client */ - ds_log("[DEBUG] Child: Configuring isolated networking. Local PID: %d, " - "Peer: %s", - (int)getpid(), peer_name ? peer_name : "(null)"); - - ds_nl_ctx_t *ctx = ds_nl_open(); - if (!ctx) { - ds_warn("[DEBUG] Child: Failed to open netlink socket"); - return -1; - } - - /* 0. Rename interface to eth0 */ - if (peer_name && peer_name[0] && strcmp(peer_name, "eth0") != 0) { - ds_log("[DEBUG] Renaming %s to eth0...", peer_name); - if (ds_nl_rename(ctx, peer_name, "eth0") < 0) - ds_warn("[DEBUG] Failed to rename %s to eth0.", peer_name); - } - - /* 1. Loopback */ - ds_nl_link_up(ctx, "lo"); - - /* 2. Bring eth0 UP - the container's DHCP client configures IP and route */ - ds_nl_link_up(ctx, "eth0"); - - ds_nl_close(ctx); - ds_log("[NET] Child: eth0 UP - awaiting DHCP lease from monitor"); - return 0; -} - -/* Compatibility wrapper */ - -/* --------------------------------------------------------------------------- - * Rootfs-side networking setup (inside container, after pivot_root) - * ---------------------------------------------------------------------------*/ - -int fix_networking_rootfs(struct ds_config *cfg) { - /* 1. Hostname */ - if (cfg->hostname[0]) { - if (sethostname(cfg->hostname, strlen(cfg->hostname)) < 0) { - ds_warn("Failed to set hostname to %s: %s", cfg->hostname, - strerror(errno)); - } - /* Persist to /etc/hostname */ - char hn_buf[256 + 2]; - snprintf(hn_buf, sizeof(hn_buf), "%.256s\n", cfg->hostname); - write_file("/etc/hostname", hn_buf); - } - - /* 2. /etc/hosts */ - char hosts_content[1024]; - const char *hostname = (cfg->hostname[0]) ? cfg->hostname : "localhost"; - - /* IPv6 is only enabled in host mode unless explicitly disabled */ - int ipv6_enabled = (cfg->net_mode == DS_NET_HOST && !cfg->disable_ipv6); - if (ipv6_enabled) { - snprintf(hosts_content, sizeof(hosts_content), - "127.0.0.1\tlocalhost\n" - "127.0.1.1\t%s\n" - "::1\t\tlocalhost ip6-localhost ip6-loopback\n" - "ff02::1\t\tip6-allnodes\n" - "ff02::2\t\tip6-allrouters\n", - hostname); - } else { - snprintf(hosts_content, sizeof(hosts_content), - "127.0.0.1\tlocalhost\n" - "127.0.1.1\t%s\n", - hostname); - } - - write_file("/etc/hosts", hosts_content); - - /* 3. resolv.conf - always use dns_server_content which ds_get_dns_servers() - * already populated with either the user's --dns servers or the compiled-in - * defaults (1.1.1.1 / 8.8.8.8). */ - mkdir("/run/resolvconf", 0755); - write_file("/run/resolvconf/resolv.conf", cfg->dns_server_content); - - /* Link /etc/resolv.conf */ - unlink("/etc/resolv.conf"); - if (symlink("/run/resolvconf/resolv.conf", "/etc/resolv.conf") < 0) { - ds_warn("Failed to link /etc/resolv.conf: %s", strerror(errno)); - } - - if (!ipv6_enabled) { - if (cfg->net_mode == DS_NET_HOST) { - /* In host mode, disabling IPv6 affects the host's netns. Warn and apply. - */ - ds_warn("--disable-ipv6 in host mode disables IPv6 on the host " - "network namespace."); - } - write_file("/proc/sys/net/ipv6/conf/all/disable_ipv6", "1"); - write_file("/proc/sys/net/ipv6/conf/default/disable_ipv6", "1"); - } - - /* 5. unprivileged ICMP sockets: new network namespaces reset - * ping_group_range to "1 0". Allow all GIDs so ping works without - * CAP_NET_RAW. */ - write_file("/proc/sys/net/ipv4/ping_group_range", "0 2147483647"); - - return 0; -} - -/* --------------------------------------------------------------------------- - * Runtime introspection - * ---------------------------------------------------------------------------*/ - -int detect_ipv6_in_container(pid_t pid) { - char path[PATH_MAX]; - build_proc_root_path(pid, "/proc/sys/net/ipv6/conf/all/disable_ipv6", path, - sizeof(path)); - - char buf[16]; - if (read_file(path, buf, sizeof(buf)) < 0) - return -1; - - /* 0 means enabled, 1 means disabled */ - return (buf[0] == '0') ? 1 : 0; -} - -/* --------------------------------------------------------------------------- - * Upstream Route Monitor - * - * Watches LINK state and IPv4 address changes on the user-declared upstream - * interfaces. When any change is detected that involves one of those - * interfaces, it re-scans to find which is currently active (RUNNING + has - * a default route) and atomically updates the policy rule. - * - * Event triggers: - * RTM_NEWLINK / RTM_DELLINK - interface state change (UP/RUNNING/DOWN) - * RTM_NEWADDR / RTM_DELADDR - IPv4 address assigned or removed - * - * 30-second heartbeat covers devices with broken netlink notifications. - * ---------------------------------------------------------------------------*/ - -/* Scan upstream interfaces in priority order; return the first that is - * RUNNING and has an IPv4 default route in any table. - * - * Entries without wildcards are checked directly (fast path). - * Entries containing '*' or '?' are expanded via a full RTM_GETLINK dump - * and matched with fnmatch() - this handles dynamic interface names like - * "*rmnet_data*" or "v4-rmnet_data*" on Qualcomm/CLAT devices where the - * interface number changes on every reboot. - * - * Each wildcard pattern slot remembers the interface it last resolved to. - * A discovery log fires only when the resolved name changes - this handles - * cleanup (dead interfaces are overwritten), prevents log spam on heartbeat - * reprobes, and uses zero dynamic allocation. */ - -/* Per-pattern "last wildcard match" tracker. Index matches g_upstream_ifaces. - * An empty string means "never matched yet". */ -static char g_last_wildcard_match[DS_MAX_UPSTREAM_IFACES][IFNAMSIZ]; - -/* Last interface returned by the ip rule probe. Suppresses the - * "[NET] Android default network:" log line on every 1.5s heartbeat - - * only log when the interface actually changes. */ -static char g_last_iprule_iface[IFNAMSIZ]; - -/* Set to 1 when we've already warned about ip rule probe failure. - * Cleared when the probe succeeds again, so the next failure after - * a working period logs once more. */ -static int g_iprule_fail_warned; - -static int find_active_upstream(ds_nl_ctx_t *ctx, char *iface_out, - int *table_out) { - /* On Android, use the kernel ip rule "fwmark 0x0/0xffff iif lo lookup - * " as the primary detection method. This rule is set by netd - * for the active default internet network only - IMS/MMS-only interfaces - * (rmnet_data1, rmnet_data6 etc) never appear here. This also ensures - * we pick the interface Qualcomm IPA / MTK CCCI has hardware reply - * sessions for, which is required for NAT'd forwarded traffic to work. - * - * This replaces the old priority-list-first approach which would pick - * the first RUNNING rmnet_data* with a default route - often an IMS - * interface that is RUNNING and has routes but whose IPA sessions only - * serve locally-originated Android traffic, not forwarded container - * packets. */ - if (is_android()) { - char default_iface[IFNAMSIZ] = {0}; - if (ds_net_get_android_default_iface_from_rules(default_iface) && - default_iface[0] && iface_is_running(default_iface)) { - int tbl = 0; - if (ds_nl_get_iface_table(ctx, default_iface, &tbl) == 0) { - if (strcmp(g_last_iprule_iface, default_iface) != 0) { - ds_log("[NET] Android default network: %s (table %d) [ip rule]", - default_iface, tbl); - safe_strncpy(g_last_iprule_iface, default_iface, - sizeof(g_last_iprule_iface)); - } - g_iprule_fail_warned = 0; /* reset so next failure logs once */ - if (iface_out) - safe_strncpy(iface_out, default_iface, IFNAMSIZ); - if (table_out) - *table_out = tbl; - return 0; - } - } - if (!g_iprule_fail_warned) { - ds_warn("[NET] ip rule probe failed - falling back to priority list"); - g_iprule_fail_warned = 1; - } - g_last_iprule_iface[0] = '\0'; - } - - /* Non-Android path (or Android fallback): scan the user-declared - * upstream interface list in priority order. */ - for (int i = 0; i < g_upstream_count; i++) { - const char *pattern = g_upstream_ifaces[i]; - - if (!is_wildcard_pattern(pattern)) { - /* Fast path: literal name */ - if (!iface_is_running(pattern)) - continue; - int tbl = 0; - if (ds_nl_get_iface_table(ctx, pattern, &tbl) == 0) { - if (iface_out) - safe_strncpy(iface_out, pattern, IFNAMSIZ); - if (table_out) - *table_out = tbl; - return 0; - } - } else { - /* Wildcard path: enumerate all real interfaces and fnmatch */ - char all_ifaces[64][IFNAMSIZ]; - int all_count = ds_nl_list_ifaces(ctx, all_ifaces, 64); - for (int j = 0; j < all_count; j++) { - if (!iface_matches_pattern(pattern, all_ifaces[j])) - continue; - /* Skip our own bridge/veth and loopback */ - if (strncmp(all_ifaces[j], "ds-", 3) == 0) - continue; - if (strcmp(all_ifaces[j], "lo") == 0) - continue; - if (!iface_is_running(all_ifaces[j])) - continue; - int tbl = 0; - if (ds_nl_get_iface_table(ctx, all_ifaces[j], &tbl) == 0) { - /* Log only when the resolved interface changes for this pattern */ - if (strcmp(g_last_wildcard_match[i], all_ifaces[j]) != 0) { - ds_log("[NET] Wildcard '%s' matched active interface '%s' " - "(table %d)", - pattern, all_ifaces[j], tbl); - safe_strncpy(g_last_wildcard_match[i], all_ifaces[j], IFNAMSIZ); - } - if (iface_out) - safe_strncpy(iface_out, all_ifaces[j], IFNAMSIZ); - if (table_out) - *table_out = tbl; - return 0; - } - } - } - } - return -ENOENT; -} - -/* Re-probe which upstream is active and update the ip rule if needed. */ -static void do_upstream_reprobe(void) { - ds_nl_ctx_t *ctx = ds_nl_open(); - if (!ctx) - return; - - char new_iface[IFNAMSIZ] = {0}; - int new_table = 0; - - if (find_active_upstream(ctx, new_iface, &new_table) != 0) { - /* No upstream active yet - leave current rule in place */ - ds_nl_close(ctx); - return; - } - - pthread_mutex_lock(&g_gw_mutex); - int old_table = g_current_gw_table; - pthread_mutex_unlock(&g_gw_mutex); - - if (new_table == old_table) { - ds_nl_close(ctx); - return; - } - - ds_log("[NET] Route monitor: upstream switch table %d → %d (%s)", old_table, - new_table, new_iface); - - uint32_t subnet_be, mask_be; - parse_cidr(DS_DEFAULT_SUBNET, &subnet_be, &mask_be); - (void)mask_be; - - if (old_table > 0) - ds_nl_del_rule4(ctx, subnet_be, DS_NAT_PREFIX, 0, 0, old_table, - DS_RULE_PRIO_FROM_SUBNET); - - if (ds_nl_add_rule4(ctx, subnet_be, DS_NAT_PREFIX, 0, 0, new_table, - DS_RULE_PRIO_FROM_SUBNET) == 0) { - pthread_mutex_lock(&g_gw_mutex); - g_current_gw_table = new_table; - pthread_mutex_unlock(&g_gw_mutex); - ds_log("[NET] Route monitor: rule updated → from %s lookup %d (prio %d)", - DS_DEFAULT_SUBNET, new_table, DS_RULE_PRIO_FROM_SUBNET); - - if (is_android()) - write_file("/proc/sys/net/ipv4/ip_forward", "1"); - } else { - ds_warn("[NET] Route monitor: failed to install new rule for table %d", - new_table); - } - - ds_nl_close(ctx); -} - -static void *route_monitor_loop(void *arg) { - (void)arg; - - /* Build a comma-separated list for the log line */ - /* DS_MAX_UPSTREAM_IFACES * (IFNAMSIZ + 1 for comma) + NUL */ - char iface_list[DS_MAX_UPSTREAM_IFACES * (IFNAMSIZ + 1) + 1]; - memset(iface_list, 0, sizeof(iface_list)); - for (int i = 0; i < g_upstream_count; i++) { - if (i > 0) - strncat(iface_list, ",", sizeof(iface_list) - strlen(iface_list) - 1); - strncat(iface_list, g_upstream_ifaces[i], - sizeof(iface_list) - strlen(iface_list) - 1); - } - ds_log("[NET] Upstream route monitor started (interfaces: %s)", iface_list); - - int sock = socket(AF_NETLINK, SOCK_RAW, NETLINK_ROUTE); - if (sock < 0) { - ds_warn("[NET] Route monitor: failed to open netlink socket: %s", - strerror(errno)); - return NULL; - } - - struct sockaddr_nl sa; - memset(&sa, 0, sizeof(sa)); - sa.nl_family = AF_NETLINK; - /* RTMGRP_LINK - interface state changes (IFF_RUNNING, link up/down) - * RTMGRP_IPV4_IFADDR - IPv4 address add/remove on upstream interfaces */ - sa.nl_groups = RTMGRP_LINK | RTMGRP_IPV4_IFADDR; - - if (bind(sock, (struct sockaddr *)&sa, sizeof(sa)) < 0) { - ds_warn("[NET] Route monitor: failed to bind netlink socket: %s", - strerror(errno)); - close(sock); - return NULL; - } - - pthread_mutex_lock(&g_gw_mutex); - g_route_monitor_sock = sock; - pthread_mutex_unlock(&g_gw_mutex); - - uint8_t buf[8192]; - struct pollfd pfd = {.fd = sock, .events = POLLIN}; - - while (!g_stop_monitor) { - /* Enforce IPv4 forwarding in real-time. Since Android kernels do not - * broadcast POLLERR/inotify events for /proc/sys/ memory variables, - * we must check it periodically. Reading a 1-byte procfs memory flag - * takes < 1 microsecond, costing 0% CPU. */ - if (is_android() && g_current_gw_table > 0) { - char val[4] = {0}; - if (read_file("/proc/sys/net/ipv4/ip_forward", val, sizeof(val)) > 0 && - val[0] == '0') { - ds_log("[NET] Route monitor: ip_forward was disabled by Android, " - "re-enabling..."); - write_file("/proc/sys/net/ipv4/ip_forward", "1\n"); - } - } - - /* 1.5-second heartbeat: aggressively re-asserts ip_forward and covers - * devices with broken netlink notifications. */ - int pr = poll(&pfd, 1, 1500); - if (pr < 0) { - if (g_stop_monitor) - break; - if (errno == EINTR) - continue; - break; - } - - if (pr == 0) { - do_upstream_reprobe(); - continue; - } - - ssize_t len = recv(sock, buf, sizeof(buf), 0); - if (len <= 0) { - if (g_stop_monitor) - break; - if (len < 0 && (errno == EINTR || errno == EAGAIN)) - continue; - break; - } - - int should_reprobe = 0; - struct nlmsghdr *h = (struct nlmsghdr *)buf; - - for (; NLMSG_OK(h, (uint32_t)len); h = NLMSG_NEXT(h, len)) { - if (h->nlmsg_type == NLMSG_DONE || h->nlmsg_type == NLMSG_ERROR) - break; - - if (h->nlmsg_type == RTM_NEWLINK || h->nlmsg_type == RTM_DELLINK) { - /* Filter: care about events on declared upstream interfaces or any - * interface matching a wildcard pattern (e.g. "*rmnet_data*"). - * A new rmnet_dataX popping up mid-session triggers a reprobe so - * the monitor can adopt the newly-active interface immediately. */ - struct ifinfomsg *ifi = NLMSG_DATA(h); - char evname[IFNAMSIZ] = {0}; - if_indextoname((unsigned int)ifi->ifi_index, evname); - if (evname[0] && strncmp(evname, "ds-", 3) != 0) { - for (int i = 0; i < g_upstream_count; i++) { - if (iface_matches_pattern(g_upstream_ifaces[i], evname)) { - should_reprobe = 1; - break; - } - } - } - } else if (h->nlmsg_type == RTM_NEWADDR || h->nlmsg_type == RTM_DELADDR) { - struct ifaddrmsg *ifa = NLMSG_DATA(h); - if (ifa->ifa_family == AF_INET) { - char evname[IFNAMSIZ] = {0}; - if_indextoname((unsigned int)ifa->ifa_index, evname); - if (evname[0] && strncmp(evname, "ds-", 3) != 0) { - for (int i = 0; i < g_upstream_count; i++) { - if (iface_matches_pattern(g_upstream_ifaces[i], evname)) { - should_reprobe = 1; - break; - } - } - } - } - } - - if (should_reprobe) - break; - } - - if (should_reprobe) - do_upstream_reprobe(); - } - - pthread_mutex_lock(&g_gw_mutex); - close(sock); - g_route_monitor_sock = -1; - pthread_mutex_unlock(&g_gw_mutex); - - ds_log("[NET] Upstream route monitor stopped"); - return NULL; -} - -void ds_net_stop_route_monitor(void) { - g_stop_monitor = 1; - pthread_mutex_lock(&g_gw_mutex); - if (g_route_monitor_sock >= 0) - shutdown(g_route_monitor_sock, SHUT_RDWR); - pthread_mutex_unlock(&g_gw_mutex); -} - -void ds_net_start_route_monitor(void) { - if (!is_android()) - return; - - if (g_upstream_count == 0) { - ds_warn("[NET] Route monitor: no upstream interfaces defined, skipping"); - return; - } - - g_stop_monitor = 0; - - pthread_t tid; - pthread_attr_t attr; - pthread_attr_init(&attr); - pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED); - - if (pthread_create(&tid, &attr, route_monitor_loop, NULL) != 0) - ds_warn("[NET] Failed to start route monitor thread: %s", strerror(errno)); - - pthread_attr_destroy(&attr); -} - -/* --------------------------------------------------------------------------- - * Network cleanup (called on container stop) - * ---------------------------------------------------------------------------*/ - -void ds_net_cleanup(struct ds_config *cfg, pid_t container_pid) { - if (cfg->net_mode != DS_NET_NAT) - return; - - ds_net_stop_route_monitor(); - - ds_nl_ctx_t *ctx = ds_nl_open(); - if (!ctx) - return; - - /* 1. Delete host-side veth - peer in dead netns is already gone */ - char veth_host[IFNAMSIZ] = {0}; - pid_t effective_pid = container_pid > 0 ? container_pid : cfg->container_pid; - if (effective_pid <= 0) { - ds_warn("[NET] cleanup: cannot derive veth name - no valid PID"); - /* still proceed with iptables cleanup */ - } else { - veth_host_name(effective_pid, veth_host, sizeof(veth_host)); - ds_nl_del_link(ctx, veth_host); - } - - /* Check how many ds-v* veths remain AFTER deleting ours. - * Shared resources (DHCP server, MASQUERADE, FORWARD, Android - * policy rules) must only be torn down when we are the last container. - * Stopping them while others are running kills their networking. */ - int surviving = ds_nl_count_ifaces_with_prefix(ctx, "ds-v"); - if (surviving > 0) { - ds_log("[NET] cleanup: %d other container(s) still running - " - "keeping shared iptables and routing rules", - surviving); - ds_ipt_remove_portforwards(cfg); - if (cfg->net_bridgeless && veth_host[0] != '\0') - ds_ipt_remove_iface_rules(veth_host); - ds_nl_close(ctx); - return; - } - - /* Last container - safe to stop shared services and remove shared rules */ - ds_dhcp_server_stop(); - - /* 2. Remove Android policy rules (last container - safe to clean up) */ - if (is_android()) { - uint32_t subnet, mask; - parse_cidr(DS_DEFAULT_SUBNET, &subnet, &mask); - - /* Remove DS policy rules at both current and legacy priority values so - * an upgrade from an older build that used hardcoded 90/100/200/201 still - * cleans up completely. del_rule4 is idempotent (ENOENT → 0). */ - int prios[] = { - DS_RULE_PRIO_TO_SUBNET, /* 6090 - current */ - DS_RULE_PRIO_FROM_SUBNET, /* 6100 - current */ - 90, - 100, - 200, - 201 /* legacy - pre-VPN-fix builds */ - }; - for (size_t i = 0; i < sizeof(prios) / sizeof(prios[0]); i++) { - ds_nl_del_rule4(ctx, 0, 0, subnet, DS_NAT_PREFIX, 0, prios[i]); - ds_nl_del_rule4(ctx, subnet, DS_NAT_PREFIX, 0, 0, 0, prios[i]); - } - } - - ds_nl_close(ctx); - - /* 3. Remove iptables rules */ - if (cfg->net_bridgeless && veth_host[0] != '\0') { - ds_ipt_remove_iface_rules(veth_host); - } - ds_ipt_remove_portforwards(cfg); - ds_ipt_remove_ds_rules(); -} diff --git a/src/pid.c b/src/pid.c index a40b6b7c..ebc44571 100644 --- a/src/pid.c +++ b/src/pid.c @@ -191,6 +191,25 @@ static void get_container_name_from_pidfile(const char *pidfile, char *name, *dot = '\0'; } +/* Resolve a scanned pidfile dirent into tmp_cfg->container_name / ->pidfile. */ +static void resolve_scanned_container(const char *dirent_name, + struct ds_config *tmp_cfg) { + char clean_name[256]; + get_container_name_from_pidfile(dirent_name, clean_name, sizeof(clean_name)); + safe_strncpy(tmp_cfg->container_name, clean_name, + sizeof(tmp_cfg->container_name)); + resolve_pidfile_from_name(clean_name, tmp_cfg->pidfile, + sizeof(tmp_cfg->pidfile)); +} + +/* Remove a stale container's pidfile and its sidecar metadata (.mount/.init). + */ +static void prune_stale_pidfile(const char *pidfile) { + unlink(pidfile); + remove_mount_path(pidfile); + remove_init_type(pidfile); +} + int count_running_containers(char *first_name, size_t size) { DIR *d = opendir(get_pids_dir()); if (!d) @@ -202,26 +221,16 @@ int count_running_containers(char *first_name, size_t size) { while ((ent = readdir(d)) != NULL) { if (is_pid_file(ent->d_name)) { struct ds_config tmp_cfg = {0}; - char clean_name[256]; - get_container_name_from_pidfile(ent->d_name, clean_name, - sizeof(clean_name)); - - safe_strncpy(tmp_cfg.container_name, clean_name, - sizeof(tmp_cfg.container_name)); - resolve_pidfile_from_name(clean_name, tmp_cfg.pidfile, - sizeof(tmp_cfg.pidfile)); + resolve_scanned_container(ent->d_name, &tmp_cfg); pid_t pid; if (is_container_running(&tmp_cfg, &pid)) { if (count == 0 && first_name && size > 0) { - safe_strncpy(first_name, clean_name, size); + safe_strncpy(first_name, tmp_cfg.container_name, size); } count++; } else if (pid == 0 && access(tmp_cfg.pidfile, F_OK) == 0) { - /* Explicit pruning during scan */ - unlink(tmp_cfg.pidfile); - remove_mount_path(tmp_cfg.pidfile); - remove_init_type(tmp_cfg.pidfile); + prune_stale_pidfile(tmp_cfg.pidfile); } } } @@ -453,14 +462,7 @@ int show_containers(struct ds_config *cfg) { while ((ent = readdir(d)) != NULL) { if (is_pid_file(ent->d_name)) { struct ds_config tmp_cfg = {0}; - char clean_name[128]; - get_container_name_from_pidfile(ent->d_name, clean_name, - sizeof(clean_name)); - - safe_strncpy(tmp_cfg.container_name, clean_name, - sizeof(tmp_cfg.container_name)); - resolve_pidfile_from_name(clean_name, tmp_cfg.pidfile, - sizeof(tmp_cfg.pidfile)); + resolve_scanned_container(ent->d_name, &tmp_cfg); pid_t pid; if (is_container_running(&tmp_cfg, &pid)) { @@ -481,7 +483,7 @@ int show_containers(struct ds_config *cfg) { containers = tmp; } - safe_strncpy(containers[count].name, clean_name, + safe_strncpy(containers[count].name, tmp_cfg.container_name, sizeof(containers[count].name)); containers[count].pid = pid; size_t nlen = strlen(containers[count].name); @@ -489,10 +491,7 @@ int show_containers(struct ds_config *cfg) { max_name_len = nlen; count++; } else if (pid == 0 && access(tmp_cfg.pidfile, F_OK) == 0) { - /* Explicit pruning during scan */ - unlink(tmp_cfg.pidfile); - remove_mount_path(tmp_cfg.pidfile); - remove_init_type(tmp_cfg.pidfile); + prune_stale_pidfile(tmp_cfg.pidfile); } } } @@ -678,7 +677,22 @@ int ds_metadata_sync(pid_t pid) { build_proc_root_path(pid, "/run/droidspaces/mount", path, sizeof(path)); if (read_file(path, mount, sizeof(mount)) >= 0) { mount[strcspn(mount, "\n")] = '\0'; - save_mount_path(pidfile, mount); + /* This marker is read from inside the container's own root, so a + * compromised container could plant an arbitrary path; it later feeds + * unmount_rootfs_img()/umount2() on the host. A legitimate image mount + * is deterministically DS_IMG_MOUNT_ROOT_UNIVERSAL/ + * (find_available_mountpoint), so require an exact match and ignore + * anything else -- this also prevents pointing at another container's + * mount. */ + char expected_mount[PATH_MAX]; + snprintf(expected_mount, sizeof(expected_mount), "%s/%s", + DS_IMG_MOUNT_ROOT_UNIVERSAL, safe_name); + if (strcmp(mount, expected_mount) == 0) + save_mount_path(pidfile, mount); + else if (mount[0]) + ds_warn("Ignoring unexpected mount marker '%s' from container %d " + "(expected %s)", + mount, pid, expected_mount); } } @@ -795,92 +809,22 @@ int scan_containers(void) { return 0; } -/** - * Scans all installed containers and their running status to determine - * if the host SELinux permissive mode is still needed. - * - * Returns: - * -1: No containers with 'selinux-permissive=yes' are installed. - * 0: At least one permissive container is installed, but none are running. - * 1: At least one permissive container is currently running. - */ -int check_selinux_permissive_needs(void) { - char pids_path[PATH_MAX]; - snprintf(pids_path, sizeof(pids_path), "%s", get_pids_dir()); - DIR *pd = opendir(pids_path); - if (!pd) - return -1; - - /* Phase 1: Fast check - are any permissive containers currently running? */ - struct dirent *ent; - while ((ent = readdir(pd)) != NULL) { - if (!is_pid_file(ent->d_name)) - continue; - - char name[256]; - get_container_name_from_pidfile(ent->d_name, name, sizeof(name)); - - struct ds_config tmp_cfg = {0}; - if (ds_config_load_by_name(name, &tmp_cfg) == 0) { - pid_t pid; - /* Check if THIS running container needs permissive mode */ - if (tmp_cfg.selinux_permissive && is_container_running(&tmp_cfg, &pid)) { - free_config_binds(&tmp_cfg); - free_config_env_vars(&tmp_cfg); - free_config_unknown_lines(&tmp_cfg); - closedir(pd); - return 1; /* Found a running permissive container - stay permissive */ - } - free_config_binds(&tmp_cfg); - free_config_env_vars(&tmp_cfg); - free_config_unknown_lines(&tmp_cfg); - } - } - closedir(pd); - - /* Phase 2: None are running. But is at least one permissive container - * installed? (Requirement: do nothing if none installed). */ - char containers_path[PATH_MAX]; - snprintf(containers_path, sizeof(containers_path), "%s/Containers", - get_workspace_dir()); - DIR *cd = opendir(containers_path); - if (!cd) - return -1; - - int permissive_installed = 0; - while ((ent = readdir(cd)) != NULL) { - if (ent->d_name[0] == '.') - continue; - - struct ds_config tmp_cfg = {0}; - if (ds_config_load_by_name(ent->d_name, &tmp_cfg) == 0) { - if (tmp_cfg.selinux_permissive) { - permissive_installed = 1; - free_config_binds(&tmp_cfg); - free_config_env_vars(&tmp_cfg); - free_config_unknown_lines(&tmp_cfg); - break; - } - free_config_binds(&tmp_cfg); - free_config_env_vars(&tmp_cfg); - free_config_unknown_lines(&tmp_cfg); - } - } - closedir(cd); - - return permissive_installed ? 0 : -1; -} - /* - * check_x11_needs - scan running containers to decide if the global - * Termux-X11 server should stay up. + * ds_feature_needs - generic "is this feature still needed?" scanner. + * + * Reads the int field at cfg_flag_offset inside ds_config to decide + * whether the associated global daemon/server should stay up. * * Returns: - * 1: at least one running container has termux_x11 enabled - * 0: termux_x11 containers exist but none are running - * -1: no containers with termux_x11 are installed + * 1: at least one running container has the feature enabled + * 0: feature containers exist but none are currently running + * -1: no containers with the feature are installed + * + * Adding support for a new feature costs zero lines here - just define + * a DS_FEAT_* constant in droidspace.h and call ds_feature_needs(). */ -int check_x11_needs(void) { +int ds_feature_needs(size_t cfg_flag_offset) { + /* Phase 1: any running container with the feature? */ DIR *pd = opendir(get_pids_dir()); if (!pd) return -1; @@ -893,11 +837,10 @@ int check_x11_needs(void) { get_container_name_from_pidfile(ent->d_name, name, sizeof(name)); struct ds_config tmp = {0}; if (ds_config_load_by_name(name, &tmp) == 0) { + int flag = *(int *)((char *)&tmp + cfg_flag_offset); pid_t pid; - int running = tmp.termux_x11 && is_container_running(&tmp, &pid); - free_config_binds(&tmp); - free_config_env_vars(&tmp); - free_config_unknown_lines(&tmp); + int running = flag && is_container_running(&tmp, &pid); + ds_config_free(&tmp); if (running) { closedir(pd); return 1; @@ -906,7 +849,7 @@ int check_x11_needs(void) { } closedir(pd); - /* Phase 2: any termux_x11 container installed at all? */ + /* Phase 2: feature installed in any container at all? */ char containers_path[PATH_MAX]; snprintf(containers_path, sizeof(containers_path), "%s/Containers", get_workspace_dir()); @@ -914,148 +857,35 @@ int check_x11_needs(void) { if (!cd) return -1; - int x11_installed = 0; + int installed = 0; while ((ent = readdir(cd)) != NULL) { if (ent->d_name[0] == '.') continue; struct ds_config tmp = {0}; - if (ds_config_load_by_name(ent->d_name, &tmp) == 0 && tmp.termux_x11) { - x11_installed = 1; - free_config_binds(&tmp); - free_config_env_vars(&tmp); - free_config_unknown_lines(&tmp); - break; + if (ds_config_load_by_name(ent->d_name, &tmp) == 0) { + int flag = *(int *)((char *)&tmp + cfg_flag_offset); + if (flag) { + installed = 1; + ds_config_free(&tmp); + break; + } + ds_config_free(&tmp); } - free_config_binds(&tmp); - free_config_env_vars(&tmp); - free_config_unknown_lines(&tmp); } closedir(cd); - return x11_installed ? 0 : -1; + return installed ? 0 : -1; } -/* - * check_virgl_needs - scan running containers to decide if the global - * VirGL server should stay up. - * - * Returns: - * 1: at least one running container has virgl enabled - * 0: virgl containers exist but none are running - * -1: no containers with virgl are installed - */ +/* Compat wrappers - thin shims over ds_feature_needs() */ +int check_selinux_permissive_needs(void) { + return ds_feature_needs(offsetof(struct ds_config, selinux_permissive)); +} +int check_x11_needs(void) { + return ds_feature_needs(offsetof(struct ds_config, termux_x11)); +} int check_virgl_needs(void) { - DIR *pd = opendir(get_pids_dir()); - if (!pd) - return -1; - - struct dirent *ent; - while ((ent = readdir(pd)) != NULL) { - if (!is_pid_file(ent->d_name)) - continue; - char name[256]; - get_container_name_from_pidfile(ent->d_name, name, sizeof(name)); - struct ds_config tmp = {0}; - if (ds_config_load_by_name(name, &tmp) == 0) { - pid_t pid; - int running = tmp.virgl && is_container_running(&tmp, &pid); - free_config_binds(&tmp); - free_config_env_vars(&tmp); - free_config_unknown_lines(&tmp); - if (running) { - closedir(pd); - return 1; - } - } - } - closedir(pd); - - /* Phase 2: any virgl container installed at all? */ - char containers_path[PATH_MAX]; - snprintf(containers_path, sizeof(containers_path), "%s/Containers", - get_workspace_dir()); - DIR *cd = opendir(containers_path); - if (!cd) - return -1; - - int virgl_installed = 0; - while ((ent = readdir(cd)) != NULL) { - if (ent->d_name[0] == '.') - continue; - struct ds_config tmp = {0}; - if (ds_config_load_by_name(ent->d_name, &tmp) == 0 && tmp.virgl) { - virgl_installed = 1; - free_config_binds(&tmp); - free_config_env_vars(&tmp); - free_config_unknown_lines(&tmp); - break; - } - free_config_binds(&tmp); - free_config_env_vars(&tmp); - free_config_unknown_lines(&tmp); - } - closedir(cd); - return virgl_installed ? 0 : -1; + return ds_feature_needs(offsetof(struct ds_config, virgl)); } - -/* - * check_pulse_needs - scan running containers to decide if the global - * PulseAudio daemon should stay up. - * - * Returns: - * 1: at least one running container has pulseaudio enabled - * 0: pulseaudio containers exist but none are running - * -1: no containers with pulseaudio are installed - */ int check_pulse_needs(void) { - DIR *pd = opendir(get_pids_dir()); - if (!pd) - return -1; - - struct dirent *ent; - while ((ent = readdir(pd)) != NULL) { - if (!is_pid_file(ent->d_name)) - continue; - char name[256]; - get_container_name_from_pidfile(ent->d_name, name, sizeof(name)); - struct ds_config tmp = {0}; - if (ds_config_load_by_name(name, &tmp) == 0) { - pid_t pid; - int running = tmp.pulseaudio && is_container_running(&tmp, &pid); - free_config_binds(&tmp); - free_config_env_vars(&tmp); - free_config_unknown_lines(&tmp); - if (running) { - closedir(pd); - return 1; - } - } - } - closedir(pd); - - /* Phase 2: any pulseaudio container installed at all? */ - char containers_path[PATH_MAX]; - snprintf(containers_path, sizeof(containers_path), "%s/Containers", - get_workspace_dir()); - DIR *cd = opendir(containers_path); - if (!cd) - return -1; - - int pulse_installed = 0; - while ((ent = readdir(cd)) != NULL) { - if (ent->d_name[0] == '.') - continue; - struct ds_config tmp = {0}; - if (ds_config_load_by_name(ent->d_name, &tmp) == 0 && tmp.pulseaudio) { - pulse_installed = 1; - free_config_binds(&tmp); - free_config_env_vars(&tmp); - free_config_unknown_lines(&tmp); - break; - } - free_config_binds(&tmp); - free_config_env_vars(&tmp); - free_config_unknown_lines(&tmp); - } - closedir(cd); - return pulse_installed ? 0 : -1; + return ds_feature_needs(offsetof(struct ds_config, pulseaudio)); } diff --git a/src/seccomp.c b/src/seccomp.c index db54a411..11034580 100644 --- a/src/seccomp.c +++ b/src/seccomp.c @@ -30,7 +30,7 @@ * Blocks direct host kernel takeover vectors (module loading, kexec). * Applied unconditionally to all kernels and all modes. */ -int ds_seccomp_apply_minimal(int privileged_mask) { +int ds_seccomp_apply_minimal(int privileged_mask, int userns_allowed) { /* noseccomp: skip everything, 32-bit binaries must work */ if (privileged_mask & DS_PRIV_NOSEC) return 0; @@ -99,36 +99,37 @@ int ds_seccomp_apply_minimal(int privileged_mask) { (struct sock_filter)BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS); #endif + if (!userns_allowed) { #ifdef __NR_clone3 - /* 6. Block clone3 */ - filter[curr++] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, - __NR_clone3, 0, 1); - filter[curr++] = (struct sock_filter)BPF_STMT( - BPF_RET | BPF_K, SECCOMP_RET_ERRNO | (ENOSYS & SECCOMP_RET_DATA)); + /* 6. Block clone3 */ + filter[curr++] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, + __NR_clone3, 0, 1); + filter[curr++] = (struct sock_filter)BPF_STMT( + BPF_RET | BPF_K, SECCOMP_RET_ERRNO | (ENOSYS & SECCOMP_RET_DATA)); #endif - /* 7. unshare(CLONE_NEWUSER) */ - filter[curr++] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, - __NR_unshare, 0, 4); - filter[curr++] = (struct sock_filter)BPF_STMT( - BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, args[0])); - filter[curr++] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JSET | BPF_K, - 0x10000000, 0, 1); - filter[curr++] = (struct sock_filter)BPF_STMT( - BPF_RET | BPF_K, SECCOMP_RET_ERRNO | (EPERM & SECCOMP_RET_DATA)); - filter[curr++] = (struct sock_filter)BPF_STMT( - BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)); - - /* 8. clone(CLONE_NEWUSER) */ - filter[curr++] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, - __NR_clone, 0, 3); - filter[curr++] = (struct sock_filter)BPF_STMT( - BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, args[0])); - filter[curr++] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JSET | BPF_K, - 0x10000000, 0, 1); - filter[curr++] = (struct sock_filter)BPF_STMT( - BPF_RET | BPF_K, SECCOMP_RET_ERRNO | (EPERM & SECCOMP_RET_DATA)); - + /* 7. unshare(CLONE_NEWUSER) */ + filter[curr++] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, + __NR_unshare, 0, 4); + filter[curr++] = (struct sock_filter)BPF_STMT( + BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, args[0])); + filter[curr++] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JSET | BPF_K, + 0x10000000, 0, 1); + filter[curr++] = (struct sock_filter)BPF_STMT( + BPF_RET | BPF_K, SECCOMP_RET_ERRNO | (EPERM & SECCOMP_RET_DATA)); + filter[curr++] = (struct sock_filter)BPF_STMT( + BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)); + + /* 8. clone(CLONE_NEWUSER) */ + filter[curr++] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, + __NR_clone, 0, 3); + filter[curr++] = (struct sock_filter)BPF_STMT( + BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, args[0])); + filter[curr++] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JSET | BPF_K, + 0x10000000, 0, 1); + filter[curr++] = (struct sock_filter)BPF_STMT( + BPF_RET | BPF_K, SECCOMP_RET_ERRNO | (EPERM & SECCOMP_RET_DATA)); + } /* * 9. CVE-2026-31431 ("Copy Fail") - mitigation layer 2. * @@ -153,7 +154,6 @@ int ds_seccomp_apply_minimal(int privileged_mask) { /* Reload nr for any rules that follow this block. */ filter[curr++] = (struct sock_filter)BPF_STMT( BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)); - /* * 10. Block host clock modification syscalls. * diff --git a/src/socketd/Makefile b/src/socketd/Makefile deleted file mode 100644 index d4bb08d8..00000000 --- a/src/socketd/Makefile +++ /dev/null @@ -1,88 +0,0 @@ -# Droidspaces v6 - droidspaces-socketd build system -# SPDX-License-Identifier: GPL-3.0-or-later - -ROOT_DIR := ../.. -SRC_DIR := . -API_DIR := .. -OUT_DIR := $(ROOT_DIR)/output -OBJ_DIR := $(OUT_DIR)/.obj/socketd - -BINARY_NAME := droidspaces-socketd -TARGET := $(OUT_DIR)/$(BINARY_NAME) - -CXX ?= g++ - -# Verbose control - V=1 shows full commands, V=0 keeps output tidy -V ?= 0 - -# Static link control - off by default -STATIC ?= 0 - -ifeq ($(V),1) - Q = - msg_cxx = - msg_ld = -else - Q = @ - msg_cxx = @printf " CXX %s\n" $< - msg_ld = @printf " CXXLD %s\n" $@ -endif - -CXXFLAGS ?= -Wall -Wextra -Wpedantic -Werror \ - -O2 -std=c++17 \ - -I$(SRC_DIR) -I$(API_DIR) - -LDFLAGS ?= -LDLIBS ?= - -ifeq ($(STATIC),1) - LDFLAGS += -static -endif - -SRCS := \ - main.cpp \ - backend_client.cpp \ - container_list.cpp \ - container_inspect.cpp \ - snapshot_lists.cpp \ - event_log.cpp \ - api_server.cpp - -OBJS := $(SRCS:%.cpp=$(OBJ_DIR)/%.o) -DEPS := $(OBJS:.o=.d) - -.PHONY: all clean help - -all: $(TARGET) - -help: - @echo "droidspaces-socketd build system" - @echo "" - @echo "Targets:" - @echo " make - Build $(BINARY_NAME)" - @echo " make clean - Remove socketd build artifacts" - @echo "" - @echo "Options:" - @echo " V=1 - Show full compiler commands" - @echo " CXX=... - Override the C++ compiler" - @echo " STATIC=1 - Link droidspaces-socketd statically" - -$(OUT_DIR): - $(Q)mkdir -p $(OUT_DIR) - -$(OBJ_DIR): - $(Q)mkdir -p $(OBJ_DIR) - -$(TARGET): $(OBJS) | $(OUT_DIR) - $(msg_ld) - $(Q)$(CXX) $(LDFLAGS) -o $@ $(OBJS) $(LDLIBS) - -$(OBJ_DIR)/%.o: %.cpp | $(OBJ_DIR) - $(msg_cxx) - $(Q)$(CXX) $(CXXFLAGS) -MMD -MP -c $< -o $@ - -clean: - $(Q)rm -rf $(OBJ_DIR) - $(Q)rm -f $(TARGET) - --include $(DEPS) diff --git a/src/socketd/api_server.cpp b/src/socketd/api_server.cpp deleted file mode 100644 index 52647d95..00000000 --- a/src/socketd/api_server.cpp +++ /dev/null @@ -1,1715 +0,0 @@ -#include "api_server.h" -#include "backend_client.h" -#include "container_list.h" -#include "container_inspect.h" -#include "snapshot_lists.h" -#include "event_log.h" -#include "../droidspace.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -#include -#include -#include -#include -#include - -namespace droidspaces::socketd { -namespace { - -constexpr std::size_t kMaxRequestHeaderBytes = 16 * 1024; -constexpr const char* kSocketApiVersion = "1.41"; -constexpr const char* kSocketMinApiVersion = "1.40"; -constexpr const char* kSocketOsType = "linux"; - -constexpr std::uint64_t kMaxStaticAssetBytes = 32ULL * 1024ULL * 1024ULL; -constexpr const char* kDefaultWebIndex = "index.html"; - -std::string socketd_arch_name() { -#if defined(__x86_64__) - return "amd64"; -#elif defined(__i386__) - return "386"; -#elif defined(__aarch64__) - return "arm64"; -#elif defined(__arm__) - return "arm"; -#elif defined(__riscv) && (__riscv_xlen == 64) - return "riscv64"; -#else - return "unknown"; -#endif -} - -std::string socketd_kernel_version() { - struct utsname uts {}; - if (::uname(&uts) != 0) { - return {}; - } - - return uts.release; -} - -std::uint64_t socketd_mem_total_bytes() { - std::ifstream meminfo("/proc/meminfo"); - if (!meminfo.is_open()) { - return 0; - } - - std::string key; - std::uint64_t value_kib = 0; - std::string unit; - - while (meminfo >> key >> value_kib >> unit) { - if (key == "MemTotal:") { - /* - * /proc/meminfo reports MemTotal in KiB. - */ - return value_kib * 1024ULL; - } - } - - return 0; -} - -unsigned int socketd_ncpu() { - const long value = ::sysconf(_SC_NPROCESSORS_ONLN); - if (value <= 0) { - return 0; - } - - if (static_cast(value) > - std::numeric_limits::max()) { - return std::numeric_limits::max(); - } - - return static_cast(value); -} - -std::string socketd_hostname() { - char hostname[256] {}; - if (::gethostname(hostname, sizeof(hostname) - 1) != 0) { - return "droidspaces"; - } - - hostname[sizeof(hostname) - 1] = '\0'; - - if (hostname[0] == '\0') { - return "droidspaces"; - } - - return hostname; -} - -std::string socketd_system_time_utc() { - std::time_t now = std::time(nullptr); - if (now == static_cast(-1)) { - return {}; - } - - std::tm tm {}; -#if defined(_POSIX_THREAD_SAFE_FUNCTIONS) || defined(__ANDROID__) || defined(__linux__) - if (::gmtime_r(&now, &tm) == nullptr) { - return {}; - } -#else - const std::tm* tmp = std::gmtime(&now); - if (tmp == nullptr) { - return {}; - } - tm = *tmp; -#endif - - char buffer[64] {}; - if (std::strftime(buffer, sizeof(buffer), "%Y-%m-%dT%H:%M:%SZ", &tm) == 0) { - return {}; - } - - return buffer; -} - -std::string json_escape(const std::string& input) { - std::string out; - out.reserve(input.size()); - - constexpr char kHex[] = "0123456789abcdef"; - - for (unsigned char ch : input) { - switch (ch) { - case '"': - out += "\\\""; - break; - case '\\': - out += "\\\\"; - break; - case '\b': - out += "\\b"; - break; - case '\f': - out += "\\f"; - break; - case '\n': - out += "\\n"; - break; - case '\r': - out += "\\r"; - break; - case '\t': - out += "\\t"; - break; - default: - if (ch < 0x20) { - out += "\\u00"; - out += kHex[(ch >> 4) & 0x0f]; - out += kHex[ch & 0x0f]; - } else { - out += static_cast(ch); - } - break; - } - } - - return out; -} - -void debug_log_request_headers(const std::string& request) { - const std::size_t header_end = request.find("\r\n\r\n"); - - const std::size_t visible_len = - header_end == std::string::npos - ? request.size() - : header_end + 4; - - std::cerr << "socketd: received HTTP request headers\n"; - std::cerr << "----- BEGIN REQUEST -----\n"; - std::cerr.write(request.data(), static_cast(visible_len)); - - /* - * HTTP headers already end with CRLF CRLF, but ensure the terminal output - * does not visually run into the separator if a malformed request arrives. - */ - if (visible_len == 0 || - request[visible_len - 1] != '\n') { - std::cerr << '\n'; - } - - std::cerr << "----- END REQUEST -----\n"; -} - -bool send_all(int fd, const void* data, std::size_t len, std::string& error) { - const auto* p = static_cast(data); - - while (len > 0) { - const ssize_t written = ::send(fd, p, len, 0); - if (written < 0) { - if (errno == EINTR) { - continue; - } - - error = "send() failed: "; - error += std::strerror(errno); - return false; - } - - if (written == 0) { - error = "send() returned 0 unexpectedly"; - return false; - } - - p += static_cast(written); - len -= static_cast(written); - } - - return true; -} - -bool send_http_response(int fd, - int status_code, - const char* reason_phrase, - const char* content_type, - const std::string& body, - bool suppress_body, - std::string& error) { - std::string header; - header.reserve(256); - - header += "HTTP/1.1 "; - header += std::to_string(status_code); - header += ' '; - header += reason_phrase; - header += "\r\n"; - - header += "Content-Type: "; - header += content_type; - header += "\r\n"; - - header += "Content-Length: "; - header += std::to_string(body.size()); - header += "\r\n"; - - header += "Server: Droidspaces/6 (Container, like Docker)\r\n"; - - header += "Api-Version: "; - header += kSocketApiVersion; - header += "\r\n"; - - header += "Ostype: "; - header += kSocketOsType; - header += "\r\n"; - - header += "Connection: close\r\n"; - header += "\r\n"; - - if (!send_all(fd, header.data(), header.size(), error)) { - return false; - } - - if (!suppress_body && !body.empty()) { - if (!send_all(fd, body.data(), body.size(), error)) { - return false; - } - } - - return true; -} - - -bool send_empty_http_response(int fd, - int status_code, - const char* reason_phrase, - std::string& error) { - std::string header; - header.reserve(192); - - header += "HTTP/1.1 "; - header += std::to_string(status_code); - header += ' '; - header += reason_phrase; - header += "\r\n"; - header += "Content-Length: 0\r\n"; - header += "Server: Droidspaces/6 (Container, like Docker)\r\n"; - header += "Api-Version: "; - header += kSocketApiVersion; - header += "\r\n"; - header += "Ostype: "; - header += kSocketOsType; - header += "\r\n"; - header += "Connection: close\r\n"; - header += "\r\n"; - - return send_all(fd, header.data(), header.size(), error); -} - -bool send_bad_request(int fd, bool suppress_body, std::string& error) { - const std::string body = "{\"message\":\"bad request\"}\n"; - return send_http_response(fd, - 400, - "Bad Request", - "application/json", - body, - suppress_body, - error); -} - -bool send_header_too_large(int fd, bool suppress_body, std::string& error) { - const std::string body = "{\"message\":\"request headers too large\"}\n"; - return send_http_response(fd, - 431, - "Request Header Fields Too Large", - "application/json", - body, - suppress_body, - error); -} - -bool send_not_found(int fd, bool suppress_body, std::string& error) { - const std::string body = "{\"message\":\"not found\"}\n"; - return send_http_response(fd, - 404, - "Not Found", - "application/json", - body, - suppress_body, - error); -} - - -bool send_internal_server_error(int fd, - const std::string& message, - bool suppress_body, - std::string& error) { - std::string body = "{\"message\":\""; - body += json_escape(message.empty() ? "internal server error" : message); - body += "\"}\n"; - - return send_http_response(fd, - 500, - "Internal Server Error", - "application/json", - body, - suppress_body, - error); -} - -bool send_no_content(int fd, std::string& error) { - return send_empty_http_response(fd, 204, "No Content", error); -} - -bool send_not_modified(int fd, std::string& error) { - return send_empty_http_response(fd, 304, "Not Modified", error); -} - -bool send_ping_ok(int fd, bool suppress_body, std::string& error) { - const std::string body = "OK"; - return send_http_response(fd, - 200, - "OK", - "text/plain; charset=utf-8", - body, - suppress_body, - error); -} - -int hex_digit_value(char c) { - if (c >= '0' && c <= '9') { - return c - '0'; - } - - if (c >= 'a' && c <= 'f') { - return c - 'a' + 10; - } - - if (c >= 'A' && c <= 'F') { - return c - 'A' + 10; - } - - return -1; -} - -bool percent_decode_url_path(const std::string& input, - std::string& output) { - output.clear(); - output.reserve(input.size()); - - for (std::size_t i = 0; i < input.size(); ++i) { - const char c = input[i]; - - if (c != '%') { - if (c == '\0') { - return false; - } - - output += c; - continue; - } - - if (i + 2 >= input.size()) { - return false; - } - - const int hi = hex_digit_value(input[i + 1]); - const int lo = hex_digit_value(input[i + 2]); - if (hi < 0 || lo < 0) { - return false; - } - - const char decoded = static_cast((hi << 4) | lo); - if (decoded == '\0') { - return false; - } - - output += decoded; - i += 2; - } - - return true; -} - -std::string dirname_of(const std::string& path) { - const std::size_t slash = path.rfind('/'); - if (slash == std::string::npos) { - return "."; - } - - if (slash == 0) { - return "/"; - } - - return path.substr(0, slash); -} - -std::string socketd_binary_dir() { - char buffer[PATH_MAX] {}; - - const ssize_t len = ::readlink("/proc/self/exe", buffer, sizeof(buffer) - 1); - if (len <= 0) { - return "."; - } - - buffer[len] = '\0'; - return dirname_of(buffer); -} - -const std::string& web_root_dir() { - static const std::string root = socketd_binary_dir() + "/../www/html"; - return root; -} - -std::string lowercase_ascii(std::string value) { - for (char& c : value) { - if (c >= 'A' && c <= 'Z') { - c = static_cast(c - 'A' + 'a'); - } - } - - return value; -} - -std::string content_type_for_path(const std::string& path) { - const std::size_t slash = path.rfind('/'); - const std::size_t dot = path.rfind('.'); - if (dot == std::string::npos || - (slash != std::string::npos && dot < slash)) { - return "application/octet-stream"; - } - - const std::string ext = lowercase_ascii(path.substr(dot + 1)); - - if (ext == "html" || ext == "htm") { - return "text/html; charset=utf-8"; - } - - if (ext == "css") { - return "text/css; charset=utf-8"; - } - - if (ext == "js" || ext == "mjs") { - return "application/javascript; charset=utf-8"; - } - - if (ext == "json" || ext == "map") { - return "application/json; charset=utf-8"; - } - - if (ext == "svg") { - return "image/svg+xml"; - } - - if (ext == "png") { - return "image/png"; - } - - if (ext == "jpg" || ext == "jpeg") { - return "image/jpeg"; - } - - if (ext == "gif") { - return "image/gif"; - } - - if (ext == "webp") { - return "image/webp"; - } - - if (ext == "ico") { - return "image/x-icon"; - } - - if (ext == "txt") { - return "text/plain; charset=utf-8"; - } - - if (ext == "wasm") { - return "application/wasm"; - } - - return "application/octet-stream"; -} - -bool build_static_asset_path(const std::string& target, - std::string& file_path, - std::string& error) { - file_path.clear(); - - const std::size_t query_pos = target.find('?'); - const std::string raw_path = - query_pos == std::string::npos ? target : target.substr(0, query_pos); - - if (raw_path.empty() || raw_path[0] != '/') { - error = "static asset request target is not an origin-form path"; - return false; - } - - std::string decoded_path; - if (!percent_decode_url_path(raw_path, decoded_path)) { - error = "static asset path contains invalid percent encoding"; - return false; - } - - if (decoded_path.empty() || decoded_path[0] != '/' || - decoded_path.find('\\') != std::string::npos) { - error = "static asset path is invalid"; - return false; - } - - std::vector segments; - std::size_t pos = 1; - while (pos <= decoded_path.size()) { - const std::size_t slash = decoded_path.find('/', pos); - const std::size_t end = slash == std::string::npos ? decoded_path.size() - : slash; - const std::string segment = decoded_path.substr(pos, end - pos); - - if (!segment.empty() && segment != ".") { - if (segment == "..") { - error = "static asset path attempts to leave document root"; - return false; - } - - segments.push_back(segment); - } - - if (slash == std::string::npos) { - break; - } - - pos = slash + 1; - } - - if (segments.empty() || decoded_path.back() == '/') { - segments.push_back(kDefaultWebIndex); - } - - std::string relative_path; - for (const std::string& segment : segments) { - if (!relative_path.empty()) { - relative_path += '/'; - } - - relative_path += segment; - } - - file_path = web_root_dir() + "/" + relative_path; - return true; -} - -bool read_regular_file(const std::string& file_path, - std::string& body, - bool& not_found, - std::string& error) { - not_found = false; - body.clear(); - - struct stat st {}; - if (::stat(file_path.c_str(), &st) != 0) { - if (errno == ENOENT || errno == ENOTDIR || errno == EACCES) { - not_found = true; - return false; - } - - error = "stat(" + file_path + ") failed: "; - error += std::strerror(errno); - return false; - } - - if (!S_ISREG(st.st_mode)) { - not_found = true; - return false; - } - - if (st.st_size < 0 || - static_cast(st.st_size) > kMaxStaticAssetBytes) { - error = "static asset is too large: " + file_path; - return false; - } - - std::ifstream file(file_path, std::ios::in | std::ios::binary); - if (!file.is_open()) { - if (errno == ENOENT || errno == ENOTDIR || errno == EACCES) { - not_found = true; - return false; - } - - error = "open(" + file_path + ") failed: "; - error += std::strerror(errno); - return false; - } - - body.resize(static_cast(st.st_size)); - if (!body.empty()) { - file.read(&body[0], static_cast(body.size())); - if (!file) { - error = "read(" + file_path + ") failed"; - return false; - } - } - - return true; -} - -bool send_static_asset_ok(int fd, - const std::string& target, - bool suppress_body, - std::string& error) { - std::string file_path; - std::string path_error; - if (!build_static_asset_path(target, file_path, path_error)) { - return send_bad_request(fd, suppress_body, error); - } - - std::string body; - bool not_found = false; - if (!read_regular_file(file_path, body, not_found, error)) { - if (not_found) { - return send_not_found(fd, suppress_body, error); - } - - return send_internal_server_error(fd, error, suppress_body, error); - } - - const std::string content_type = content_type_for_path(file_path); - return send_http_response(fd, - 200, - "OK", - content_type.c_str(), - body, - suppress_body, - error); -} - -bool is_ascii_digit(char c) { - return c >= '0' && c <= '9'; -} - -bool is_versioned_api_path(const std::string& path, - const char* endpoint_path) { - const std::size_t endpoint_len = std::strlen(endpoint_path); - - if (path.size() <= endpoint_len) { - return false; - } - - if (path.compare(path.size() - endpoint_len, - endpoint_len, - endpoint_path) != 0) { - return false; - } - - const std::string prefix = - path.substr(0, path.size() - endpoint_len); - - /* - * Accept: - * - * /v1.40/_ping - * /v1.40/version - * - * Prefix must be: - * - * /v. - */ - if (prefix.size() < 5) { - return false; - } - - if (prefix[0] != '/' || prefix[1] != 'v') { - return false; - } - - std::size_t i = 2; - const std::size_t major_start = i; - - while (i < prefix.size() && is_ascii_digit(prefix[i])) { - ++i; - } - - if (i == major_start) { - return false; - } - - if (i >= prefix.size() || prefix[i] != '.') { - return false; - } - - ++i; - const std::size_t minor_start = i; - - while (i < prefix.size() && is_ascii_digit(prefix[i])) { - ++i; - } - - if (i == minor_start) { - return false; - } - - return i == prefix.size(); -} - - -bool is_api_target(const std::string& target, - const char* endpoint_path) { - const std::size_t query_pos = target.find('?'); - const std::string path = - query_pos == std::string::npos ? target : target.substr(0, query_pos); - - return path == endpoint_path || - is_versioned_api_path(path, endpoint_path); -} - - -bool strip_api_version_prefix(const std::string& path, - std::string& unversioned_path) { - if (path.rfind("/v", 0) != 0) { - unversioned_path = path; - return true; - } - - std::size_t i = 2; - const std::size_t major_start = i; - - while (i < path.size() && is_ascii_digit(path[i])) { - ++i; - } - - if (i == major_start || i >= path.size() || path[i] != '.') { - unversioned_path = path; - return true; - } - - ++i; - const std::size_t minor_start = i; - - while (i < path.size() && is_ascii_digit(path[i])) { - ++i; - } - - if (i == minor_start || i >= path.size() || path[i] != '/') { - unversioned_path = path; - return true; - } - - unversioned_path = path.substr(i); - return true; -} - -bool parse_container_ref_with_suffix(const std::string& target, - const char* suffix, - std::string& ref_out) { - const std::size_t query_pos = target.find('?'); - const std::string path = - query_pos == std::string::npos ? target : target.substr(0, query_pos); - - std::string unversioned_path; - if (!strip_api_version_prefix(path, unversioned_path)) { - return false; - } - - constexpr const char* kPrefix = "/containers/"; - const std::size_t prefix_len = std::strlen(kPrefix); - const std::size_t suffix_len = std::strlen(suffix); - - if (unversioned_path.size() <= prefix_len + suffix_len || - unversioned_path.compare(0, prefix_len, kPrefix) != 0 || - unversioned_path.compare(unversioned_path.size() - suffix_len, - suffix_len, - suffix) != 0) { - return false; - } - - ref_out = unversioned_path.substr( - prefix_len, - unversioned_path.size() - prefix_len - suffix_len); - - return !ref_out.empty() && ref_out.find('/') == std::string::npos; -} - -bool parse_container_inspect_ref(const std::string& target, - std::string& ref_out) { - return parse_container_ref_with_suffix(target, "/json", ref_out); -} - -bool parse_container_action_ref(const std::string& target, - const char* action_suffix, - std::string& ref_out) { - return parse_container_ref_with_suffix(target, action_suffix, ref_out); -} - -bool is_truthy_query_value(const std::string& value) { - return value.empty() || - value == "1" || - value == "true"; -} - -ContainerListRequest parse_container_list_request( - const std::string& target) { - ContainerListRequest request {}; - - const std::size_t query_pos = target.find('?'); - if (query_pos == std::string::npos || - query_pos + 1 >= target.size()) { - return request; - } - - /* - * This is intentionally a very small query parser for the bring-up phase. - * It extracts only the public API semantic that socketd currently cares - * about: ?all=1. Unknown query parameters are ignored. - */ - std::size_t pos = query_pos + 1; - - while (pos <= target.size()) { - const std::size_t amp = target.find('&', pos); - const std::size_t end = - amp == std::string::npos ? target.size() : amp; - - const std::string item = target.substr(pos, end - pos); - const std::size_t eq = item.find('='); - - const std::string key = - eq == std::string::npos ? item : item.substr(0, eq); - - const std::string value = - eq == std::string::npos ? "" : item.substr(eq + 1); - - if (key == "all") { - request.include_all = is_truthy_query_value(value); - } - - if (amp == std::string::npos) { - break; - } - - pos = amp + 1; - } - - return request; -} - -EventsRequest parse_events_request(const std::string& target) { - EventsRequest request {}; - - const std::size_t query_pos = target.find('?'); - if (query_pos == std::string::npos || - query_pos + 1 >= target.size()) { - return request; - } - - /* - * Small, deliberate parser for only the fields observed from Portainer's - * event-log request. Unknown parameters are ignored for now. - */ - std::size_t pos = query_pos + 1; - - while (pos <= target.size()) { - const std::size_t amp = target.find('&', pos); - const std::size_t end = - amp == std::string::npos ? target.size() : amp; - - const std::string item = target.substr(pos, end - pos); - const std::size_t eq = item.find('='); - - const std::string key = - eq == std::string::npos ? item : item.substr(0, eq); - - const std::string value = - eq == std::string::npos ? "" : item.substr(eq + 1); - - if (key == "since") { - request.since = value; - } else if (key == "until") { - request.until = value; - } - - if (amp == std::string::npos) { - break; - } - - pos = amp + 1; - } - - return request; -} - - -struct ContainerLifecycleRequest { - int timeout_seconds = -1; -}; - -bool parse_nonnegative_int(const std::string& value, - int& out, - std::string& error) { - if (value.empty()) { - error = "empty integer value"; - return false; - } - - errno = 0; - char* end = nullptr; - const long parsed = std::strtol(value.c_str(), &end, 10); - if (errno != 0 || end == value.c_str() || *end != '\0' || parsed < 0 || - parsed > std::numeric_limits::max()) { - error = "invalid non-negative integer: "; - error += value; - return false; - } - - out = static_cast(parsed); - return true; -} - -bool parse_container_lifecycle_request(const std::string& target, - ContainerLifecycleRequest& request, - std::string& error) { - request = ContainerLifecycleRequest {}; - - const std::size_t query_pos = target.find('?'); - if (query_pos == std::string::npos || query_pos + 1 >= target.size()) { - return true; - } - - std::size_t pos = query_pos + 1; - while (pos <= target.size()) { - const std::size_t amp = target.find('&', pos); - const std::size_t end = - amp == std::string::npos ? target.size() : amp; - - const std::string item = target.substr(pos, end - pos); - const std::size_t eq = item.find('='); - const std::string key = - eq == std::string::npos ? item : item.substr(0, eq); - const std::string value = - eq == std::string::npos ? "" : item.substr(eq + 1); - - if (key == "t") { - if (!parse_nonnegative_int(value, request.timeout_seconds, error)) { - error = "invalid lifecycle timeout query parameter: " + value; - return false; - } - } - - if (amp == std::string::npos) { - break; - } - pos = amp + 1; - } - - return true; -} - -bool parse_port(const std::string& value, - std::uint16_t& port_out, - std::string& error) { - if (value.empty()) { - error = "TCP port is empty"; - return false; - } - - errno = 0; - char* end = nullptr; - const unsigned long parsed = std::strtoul(value.c_str(), &end, 10); - - if (errno != 0 || end == value.c_str() || *end != '\0') { - error = "invalid TCP port: "; - error += value; - return false; - } - - if (parsed == 0 || parsed > 65535UL) { - error = "TCP port is outside valid range: "; - error += value; - return false; - } - - port_out = static_cast(parsed); - return true; -} - -std::string build_version_json() { - const std::string arch = socketd_arch_name(); - const std::string kernel_version = socketd_kernel_version(); - - std::string body; - body.reserve(512); - - body += "{"; - - body += "\"Platform\":{\"Name\":\""; - body += json_escape(DS_PROJECT_NAME); - body += "\"},"; - - body += "\"Components\":[{"; - body += "\"Name\":\"Engine\","; - body += "\"Version\":\""; - body += json_escape(DS_VERSION); - body += "\","; - body += "\"Details\":{}"; - body += "}],"; - - body += "\"Version\":\""; - body += json_escape(DS_VERSION); - body += "\","; - - body += "\"ApiVersion\":\""; - body += kSocketApiVersion; - body += "\","; - - body += "\"MinAPIVersion\":\""; - body += kSocketMinApiVersion; - body += "\","; - - body += "\"Os\":\""; - body += kSocketOsType; - body += "\","; - - body += "\"Arch\":\""; - body += json_escape(arch); - body += "\""; - - if (!kernel_version.empty()) { - body += ",\"KernelVersion\":\""; - body += json_escape(kernel_version); - body += "\""; - } - - body += "}\n"; - return body; -} - -bool send_version_ok(int fd, bool suppress_body, std::string& error) { - const std::string body = build_version_json(); - - return send_http_response(fd, - 200, - "OK", - "application/json", - body, - suppress_body, - error); -} - -bool build_info_json(std::string& body, std::string& error) { - BackendClient backend; - InfoResult info; - - if (!backend.info(info, error)) { - return false; - } - - /* - * /info remains primarily a socketd-local Docker-shaped compatibility - * document: host facts such as kernel, architecture, CPU count, and memory - * are gathered in this process. Backend INFO contributes the live - * Droidspaces inventory counters. - */ - const std::string arch = socketd_arch_name(); - const std::string kernel_version = socketd_kernel_version(); - const std::string hostname = socketd_hostname(); - const std::string system_time = socketd_system_time_utc(); - const unsigned int ncpu = socketd_ncpu(); - const std::uint64_t mem_total = socketd_mem_total_bytes(); - - body.clear(); - body.reserve(1400); - - body += "{"; - - body += "\"ID\":\"\","; - - body += "\"Containers\":"; - body += std::to_string(info.containers_total); - body += ","; - - body += "\"ContainersRunning\":"; - body += std::to_string(info.containers_running); - body += ","; - - body += "\"ContainersPaused\":0,"; - - body += "\"ContainersStopped\":"; - body += std::to_string(info.containers_stopped); - body += ","; - - /* - * CONCERN(socketd-info): - * The current INFO backend payload carries container inventory counters only. - * Preserve the existing Images=0 field until the plan explicitly extends the - * INFO wire payload or chooses a socketd-side count source for pseudo-images. - */ - body += "\"Images\":0,"; - - body += "\"Driver\":\"droidspaces\","; - body += "\"DriverStatus\":[],"; - body += "\"Plugins\":{"; - body += "\"Volume\":[],"; - body += "\"Network\":[],"; - body += "\"Authorization\":[],"; - body += "\"Log\":[]"; - body += "},"; - - body += "\"MemoryLimit\":false,"; - body += "\"SwapLimit\":false,"; - body += "\"CpuCfsPeriod\":false,"; - body += "\"CpuCfsQuota\":false,"; - body += "\"CPUShares\":false,"; - body += "\"CPUSet\":false,"; - body += "\"PidsLimit\":false,"; - body += "\"IPv4Forwarding\":false,"; - body += "\"Debug\":false,"; - body += "\"NFd\":0,"; - body += "\"OomKillDisable\":false,"; - body += "\"NGoroutines\":0,"; - - body += "\"SystemTime\":\""; - body += json_escape(system_time); - body += "\","; - - body += "\"LoggingDriver\":\"\","; - body += "\"CgroupDriver\":\"\","; - body += "\"NEventsListener\":0,"; - - body += "\"KernelVersion\":\""; - body += json_escape(kernel_version); - body += "\","; - - body += "\"OperatingSystem\":\"Droidspaces\","; - body += "\"OSVersion\":\"\","; - body += "\"OSType\":\"linux\","; - - body += "\"Architecture\":\""; - body += json_escape(arch); - body += "\","; - - body += "\"IndexServerAddress\":\"\","; - body += "\"RegistryConfig\":null,"; - - body += "\"NCPU\":"; - body += std::to_string(ncpu); - body += ","; - - body += "\"MemTotal\":"; - body += std::to_string(mem_total); - body += ","; - - body += "\"GenericResources\":[],"; - body += "\"DockerRootDir\":\"\","; - body += "\"HttpProxy\":\"\","; - body += "\"HttpsProxy\":\"\","; - body += "\"NoProxy\":\"\","; - - body += "\"Name\":\""; - body += json_escape(hostname); - body += "\","; - - body += "\"Labels\":[],"; - body += "\"ExperimentalBuild\":false,"; - - body += "\"ServerVersion\":\""; - body += json_escape(DS_VERSION); - body += "\","; - - body += "\"Runtimes\":{},"; - body += "\"DefaultRuntime\":\"\","; - body += "\"Swarm\":{\"NodeID\":\"\"},"; - body += "\"LiveRestoreEnabled\":false,"; - body += "\"Isolation\":\"\","; - body += "\"InitBinary\":\"\","; - body += "\"ContainerdCommit\":{\"ID\":\"\"},"; - body += "\"RuncCommit\":{\"ID\":\"\"},"; - body += "\"InitCommit\":{\"ID\":\"\"},"; - body += "\"SecurityOptions\":[],"; - body += "\"Warnings\":[]"; - - body += "}\n"; - return true; -} - -bool send_info_ok(int fd, bool suppress_body, std::string& error) { - std::string body; - - if (!build_info_json(body, error)) { - return false; - } - - return send_http_response(fd, - 200, - "OK", - "application/json", - body, - suppress_body, - error); -} - -bool send_container_list_ok(int fd, - const std::string& target, - bool suppress_body, - std::string& error) { - const ContainerListRequest request = - parse_container_list_request(target); - - std::string body; - if (!request_container_list_json_from_core(request, body, error)) { - return false; - } - - return send_http_response(fd, - 200, - "OK", - "application/json", - body, - suppress_body, - error); -} - - -bool send_container_inspect_ok(int fd, - const std::string& ref, - bool suppress_body, - std::string& error) { - std::string body; - bool not_found = false; - - if (!request_container_inspect_json_from_core(ref, body, not_found, error)) { - if (not_found) { - return send_not_found(fd, suppress_body, error); - } - - return false; - } - - return send_http_response(fd, - 200, - "OK", - "application/json", - body, - suppress_body, - error); -} - -bool send_container_start_ok(int fd, - const std::string& ref, - std::string& error) { - BackendClient backend; - LifecycleResult result; - std::string backend_error; - - if (backend.start_container(ref, result, backend_error)) { - return send_no_content(fd, error); - } - - if (result.not_found) { - return send_not_found(fd, false, error); - } - - if (result.already_running) { - return send_not_modified(fd, error); - } - - return send_internal_server_error(fd, backend_error, false, error); -} - -bool send_container_stop_ok(int fd, - const std::string& target, - const std::string& ref, - std::string& error) { - ContainerLifecycleRequest request; - std::string parse_error; - if (!parse_container_lifecycle_request(target, request, parse_error)) { - return send_bad_request(fd, false, error); - } - - BackendClient backend; - LifecycleResult result; - std::string backend_error; - - if (backend.stop_container(ref, request.timeout_seconds, result, - backend_error)) { - return send_no_content(fd, error); - } - - if (result.not_found) { - return send_not_found(fd, false, error); - } - - if (result.already_stopped) { - return send_not_modified(fd, error); - } - - return send_internal_server_error(fd, backend_error, false, error); -} - -bool send_container_restart_ok(int fd, - const std::string& target, - const std::string& ref, - std::string& error) { - ContainerLifecycleRequest request; - std::string parse_error; - if (!parse_container_lifecycle_request(target, request, parse_error)) { - return send_bad_request(fd, false, error); - } - - BackendClient backend; - LifecycleResult result; - std::string backend_error; - - if (backend.restart_container(ref, request.timeout_seconds, result, - backend_error)) { - return send_no_content(fd, error); - } - - if (result.not_found) { - return send_not_found(fd, false, error); - } - - return send_internal_server_error(fd, backend_error, false, error); -} - -bool send_image_list_ok(int fd, - bool suppress_body, - std::string& error) { - std::string body; - if (!request_image_list_json_from_core(body, error)) { - return false; - } - - return send_http_response(fd, - 200, - "OK", - "application/json", - body, - suppress_body, - error); -} - -bool send_volume_list_ok(int fd, - bool suppress_body, - std::string& error) { - std::string body; - if (!request_volume_list_json_from_core(body, error)) { - return false; - } - - return send_http_response(fd, - 200, - "OK", - "application/json", - body, - suppress_body, - error); -} - -bool send_network_list_ok(int fd, - bool suppress_body, - std::string& error) { - std::string body; - if (!request_network_list_json_from_core(body, error)) { - return false; - } - - return send_http_response(fd, - 200, - "OK", - "application/json", - body, - suppress_body, - error); -} - -bool send_events_ok(int fd, - const std::string& target, - bool suppress_body, - std::string& error) { - const EventsRequest request = parse_events_request(target); - - std::string body; - if (!request_event_log_stream_from_core(request, body, error)) { - return false; - } - - /* - * API v1.40-compatible behavior: - * Moby used application/json for event streams at this API level. - * An empty body is intentional and accepted by Portainer's event-log parser. - */ - return send_http_response(fd, - 200, - "OK", - "application/json", - body, - suppress_body, - error); -} - -} // namespace - -bool parse_tcp_listen_endpoint(const std::string& value, - TcpListenConfig& out, - std::string& error) { - out = TcpListenConfig {}; - - if (value.empty()) { - return true; - } - - const std::size_t colon_pos = value.rfind(':'); - - if (colon_pos == std::string::npos) { - return parse_port(value, out.port, error); - } - - const std::string address = value.substr(0, colon_pos); - const std::string port = value.substr(colon_pos + 1); - - if (address.empty()) { - error = "TCP bind address is empty"; - return false; - } - - out.bind_address = address; - return parse_port(port, out.port, error); -} - -ApiServer::ApiServer(TcpListenConfig config) : config_(std::move(config)) {} - -bool ApiServer::create_listener(int& fd_out, std::string& error) const { - fd_out = -1; - - const int fd = ::socket(AF_INET, SOCK_STREAM, 0); - if (fd < 0) { - error = "socket(AF_INET, SOCK_STREAM) failed: "; - error += std::strerror(errno); - return false; - } - - const int one = 1; - if (::setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)) < 0) { - error = "setsockopt(SO_REUSEADDR) failed: "; - error += std::strerror(errno); - ::close(fd); - return false; - } - - sockaddr_in addr {}; - addr.sin_family = AF_INET; - addr.sin_port = htons(config_.port); - - const int pton_result = - ::inet_pton(AF_INET, config_.bind_address.c_str(), &addr.sin_addr); - - if (pton_result != 1) { - error = "invalid IPv4 bind address: "; - error += config_.bind_address; - ::close(fd); - return false; - } - - if (::bind(fd, - reinterpret_cast(&addr), - sizeof(addr)) < 0) { - error = "bind("; - error += config_.bind_address; - error += ':'; - error += std::to_string(config_.port); - error += ") failed: "; - error += std::strerror(errno); - ::close(fd); - return false; - } - - if (::listen(fd, SOMAXCONN) < 0) { - error = "listen() failed: "; - error += std::strerror(errno); - ::close(fd); - return false; - } - - fd_out = fd; - return true; -} - -bool ApiServer::handle_client(int client_fd, std::string& error) const { - std::string request; - request.reserve(1024); - - char buffer[4096]; - - while (request.find("\r\n\r\n") == std::string::npos) { - const ssize_t n = ::recv(client_fd, buffer, sizeof(buffer), 0); - - if (n < 0) { - if (errno == EINTR) { - continue; - } - - error = "recv() failed: "; - error += std::strerror(errno); - return false; - } - - if (n == 0) { - error = "client closed connection before sending full HTTP headers"; - return false; - } - - request.append(buffer, static_cast(n)); - - if (request.size() > kMaxRequestHeaderBytes) { - std::string response_error; - (void)send_header_too_large(client_fd, false, response_error); - - error = "request header exceeded configured limit"; - return false; - } - } -// DEBUG FEATURE: perhaps remove later; do NOT expect this to stay. - debug_log_request_headers(request); - - const std::size_t line_end = request.find("\r\n"); - if (line_end == std::string::npos) { - return send_bad_request(client_fd, false, error); - } - - const std::string request_line = request.substr(0, line_end); - - std::istringstream line_stream(request_line); - std::string method; - std::string target; - std::string version; - std::string trailing; - - if (!(line_stream >> method >> target >> version) || - (line_stream >> trailing)) { - return send_bad_request(client_fd, false, error); - } - - if (version.rfind("HTTP/", 0) != 0) { - return send_bad_request(client_fd, false, error); - } - - const bool is_head = method == "HEAD"; - const bool is_get = method == "GET"; - const bool is_post = method == "POST"; - - if ((is_get || is_head) && is_api_target(target, "/_ping")) { - return send_ping_ok(client_fd, is_head, error); - } - - if (is_get && is_api_target(target, "/version")) { - return send_version_ok(client_fd, false, error); - } - - if (is_get && is_api_target(target, "/info")) { - return send_info_ok(client_fd, false, error); - } - - - if (is_get && is_api_target(target, "/containers/json")) { - return send_container_list_ok(client_fd, target, false, error); - } - - std::string inspect_ref; - if (is_get && parse_container_inspect_ref(target, inspect_ref)) { - return send_container_inspect_ok(client_fd, inspect_ref, false, error); - } - - std::string lifecycle_ref; - if (is_post && parse_container_action_ref(target, "/start", lifecycle_ref)) { - return send_container_start_ok(client_fd, lifecycle_ref, error); - } - - if (is_post && parse_container_action_ref(target, "/stop", lifecycle_ref)) { - return send_container_stop_ok(client_fd, target, lifecycle_ref, error); - } - - if (is_post && parse_container_action_ref(target, "/restart", lifecycle_ref)) { - return send_container_restart_ok(client_fd, target, lifecycle_ref, error); - } - - if (is_get && is_api_target(target, "/images/json")) { - return send_image_list_ok(client_fd, false, error); - } - - if (is_get && is_api_target(target, "/volumes")) { - return send_volume_list_ok(client_fd, false, error); - } - - if (is_get && is_api_target(target, "/networks")) { - return send_network_list_ok(client_fd, false, error); - } - - if (is_get && is_api_target(target, "/events")) { - return send_events_ok(client_fd, target, false, error); - } - - if (is_get || is_head) { - return send_static_asset_ok(client_fd, target, is_head, error); - } - - return send_not_found(client_fd, false, error); -} - -bool ApiServer::run(std::string& error) { - int listener_fd = -1; - if (!create_listener(listener_fd, error)) { - return false; - } -// To tty - std::cerr << "socketd: listening on http://" - << config_.bind_address - << ':' - << config_.port - << '\n'; -// To API - const std::string listen_target = - "tcp://" + config_.bind_address + ":" + std::to_string(config_.port); - - const SocketdEventAttributes attrs[] = { - {"name", "droidspaces-socketd"}, - {"component", "socketd"}, - {"listen", listen_target}, - }; - - record_socketd_event("daemon", - "start", - "droidspaces-socketd", - attrs, - sizeof(attrs) / sizeof(attrs[0])); - - for (;;) { - const int client_fd = ::accept(listener_fd, nullptr, nullptr); - if (client_fd < 0) { - if (errno == EINTR) { - continue; - } - - error = "accept() failed: "; - error += std::strerror(errno); - ::close(listener_fd); - return false; - } - - std::string client_error; - if (!handle_client(client_fd, client_error)) { - std::cerr << "socketd: client request failed: " - << client_error - << '\n'; - } - - ::close(client_fd); - } -} - -} // namespace droidspaces::socketd diff --git a/src/socketd/api_server.h b/src/socketd/api_server.h deleted file mode 100644 index c1120e1b..00000000 --- a/src/socketd/api_server.h +++ /dev/null @@ -1,39 +0,0 @@ -#pragma once - -#include -#include - -namespace droidspaces::socketd { - -struct TcpListenConfig { - std::string bind_address = "0.0.0.0"; - std::uint16_t port = 2375; -}; - -/* - * Parses: - * - * "" -> 0.0.0.0:2375 - * "2375" -> 0.0.0.0:2375 - * "127.0.0.1:2375" -> 127.0.0.1:2375 - * "0.0.0.0:2375" -> 0.0.0.0:2375 - * - * This first revision intentionally accepts IPv4 only. - */ -bool parse_tcp_listen_endpoint(const std::string &value, TcpListenConfig &out, - std::string &error); - -class ApiServer { -public: - explicit ApiServer(TcpListenConfig config); - - bool run(std::string &error); - -private: - bool create_listener(int &fd_out, std::string &error) const; - bool handle_client(int client_fd, std::string &error) const; - - TcpListenConfig config_; -}; - -} // namespace droidspaces::socketd diff --git a/src/socketd/backend_client.cpp b/src/socketd/backend_client.cpp deleted file mode 100644 index 10927c88..00000000 --- a/src/socketd/backend_client.cpp +++ /dev/null @@ -1,766 +0,0 @@ -#include "backend_client.h" - -#include "socketd_protocol.h" - -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include - -namespace droidspaces::socketd { -namespace { - -bool write_exact(int fd, const void* buf, std::size_t len, std::string& error) { - const auto* p = static_cast(buf); - - while (len > 0) { - const ssize_t written = ::write(fd, p, len); - if (written < 0) { - if (errno == EINTR) { - continue; - } - - error = "write() failed: "; - error += std::strerror(errno); - return false; - } - - if (written == 0) { - error = "write() returned 0 unexpectedly"; - return false; - } - - p += static_cast(written); - len -= static_cast(written); - } - - return true; -} - -bool read_exact(int fd, void* buf, std::size_t len, std::string& error) { - auto* p = static_cast(buf); - - while (len > 0) { - const ssize_t received = ::read(fd, p, len); - if (received < 0) { - if (errno == EINTR) { - continue; - } - - error = "read() failed: "; - error += std::strerror(errno); - return false; - } - - if (received == 0) { - error = "peer closed connection before full response arrived"; - return false; - } - - p += static_cast(received); - len -= static_cast(received); - } - - return true; -} - -int connect_backend(std::string& error) { - const int fd = ::socket(AF_UNIX, SOCK_STREAM, 0); - if (fd < 0) { - error = "socket(AF_UNIX) failed: "; - error += std::strerror(errno); - return -1; - } - - sockaddr_un addr {}; - addr.sun_family = AF_UNIX; - - const std::size_t name_len = std::strlen(DS_SOCKETD_BACKEND_SOCK_NAME); - if (name_len >= sizeof(addr.sun_path)) { - error = "backend abstract socket name is too long"; - ::close(fd); - return -1; - } - - /* - * Linux abstract AF_UNIX address: - * sun_path[0] = '\0' - * sun_path[1..] = socket name - */ - std::memcpy(addr.sun_path + 1, DS_SOCKETD_BACKEND_SOCK_NAME, name_len); - - const socklen_t addr_len = static_cast( - offsetof(sockaddr_un, sun_path) + 1 + name_len); - - if (::connect(fd, reinterpret_cast(&addr), addr_len) < 0) { - error = "connect(@"; - error += DS_SOCKETD_BACKEND_SOCK_NAME; - error += ") failed: "; - error += std::strerror(errno); - ::close(fd); - return -1; - } - - return fd; -} - -std::uint64_t ntoh64(std::uint64_t value) { -#if defined(__BYTE_ORDER__) && (__BYTE_ORDER__ == __ORDER_BIG_ENDIAN__) - return value; -#else - return (static_cast( - ntohl(static_cast(value & 0xffffffffULL))) - << 32) | - static_cast( - ntohl(static_cast(value >> 32))); -#endif -} - -std::uint64_t hton64(std::uint64_t value) { - return ntoh64(value); -} - -std::string decode_fixed_string(const char* data, std::size_t size) { - if (!data || size == 0) { - return {}; - } - - const void* nul = std::memchr(data, '\0', size); - const std::size_t len = - nul ? static_cast(nul) - data : size; - - /* - * CONCERN(socketd-wire): - * Backend wire strings are fixed-width char arrays rather than - * length-prefixed blobs. Decode defensively and tolerate a field that fills - * its entire slot without an in-bounds NUL terminator. - */ - return std::string(data, len); -} - -bool expect_ok_status(std::uint16_t status, - const char* operation, - std::string& error) { - if (status == DS_SOCKETD_STATUS_OK) { - return true; - } - - error = operation; - error += " returned backend status "; - error += std::to_string(status); - return false; -} - -} // namespace - -bool BackendClient::request(std::uint16_t opcode, - const void* payload, - std::uint32_t payload_len, - std::uint16_t& status_out, - std::string& payload_out, - std::string& error) const { - if (payload_len > DS_SOCKETD_MAX_PAYLOAD) { - error = "request payload exceeds DS_SOCKETD_MAX_PAYLOAD"; - return false; - } - - /* - * The current C bridge handles exactly one request per accepted connection, - * then closes it. Mirror that contract here: one RPC == one connection. - */ - const int fd = connect_backend(error); - if (fd < 0) { - return false; - } - - ds_socketd_request_header req {}; - req.magic_be = htonl(DS_SOCKETD_PROTO_MAGIC); - req.version_be = htons(DS_SOCKETD_PROTO_VERSION); - req.opcode_be = htons(opcode); - req.payload_len_be = htonl(payload_len); - - if (!write_exact(fd, &req, sizeof(req), error)) { - ::close(fd); - return false; - } - - if (payload_len > 0 && payload != nullptr) { - if (!write_exact(fd, payload, payload_len, error)) { - ::close(fd); - return false; - } - } - - ds_socketd_response_header resp {}; - if (!read_exact(fd, &resp, sizeof(resp), error)) { - ::close(fd); - return false; - } - - const std::uint32_t magic = ntohl(resp.magic_be); - const std::uint16_t version = ntohs(resp.version_be); - const std::uint16_t status = ntohs(resp.status_be); - const std::uint32_t response_payload_len = ntohl(resp.payload_len_be); - - if (magic != DS_SOCKETD_PROTO_MAGIC) { - error = "backend response used invalid protocol magic"; - ::close(fd); - return false; - } - - if (version != DS_SOCKETD_PROTO_VERSION) { - error = "backend response used unsupported protocol version"; - ::close(fd); - return false; - } - - if (response_payload_len > DS_SOCKETD_MAX_PAYLOAD) { - error = "backend response payload exceeds DS_SOCKETD_MAX_PAYLOAD"; - ::close(fd); - return false; - } - - payload_out.clear(); - payload_out.resize(response_payload_len); - - if (response_payload_len > 0) { - if (!read_exact(fd, payload_out.data(), response_payload_len, error)) { - ::close(fd); - return false; - } - } - - ::close(fd); - - status_out = status; - return true; -} - -bool BackendClient::ping(std::string& error) const { - std::uint16_t status = DS_SOCKETD_STATUS_INTERNAL_ERROR; - std::string payload; - - if (!request(DS_SOCKETD_OP_PING, nullptr, 0, status, payload, error)) { - return false; - } - - if (status != DS_SOCKETD_STATUS_OK) { - error = "PING returned backend status "; - error += std::to_string(status); - return false; - } - - if (payload != "PONG") { - error = "PING returned unexpected payload: "; - error += payload; - return false; - } - - return true; -} - -bool BackendClient::capabilities(CapabilitiesResult& out, - std::string& error) const { - std::uint16_t status = DS_SOCKETD_STATUS_INTERNAL_ERROR; - std::string payload; - - if (!request(DS_SOCKETD_OP_CAPABILITIES, nullptr, 0, - status, payload, error)) { - return false; - } - - if (status != DS_SOCKETD_STATUS_OK) { - error = "CAPABILITIES returned backend status "; - error += std::to_string(status); - return false; - } - - if (payload.size() != sizeof(std::uint32_t)) { - error = "CAPABILITIES returned payload of unexpected size"; - return false; - } - - std::uint32_t mask_be = 0; - std::memcpy(&mask_be, payload.data(), sizeof(mask_be)); - out.mask = ntohl(mask_be); - - return true; -} - -bool BackendClient::list_containers( - bool include_all, - std::vector& out, - std::string& error) const { - ds_socketd_list_containers_req req {}; - req.include_all = include_all ? 1u : 0u; - - std::uint16_t status = DS_SOCKETD_STATUS_INTERNAL_ERROR; - std::string payload; - - if (!request(DS_SOCKETD_OP_LIST_CONTAINERS, - &req, - static_cast(sizeof(req)), - status, - payload, - error)) { - return false; - } - - if (!expect_ok_status(status, "LIST_CONTAINERS", error)) { - return false; - } - - if (payload.size() % sizeof(ds_socketd_container_record) != 0) { - error = "LIST_CONTAINERS returned payload of invalid size"; - return false; - } - - out.clear(); - - const std::size_t count = - payload.size() / sizeof(ds_socketd_container_record); - out.reserve(count); - - for (std::size_t i = 0; i < count; ++i) { - ds_socketd_container_record wire {}; - std::memcpy(&wire, - payload.data() + i * sizeof(wire), - sizeof(wire)); - - ContainerRecordResult result; - result.name = - decode_fixed_string(wire.name, sizeof(wire.name)); - result.uuid = - decode_fixed_string(wire.uuid, sizeof(wire.uuid)); - result.rootfs_path = - decode_fixed_string(wire.rootfs_path, sizeof(wire.rootfs_path)); - result.hostname = - decode_fixed_string(wire.hostname, sizeof(wire.hostname)); - result.nat_ip = - decode_fixed_string(wire.nat_ip, sizeof(wire.nat_ip)); - result.custom_init = - decode_fixed_string(wire.custom_init, sizeof(wire.custom_init)); - - result.pid = static_cast( - ntohl(static_cast(wire.pid_be))); - - result.net_mode = wire.net_mode; - - result.started_at = static_cast( - ntoh64(static_cast(wire.started_at_be))); - - std::size_t port_count = wire.port_count; - if (port_count > DS_SOCKETD_RECORD_PORTS_MAX) { - port_count = DS_SOCKETD_RECORD_PORTS_MAX; - } - - result.ports.reserve(port_count); - - for (std::size_t j = 0; j < port_count; ++j) { - const ds_socketd_port_record& port_wire = wire.ports[j]; - - ContainerPortResult port; - port.host_port = ntohs(port_wire.host_port_be); - port.host_port_end = ntohs(port_wire.host_port_end_be); - port.container_port = ntohs(port_wire.container_port_be); - port.container_port_end = - ntohs(port_wire.container_port_end_be); - port.proto = port_wire.proto; - - result.ports.push_back(std::move(port)); - } - - out.push_back(std::move(result)); - } - - return true; -} - - -bool BackendClient::inspect_container(const std::string& ref, - ContainerInspectResult& out, - bool& not_found, - std::string& error) const { - not_found = false; - - if (ref.empty() || ref.size() >= DS_SOCKETD_RECORD_NAME_MAX) { - error = "container reference is empty or too long"; - return false; - } - - ds_socketd_inspect_container_req req {}; - std::memcpy(req.target, ref.c_str(), ref.size()); - - std::uint16_t status = DS_SOCKETD_STATUS_INTERNAL_ERROR; - std::string payload; - - if (!request(DS_SOCKETD_OP_INSPECT_CONTAINER, - &req, - static_cast(sizeof(req)), - status, - payload, - error)) { - return false; - } - - if (status == DS_SOCKETD_STATUS_NOT_FOUND) { - not_found = true; - error = "container not found"; - return false; - } - - if (!expect_ok_status(status, "INSPECT_CONTAINER", error)) { - return false; - } - - if (payload.size() != sizeof(ds_socketd_inspect_container_record_v1)) { - error = "INSPECT_CONTAINER returned payload of unexpected size"; - return false; - } - - ds_socketd_inspect_container_record_v1 wire {}; - std::memcpy(&wire, payload.data(), sizeof(wire)); - - const std::uint16_t record_version = ntohs(wire.record_version_be); - const std::uint32_t record_size = ntohl(wire.record_size_be); - if (record_version != 1u || record_size != sizeof(wire)) { - error = "INSPECT_CONTAINER returned unsupported inspect record version"; - return false; - } - - ContainerInspectResult result; - result.name = decode_fixed_string(wire.name, sizeof(wire.name)); - result.uuid = decode_fixed_string(wire.uuid, sizeof(wire.uuid)); - result.rootfs_path = - decode_fixed_string(wire.rootfs_path, sizeof(wire.rootfs_path)); - result.image_ref = - decode_fixed_string(wire.image_ref, sizeof(wire.image_ref)); - result.hostname = decode_fixed_string(wire.hostname, sizeof(wire.hostname)); - result.nat_ip = decode_fixed_string(wire.nat_ip, sizeof(wire.nat_ip)); - result.custom_init = - decode_fixed_string(wire.custom_init, sizeof(wire.custom_init)); - result.dns_servers = - decode_fixed_string(wire.dns_servers, sizeof(wire.dns_servers)); - - result.pid = static_cast( - ntohl(static_cast(wire.pid_be))); - result.started_at = static_cast( - ntoh64(static_cast(wire.started_at_be))); - result.memory_limit = static_cast( - ntoh64(static_cast(wire.memory_limit_be))); - result.cpu_quota = static_cast( - ntoh64(static_cast(wire.cpu_quota_be))); - result.cpu_period = static_cast( - ntoh64(static_cast(wire.cpu_period_be))); - result.pids_limit = static_cast( - ntoh64(static_cast(wire.pids_limit_be))); - result.privileged_mask = static_cast( - ntohl(static_cast(wire.privileged_mask_be))); - - result.net_mode = wire.net_mode; - result.foreground = wire.foreground != 0; - result.volatile_mode = wire.volatile_mode != 0; - result.force_cgroupv1 = wire.force_cgroupv1 != 0; - result.disable_ipv6 = wire.disable_ipv6 != 0; - result.android_storage = wire.android_storage != 0; - result.selinux_permissive = wire.selinux_permissive != 0; - result.hw_access = wire.hw_access != 0; - result.gpu_mode = wire.gpu_mode != 0; - result.termux_x11 = wire.termux_x11 != 0; - result.block_nested_ns = wire.block_nested_ns != 0; - result.is_img_mount = wire.is_img_mount != 0; - - std::size_t env_count = ntohs(wire.env_count_be); - if (env_count > DS_SOCKETD_INSPECT_ENV_MAX) { - env_count = DS_SOCKETD_INSPECT_ENV_MAX; - } - result.env_total_count = ntohs(wire.env_total_count_be); - result.env.reserve(env_count); - for (std::size_t i = 0; i < env_count; ++i) { - InspectEnvResult env; - env.key = decode_fixed_string(wire.env[i].key, sizeof(wire.env[i].key)); - env.value = - decode_fixed_string(wire.env[i].value, sizeof(wire.env[i].value)); - result.env.push_back(std::move(env)); - } - - std::size_t bind_count = ntohs(wire.bind_count_be); - if (bind_count > DS_SOCKETD_INSPECT_BINDS_MAX) { - bind_count = DS_SOCKETD_INSPECT_BINDS_MAX; - } - result.bind_total_count = ntohs(wire.bind_total_count_be); - result.binds.reserve(bind_count); - for (std::size_t i = 0; i < bind_count; ++i) { - InspectBindResult bind; - bind.source = decode_fixed_string(wire.binds[i].source, - sizeof(wire.binds[i].source)); - bind.destination = decode_fixed_string(wire.binds[i].destination, - sizeof(wire.binds[i].destination)); - bind.read_only = wire.binds[i].read_only != 0; - result.binds.push_back(std::move(bind)); - } - - std::size_t port_count = ntohs(wire.port_count_be); - if (port_count > DS_SOCKETD_RECORD_PORTS_MAX) { - port_count = DS_SOCKETD_RECORD_PORTS_MAX; - } - result.port_total_count = ntohs(wire.port_total_count_be); - result.ports.reserve(port_count); - for (std::size_t i = 0; i < port_count; ++i) { - const ds_socketd_port_record& port_wire = wire.ports[i]; - - ContainerPortResult port; - port.host_port = ntohs(port_wire.host_port_be); - port.host_port_end = ntohs(port_wire.host_port_end_be); - port.container_port = ntohs(port_wire.container_port_be); - port.container_port_end = ntohs(port_wire.container_port_end_be); - port.proto = port_wire.proto; - result.ports.push_back(std::move(port)); - } - - out = std::move(result); - return true; -} - - -bool BackendClient::info(InfoResult& out, std::string& error) const { - std::uint16_t status = DS_SOCKETD_STATUS_INTERNAL_ERROR; - std::string payload; - - if (!request(DS_SOCKETD_OP_INFO, nullptr, 0, - status, payload, error)) { - return false; - } - - if (!expect_ok_status(status, "INFO", error)) { - return false; - } - - if (payload.size() != sizeof(ds_socketd_info_payload)) { - error = "INFO returned payload of unexpected size"; - return false; - } - - ds_socketd_info_payload wire {}; - std::memcpy(&wire, payload.data(), sizeof(wire)); - - out.containers_total = ntohl(wire.containers_total_be); - out.containers_running = ntohl(wire.containers_running_be); - out.containers_stopped = ntohl(wire.containers_stopped_be); - - return true; -} - -bool BackendClient::list_images( - std::vector& out, - std::string& error) const { - std::uint16_t status = DS_SOCKETD_STATUS_INTERNAL_ERROR; - std::string payload; - - if (!request(DS_SOCKETD_OP_LIST_IMAGES, nullptr, 0, - status, payload, error)) { - return false; - } - - if (!expect_ok_status(status, "LIST_IMAGES", error)) { - return false; - } - - if (payload.size() % sizeof(ds_socketd_image_record) != 0) { - error = "LIST_IMAGES returned payload of invalid size"; - return false; - } - - out.clear(); - - const std::size_t count = - payload.size() / sizeof(ds_socketd_image_record); - out.reserve(count); - - for (std::size_t i = 0; i < count; ++i) { - ds_socketd_image_record wire {}; - std::memcpy(&wire, - payload.data() + i * sizeof(wire), - sizeof(wire)); - - ImageRecordResult result; - result.name = - decode_fixed_string(wire.name, sizeof(wire.name)); - result.rootfs_path = - decode_fixed_string(wire.rootfs_path, sizeof(wire.rootfs_path)); - result.uuid = - decode_fixed_string(wire.uuid, sizeof(wire.uuid)); - - result.is_running = - ntohl(static_cast(wire.is_running_be)) != 0; - - result.created_at = static_cast( - ntoh64(static_cast(wire.created_at_be))); - - out.push_back(std::move(result)); - } - - return true; -} - - - -bool BackendClient::lifecycle_request(std::uint16_t opcode, - const std::string& ref, - int timeout_seconds, - LifecycleResult& out, - std::string& error) const { - out = LifecycleResult {}; - - if (ref.empty() || ref.size() >= DS_SOCKETD_RECORD_NAME_MAX) { - error = "container reference is empty or too long"; - return false; - } - - if (timeout_seconds < -1) { - error = "container lifecycle timeout is invalid"; - return false; - } - - ds_socketd_lifecycle_req req {}; - std::memcpy(req.target, ref.c_str(), ref.size()); - req.timeout_seconds_be = static_cast( - htonl(static_cast(timeout_seconds))); - - std::uint16_t status = DS_SOCKETD_STATUS_INTERNAL_ERROR; - std::string payload; - - if (!request(opcode, - &req, - static_cast(sizeof(req)), - status, - payload, - error)) { - return false; - } - - if (status == DS_SOCKETD_STATUS_OK) { - return true; - } - - switch (status) { - case DS_SOCKETD_STATUS_NOT_FOUND: - out.not_found = true; - error = "container not found"; - return false; - case DS_SOCKETD_STATUS_ALREADY_RUNNING: - out.already_running = true; - error = "container already running"; - return false; - case DS_SOCKETD_STATUS_ALREADY_STOPPED: - out.already_stopped = true; - error = "container already stopped"; - return false; - default: - return expect_ok_status(status, "LIFECYCLE", error); - } -} - -bool BackendClient::start_container(const std::string& ref, - LifecycleResult& out, - std::string& error) const { - return lifecycle_request(DS_SOCKETD_OP_START_CONTAINER, ref, -1, out, error); -} - -bool BackendClient::stop_container(const std::string& ref, - int timeout_seconds, - LifecycleResult& out, - std::string& error) const { - return lifecycle_request(DS_SOCKETD_OP_STOP_CONTAINER, - ref, - timeout_seconds, - out, - error); -} - -bool BackendClient::restart_container(const std::string& ref, - int timeout_seconds, - LifecycleResult& out, - std::string& error) const { - return lifecycle_request(DS_SOCKETD_OP_RESTART_CONTAINER, - ref, - timeout_seconds, - out, - error); -} - -bool BackendClient::poll_events( - std::int64_t since, - std::vector& out, - std::string& error) const { - if (since < 0) { - error = "POLL_EVENTS does not accept a negative 'since' value"; - return false; - } - - ds_socketd_poll_events_req req {}; - req.since_be = static_cast( - hton64(static_cast(since))); - - std::uint16_t status = DS_SOCKETD_STATUS_INTERNAL_ERROR; - std::string payload; - - if (!request(DS_SOCKETD_OP_POLL_EVENTS, - &req, - static_cast(sizeof(req)), - status, - payload, - error)) { - return false; - } - - if (!expect_ok_status(status, "POLL_EVENTS", error)) { - return false; - } - - if (payload.size() % sizeof(ds_socketd_core_event_record) != 0) { - error = "POLL_EVENTS returned payload of invalid size"; - return false; - } - - out.clear(); - - const std::size_t count = - payload.size() / sizeof(ds_socketd_core_event_record); - out.reserve(count); - - for (std::size_t i = 0; i < count; ++i) { - ds_socketd_core_event_record wire {}; - std::memcpy(&wire, - payload.data() + i * sizeof(wire), - sizeof(wire)); - - CoreEventResult result; - result.time = static_cast( - ntoh64(static_cast(wire.time_be))); - result.time_nano = static_cast( - ntoh64(static_cast(wire.time_nano_be))); - - result.type = - decode_fixed_string(wire.type, sizeof(wire.type)); - result.action = - decode_fixed_string(wire.action, sizeof(wire.action)); - result.actor_id = - decode_fixed_string(wire.actor_id, sizeof(wire.actor_id)); - result.actor_name = - decode_fixed_string(wire.actor_name, sizeof(wire.actor_name)); - - out.push_back(std::move(result)); - } - - return true; -} - -} // namespace droidspaces::socketd diff --git a/src/socketd/backend_client.h b/src/socketd/backend_client.h deleted file mode 100644 index b138f62c..00000000 --- a/src/socketd/backend_client.h +++ /dev/null @@ -1,151 +0,0 @@ -#pragma once - -#include -#include -#include - -namespace droidspaces::socketd { - -struct CapabilitiesResult { - std::uint32_t mask = 0; -}; - -struct ContainerPortResult { - std::uint16_t host_port = 0; - std::uint16_t host_port_end = 0; - std::uint16_t container_port = 0; - std::uint16_t container_port_end = 0; - std::uint8_t proto = 0; /* 0 = tcp, 1 = udp */ -}; - -struct InspectEnvResult { - std::string key; - std::string value; -}; - -struct InspectBindResult { - std::string source; - std::string destination; - bool read_only = false; -}; - -struct ContainerInspectResult { - std::string name; - std::string uuid; - std::string rootfs_path; - std::string image_ref; - std::string hostname; - std::string nat_ip; - std::string custom_init; - std::string dns_servers; - std::int32_t pid = 0; - std::uint8_t net_mode = 0; /* 0 = host, 1 = nat, 2 = none */ - std::int64_t started_at = 0; - std::int64_t memory_limit = 0; - std::int64_t cpu_quota = 0; - std::int64_t cpu_period = 0; - std::int64_t pids_limit = 0; - std::int32_t privileged_mask = 0; - bool foreground = false; - bool volatile_mode = false; - bool force_cgroupv1 = false; - bool disable_ipv6 = false; - bool android_storage = false; - bool selinux_permissive = false; - bool hw_access = false; - bool gpu_mode = false; - bool termux_x11 = false; - bool block_nested_ns = false; - bool is_img_mount = false; - std::uint16_t env_total_count = 0; - std::uint16_t bind_total_count = 0; - std::uint16_t port_total_count = 0; - std::vector env; - std::vector binds; - std::vector ports; -}; - -struct ContainerRecordResult { - std::string name; - std::string uuid; - std::string rootfs_path; - std::string hostname; - std::string nat_ip; - std::string custom_init; - std::int32_t pid = 0; - std::uint8_t net_mode = 0; /* 0 = host, 1 = nat, 2 = none */ - std::int64_t started_at = 0; - std::vector ports; -}; - -struct InfoResult { - std::uint32_t containers_total = 0; - std::uint32_t containers_running = 0; - std::uint32_t containers_stopped = 0; -}; - -struct ImageRecordResult { - std::string name; - std::string rootfs_path; - std::string uuid; - bool is_running = false; - std::int64_t created_at = 0; -}; - -struct CoreEventResult { - std::int64_t time = 0; - std::int64_t time_nano = 0; - std::string type; - std::string action; - std::string actor_id; - std::string actor_name; -}; - -struct LifecycleResult { - bool not_found = false; - bool already_running = false; - bool already_stopped = false; -}; - -class BackendClient { -public: - BackendClient() = default; - - bool ping(std::string &error) const; - bool capabilities(CapabilitiesResult &out, std::string &error) const; - - bool list_containers(bool include_all, - std::vector &out, - std::string &error) const; - - bool inspect_container(const std::string &ref, - ContainerInspectResult &out, - bool ¬_found, - std::string &error) const; - - bool info(InfoResult &out, std::string &error) const; - - bool list_images(std::vector &out, - std::string &error) const; - - bool poll_events(std::int64_t since, std::vector &out, - std::string &error) const; - - bool start_container(const std::string &ref, LifecycleResult &out, - std::string &error) const; - bool stop_container(const std::string &ref, int timeout_seconds, - LifecycleResult &out, std::string &error) const; - bool restart_container(const std::string &ref, int timeout_seconds, - LifecycleResult &out, std::string &error) const; - -private: - bool lifecycle_request(std::uint16_t opcode, const std::string &ref, - int timeout_seconds, LifecycleResult &out, - std::string &error) const; - - bool request(std::uint16_t opcode, const void *payload, - std::uint32_t payload_len, std::uint16_t &status_out, - std::string &payload_out, std::string &error) const; -}; - -} // namespace droidspaces::socketd diff --git a/src/socketd/container_inspect.cpp b/src/socketd/container_inspect.cpp deleted file mode 100644 index 9c9dde85..00000000 --- a/src/socketd/container_inspect.cpp +++ /dev/null @@ -1,410 +0,0 @@ -#include "container_inspect.h" - -#include "backend_client.h" - -#include -#include -#include -#include - -namespace droidspaces::socketd { -namespace { - -constexpr const char* kDockerZeroTime = "0001-01-01T00:00:00Z"; - -std::string json_escape(const std::string& input) { - std::string out; - out.reserve(input.size()); - - constexpr char kHex[] = "0123456789abcdef"; - - for (unsigned char ch : input) { - switch (ch) { - case '"': - out += "\\\""; - break; - case '\\': - out += "\\\\"; - break; - case '\b': - out += "\\b"; - break; - case '\f': - out += "\\f"; - break; - case '\n': - out += "\\n"; - break; - case '\r': - out += "\\r"; - break; - case '\t': - out += "\\t"; - break; - default: - if (ch < 0x20) { - out += "\\u00"; - out += kHex[(ch >> 4) & 0x0f]; - out += kHex[ch & 0x0f]; - } else { - out += static_cast(ch); - } - break; - } - } - - return out; -} - -std::string rfc3339_utc(std::int64_t unix_seconds) { - if (unix_seconds <= 0) { - return kDockerZeroTime; - } - - std::time_t value = static_cast(unix_seconds); - if (static_cast(value) != unix_seconds) { - return kDockerZeroTime; - } - - std::tm tm {}; -#if defined(_POSIX_THREAD_SAFE_FUNCTIONS) || defined(__ANDROID__) || defined(__linux__) - if (::gmtime_r(&value, &tm) == nullptr) { - return kDockerZeroTime; - } -#else - const std::tm* tmp = std::gmtime(&value); - if (tmp == nullptr) { - return kDockerZeroTime; - } - tm = *tmp; -#endif - - char buffer[64] {}; - if (std::strftime(buffer, sizeof(buffer), "%Y-%m-%dT%H:%M:%SZ", &tm) == 0) { - return kDockerZeroTime; - } - - return buffer; -} - -const char* port_type(std::uint8_t proto) { - return proto == 1u ? "udp" : "tcp"; -} - -const char* docker_network_mode(std::uint8_t mode) { - switch (mode) { - case 1u: - return "bridge"; - case 2u: - return "none"; - case 0u: - default: - return "host"; - } -} - -std::string container_command(const ContainerInspectResult& record) { - return record.custom_init.empty() ? "/sbin/init" : record.custom_init; -} - -const char* container_state_status(const ContainerInspectResult& record) { - return record.pid > 0 ? "running" : "exited"; -} - -void append_string_field(std::string& out, - const char* name, - const std::string& value) { - out += '"'; - out += name; - out += "\":\""; - out += json_escape(value); - out += '"'; -} - -void append_port_map_json(std::string& out, - const ContainerInspectResult& record, - bool include_host_bindings) { - out += '{'; - - for (std::size_t i = 0; i < record.ports.size(); ++i) { - if (i != 0) { - out += ','; - } - - const ContainerPortResult& port = record.ports[i]; - out += '"'; - out += std::to_string(port.container_port); - out += '/'; - out += port_type(port.proto); - out += "\":"; - - if (include_host_bindings) { - out += "[{\"HostIp\":\"\",\"HostPort\":\""; - out += std::to_string(port.host_port); - out += "\"}]"; - } else { - out += "{}"; - } - } - - out += '}'; -} - -void append_config_json(std::string& out, - const ContainerInspectResult& record, - const std::string& command) { - out += "\"Config\":{"; - append_string_field(out, "Hostname", record.hostname); - out += ",\"Domainname\":\"\","; - out += "\"User\":\"\","; - out += "\"AttachStdin\":false,"; - out += "\"AttachStdout\":false,"; - out += "\"AttachStderr\":false,"; - out += "\"ExposedPorts\":"; - append_port_map_json(out, record, false); - out += ','; - out += "\"Tty\":false,"; - out += "\"OpenStdin\":false,"; - out += "\"StdinOnce\":false,"; - out += "\"Env\":[],"; - out += "\"Cmd\":[\""; - out += json_escape(command); - out += "\"],"; - append_string_field(out, "Image", record.image_ref.empty() ? record.rootfs_path : record.image_ref); - out += ",\"Volumes\":{},"; - out += "\"WorkingDir\":\"\","; - out += "\"Entrypoint\":[],"; - out += "\"OnBuild\":[],"; - out += "\"Labels\":{}"; - out += '}'; -} - -void append_host_config_json(std::string& out, - const ContainerInspectResult& record) { - out += "\"HostConfig\":{"; - out += "\"Binds\":[],"; - out += "\"ContainerIDFile\":\"\","; - out += "\"LogConfig\":{\"Type\":\"\",\"Config\":{}},"; - out += "\"NetworkMode\":\""; - out += docker_network_mode(record.net_mode); - out += "\","; - out += "\"PortBindings\":"; - append_port_map_json(out, record, true); - out += ','; - out += "\"RestartPolicy\":{\"Name\":\"\",\"MaximumRetryCount\":0},"; - out += "\"AutoRemove\":false,"; - out += "\"VolumeDriver\":\"\","; - out += "\"VolumesFrom\":[],"; - out += "\"CapAdd\":[],"; - out += "\"CapDrop\":[],"; - out += "\"CgroupnsMode\":\"\","; - out += "\"Dns\":[],"; - out += "\"DnsOptions\":[],"; - out += "\"DnsSearch\":[],"; - out += "\"ExtraHosts\":[],"; - out += "\"GroupAdd\":[],"; - out += "\"IpcMode\":\"\","; - out += "\"Cgroup\":\"\","; - out += "\"Links\":[],"; - out += "\"OomScoreAdj\":0,"; - out += "\"PidMode\":\"\","; - out += "\"Privileged\":false,"; - out += "\"PublishAllPorts\":false,"; - out += "\"ReadonlyRootfs\":false,"; - out += "\"SecurityOpt\":[],"; - out += "\"UTSMode\":\"\","; - out += "\"UsernsMode\":\"\","; - out += "\"ShmSize\":0,"; - out += "\"Runtime\":\"\","; - out += "\"ConsoleSize\":[0,0],"; - out += "\"Isolation\":\"\","; - out += "\"CpuShares\":0,"; - out += "\"Memory\":0,"; - out += "\"NanoCpus\":0,"; - out += "\"CgroupParent\":\"\","; - out += "\"BlkioWeight\":0,"; - out += "\"BlkioWeightDevice\":[],"; - out += "\"BlkioDeviceReadBps\":[],"; - out += "\"BlkioDeviceWriteBps\":[],"; - out += "\"BlkioDeviceReadIOps\":[],"; - out += "\"BlkioDeviceWriteIOps\":[],"; - out += "\"CpuPeriod\":0,"; - out += "\"CpuQuota\":0,"; - out += "\"CpuRealtimePeriod\":0,"; - out += "\"CpuRealtimeRuntime\":0,"; - out += "\"CpusetCpus\":\"\","; - out += "\"CpusetMems\":\"\","; - out += "\"Devices\":[],"; - out += "\"DeviceCgroupRules\":[],"; - out += "\"DeviceRequests\":[],"; - out += "\"KernelMemory\":0,"; - out += "\"KernelMemoryTCP\":0,"; - out += "\"MemoryReservation\":0,"; - out += "\"MemorySwap\":0,"; - out += "\"MemorySwappiness\":0,"; - out += "\"OomKillDisable\":false,"; - out += "\"PidsLimit\":0,"; - out += "\"Ulimits\":[],"; - out += "\"CpuCount\":0,"; - out += "\"CpuPercent\":0,"; - out += "\"IOMaximumIOps\":0,"; - out += "\"IOMaximumBandwidth\":0,"; - out += "\"MaskedPaths\":[],"; - out += "\"ReadonlyPaths\":[]"; - out += '}'; -} - -void append_state_json(std::string& out, - const ContainerInspectResult& record, - const std::string& started_at) { - out += "\"State\":{"; - out += "\"Status\":\""; - out += container_state_status(record); - out += "\","; - out += "\"Running\":"; - out += record.pid > 0 ? "true" : "false"; - out += ','; - out += "\"Paused\":false,"; - out += "\"Restarting\":false,"; - out += "\"OOMKilled\":false,"; - out += "\"Dead\":false,"; - out += "\"Pid\":"; - out += std::to_string(record.pid > 0 ? record.pid : 0); - out += ','; - out += "\"ExitCode\":0,"; - out += "\"Error\":\"\","; - out += "\"StartedAt\":\""; - out += json_escape(started_at); - out += "\","; - out += "\"FinishedAt\":\""; - out += kDockerZeroTime; - out += "\""; - out += '}'; -} - -void append_networks_json(std::string& out, - const ContainerInspectResult& record) { - out += "\"Networks\":{"; - - if (record.net_mode == 1u) { - out += "\"droidspaces-bridge\":{"; - out += "\"IPAMConfig\":{},"; - out += "\"Links\":[],"; - out += "\"Aliases\":[],"; - out += "\"NetworkID\":\"\","; - out += "\"EndpointID\":\"\","; - out += "\"Gateway\":\"\","; - out += "\"IPAddress\":\""; - out += json_escape(record.nat_ip); - out += "\","; - out += "\"IPPrefixLen\":0,"; - out += "\"IPv6Gateway\":\"\","; - out += "\"GlobalIPv6Address\":\"\","; - out += "\"GlobalIPv6PrefixLen\":0,"; - out += "\"MacAddress\":\"\","; - out += "\"DriverOpts\":{}"; - out += '}'; - } - - out += '}'; -} - -void append_network_settings_json(std::string& out, - const ContainerInspectResult& record) { - out += "\"NetworkSettings\":{"; - out += "\"Bridge\":\"\","; - out += "\"SandboxID\":\"\","; - out += "\"HairpinMode\":false,"; - out += "\"LinkLocalIPv6Address\":\"\","; - out += "\"LinkLocalIPv6PrefixLen\":0,"; - out += "\"Ports\":"; - append_port_map_json(out, record, true); - out += ','; - out += "\"SandboxKey\":\"\","; - out += "\"SecondaryIPAddresses\":[],"; - out += "\"SecondaryIPv6Addresses\":[],"; - out += "\"EndpointID\":\"\","; - out += "\"Gateway\":\"\","; - out += "\"GlobalIPv6Address\":\"\","; - out += "\"GlobalIPv6PrefixLen\":0,"; - out += "\"IPAddress\":\""; - if (record.net_mode == 1u) { - out += json_escape(record.nat_ip); - } - out += "\","; - out += "\"IPPrefixLen\":0,"; - out += "\"IPv6Gateway\":\"\","; - out += "\"MacAddress\":\"\","; - append_networks_json(out, record); - out += '}'; -} - -void append_inspect_json(std::string& out, - const ContainerInspectResult& record) { - const std::string command = container_command(record); - const std::string started_at = rfc3339_utc(record.started_at); - const std::string created = started_at; - - out += '{'; - - out += "\"AppArmorProfile\":\"\","; - out += "\"Args\":[],"; - append_config_json(out, record, command); - out += ','; - out += "\"Created\":\""; - out += json_escape(created); - out += "\","; - out += "\"Driver\":\"droidspaces\","; - out += "\"ExecIDs\":[],"; - append_host_config_json(out, record); - out += ','; - out += "\"HostnamePath\":\"\","; - out += "\"HostsPath\":\"\","; - out += "\"LogPath\":\"\","; - append_string_field(out, "Id", record.uuid); - out += ','; - append_string_field(out, "Image", record.image_ref.empty() ? record.rootfs_path : record.image_ref); - out += ','; - out += "\"MountLabel\":\"\","; - out += "\"Name\":\"/"; - out += json_escape(record.name); - out += "\","; - append_network_settings_json(out, record); - out += ','; - append_string_field(out, "Path", command); - out += ','; - out += "\"ProcessLabel\":\"\","; - out += "\"ResolvConfPath\":\"\","; - out += "\"RestartCount\":0,"; - append_state_json(out, record, started_at); - out += ','; - out += "\"Mounts\":[]"; - - out += "}\n"; -} - -} // namespace - -bool request_container_inspect_json_from_core(const std::string& ref, - std::string& json_out, - bool& not_found, - std::string& error) { - error.clear(); - not_found = false; - - BackendClient backend; - ContainerInspectResult record; - - if (!backend.inspect_container(ref, record, not_found, error)) { - return false; - } - - std::string body; - body.reserve(4096); - append_inspect_json(body, record); - - json_out = std::move(body); - return true; -} - -} // namespace droidspaces::socketd diff --git a/src/socketd/container_inspect.h b/src/socketd/container_inspect.h deleted file mode 100644 index ff565793..00000000 --- a/src/socketd/container_inspect.h +++ /dev/null @@ -1,17 +0,0 @@ -#pragma once - -#include - -namespace droidspaces::socketd { - -/* - * Socketd-owned renderer for Docker-compatible container inspect payloads. - * The backend supplies Droidspaces facts; this layer fills the broad Docker - * object skeleton with stable placeholders for unsupported Docker-only fields. - */ -bool request_container_inspect_json_from_core(const std::string& ref, - std::string& json_out, - bool& not_found, - std::string& error); - -} // namespace droidspaces::socketd diff --git a/src/socketd/container_list.cpp b/src/socketd/container_list.cpp deleted file mode 100644 index be14f0b6..00000000 --- a/src/socketd/container_list.cpp +++ /dev/null @@ -1,237 +0,0 @@ -#include "container_list.h" - -#include "backend_client.h" - -#include -#include -#include -#include -#include - -namespace droidspaces::socketd { -namespace { - -std::string json_escape(const std::string& input) { - std::string out; - out.reserve(input.size()); - - constexpr char kHex[] = "0123456789abcdef"; - - for (unsigned char ch : input) { - switch (ch) { - case '"': - out += "\\\""; - break; - case '\\': - out += "\\\\"; - break; - case '\b': - out += "\\b"; - break; - case '\f': - out += "\\f"; - break; - case '\n': - out += "\\n"; - break; - case '\r': - out += "\\r"; - break; - case '\t': - out += "\\t"; - break; - default: - if (ch < 0x20) { - out += "\\u00"; - out += kHex[(ch >> 4) & 0x0f]; - out += kHex[ch & 0x0f]; - } else { - out += static_cast(ch); - } - break; - } - } - - return out; -} - -const char* container_state(const ContainerRecordResult& record) { - return record.pid > 0 ? "running" : "exited"; -} - -const char* container_status(const ContainerRecordResult& record) { - return record.pid > 0 ? "Up" : "Exited"; -} - -const char* port_type(std::uint8_t proto) { - return proto == 1u ? "udp" : "tcp"; -} - -void append_ports_json(std::string& out, - const ContainerRecordResult& record) { - out += "\"Ports\":["; - - for (std::size_t i = 0; i < record.ports.size(); ++i) { - if (i != 0) { - out += ","; - } - - const ContainerPortResult& port = record.ports[i]; - - out += "{"; - out += "\"PrivatePort\":"; - out += std::to_string(port.container_port); - out += ","; - out += "\"PublicPort\":"; - out += std::to_string(port.host_port); - out += ","; - out += "\"Type\":\""; - out += port_type(port.proto); - out += "\""; - out += "}"; - } - - out += "]"; -} - -void append_network_settings_json(std::string& out, - const ContainerRecordResult& record) { - out += "\"NetworkSettings\":{\"Networks\":{"; - - /* - * The Phase 4 public JSON projection follows the current compatibility plan: - * only NAT-mode containers receive a Docker-style synthetic bridge attachment - * in /containers/json. Host and none modes remain represented by an empty - * Networks object here. - */ - if (record.net_mode == 1u && !record.nat_ip.empty()) { - out += "\"droidspaces-bridge\":{"; - out += "\"IPAddress\":\""; - out += json_escape(record.nat_ip); - out += "\""; - out += "}"; - } - - out += "}}"; -} - - -/* - * Docker inspect field-mapping design note - * ---------------------------------------- - * Keep the future GET /containers/{id}/json inspect renderer explicit about - * which fields are Droidspaces-native, compatibility-only, or deliberately not - * exposed. The /containers/json projection below should stay aligned with these - * identities so Docker-compatible clients see stable values across list and - * inspect responses. - * - * | Inspect field | Classification | Droidspaces source / compatibility value | - * | --- | --- | --- | - * | Id | Real Droidspaces-backed value | cfg.uuid / ContainerRecordResult::uuid. | - * | Name | Real Droidspaces-backed value | "/" + cfg.container_name / record.name. | - * | Config.Hostname | Real Droidspaces-backed value | cfg.hostname / record.hostname. | - * | Config.Image, Image | Real Droidspaces-backed value | rootfs image path when present, otherwise rootfs path or the pseudo-image UUID used by /images/json. | - * | Config.Env | Real Droidspaces-backed value | cfg.env_vars; include the default PATH when no environment is configured. | - * | Config.Cmd | Real Droidspaces-backed value | Single-element array containing cfg.custom_init, or /sbin/init when unset. | - * | Path | Real Droidspaces-backed value | First Config.Cmd element. | - * | Args | Stable compatibility placeholder | Remaining Config.Cmd elements; currently [] for the Droidspaces init model. | - * | HostConfig.NetworkMode | Real Droidspaces-backed value | "bridge" for NAT, "host" for host networking, "none" for no network. | - * | HostConfig.PortBindings | Real Droidspaces-backed value | cfg.port_forwards. | - * | HostConfig.Memory, CpuPeriod, CpuQuota, PidsLimit | Real Droidspaces-backed value | cfg.memory_limit, cfg.cpu_period, cfg.cpu_quota, cfg.pids_limit; 0 means unlimited/unset. | - * | HostConfig.Privileged | Real Droidspaces-backed value | cfg.privileged_mask != 0. | - * | Mounts | Real Droidspaces-backed value | cfg.binds. | - * | NetworkSettings.Networks | Real Droidspaces-backed value | Extend the current droidspaces-bridge NAT projection with address/attachment details as they become available. | - * | State.Running, State.Pid, State.StartedAt, State.Status | Real Droidspaces-backed value | PID and started_at; stopped containers use pid 0, zero time/empty time, and exited status. | - * | Docker-specific fields with no Droidspaces equivalent | Stable compatibility placeholder | Empty strings, false, zero, empty arrays, or empty objects, chosen consistently by field type. | - * | Fields that would imply unsupported Docker semantics | Intentionally omitted field | Omit rather than invent behavior, unless a Docker client requires a typed placeholder for compatibility. | - * - * Public-facing rationale lives in Documentation/socketd-inspect-field-mapping.md. - */ - -void append_container_json(std::string& out, - const ContainerRecordResult& record) { - const std::string command = - record.custom_init.empty() ? "/sbin/init" : record.custom_init; - - out += "{"; - - out += "\"Id\":\""; - out += json_escape(record.uuid); - out += "\","; - - out += "\"Names\":[\"/"; - out += json_escape(record.name); - out += "\"],"; - - out += "\"Image\":\""; - out += json_escape(record.rootfs_path); - out += "\","; - - out += "\"ImageID\":\""; - out += json_escape(record.uuid); - out += "\","; - - out += "\"Command\":\""; - out += json_escape(command); - out += "\","; - - out += "\"Created\":"; - out += std::to_string(record.started_at > 0 ? record.started_at : 0); - out += ","; - - append_ports_json(out, record); - out += ","; - - out += "\"Labels\":{},"; - - out += "\"State\":\""; - out += container_state(record); - out += "\","; - - out += "\"Status\":\""; - out += container_status(record); - out += "\","; - - append_network_settings_json(out, record); - out += ","; - - out += "\"Mounts\":[]"; - - out += "}"; -} - -} // namespace - -bool request_container_list_json_from_core( - const ContainerListRequest& request, - std::string& json_out, - std::string& error) { - error.clear(); - - BackendClient backend; - std::vector records; - - if (!backend.list_containers(request.include_all, records, error)) { - return false; - } - - std::string body; - body.reserve(256 + records.size() * 512); - - body += "["; - - for (std::size_t i = 0; i < records.size(); ++i) { - if (i != 0) { - body += ","; - } - - append_container_json(body, records[i]); - } - - body += "]\n"; - - json_out = std::move(body); - return true; -} - -} // namespace droidspaces::socketd diff --git a/src/socketd/container_list.h b/src/socketd/container_list.h deleted file mode 100644 index a4c4f2f3..00000000 --- a/src/socketd/container_list.h +++ /dev/null @@ -1,29 +0,0 @@ -#pragma once - -#include - -namespace droidspaces::socketd { - -struct ContainerListRequest { - /* - * Docker-compatible public API spelling: - * - * GET /containers/json?all=1 - * - * The extension keeps this as a semantic flag rather than exposing the - * raw HTTP query string to any eventual core-side implementation. - */ - bool include_all = false; -}; - -/* - * Socketd-owned seam for obtaining the Docker-compatible container list - * payload. The HTTP server remains independent of backend protocol details; - * this layer requests typed core records through BackendClient and renders the - * public JSON shape expected by Docker-compatible consumers. - */ -bool request_container_list_json_from_core(const ContainerListRequest &request, - std::string &json_out, - std::string &error); - -} // namespace droidspaces::socketd diff --git a/src/socketd/event_log.cpp b/src/socketd/event_log.cpp deleted file mode 100644 index 687f0a01..00000000 --- a/src/socketd/event_log.cpp +++ /dev/null @@ -1,315 +0,0 @@ -#include "event_log.h" - -#include "backend_client.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace droidspaces::socketd { -namespace { - -constexpr std::size_t kMaxSocketdEvents = 128; - -struct EventAttribute { - std::string key; - std::string value; -}; - -struct SocketdEvent { - std::string type; - std::string action; - std::string actor_id; - std::vector attributes; - std::int64_t time = 0; - std::int64_t time_nano = 0; -}; - -std::deque g_socketd_events; - -std::int64_t unix_time_seconds_now() { - using clock = std::chrono::system_clock; - const auto now = clock::now(); - return std::chrono::duration_cast( - now.time_since_epoch()) - .count(); -} - -std::int64_t unix_time_nanos_now() { - using clock = std::chrono::system_clock; - const auto now = clock::now(); - return std::chrono::duration_cast( - now.time_since_epoch()) - .count(); -} - -std::string json_escape(const std::string& input) { - std::string out; - out.reserve(input.size()); - - constexpr char kHex[] = "0123456789abcdef"; - - for (unsigned char ch : input) { - switch (ch) { - case '"': - out += "\\\""; - break; - case '\\': - out += "\\\\"; - break; - case '\b': - out += "\\b"; - break; - case '\f': - out += "\\f"; - break; - case '\n': - out += "\\n"; - break; - case '\r': - out += "\\r"; - break; - case '\t': - out += "\\t"; - break; - default: - if (ch < 0x20) { - out += "\\u00"; - out += kHex[(ch >> 4) & 0x0f]; - out += kHex[ch & 0x0f]; - } else { - out += static_cast(ch); - } - break; - } - } - - return out; -} - -bool parse_optional_epoch_seconds(const std::string& value, - std::int64_t& out, - std::string& error) { - out = 0; - - if (value.empty()) { - return true; - } - - char* end = nullptr; - errno = 0; - const long long parsed = std::strtoll(value.c_str(), &end, 10); - - if (errno != 0 || end == value.c_str() || *end != '\0') { - error = "invalid event timestamp: "; - error += value; - return false; - } - - if (parsed < 0) { - error = "negative event timestamp is unsupported: "; - error += value; - return false; - } - - out = static_cast(parsed); - return true; -} - -bool event_in_window(const SocketdEvent& event, - std::int64_t since, - std::int64_t until) { - if (since > 0 && event.time < since) { - return false; - } - - if (until > 0 && event.time > until) { - return false; - } - - return true; -} - -std::string encode_event_json_line(const SocketdEvent& event) { - std::string out; - out.reserve(512); - - out += "{"; - - out += "\"Type\":\""; - out += json_escape(event.type); - out += "\","; - - out += "\"Action\":\""; - out += json_escape(event.action); - out += "\","; - - out += "\"Actor\":{"; - out += "\"ID\":\""; - out += json_escape(event.actor_id); - out += "\","; - out += "\"Attributes\":{"; - - for (std::size_t i = 0; i < event.attributes.size(); ++i) { - if (i != 0) { - out += ","; - } - - out += "\""; - out += json_escape(event.attributes[i].key); - out += "\":\""; - out += json_escape(event.attributes[i].value); - out += "\""; - } - - out += "}"; - out += "},"; - - out += "\"scope\":\"local\","; - - out += "\"time\":"; - out += std::to_string(event.time); - out += ","; - - out += "\"timeNano\":"; - out += std::to_string(event.time_nano); - - out += "}\n"; - return out; -} - -SocketdEvent socketd_event_from_core_event(const CoreEventResult& core) { - SocketdEvent event; - event.type = core.type; - event.action = core.action; - event.actor_id = core.actor_id; - event.time = core.time; - event.time_nano = core.time_nano; - - if (!core.actor_name.empty()) { - event.attributes.push_back(EventAttribute{ - "name", - core.actor_name, - }); - } - - return event; -} - -} // namespace - -void record_socketd_event(const std::string& type, - const std::string& action, - const std::string& actor_id, - const SocketdEventAttributes* attributes, - std::size_t attribute_count) { - SocketdEvent event; - event.type = type; - event.action = action; - event.actor_id = actor_id; - event.time = unix_time_seconds_now(); - event.time_nano = unix_time_nanos_now(); - - event.attributes.reserve(attribute_count); - for (std::size_t i = 0; i < attribute_count; ++i) { - event.attributes.push_back(EventAttribute{ - attributes[i].key, - attributes[i].value, - }); - } - - if (g_socketd_events.size() >= kMaxSocketdEvents) { - g_socketd_events.pop_front(); - } - - g_socketd_events.push_back(std::move(event)); -} - -bool request_event_log_stream_from_core( - const EventsRequest& request, - std::string& stream_out, - std::string& error) { - error.clear(); - stream_out.clear(); - - std::int64_t since = 0; - std::int64_t until = 0; - - if (!parse_optional_epoch_seconds(request.since, since, error)) { - return false; - } - - if (!parse_optional_epoch_seconds(request.until, until, error)) { - return false; - } - - if (since > 0 && until > 0 && since > until) { - error = "event 'since' timestamp is after 'until'"; - return false; - } - - std::vector merged_events; - merged_events.reserve(g_socketd_events.size()); - - for (const SocketdEvent& event : g_socketd_events) { - if (!event_in_window(event, since, until)) { - continue; - } - - merged_events.push_back(event); - } - - BackendClient backend; - std::vector core_events; - std::string backend_error; - - if (backend.poll_events(since, core_events, backend_error)) { - merged_events.reserve(merged_events.size() + core_events.size()); - - for (const CoreEventResult& core_event : core_events) { - SocketdEvent event = socketd_event_from_core_event(core_event); - - if (!event_in_window(event, since, until)) { - continue; - } - - merged_events.push_back(std::move(event)); - } - } else { - /* - * CONCERN(socketd-events): - * The event endpoint follows the current compatibility plan and degrades - * gracefully when the privileged backend is unavailable: socketd-local - * daemon events remain visible instead of failing the entire /events - * response. If a later contract requires hard backend failure, tighten - * that policy here. - */ - } - - std::stable_sort( - merged_events.begin(), - merged_events.end(), - [](const SocketdEvent& lhs, const SocketdEvent& rhs) { - if (lhs.time_nano != rhs.time_nano) { - return lhs.time_nano < rhs.time_nano; - } - - return lhs.time < rhs.time; - }); - - for (const SocketdEvent& event : merged_events) { - stream_out += encode_event_json_line(event); - } - - return true; -} - -} // namespace droidspaces::socketd diff --git a/src/socketd/event_log.h b/src/socketd/event_log.h deleted file mode 100644 index ddbd3e3f..00000000 --- a/src/socketd/event_log.h +++ /dev/null @@ -1,39 +0,0 @@ -#pragma once - -#include -#include - -namespace droidspaces::socketd { - -struct EventsRequest { - std::string since; - std::string until; -}; - -struct SocketdEventAttributes { - std::string key; - std::string value; -}; - -/* - * Record a socketd-owned engine-style event in the extension-local journal. - * These local daemon events are merged with backend core lifecycle events by - * request_event_log_stream_from_core(). - */ -void record_socketd_event(const std::string &type, const std::string &action, - const std::string &actor_id, - const SocketdEventAttributes *attributes, - std::size_t attribute_count); - -/* - * Return events matching the requested time window as a Docker-compatible - * stream of JSON objects. The response merges: - * - * - socketd-owned local events recorded in this process, and - * - core lifecycle events fetched from the privileged backend bridge. - */ -bool request_event_log_stream_from_core(const EventsRequest &request, - std::string &stream_out, - std::string &error); - -} // namespace droidspaces::socketd diff --git a/src/socketd/main.cpp b/src/socketd/main.cpp deleted file mode 100644 index e5e977fd..00000000 --- a/src/socketd/main.cpp +++ /dev/null @@ -1,183 +0,0 @@ -#include "api_server.h" -#include "backend_client.h" -#include "event_log.h" - -#include "socketd_protocol.h" - -#include -#include -#include -#include -#include - -namespace { - -using droidspaces::socketd::ApiServer; -using droidspaces::socketd::BackendClient; -using droidspaces::socketd::CapabilitiesResult; -using droidspaces::socketd::TcpListenConfig; -using droidspaces::socketd::parse_tcp_listen_endpoint; -using droidspaces::socketd::SocketdEventAttributes; -using droidspaces::socketd::record_socketd_event; - -constexpr std::uint32_t kRequiredBackendCapabilities = - DS_SOCKETD_CAP_PROTOCOL_V1 | - DS_SOCKETD_CAP_PING | - DS_SOCKETD_CAP_CAPABILITIES; - -void print_usage(const char* argv0) { - std::cerr - << "Usage:\n" - << " " << argv0 << " --listen-tcp [PORT]\n" - << " " << argv0 << " --listen-tcp [ADDR:PORT]\n" - << "\n" - << "Examples:\n" - << " " << argv0 << " --listen-tcp\n" - << " " << argv0 << " --listen-tcp 2375\n" - << " " << argv0 << " --listen-tcp 127.0.0.1:2375\n" - << " " << argv0 << " --listen-tcp 0.0.0.0:2375\n"; -} - -bool check_backend(std::string& error) { - BackendClient backend; - - if (!backend.ping(error)) { - error = "backend PING failed: " + error; - return false; - } - - CapabilitiesResult caps {}; - if (!backend.capabilities(caps, error)) { - error = "backend CAPABILITIES failed: " + error; - return false; - } - - if ((caps.mask & kRequiredBackendCapabilities) != - kRequiredBackendCapabilities) { - error = "backend is missing required base capabilities"; - return false; - } -// To tty - std::cerr << "socketd: backend handshake OK, capabilities mask: 0x" - << std::hex - << caps.mask - << std::dec - << '\n'; - -// To API -const std::string caps_text = "0x" + [&caps]() { - char buffer[32] {}; - std::snprintf(buffer, sizeof(buffer), "%x", caps.mask); - return std::string(buffer); -}(); - -const SocketdEventAttributes attrs[] = { - {"name", "droidspaces-backend"}, - {"component", "socketd"}, - {"capabilities", caps_text}, -}; - -record_socketd_event("daemon", - "connect", - "droidspaces-backend", - attrs, - sizeof(attrs) / sizeof(attrs[0])); - - return true; -} - -} // namespace - -int main(int argc, char** argv) { - /* - * Avoid process termination if a TCP peer disappears while a response - * is being written. - */ - (void)std::signal(SIGPIPE, SIG_IGN); - - bool listen_tcp = false; - TcpListenConfig tcp_config {}; - - for (int i = 1; i < argc; ++i) { - const std::string arg = argv[i]; - - if (arg == "--help" || arg == "-h") { - print_usage(argv[0]); - return 0; - } - - if (arg == "--listen-tcp") { - if (listen_tcp) { - std::cerr << "socketd: --listen-tcp specified more than once\n"; - return 2; - } - - listen_tcp = true; - - if (i + 1 < argc) { - const std::string next = argv[i + 1]; - - if (!next.empty() && next[0] != '-') { - std::string parse_error; - if (!parse_tcp_listen_endpoint(next, tcp_config, parse_error)) { - std::cerr << "socketd: " << parse_error << '\n'; - return 2; - } - - ++i; - } - } - - continue; - } - - if (arg.rfind("--listen-tcp=", 0) == 0) { - if (listen_tcp) { - std::cerr << "socketd: --listen-tcp specified more than once\n"; - return 2; - } - - listen_tcp = true; - - const std::string value = arg.substr(std::string("--listen-tcp=").size()); - - std::string parse_error; - if (!parse_tcp_listen_endpoint(value, tcp_config, parse_error)) { - std::cerr << "socketd: " << parse_error << '\n'; - return 2; - } - - continue; - } - - std::cerr << "socketd: unknown argument: " << arg << '\n'; - print_usage(argv[0]); - return 2; - } - - if (!listen_tcp) { - std::cerr << "socketd: no listener configured\n"; - print_usage(argv[0]); - return 2; - } - - std::string error; - if (!check_backend(error)) { - /* - * Keep the HTTP listener available even when the privileged backend is - * absent. This lets the socketd HTTP/static WebUI surface be tested in - * isolation. Backend-dependent Docker API routes still fail normally when - * they attempt their per-request BackendClient RPC. - */ - std::cerr << "socketd: warning: " << error << '\n'; - std::cerr << "socketd: warning: continuing without backend handshake\n"; - } - - ApiServer server(tcp_config); - if (!server.run(error)) { - std::cerr << "socketd: server failed: " << error << '\n'; - return 1; - } - - return 0; -} diff --git a/src/socketd/snapshot_lists.cpp b/src/socketd/snapshot_lists.cpp deleted file mode 100644 index eb7fd4e4..00000000 --- a/src/socketd/snapshot_lists.cpp +++ /dev/null @@ -1,207 +0,0 @@ -#include "snapshot_lists.h" - -#include "backend_client.h" - -#include -#include -#include -#include - -namespace droidspaces::socketd { -namespace { - -std::string json_escape(const std::string& input) { - std::string out; - out.reserve(input.size()); - - constexpr char kHex[] = "0123456789abcdef"; - - for (unsigned char ch : input) { - switch (ch) { - case '"': - out += "\\\""; - break; - case '\\': - out += "\\\\"; - break; - case '\b': - out += "\\b"; - break; - case '\f': - out += "\\f"; - break; - case '\n': - out += "\\n"; - break; - case '\r': - out += "\\r"; - break; - case '\t': - out += "\\t"; - break; - default: - if (ch < 0x20) { - out += "\\u00"; - out += kHex[(ch >> 4) & 0x0f]; - out += kHex[ch & 0x0f]; - } else { - out += static_cast(ch); - } - break; - } - } - - return out; -} - -void append_image_json(std::string& out, - const ImageRecordResult& image) { - out += "{"; - - out += "\"Containers\":"; - out += image.is_running ? "1" : "0"; - out += ","; - - out += "\"Created\":"; - out += std::to_string(image.created_at > 0 ? image.created_at : 0); - out += ","; - - out += "\"Id\":\""; - out += json_escape(image.uuid); - out += "\","; - - out += "\"Labels\":{},"; - - out += "\"ParentId\":\"\","; - - out += "\"RepoDigests\":[],"; - - out += "\"RepoTags\":[\""; - out += json_escape(image.name); - out += ":latest\"],"; - - /* - * CONCERN(socketd-images): - * This public image summary intentionally follows the current compatibility - * plan's minimal pseudo-image projection. If a later Docker/Portainer - * compatibility pass requires additional image-summary fields, extend this - * socketd JSON seam rather than changing the core-side image record. - */ - out += "\"Size\":0,"; - out += "\"VirtualSize\":0"; - - out += "}"; -} - -} // namespace - -bool request_image_list_json_from_core(std::string& json_out, - std::string& error) { - error.clear(); - - BackendClient backend; - std::vector images; - - if (!backend.list_images(images, error)) { - return false; - } - - std::string body; - body.reserve(256 + images.size() * 256); - - body += "["; - - for (std::size_t i = 0; i < images.size(); ++i) { - if (i != 0) { - body += ","; - } - - append_image_json(body, images[i]); - } - - body += "]\n"; - - json_out = std::move(body); - return true; -} - -bool request_volume_list_json_from_core(std::string& json_out, - std::string& error) { - error.clear(); - - /* - * Docker-managed named volumes have no direct Droidspaces analogue. - * Keep the endpoint structurally valid while intentionally exposing none. - */ - json_out = "{\"Volumes\":[],\"Warnings\":[]}\n"; - return true; -} - -bool request_network_list_json_from_core(std::string& json_out, - std::string& error) { - error.clear(); - - /* - * Fixed public compatibility facade for Droidspaces networking modes. - * These are socketd-owned Docker-shaped descriptors; no mutable Docker - * network object is being introduced into the core runtime. - */ - json_out = - "[" - "{" - "\"Name\":\"droidspaces-bridge\"," - "\"Id\":\"droidspaces-bridge\"," - "\"Created\":\"1970-01-01T00:00:00Z\"," - "\"Scope\":\"local\"," - "\"Driver\":\"bridge\"," - "\"EnableIPv6\":false," - "\"IPAM\":{\"Driver\":\"default\",\"Options\":{},\"Config\":[]}," - "\"Internal\":false," - "\"Attachable\":false," - "\"Ingress\":false," - "\"ConfigFrom\":{\"Network\":\"\"}," - "\"ConfigOnly\":false," - "\"Containers\":{}," - "\"Options\":{}," - "\"Labels\":{}" - "}," - "{" - "\"Name\":\"host\"," - "\"Id\":\"host\"," - "\"Created\":\"1970-01-01T00:00:00Z\"," - "\"Scope\":\"local\"," - "\"Driver\":\"host\"," - "\"EnableIPv6\":false," - "\"IPAM\":{\"Driver\":\"default\",\"Options\":{},\"Config\":[]}," - "\"Internal\":false," - "\"Attachable\":false," - "\"Ingress\":false," - "\"ConfigFrom\":{\"Network\":\"\"}," - "\"ConfigOnly\":false," - "\"Containers\":{}," - "\"Options\":{}," - "\"Labels\":{}" - "}," - "{" - "\"Name\":\"none\"," - "\"Id\":\"none\"," - "\"Created\":\"1970-01-01T00:00:00Z\"," - "\"Scope\":\"local\"," - "\"Driver\":\"null\"," - "\"EnableIPv6\":false," - "\"IPAM\":{\"Driver\":\"default\",\"Options\":{},\"Config\":[]}," - "\"Internal\":false," - "\"Attachable\":false," - "\"Ingress\":false," - "\"ConfigFrom\":{\"Network\":\"\"}," - "\"ConfigOnly\":false," - "\"Containers\":{}," - "\"Options\":{}," - "\"Labels\":{}" - "}" - "]\n"; - - return true; -} - -} // namespace droidspaces::socketd diff --git a/src/socketd/snapshot_lists.h b/src/socketd/snapshot_lists.h deleted file mode 100644 index d3e3ba80..00000000 --- a/src/socketd/snapshot_lists.h +++ /dev/null @@ -1,25 +0,0 @@ -#pragma once - -#include - -namespace droidspaces::socketd { - -/* - * Extension-owned seams for Portainer snapshot inventory probes. - * - * Images are rendered from typed backend pseudo-image records. Volumes and - * networks remain socketd-owned compatibility projections: Droidspaces has no - * Docker-managed named-volume model, while the network list is a fixed public - * facade over Droidspaces networking modes. - */ - -bool request_image_list_json_from_core(std::string &json_out, - std::string &error); - -bool request_volume_list_json_from_core(std::string &json_out, - std::string &error); - -bool request_network_list_json_from_core(std::string &json_out, - std::string &error); - -} // namespace droidspaces::socketd diff --git a/src/socketd_bridge.c b/src/socketd_bridge.c index 07f8be51..7931c441 100644 --- a/src/socketd_bridge.c +++ b/src/socketd_bridge.c @@ -9,6 +9,9 @@ #include "droidspace.h" #include "socketd_protocol.h" +#include +#include + /* * The public Docker/Podman-compatible socket belongs to the external C++ * droidspaces-socketd daemon. This bridge is deliberately narrower: it is a @@ -16,6 +19,24 @@ * Droidspaces-native operations needed by that compatibility daemon. */ +#define DS_SOCKETD_GROUP "droidspaces" +#define DS_SOCKETD_CONN_TIMEOUT_SEC 10 + +static void socketd_set_conn_timeouts(int fd) { + struct timeval tv; + memset(&tv, 0, sizeof(tv)); + tv.tv_sec = DS_SOCKETD_CONN_TIMEOUT_SEC; + + if (setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)) < 0) { + ds_warn("socketd backend: failed to set receive timeout: %s", + strerror(errno)); + } + + if (setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)) < 0) { + ds_warn("socketd backend: failed to set send timeout: %s", strerror(errno)); + } +} + static int socketd_read_exact(int fd, void *buf, size_t len) { uint8_t *p = (uint8_t *)buf; while (len > 0) { @@ -65,26 +86,9 @@ static int socketd_send_response(int fd, enum ds_socketd_status status, } static int socketd_peer_authorized(int fd) { -#ifdef SO_PEERCRED - struct ucred cred; - socklen_t cred_len = sizeof(cred); - memset(&cred, 0, sizeof(cred)); - - if (getsockopt(fd, SOL_SOCKET, SO_PEERCRED, &cred, &cred_len) < 0) - return 0; - if (cred_len != sizeof(cred)) - return 0; - - /* - * The first socketd implementation is expected to run as root beside the - * Droidspaces daemon. Same-EUID access keeps local developer/test launches - * usable on desktop Linux without opening the bridge to arbitrary users. - */ - return cred.uid == 0 || cred.uid == geteuid(); -#else - (void)fd; - return 0; -#endif + /* Shared with the main daemon: root or a 'droidspaces' group member, and only + * from our own PID namespace (the abstract socket has no filesystem ACL). */ + return ds_peer_authorized(fd, DS_SOCKETD_GROUP); } static int socketd_discard_payload(int fd, uint32_t len) { @@ -106,6 +110,17 @@ static int socketd_read_payload(int fd, void *buf, uint32_t expected_len, return socketd_read_exact(fd, buf, expected_len); } +/* Drain a payload the current opcode does not consume; on a short read reply + * BAD_REQUEST. Returns 0 to continue, -1 if the caller should stop handling + * the connection. */ +static int socketd_drain_payload(int conn, uint32_t payload_len) { + if (payload_len > 0 && socketd_discard_payload(conn, payload_len) < 0) { + socketd_send_response(conn, DS_SOCKETD_STATUS_BAD_REQUEST, NULL, 0); + return -1; + } + return 0; +} + /* * Wire-format 64-bit host -> network conversion. * @@ -122,8 +137,8 @@ static uint64_t socketd_hton64(uint64_t value) { #endif } static uint64_t socketd_ntoh64(uint64_t value) { - return socketd_hton64( - value); // The byte swap is symmetric. Could be an alias via __attribute__ + /* Network<->host 64-bit swap is symmetric, so this just forwards. */ + return socketd_hton64(value); } static int socketd_read_proc_start_ticks(pid_t pid, @@ -250,9 +265,7 @@ static int socketd_pidfile_to_container_name(const char *filename, } static void socketd_free_loaded_config(struct ds_config *cfg) { - free_config_binds(cfg); - free_config_env_vars(cfg); - free_config_unknown_lines(cfg); + ds_config_free(cfg); } static int socketd_is_uuid_prefix_ref(const char *ref) { @@ -355,7 +368,6 @@ static int socketd_load_config_by_ref(const char *ref, struct ds_config *cfg) { static void socketd_pack_port_record(struct ds_socketd_port_record *dst, const struct ds_port_forward *src); - static void socketd_pack_container_record(struct ds_socketd_container_record *record, const struct ds_config *cfg, pid_t pid) { @@ -405,7 +417,6 @@ socketd_pack_container_record(struct ds_socketd_container_record *record, record->started_at_be = (int64_t)socketd_hton64((uint64_t)started_at); } - static int socketd_u16_count(int count, int max_count) { if (count < 0) return 0; @@ -438,8 +449,8 @@ static void socketd_pack_inspect_record( safe_strncpy(record->rootfs_path, cfg->rootfs_path, sizeof(record->rootfs_path)); - const char *image_ref = cfg->rootfs_img_path[0] ? cfg->rootfs_img_path - : cfg->rootfs_path; + const char *image_ref = + cfg->rootfs_img_path[0] ? cfg->rootfs_img_path : cfg->rootfs_path; safe_strncpy(record->image_ref, image_ref, sizeof(record->image_ref)); safe_strncpy(record->hostname, cfg->hostname, sizeof(record->hostname)); @@ -458,7 +469,8 @@ static void socketd_pack_inspect_record( int64_t started_at = pid > 0 ? socketd_container_started_at_epoch(pid) : 0; record->started_at_be = (int64_t)socketd_hton64((uint64_t)started_at); - record->memory_limit_be = (int64_t)socketd_hton64((uint64_t)cfg->memory_limit); + record->memory_limit_be = + (int64_t)socketd_hton64((uint64_t)cfg->memory_limit); record->cpu_quota_be = (int64_t)socketd_hton64((uint64_t)cfg->cpu_quota); record->cpu_period_be = (int64_t)socketd_hton64((uint64_t)cfg->cpu_period); record->pids_limit_be = (int64_t)socketd_hton64((uint64_t)cfg->pids_limit); @@ -477,8 +489,8 @@ static void socketd_pack_inspect_record( record->block_nested_ns = cfg->block_nested_ns ? 1u : 0u; record->is_img_mount = cfg->is_img_mount ? 1u : 0u; - int env_count = socketd_u16_count(cfg->env_var_count, - DS_SOCKETD_INSPECT_ENV_MAX); + int env_count = + socketd_u16_count(cfg->env_var_count, DS_SOCKETD_INSPECT_ENV_MAX); record->env_count_be = htons((uint16_t)env_count); record->env_total_count_be = htons((uint16_t)socketd_u16_count(cfg->env_var_count, UINT16_MAX)); @@ -491,8 +503,8 @@ static void socketd_pack_inspect_record( sizeof(record->env[i].value)); } - int bind_count = socketd_u16_count(cfg->bind_count, - DS_SOCKETD_INSPECT_BINDS_MAX); + int bind_count = + socketd_u16_count(cfg->bind_count, DS_SOCKETD_INSPECT_BINDS_MAX); record->bind_count_be = htons((uint16_t)bind_count); record->bind_total_count_be = htons((uint16_t)socketd_u16_count(cfg->bind_count, UINT16_MAX)); @@ -504,8 +516,8 @@ static void socketd_pack_inspect_record( record->binds[i].read_only = cfg->binds[i].ro ? 1u : 0u; } - int port_count = socketd_u16_count(cfg->port_forward_count, - DS_SOCKETD_RECORD_PORTS_MAX); + int port_count = + socketd_u16_count(cfg->port_forward_count, DS_SOCKETD_RECORD_PORTS_MAX); record->port_count_be = htons((uint16_t)port_count); record->port_total_count_be = htons((uint16_t)socketd_u16_count(cfg->port_forward_count, UINT16_MAX)); @@ -513,35 +525,43 @@ static void socketd_pack_inspect_record( socketd_pack_port_record(&record->ports[i], &cfg->port_forwards[i]); } -static int socketd_append_container_record( - struct ds_socketd_container_record **records_inout, size_t *count_inout, - size_t *capacity_inout, const struct ds_socketd_container_record *record) { - if (!records_inout || !count_inout || !capacity_inout || !record) +/* Ensure *records_inout has room for one more elem_size element, doubling the + * capacity (min 16) with wrap and DS_SOCKETD_MAX_PAYLOAD bounds checks and + * zero-filling the newly grown tail. Returns 0 on success, -1 on failure. */ +static int socketd_grow_array(void **records_inout, size_t *count_inout, + size_t *capacity_inout, size_t elem_size) { + if (!records_inout || !count_inout || !capacity_inout || elem_size == 0) return -1; - if (*count_inout >= *capacity_inout) { - size_t old_capacity = *capacity_inout; - size_t new_capacity = old_capacity == 0 ? 16u : old_capacity * 2u; + if (*count_inout < *capacity_inout) + return 0; - if (new_capacity < old_capacity) - return -1; + size_t old_capacity = *capacity_inout; + size_t new_capacity = old_capacity == 0 ? 16u : old_capacity * 2u; + if (new_capacity < old_capacity) + return -1; + if (new_capacity > DS_SOCKETD_MAX_PAYLOAD / elem_size) + return -1; - if (new_capacity > - DS_SOCKETD_MAX_PAYLOAD / sizeof(struct ds_socketd_container_record)) { - return -1; - } + void *grown = realloc(*records_inout, new_capacity * elem_size); + if (!grown) + return -1; - struct ds_socketd_container_record *grown = - realloc(*records_inout, new_capacity * sizeof(*grown)); - if (!grown) - return -1; + memset((char *)grown + old_capacity * elem_size, 0, + (new_capacity - old_capacity) * elem_size); - memset(grown + old_capacity, 0, - (new_capacity - old_capacity) * sizeof(*grown)); + *records_inout = grown; + *capacity_inout = new_capacity; + return 0; +} - *records_inout = grown; - *capacity_inout = new_capacity; - } +static int socketd_append_container_record( + struct ds_socketd_container_record **records_inout, size_t *count_inout, + size_t *capacity_inout, const struct ds_socketd_container_record *record) { + if (!record || + socketd_grow_array((void **)records_inout, count_inout, capacity_inout, + sizeof(**records_inout)) < 0) + return -1; (*records_inout)[*count_inout] = *record; (*count_inout)++; @@ -584,33 +604,11 @@ static int socketd_append_image_record(struct ds_socketd_image_record **records_inout, size_t *count_inout, size_t *capacity_inout, const struct ds_socketd_image_record *record) { - if (!records_inout || !count_inout || !capacity_inout || !record) + if (!record || + socketd_grow_array((void **)records_inout, count_inout, capacity_inout, + sizeof(**records_inout)) < 0) return -1; - if (*count_inout >= *capacity_inout) { - size_t old_capacity = *capacity_inout; - size_t new_capacity = old_capacity == 0 ? 16u : old_capacity * 2u; - - if (new_capacity < old_capacity) - return -1; - - if (new_capacity > - DS_SOCKETD_MAX_PAYLOAD / sizeof(struct ds_socketd_image_record)) { - return -1; - } - - struct ds_socketd_image_record *grown = - realloc(*records_inout, new_capacity * sizeof(*grown)); - if (!grown) - return -1; - - memset(grown + old_capacity, 0, - (new_capacity - old_capacity) * sizeof(*grown)); - - *records_inout = grown; - *capacity_inout = new_capacity; - } - (*records_inout)[*count_inout] = *record; (*count_inout)++; return 0; @@ -619,33 +617,11 @@ socketd_append_image_record(struct ds_socketd_image_record **records_inout, static int socketd_append_core_event_record( struct ds_socketd_core_event_record **records_inout, size_t *count_inout, size_t *capacity_inout, const struct ds_socketd_core_event_record *record) { - if (!records_inout || !count_inout || !capacity_inout || !record) + if (!record || + socketd_grow_array((void **)records_inout, count_inout, capacity_inout, + sizeof(**records_inout)) < 0) return -1; - if (*count_inout >= *capacity_inout) { - size_t old_capacity = *capacity_inout; - size_t new_capacity = old_capacity == 0 ? 16u : old_capacity * 2u; - - if (new_capacity < old_capacity) - return -1; - - if (new_capacity > - DS_SOCKETD_MAX_PAYLOAD / sizeof(struct ds_socketd_core_event_record)) { - return -1; - } - - struct ds_socketd_core_event_record *grown = - realloc(*records_inout, new_capacity * sizeof(*grown)); - if (!grown) - return -1; - - memset(grown + old_capacity, 0, - (new_capacity - old_capacity) * sizeof(*grown)); - - *records_inout = grown; - *capacity_inout = new_capacity; - } - (*records_inout)[*count_inout] = *record; (*count_inout)++; return 0; @@ -774,13 +750,9 @@ static int socketd_validate_start_config(struct ds_config *cfg) { return 0; } -static enum ds_socketd_status -socketd_read_lifecycle_request(int conn, - uint32_t payload_len, - struct ds_socketd_lifecycle_req *req_out, - char *target_out, - size_t target_size, - int *timeout_seconds_out) { +static enum ds_socketd_status socketd_read_lifecycle_request( + int conn, uint32_t payload_len, struct ds_socketd_lifecycle_req *req_out, + char *target_out, size_t target_size, int *timeout_seconds_out) { if (!req_out || !target_out || target_size == 0 || !timeout_seconds_out) return DS_SOCKETD_STATUS_BAD_REQUEST; @@ -790,12 +762,15 @@ socketd_read_lifecycle_request(int conn, return DS_SOCKETD_STATUS_BAD_REQUEST; } + /* The wire format does not guarantee target[] is NUL-terminated; force it + * before safe_strncpy() (which calls strlen) so a client sending 256 + * non-zero bytes cannot make strlen read past the fixed-size field. */ + req_out->target[sizeof(req_out->target) - 1] = '\0'; safe_strncpy(target_out, req_out->target, target_size); if (!target_out[0]) return DS_SOCKETD_STATUS_BAD_REQUEST; - int32_t timeout = - (int32_t)ntohl((uint32_t)req_out->timeout_seconds_be); + int32_t timeout = (int32_t)ntohl((uint32_t)req_out->timeout_seconds_be); if (timeout < -1) return DS_SOCKETD_STATUS_BAD_REQUEST; @@ -878,16 +853,14 @@ static enum ds_socketd_status socketd_lifecycle_restart(struct ds_config *cfg, return DS_SOCKETD_STATUS_OK; } -static int socketd_handle_lifecycle_request(int conn, - uint32_t payload_len, +static int socketd_handle_lifecycle_request(int conn, uint32_t payload_len, enum ds_socketd_opcode opcode) { struct ds_socketd_lifecycle_req req; char target[DS_SOCKETD_RECORD_NAME_MAX]; int timeout_seconds = -1; - enum ds_socketd_status status = - socketd_read_lifecycle_request(conn, payload_len, &req, target, - sizeof(target), &timeout_seconds); + enum ds_socketd_status status = socketd_read_lifecycle_request( + conn, payload_len, &req, target, sizeof(target), &timeout_seconds); if (status != DS_SOCKETD_STATUS_OK) { socketd_send_response(conn, status, NULL, 0); return 0; @@ -961,23 +934,10 @@ static void socketd_handle_conn(int conn) { return; } - /* - * The currently implemented opcodes do not consume payloads, but draining - * a well-sized payload keeps the framing strict and future-proofs callers. - */ -#if 0 - if (payload_len > 0 && socketd_discard_payload(conn, payload_len) < 0) { - socketd_send_response(conn, DS_SOCKETD_STATUS_BAD_REQUEST, NULL, 0); - return; - } -#endif // deprecated - switch ((enum ds_socketd_opcode)opcode) { case DS_SOCKETD_OP_PING: { - if (payload_len > 0 && socketd_discard_payload(conn, payload_len) < 0) { - socketd_send_response(conn, DS_SOCKETD_STATUS_BAD_REQUEST, NULL, 0); + if (socketd_drain_payload(conn, payload_len) < 0) return; - } static const char pong[] = "PONG"; socketd_send_response(conn, DS_SOCKETD_STATUS_OK, pong, @@ -986,17 +946,15 @@ static void socketd_handle_conn(int conn) { } case DS_SOCKETD_OP_CAPABILITIES: { - if (payload_len > 0 && socketd_discard_payload(conn, payload_len) < 0) { - socketd_send_response(conn, DS_SOCKETD_STATUS_BAD_REQUEST, NULL, 0); + if (socketd_drain_payload(conn, payload_len) < 0) return; - } uint32_t caps_be = htonl(DS_SOCKETD_CAP_PROTOCOL_V1 | DS_SOCKETD_CAP_PING | DS_SOCKETD_CAP_CAPABILITIES | DS_SOCKETD_CAP_INFO | - DS_SOCKETD_CAP_LIST_CONTAINERS | DS_SOCKETD_CAP_INSPECT_CONTAINER | - DS_SOCKETD_CAP_LIFECYCLE | DS_SOCKETD_CAP_LIST_IMAGES | - DS_SOCKETD_CAP_POLL_EVENTS); + DS_SOCKETD_CAP_LIST_CONTAINERS | + DS_SOCKETD_CAP_INSPECT_CONTAINER | DS_SOCKETD_CAP_LIFECYCLE | + DS_SOCKETD_CAP_LIST_IMAGES | DS_SOCKETD_CAP_POLL_EVENTS); socketd_send_response(conn, DS_SOCKETD_STATUS_OK, &caps_be, (uint32_t)sizeof(caps_be)); @@ -1004,10 +962,8 @@ static void socketd_handle_conn(int conn) { } case DS_SOCKETD_OP_INFO: { - if (payload_len > 0 && socketd_discard_payload(conn, payload_len) < 0) { - socketd_send_response(conn, DS_SOCKETD_STATUS_BAD_REQUEST, NULL, 0); + if (socketd_drain_payload(conn, payload_len) < 0) return; - } uint32_t installed_count = 0; if (socketd_count_installed_containers(&installed_count) < 0) { @@ -1199,10 +1155,8 @@ static void socketd_handle_conn(int conn) { } case DS_SOCKETD_OP_LIST_IMAGES: { - if (payload_len > 0 && socketd_discard_payload(conn, payload_len) < 0) { - socketd_send_response(conn, DS_SOCKETD_STATUS_BAD_REQUEST, NULL, 0); + if (socketd_drain_payload(conn, payload_len) < 0) return; - } size_t capacity = 16; struct ds_socketd_image_record *records = @@ -1276,18 +1230,19 @@ static void socketd_handle_conn(int conn) { return; } - case DS_SOCKETD_OP_INSPECT_CONTAINER: { struct ds_socketd_inspect_container_req inspect_req; memset(&inspect_req, 0, sizeof(inspect_req)); - if (socketd_read_payload(conn, &inspect_req, - (uint32_t)sizeof(inspect_req), + if (socketd_read_payload(conn, &inspect_req, (uint32_t)sizeof(inspect_req), payload_len) < 0) { socketd_send_response(conn, DS_SOCKETD_STATUS_BAD_REQUEST, NULL, 0); return; } + /* Force NUL-termination of the wire field before safe_strncpy()'s strlen + * to avoid an out-of-bounds read past the fixed-size target[]. */ + inspect_req.target[sizeof(inspect_req.target) - 1] = '\0'; char target[DS_SOCKETD_RECORD_NAME_MAX]; safe_strncpy(target, inspect_req.target, sizeof(target)); if (!target[0]) { @@ -1394,7 +1349,6 @@ static void socketd_handle_conn(int conn) { return; } - case DS_SOCKETD_OP_START_CONTAINER: case DS_SOCKETD_OP_STOP_CONTAINER: case DS_SOCKETD_OP_RESTART_CONTAINER: @@ -1410,12 +1364,10 @@ static void socketd_handle_conn(int conn) { static int socketd_bridge_loop(void) { struct sockaddr_un addr; - int server = socket(AF_UNIX, SOCK_STREAM, 0); + int server = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0); if (server < 0) return -1; - fcntl(server, F_SETFD, FD_CLOEXEC); - socklen_t addr_len = socketd_backend_addr(&addr); if (bind(server, (struct sockaddr *)&addr, addr_len) < 0) { close(server); @@ -1431,7 +1383,7 @@ static int socketd_bridge_loop(void) { DS_SOCKETD_BACKEND_SOCK_NAME); for (;;) { - int conn = accept(server, NULL, NULL); + int conn = accept4(server, NULL, NULL, SOCK_CLOEXEC); if (conn < 0) { if (errno == EINTR) continue; @@ -1439,7 +1391,7 @@ static int socketd_bridge_loop(void) { continue; } - fcntl(conn, F_SETFD, FD_CLOEXEC); + socketd_set_conn_timeouts(conn); socketd_handle_conn(conn); close(conn); } diff --git a/src/terminal.c b/src/terminal.c index 84ac19d6..d68b150f 100644 --- a/src/terminal.c +++ b/src/terminal.c @@ -20,10 +20,10 @@ int ds_openpty(int *master, int *slave, char *name) { if (m < 0) return -1; - /* unlock slave */ + /* best-effort: vendor 4.9 kernels may return EINVAL/EIO on newinstance + * devpts mounts; kernel auto-unlocks if needed */ int unlock = 0; - if (ioctl(m, TIOCSPTLCK, &unlock) < 0) - goto err; + (void)ioctl(m, TIOCSPTLCK, &unlock); /* try kernel 4.13+ path-free method first */ int s = ioctl(m, TIOCGPTPEER, O_RDWR | O_NOCTTY | O_CLOEXEC); @@ -125,11 +125,11 @@ int ds_setup_tios(int fd, struct termios *old) { #ifdef IEXTEN new_tios.c_lflag &= (tcflag_t)~IEXTEN; #endif - /* Keep host's ONLCR enabled to avoid staircase output if the container side - * stops sending \r (e.g. during shutdown or sudo execution). Duplicate \r - * are harmless. */ - // new_tios.c_oflag &= (tcflag_t)~ONLCR; - new_tios.c_oflag |= OPOST; + /* Disable output processing: OPOST with ONLCR active on the host PTY causes + * the line discipline to transform \n -> \r\n, corrupting TUI escape + * sequences from tmux, vim, etc. The container shell sets its own ONLCR on + * the inner slave, so \r\n translation happens exactly once, there. */ + new_tios.c_oflag &= (tcflag_t) ~(OPOST | ONLCR); new_tios.c_cc[VMIN] = 1; new_tios.c_cc[VTIME] = 0; diff --git a/src/utils.c b/src/utils.c index 1151d9c2..f0df8334 100644 --- a/src/utils.c +++ b/src/utils.c @@ -8,6 +8,10 @@ #include "droidspace.h" #include "socketd_protocol.h" #include +#include +#include +#include +#include #include #include @@ -30,9 +34,59 @@ void safe_strncpy(char *dst, const char *src, size_t size) { snprintf(dst, size, "%s", src); } +/* Append comma-separated interface names/wildcards from `val` to ifaces[], + * trimming surrounding whitespace and skipping empty or over-long (>= IFNAMSIZ) + * tokens. Exact duplicates (already present in ifaces[0..*count)) are + * silently skipped, mirroring the bind-mount/port-forward de-dup so re-parsing + * the same list (e.g. on container restart) is a no-op instead of growing the + * array. *count is advanced up to `max`; the return value is the number of + * tokens dropped because the array was already full (0 = all fit), letting + * the caller warn or fail as it likes. */ +int ds_parse_iface_csv(const char *val, char ifaces[][IFNAMSIZ], int *count, + int max) { + char copy[1024]; + safe_strncpy(copy, val, sizeof(copy)); + int dropped = 0; + char *sp; + for (char *tok = strtok_r(copy, ",", &sp); tok; + tok = strtok_r(NULL, ",", &sp)) { + while (*tok == ' ' || *tok == '\t') + tok++; + char *end = tok + strlen(tok) - 1; + while (end > tok && (*end == ' ' || *end == '\t')) + *end-- = '\0'; + if (!tok[0] || strlen(tok) >= IFNAMSIZ) + continue; + int dup = 0; + for (int i = 0; i < *count; i++) { + if (strcmp(ifaces[i], tok) == 0) { + dup = 1; + break; + } + } + if (dup) + continue; + if (*count >= max) { + dropped++; + continue; + } + safe_strncpy(ifaces[(*count)++], tok, IFNAMSIZ); + } + return dropped; +} + /* Mirrors ContainerManager.sanitizeContainerName() in the Android app. * Replaces spaces with dashes so directory names are consistent. */ void sanitize_container_name(const char *name, char *out, size_t size) { + /* Guard size == 0: 'size - 1' is size_t, so it would wrap to SIZE_MAX and + * turn the copy into an unbounded write. (safe_strncpy/read_file guard this + * the same way.) */ + if (!out || size == 0) + return; + if (!name) { + out[0] = '\0'; + return; + } size_t i; for (i = 0; i < size - 1 && name[i] != '\0'; i++) out[i] = (name[i] == ' ') ? '-' : name[i]; @@ -379,20 +433,32 @@ int write_file(const char *path, const char *content) { } int write_file_atomic(const char *path, const char *content) { + /* Randomized name + O_EXCL (via mkstemp) so a symlink pre-planted at a + * predictable ".tmp" cannot redirect this (often root-owned) write or + * let it truncate an arbitrary followed target. */ char tmp[PATH_MAX]; - snprintf(tmp, sizeof(tmp), "%s.tmp", path); + int n = snprintf(tmp, sizeof(tmp), "%s.XXXXXX", path); + if (n < 0 || n >= (int)sizeof(tmp)) + return -1; - if (write_file(tmp, content) < 0) + int fd = mkstemp(tmp); + if (fd < 0) return -1; + (void)fcntl(fd, F_SETFD, FD_CLOEXEC); - /* fsync before rename - ensures data hits disk on Android before reboot */ - int sync_fd = open(tmp, O_RDONLY | O_CLOEXEC); - if (sync_fd >= 0) { - fsync(sync_fd); - close(sync_fd); - } + size_t len = strlen(content); + int ok = (write_all(fd, content, len) == (ssize_t)len); + + /* mkstemp creates the file 0600; preserve the previous 0644 semantics. */ + if (ok && fchmod(fd, 0644) < 0) + ok = 0; + + /* fsync before rename - ensures data hits disk on Android before reboot. */ + if (ok && fsync(fd) < 0) + ok = 0; + close(fd); - if (rename(tmp, path) < 0) { + if (!ok || rename(tmp, path) < 0) { unlink(tmp); return -1; } @@ -471,27 +537,19 @@ int generate_uuid(char *buf, size_t size) { } } - /* Fallback path: seeded rand() */ - static int seeded = 0; - if (!seeded) { - struct timespec ts; - clock_gettime(CLOCK_REALTIME, &ts); - - unsigned int seed = - (unsigned int)(ts.tv_nsec ^ ts.tv_sec ^ getpid() ^ getppid()); + /* Fallback path: getrandom(2). Unlike /dev/urandom it needs no device node + * (e.g. when /dev is not yet populated). If it too is unavailable, fail + * closed rather than emit a predictable time+pid-seeded rand() identifier. */ + ssize_t g = getrandom(raw, sizeof(raw), 0); + if (g == (ssize_t)sizeof(raw)) { + for (int i = 0; i < (int)sizeof(raw); i++) + snprintf(buf + i * 2, 3, "%02x", raw[i]); - srand(seed); - seeded = 1; + buf[DS_UUID_LEN] = '\0'; + return 0; } - for (int i = 0; i < DS_UUID_LEN / 2; i++) - raw[i] = (unsigned char)(rand() & 0xFF); - - for (int i = 0; i < (int)sizeof(raw); i++) - snprintf(buf + i * 2, 3, "%02x", raw[i]); - - buf[DS_UUID_LEN] = '\0'; - return 0; + return -1; } /* --------------------------------------------------------------------------- @@ -682,18 +740,21 @@ int read_and_validate_pid(const char *pidfile, pid_t *pid_out) { * Mount sidecar files (.mount) * ---------------------------------------------------------------------------*/ -/* Internal helper to convert pidfile path to mount sidecar path: foo.pid -> - * foo.mount */ -static void pidfile_to_mountfile(const char *pidfile, char *buf, size_t size) { +/* Convert a pidfile path to a sibling sidecar path with the given extension: + * foo.pid -> foo (replacing a trailing .pid, else appending). */ +static void pidfile_to_sidecar(const char *pidfile, const char *ext, char *buf, + size_t size) { safe_strncpy(buf, pidfile, size); char *dot = strrchr(buf, '.'); - if (dot && strcmp(dot, DS_EXT_PID) == 0) { - /* If it ends in .pid, replace it */ - snprintf(dot, size - (size_t)(dot - buf), DS_EXT_MOUNT); - } else { - /* Otherwise just append */ - strncat(buf, DS_EXT_MOUNT, size - strlen(buf) - 1); - } + if (dot && strcmp(dot, DS_EXT_PID) == 0) + snprintf(dot, size - (size_t)(dot - buf), "%s", ext); + else + strncat(buf, ext, size - strlen(buf) - 1); +} + +/* foo.pid -> foo.mount */ +static void pidfile_to_mountfile(const char *pidfile, char *buf, size_t size) { + pidfile_to_sidecar(pidfile, DS_EXT_MOUNT, buf, size); } /* Save mount path alongside a pidfile: foo.pid -> foo.mount */ @@ -719,16 +780,9 @@ int remove_mount_path(const char *pidfile) { * Init-type sidecar files (.init) * ---------------------------------------------------------------------------*/ +/* foo.pid -> foo.init */ static void pidfile_to_initfile(const char *pidfile, char *buf, size_t size) { - safe_strncpy(buf, pidfile, size); - char *dot = strrchr(buf, '.'); - if (dot && strcmp(dot, DS_EXT_PID) == 0) { - /* If it ends in .pid, replace it */ - snprintf(dot, size - (size_t)(dot - buf), DS_EXT_INIT); - } else { - /* Otherwise just append */ - strncat(buf, DS_EXT_INIT, size - strlen(buf) - 1); - } + pidfile_to_sidecar(pidfile, DS_EXT_INIT, buf, size); } static const char *init_type_to_string(ds_init_type_t type) { @@ -1465,23 +1519,32 @@ int get_selinux_context(const char *path, char *buf, size_t size) { if (!path || !buf || size == 0) return -1; - /* Use lgetxattr to read the security.selinux attribute */ ssize_t len = lgetxattr(path, "security.selinux", buf, size - 1); - if (len < 0) { #ifdef SYS_lgetxattr + if (len < 0) len = syscall(SYS_lgetxattr, path, "security.selinux", buf, size - 1); #endif - } - - /* FIX: Check bounds before writing null terminator */ - if (len < 0 || len >= (ssize_t)(size - 1)) { + if (len < 0 || len >= (ssize_t)(size - 1)) return -1; - } buf[len] = '\0'; return 0; } +/* Transition self into u:r:droidspacesd:s0. + * Best-effort: if the write fails (policy not loaded, permissive host), + * the caller inherits whatever domain the root process already holds. + * No MLS suffix -- droidspacesd is declared permissive in sepolicy.rule. */ +void ds_selinux_enter_domain(void) { + const char *ctx = "u:r:droidspacesd:s0"; + int fd = open("/proc/self/attr/current", O_WRONLY | O_CLOEXEC); + if (fd < 0) + return; + if (write(fd, ctx, strlen(ctx) + 1) < 0) { /* ignore -- best effort */ + } + close(fd); +} + int ds_get_selinux_status(void) { char buf[16]; if (read_file("/sys/fs/selinux/enforce", buf, sizeof(buf)) < 0) @@ -2414,6 +2477,51 @@ void ds_daemon_remove_pid(const char *filename) { unlink(path); } +/* Resolve a cached pid or fall back to the pidfile. */ +pid_t ds_resolve_daemon_pid(pid_t cached, const char *pidfile) { + return cached > 0 ? cached : ds_daemon_read_pid(pidfile); +} + +/* + * ds_global_daemon_stop - unified teardown for X11/VirGL/PulseAudio daemons. + * + * check_fn : feature-needs checker (returns 1 if still needed) + * cached_pid : cfg->x11_pid / cfg->virgl_pid / cfg->pulse_pid + * pid_out : pointer to that field (zeroed on stop) + * pidfile : e.g. "x11.xpid" + * sock_path : socket to unlink after stop + * tag : log prefix e.g. "[X11]" + */ +void ds_global_daemon_stop(int (*check_fn)(void), pid_t cached_pid, + pid_t *pid_out, const char *pidfile, + const char *sock_path, const char *tag) { + if (!is_android()) + return; + + if (check_fn() == 1) { + ds_log("%s keeping global server running for other active containers", tag); + return; + } + + pid_t pid = ds_resolve_daemon_pid(cached_pid, pidfile); + if (pid > 0) { + ds_log("%s terminating (PID %d)...", tag, (int)pid); + kill(pid, SIGTERM); + for (int i = 0; i < 10 && kill(pid, 0) == 0; i++) + usleep(100000); + if (kill(pid, 0) == 0) { + kill(pid, SIGKILL); + waitpid(pid, NULL, 0); + } + if (pid_out) + *pid_out = 0; + } + + ds_daemon_remove_pid(pidfile); + if (sock_path) + unlink(sock_path); +} + /* Set oom_score_adj to -1000 (unkillable). Best-effort, no error return. */ void ds_oom_protect(void) { FILE *f = fopen("/proc/self/oom_score_adj", "w"); @@ -2423,6 +2531,89 @@ void ds_oom_protect(void) { } } +void ds_daemon_child_preamble(void) { + /* Ignore hangups, keyboard interrupts, and broken pipes so the helper + * survives terminal disconnects (SIGTERM remains the shutdown signal). */ + signal(SIGHUP, SIG_IGN); + signal(SIGINT, SIG_IGN); + signal(SIGQUIT, SIG_IGN); + signal(SIGPIPE, SIG_IGN); + /* Protect from the OOM killer while still root, before any privilege drop. */ + ds_oom_protect(); +} + +int ds_peer_in_pidns(pid_t peer_pid) { + if (peer_pid <= 0) + return 1; /* unknown -> fail open */ + + char path[64], self_ns[64], peer_ns[64]; + ssize_t sn = readlink("/proc/self/ns/pid", self_ns, sizeof(self_ns) - 1); + snprintf(path, sizeof(path), "/proc/%d/ns/pid", (int)peer_pid); + ssize_t pn = readlink(path, peer_ns, sizeof(peer_ns) - 1); + if (sn <= 0 || pn <= 0) + return 1; /* cannot determine -> fail open, never wrongly deny */ + + self_ns[sn] = '\0'; + peer_ns[pn] = '\0'; + return strcmp(self_ns, peer_ns) == 0; +} + +int ds_peer_authorized(int fd, const char *group_name) { +#ifdef SO_PEERCRED + struct ucred cred; + socklen_t clen = sizeof(cred); + memset(&cred, 0, sizeof(cred)); + if (getsockopt(fd, SOL_SOCKET, SO_PEERCRED, &cred, &clen) < 0 || + clen != sizeof(cred)) + return 0; + + /* Abstract sockets are network-namespace scoped, so reject peers outside our + * PID namespace (host-net container guard) before any uid/group check. */ + if (!ds_peer_in_pidns(cred.pid)) + return 0; + + if (cred.uid == 0) + return 1; + + if (!group_name) + return 0; + struct group *gr = getgrnam(group_name); + struct passwd *pw = getpwuid(cred.uid); + if (!gr || !pw) + return 0; + + /* getgrouplist() returns -1 when the user is in more than ngroups groups, + * setting ngroups to the required count while filling only the first slots. + * Reallocate and retry so a member with many supplementary groups is still + * authorized instead of being read out of bounds / wrongly denied. */ + int ngroups = 64; + gid_t stackgroups[64]; + gid_t *groups = stackgroups; + gid_t *heapgroups = NULL; + int authorized = 0; + if (getgrouplist(pw->pw_name, pw->pw_gid, groups, &ngroups) < 0) { + heapgroups = malloc(sizeof(gid_t) * (size_t)ngroups); + if (heapgroups && + getgrouplist(pw->pw_name, pw->pw_gid, heapgroups, &ngroups) >= 0) + groups = heapgroups; + else + ngroups = 0; /* fail closed */ + } + for (int i = 0; i < ngroups; i++) { + if (groups[i] == gr->gr_gid) { + authorized = 1; + break; + } + } + free(heapgroups); + return authorized; +#else + (void)fd; + (void)group_name; + return 0; +#endif +} + /* * Fork a log-relay child that reads from pipe_read_fd and writes timestamped * lines to / with [tag] prefix. The relay ignores all @@ -2569,12 +2760,22 @@ pid_t ds_spawn_daemon(ds_child_fn child_fn, void *user_data, */ int ds_bind_mount_socket(const char *src, const char *dst, uid_t uid, const char *label) { - int fd = open(dst, O_WRONLY | O_CREAT | O_CLOEXEC, 0666); + /* O_NOFOLLOW: never create through, chown/chmod, or bind-mount over a symlink + * planted at dst (which lives in the container's own /tmp). Operate on the + * opened inode via fchown/fchmod so there is no path-re-resolution race. */ + int fd = open(dst, O_WRONLY | O_CREAT | O_NOFOLLOW | O_CLOEXEC, 0666); if (fd >= 0) { + if (fchown(fd, uid, uid) < 0) { /* ignore */ + } + fchmod(fd, 0666); close(fd); - if (chown(dst, uid, uid) < 0) { /* ignore */ + } else { + struct stat st; + if (lstat(dst, &st) == 0 && S_ISLNK(st.st_mode)) { + ds_warn("[%s] refusing to bind-mount socket: %s is a symlink", label, + dst); + return -1; } - chmod(dst, 0666); } if (mount(src, dst, NULL, MS_BIND, NULL) != 0) { ds_warn("[%s] failed to bind-mount socket: %s", label, strerror(errno)); @@ -2582,3 +2783,23 @@ int ds_bind_mount_socket(const char *src, const char *dst, uid_t uid, } return 0; } + +int ds_bridge_termux_socket(const char *leaf, const char *dst, + const char *env_key, const char *env_val, + const char *label) { + char src[PATH_MAX]; + snprintf(src, sizeof(src), "%s/%s", DS_TERMUX_TMP_OLDROOT, leaf); + + struct stat st; + if (stat(src, &st) != 0) { + ds_warn("%s: socket not found at %s - skipping socket bridge", label, src); + return 0; + } + + if (ds_bind_mount_socket(src, dst, st.st_uid, label) < 0) + return 0; + + ds_log("%s: socket bind-mounted into container", label); + setenv(env_key, env_val, 1); + return 0; +} diff --git a/src/virtualize.c b/src/virtualize.c index 92faebe8..fba3d765 100644 --- a/src/virtualize.c +++ b/src/virtualize.c @@ -294,8 +294,15 @@ static char *gen_stat(struct ds_config *cfg, size_t *out_len) { /* Pass 2: emit with recomputed aggregate */ int agg_done = 0; while (fgets(line, sizeof(line), f)) { - if (off + 1024 >= cap) { - cap *= 2; + /* A single /proc/stat line (intr, softirq) can be up to sizeof(line)-1 + * bytes on hosts with many IRQ vectors. A fixed-headroom guard is not + * enough: size the growth to the actual line length (plus slack for the + * generated aggregate "cpu" line and the trailing NUL) before either the + * memcpy passthrough or the snprintf below can write. */ + size_t need = strlen(line) + 512; + if (off + need >= cap) { + while (off + need >= cap) + cap *= 2; char *nb = realloc(buf, cap); if (!nb) { free(buf); @@ -403,7 +410,15 @@ static char *gen_uptime(struct ds_config *cfg, size_t *out_len) { char *buf = malloc(64); if (!buf) return NULL; - *out_len = (size_t)snprintf(buf, 64, "%.2f %.2f\n", up, idle); + /* snprintf returns the length it *would* have written; clamp to the bytes + * actually in the buffer so a consumer's write_all() cannot over-read the + * heap if the formatted output ever exceeds 63 chars. */ + int n = snprintf(buf, 64, "%.2f %.2f\n", up, idle); + if (n < 0) { + free(buf); + return NULL; + } + *out_len = (n < 64) ? (size_t)n : 63; return buf; } @@ -468,9 +483,11 @@ static void bind_vfile(const char *vpath, const char *target, size_t len __attribute__((unused))) { if (write_file(vpath, content) < 0) return; - /* Ensure the target exists as a regular file for bind_mount */ + /* Ensure the target exists as a regular file for bind_mount. O_NOFOLLOW so a + * symlink planted at the final component is not followed (bind_mount also + * rejects a symlinked target as defense-in-depth). */ if (access(target, F_OK) != 0) { - int fd = open(target, O_WRONLY | O_CREAT | O_CLOEXEC, 0444); + int fd = open(target, O_WRONLY | O_CREAT | O_NOFOLLOW | O_CLOEXEC, 0444); if (fd >= 0) close(fd); } diff --git a/www/html/assets/droidspaces-mark.svg b/www/html/assets/droidspaces-mark.svg deleted file mode 100644 index dcaf41f7..00000000 --- a/www/html/assets/droidspaces-mark.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/www/html/index.html b/www/html/index.html deleted file mode 100644 index 0e4a3c34..00000000 --- a/www/html/index.html +++ /dev/null @@ -1,176 +0,0 @@ - - - - - - - Droidspaces WebUI - - - - - - - - -
-
-
-
-
checking local daemon
-

Droidspaces
WebUI.

-

- A local browser control surface for the Docker-compatible Droidspaces socket API. Served by socketd, controlled by relative API paths, no CORS detour. -

-
- - -
-
-
containers
-
running
-
stopped
-
-
- - -
-
- -
- -
-
-
-
- -

Containers

-

Start, stop, restart, and inspect Droidspaces containers through the local socketd API.

-
-
- -
-
- - -
- -
-
- -
- - - -
- -
-
- -

API health

-

These checks use only relative paths, so they exercise the same-origin WebUI model served directly by socketd.

-
-
-

Daemon

-
    -
  • Waiting for first check…
  • -
-
-
-

Manual endpoint

-
- - - -
No manual request yet.
-
-
-
-
-
- -
-

Droidspaces WebUI · served locally by droidspaces-socketd

-
- - - - diff --git a/www/html/webui.css b/www/html/webui.css deleted file mode 100644 index 8bc7e025..00000000 --- a/www/html/webui.css +++ /dev/null @@ -1,229 +0,0 @@ -*, *::before, *::after { - box-sizing: border-box; - margin: 0; - padding: 0; - -webkit-tap-highlight-color: transparent !important; - outline: none !important; -} - -a, button, input, select, textarea { - -webkit-tap-highlight-color: rgba(0,0,0,0) !important; - -webkit-tap-highlight-color: transparent !important; - outline: none !important; -} - -:root { - --bg: #0a0c0f; - --bg2: #0f1217; - --bg3: #151a22; - --border: #1e2530; - --border2: #2a3340; - --text: #e8edf5; - --muted: #6b7990; - --accent: #4f8ef7; - --accent2: #7eb3ff; - --android-color: rgb(52, 168, 83); - --green: #3ecf8e; - --red: #ff6b6b; - --yellow: #febc2e; - --mono: "Space Mono", "SFMono-Regular", Consolas, "Liberation Mono", monospace; - --sans: "DM Sans", Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; -} - -@media (prefers-color-scheme: light) { - :root:not([data-theme="dark"]) { - --bg: #ffffff; - --bg2: #f9fafb; - --bg3: #f3f4f6; - --border: #e5e7eb; - --border2: #d1d5db; - --text: #111827; - --muted: #4b5563; - --accent: #2563eb; - --accent2: #1d4ed8; - } -} - -:root[data-theme="light"] { - --bg: #ffffff; - --bg2: #f9fafb; - --bg3: #f3f4f6; - --border: #e5e7eb; - --border2: #d1d5db; - --text: #111827; - --muted: #4b5563; - --accent: #2563eb; - --accent2: #1d4ed8; -} - -html { scroll-behavior: smooth; } - -body { - min-height: 100vh; - background: var(--bg); - color: var(--text); - font-family: var(--sans); - font-size: 15px; - line-height: 1.6; - -webkit-font-smoothing: antialiased; -} - -body::before { - content: ""; - position: fixed; - inset: 0; - background: - radial-gradient(ellipse 55% 40% at 20% 10%, rgba(79,142,247,0.08), transparent 60%), - radial-gradient(ellipse 35% 50% at 85% 18%, rgba(62,207,142,0.05), transparent 55%); - pointer-events: none; - z-index: -1; -} - -button, input { font: inherit; } -code, pre { font-family: var(--mono); } - -nav { - position: sticky; - top: 0; - z-index: 100; - padding: 0 2rem; - height: 56px; - background: rgba(10,12,15,0.86); - backdrop-filter: blur(12px); - border-bottom: 1px solid var(--border); - transition: background 0.3s, border-color 0.3s; -} -:root[data-theme="light"] nav { background: rgba(255,255,255,0.86); } -@media (prefers-color-scheme: light) { - :root:not([data-theme="dark"]) nav { background: rgba(255,255,255,0.86); } -} - -.container { max-width: 1100px; margin: 0 auto; width: 100%; } -.nav-inner { height: 100%; display: flex; align-items: center; justify-content: space-between; gap: 1rem; } -.nav-logo { display: flex; align-items: center; gap: 0.6rem; text-decoration: none; min-width: 0; } -.logo-icon { width: 20px; height: 20px; color: var(--accent2); flex-shrink: 0; } -.logo-text { font-family: var(--mono); font-size: 1.1rem; font-weight: 700; color: var(--accent2); letter-spacing: -0.02em; transform: translateY(-1px); white-space: nowrap; } -.nav-subtitle { font-family: var(--mono); font-size: 0.72rem; color: var(--muted); border-left: 1px solid var(--border2); padding-left: 0.65rem; text-transform: uppercase; letter-spacing: 0.08em; } -.nav-actions { display: flex; align-items: center; gap: 0.45rem; } -.nav-link { color: var(--muted); text-decoration: none; border-radius: 6px; padding: 0.35rem 0.75rem; font-size: 0.85rem; font-weight: 500; transition: background 0.15s, color 0.15s; } -.nav-link:hover { color: var(--text); background: var(--bg3); } -.theme-toggle { background: none; border: none; color: var(--muted); cursor: pointer; padding: 0.25rem; display: flex; align-items: center; justify-content: center; transition: color 0.15s; } -.theme-toggle:hover { color: var(--text); } -.theme-toggle svg { width: 18px; height: 18px; } - -.hero { padding: 5.25rem 2rem 3.75rem; position: relative; overflow: hidden; } -.webui-hero-inner { max-width: 1100px; display: grid; grid-template-columns: minmax(0, 0.95fr) minmax(420px, 1.05fr); gap: 4rem; align-items: center; } -.hero-badge { display: inline-flex; align-items: center; gap: 0.5rem; background: rgba(79,142,247,0.08); border: 1px solid rgba(79,142,247,0.2); border-radius: 100px; padding: 0.3rem 0.75rem; font-family: var(--mono); font-size: 0.72rem; color: var(--accent2); margin-bottom: 1.5rem; letter-spacing: 0.04em; } -.hero-badge span { width: 6px; height: 6px; background: var(--yellow); border-radius: 50%; animation: pulse 2s infinite; } -.hero-badge.online span { background: var(--green); } -.hero-badge.offline span { background: var(--red); } -@keyframes pulse { 0%,100%{opacity:1}50%{opacity:0.35} } - -h1 { font-family: var(--mono); font-size: clamp(2.2rem, 4vw, 3.2rem); font-weight: 700; line-height: 1.1; letter-spacing: -0.04em; color: var(--text); margin-bottom: 1.25rem; } -h1 em { font-style: normal; color: var(--android-color); } -h2 { font-family: var(--mono); font-size: clamp(1.5rem, 2.5vw, 2rem); font-weight: 700; letter-spacing: -0.03em; color: var(--text); margin-bottom: 1rem; } -h3 { font-family: var(--mono); } -.hero-desc { color: var(--muted); font-size: 1rem; font-weight: 300; line-height: 1.7; margin-bottom: 2rem; max-width: 520px; } -.hero-cta { display: flex; gap: 0.75rem; flex-wrap: wrap; } -.btn { display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem; padding: 0.65rem 1.4rem; border-radius: 8px; font-family: var(--sans); font-size: 0.875rem; font-weight: 600; text-decoration: none; transition: all 0.15s; cursor: pointer; border: none; } -.btn-primary { background: var(--accent); color: #fff; } -.btn-primary:hover { background: #6ba4ff; transform: translateY(-1px); } -.btn-ghost { background: transparent; color: var(--muted); border: 1px solid var(--border2); } -.btn-ghost:hover { color: var(--text); border-color: var(--muted); } -.btn-danger { background: transparent; color: var(--red); border: 1px solid rgba(255,107,107,0.35); } -.btn-danger:hover { border-color: var(--red); background: rgba(255,107,107,0.06); } -.btn:disabled { opacity: 0.55; cursor: not-allowed; transform: none; } - -.hero-stats { display: flex; gap: 2rem; margin-top: 2.5rem; } -.stat-val { font-family: var(--mono); font-size: 1.4rem; font-weight: 700; color: var(--text); } -.stat-label { font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; margin-top: 0.1rem; } - -.terminal { background: var(--bg2); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; font-family: var(--mono); font-size: 0.78rem; } -.terminal-bar { background: var(--bg3); padding: 0.6rem 1rem; display: flex; align-items: center; gap: 0.5rem; border-bottom: 1px solid var(--border); } -.dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; } -.dot-r { background: #ff5f57; } -.dot-y { background: #febc2e; } -.dot-g { background: #28c840; } -.terminal-title { font-size: 0.7rem; color: var(--muted); margin: 0 auto; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.terminal-body { padding: 1.25rem; line-height: 1.8; overflow-x: auto; } -.t-dim { color: var(--muted); } -.t-green { color: var(--green); } -.t-blue { color: var(--accent2); } -.t-red { color: var(--red); } -.t-prompt { color: var(--accent); } -.t-white { color: var(--text); } - -.section { padding: 4rem 2rem; } -.section-label { font-family: var(--mono); font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.1em; color: var(--accent); margin-bottom: 0.75rem; } -.section-desc { color: var(--muted); max-width: 620px; line-height: 1.7; margin-bottom: 2rem; } -.section-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; margin-bottom: 1.75rem; } -.section-tools { display: flex; align-items: center; gap: 0.75rem; margin-top: 0.4rem; } -.toggle-line { color: var(--muted); font-size: 0.82rem; display: inline-flex; align-items: center; gap: 0.45rem; white-space: nowrap; } -.toggle-line input { accent-color: var(--accent); } -.divider { height: 1px; background: var(--border); max-width: 1100px; margin: 0 auto; } - -.alert { background: rgba(255,107,107,0.08); color: var(--red); border: 1px solid rgba(255,107,107,0.22); border-radius: 10px; padding: 0.85rem 1rem; margin-bottom: 1rem; font-size: 0.86rem; } -.alert.ok { background: rgba(62,207,142,0.08); color: var(--green); border-color: rgba(62,207,142,0.22); } - -.containers-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; } -.container-card { background: var(--bg2); border: 1px solid var(--border); border-radius: 12px; padding: 1.35rem; transition: transform 0.15s, border-color 0.15s, background 0.15s; } -.container-card:hover { background: var(--bg); border-color: var(--border2); transform: translateY(-1px); } -.card-top { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; margin-bottom: 1rem; } -.container-name { font-family: var(--mono); color: var(--text); font-weight: 700; letter-spacing: -0.01em; word-break: break-word; } -.container-id { color: var(--muted); font-family: var(--mono); font-size: 0.72rem; margin-top: 0.15rem; } -.status-pill { display: inline-flex; align-items: center; gap: 0.4rem; border: 1px solid var(--border2); border-radius: 999px; padding: 0.18rem 0.55rem; color: var(--muted); font-family: var(--mono); font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.05em; white-space: nowrap; } -.status-pill::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--muted); } -.status-pill.running { color: var(--green); border-color: rgba(62,207,142,0.28); } -.status-pill.running::before { background: var(--green); } -.status-pill.exited, .status-pill.stopped { color: var(--red); border-color: rgba(255,107,107,0.28); } -.status-pill.exited::before, .status-pill.stopped::before { background: var(--red); } -.card-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 0.65rem; margin: 1rem 0 1.15rem; } -.meta-item { background: var(--bg3); border: 1px solid var(--border); border-radius: 8px; padding: 0.65rem; min-width: 0; } -.meta-label { color: var(--muted); font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.07em; margin-bottom: 0.15rem; } -.meta-value { color: var(--text); font-family: var(--mono); font-size: 0.76rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.card-actions { display: flex; flex-wrap: wrap; gap: 0.55rem; } -.card-actions .btn { padding: 0.5rem 0.9rem; font-size: 0.8rem; } - -.empty-state { background: var(--bg2); border: 1px solid var(--border); border-radius: 12px; padding: 2rem; text-align: center; color: var(--muted); } -.empty-state h3 { color: var(--text); margin: 0.5rem 0; } -.empty-icon { color: var(--accent2); font-size: 2rem; font-family: var(--mono); } - -.req-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; } -.req-card { background: var(--bg2); border: 1px solid var(--border); border-radius: 10px; padding: 1.5rem; } -.req-card h3 { font-size: 0.78rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--accent2); margin-bottom: 0.75rem; } -.req-card ul { list-style: none; } -.req-card li { font-size: 0.83rem; color: var(--muted); padding: 0.3rem 0; padding-left: 1rem; position: relative; } -.req-card li::before { content: "-"; position: absolute; left: 0; color: var(--border2); } - -.details-grid { display: grid; grid-template-columns: 0.75fr 1.25fr; gap: 1rem; align-items: start; } -.detail-list { display: grid; gap: 0.65rem; } -.detail-list div { display: grid; grid-template-columns: 9rem 1fr; gap: 0.75rem; border-bottom: 1px solid var(--border); padding-bottom: 0.55rem; } -.detail-list div:last-child { border-bottom: none; padding-bottom: 0; } -.detail-list dt { color: var(--muted); font-size: 0.76rem; text-transform: uppercase; letter-spacing: 0.06em; } -.detail-list dd { color: var(--text); font-family: var(--mono); font-size: 0.78rem; word-break: break-word; } -.json-view { color: var(--muted); padding: 1rem; max-height: 420px; overflow: auto; font-size: 0.73rem; line-height: 1.55; } - -.manual-form { display: flex; gap: 0.5rem; margin-bottom: 0.9rem; } -.manual-form input { flex: 1; min-width: 0; background: var(--bg3); color: var(--text); border: 1px solid var(--border2); border-radius: 8px; padding: 0.55rem 0.7rem; font-family: var(--mono); font-size: 0.8rem; } -.manual-output { background: var(--bg3); border: 1px solid var(--border); border-radius: 8px; padding: 0.8rem; color: var(--muted); min-height: 7rem; max-height: 17rem; overflow: auto; font-size: 0.73rem; } - -footer { border-top: 1px solid var(--border); padding: 2rem; text-align: center; } -footer p { font-size: 0.8rem; color: var(--muted); } - -@media (max-width: 860px) { - .webui-hero-inner { grid-template-columns: 1fr; gap: 3rem; } - .containers-grid, .req-grid, .details-grid { grid-template-columns: 1fr; } -} - -@media (max-width: 640px) { - nav { padding: 0 1rem; } - .nav-link-wide, .nav-subtitle { display: none; } - .hero { padding: 3.25rem 1rem 2.5rem; } - .section { padding: 3rem 1rem; } - .webui-terminal { display: none; } - .hero-stats { gap: 1.35rem; } - .section-head { flex-direction: column; } - .card-meta { grid-template-columns: 1fr; } - .manual-form { flex-direction: column; } - .detail-list div { grid-template-columns: 1fr; gap: 0.1rem; } -} diff --git a/www/html/webui.js b/www/html/webui.js deleted file mode 100644 index 7ffb1c79..00000000 --- a/www/html/webui.js +++ /dev/null @@ -1,420 +0,0 @@ -(function () { - "use strict"; - - const state = { - containers: [], - version: null, - info: null, - busyRefs: new Set(), - lastError: "" - }; - - const $ = (id) => document.getElementById(id); - - const els = { - badge: $("daemon-badge"), - badgeText: $("daemon-badge-text"), - consolePing: $("console-ping"), - consoleVersion: $("console-version"), - consoleContainers: $("console-containers"), - statTotal: $("stat-total"), - statRunning: $("stat-running"), - statStopped: $("stat-stopped"), - refreshButton: $("refresh-button"), - recheckButton: $("recheck-button"), - showAll: $("show-all"), - grid: $("containers-grid"), - empty: $("empty-state"), - alert: $("global-alert"), - details: $("details"), - detailsTitle: $("details-title"), - detailsSummary: $("details-summary"), - detailsJson: $("details-json"), - diagnostics: $("daemon-diagnostics"), - manualForm: $("manual-form"), - manualPath: $("manual-path"), - manualOutput: $("manual-output"), - themeToggle: $("theme-toggle") - }; - - function setText(el, value) { - if (el) el.textContent = value; - } - - function escapeHtml(value) { - return String(value) - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); - } - - function shortId(value) { - const text = String(value || ""); - return text.length > 12 ? text.slice(0, 12) : text || "—"; - } - - function normalizeName(container) { - if (!container) return "unknown"; - if (Array.isArray(container.Names) && container.Names.length > 0) { - return String(container.Names[0]).replace(/^\/+/, "") || "unknown"; - } - if (typeof container.Names === "string") return container.Names.replace(/^\/+/, "") || "unknown"; - if (container.Name) return String(container.Name).replace(/^\/+/, "") || "unknown"; - if (container.Id) return shortId(container.Id); - return "unknown"; - } - - function containerRef(container) { - return normalizeName(container) || container.Id || ""; - } - - function normalizeState(container) { - const raw = String(container.State || container.Status || "unknown").toLowerCase(); - if (raw.includes("running") || raw === "running") return "running"; - if (raw.includes("exit") || raw.includes("stop") || raw === "created") return "stopped"; - return raw.replace(/[^a-z0-9_-]+/g, "-") || "unknown"; - } - - function normalizeImage(container) { - return container.Image || container.ImageID || container.ImageRef || "—"; - } - - function normalizeStatus(container) { - return container.Status || container.State || "unknown"; - } - - function summarizePorts(container) { - const ports = Array.isArray(container.Ports) ? container.Ports : []; - if (!ports.length) return "—"; - return ports.slice(0, 3).map((port) => { - const type = port.Type || port.Proto || "tcp"; - const privatePort = port.PrivatePort || port.ContainerPort || "?"; - const publicPort = port.PublicPort || port.HostPort; - if (publicPort) return `${publicPort}->${privatePort}/${type}`; - return `${privatePort}/${type}`; - }).join(", ") + (ports.length > 3 ? " …" : ""); - } - - function setDaemonStatus(kind, message) { - els.badge.classList.remove("online", "offline"); - if (kind) els.badge.classList.add(kind); - setText(els.badgeText, message); - } - - function setAlert(message, ok) { - if (!message) { - els.alert.hidden = true; - els.alert.textContent = ""; - els.alert.classList.remove("ok"); - return; - } - els.alert.hidden = false; - els.alert.textContent = message; - els.alert.classList.toggle("ok", Boolean(ok)); - } - - async function apiFetch(path, options) { - const response = await fetch(path, Object.assign({ cache: "no-store" }, options || {})); - const contentType = response.headers.get("content-type") || ""; - let body; - if (contentType.includes("application/json")) { - body = await response.json().catch(() => null); - } else { - body = await response.text().catch(() => ""); - } - - if (!response.ok && response.status !== 304) { - const message = body && typeof body === "object" && body.message - ? body.message - : `${response.status} ${response.statusText}`; - const err = new Error(message); - err.status = response.status; - err.body = body; - throw err; - } - - return { response, body }; - } - - async function checkDaemon() { - setDaemonStatus("", "checking local daemon"); - setText(els.consolePing, "pending"); - setText(els.consoleVersion, "pending"); - - try { - const ping = await apiFetch("/_ping"); - setText(els.consolePing, String(ping.body || "OK")); - - const version = await apiFetch("/version"); - state.version = version.body; - const versionText = version.body && version.body.Version - ? `${version.body.Version} · API ${version.body.ApiVersion || "?"}` - : "version response received"; - setText(els.consoleVersion, versionText); - setDaemonStatus("online", "local daemon online"); - return true; - } catch (err) { - state.lastError = err.message; - setText(els.consolePing, `failed: ${err.message}`); - setText(els.consoleVersion, "skipped"); - setDaemonStatus("offline", "local daemon unavailable"); - return false; - } - } - - async function loadInfo() { - try { - const info = await apiFetch("/info"); - state.info = info.body; - } catch (err) { - state.info = null; - } - } - - async function loadContainers() { - const all = els.showAll.checked ? "1" : "0"; - const result = await apiFetch(`/containers/json?all=${all}`); - state.containers = Array.isArray(result.body) ? result.body : []; - setText(els.consoleContainers, `${state.containers.length} container(s)`); - } - - function renderStats() { - const total = state.containers.length; - const running = state.containers.filter((c) => normalizeState(c) === "running").length; - const stopped = total - running; - setText(els.statTotal, String(total)); - setText(els.statRunning, String(running)); - setText(els.statStopped, String(stopped)); - } - - function actionButton(action, label, container, cssClass) { - const ref = containerRef(container); - const disabled = state.busyRefs.has(`${ref}:${action}`) ? " disabled" : ""; - const cls = cssClass ? ` ${cssClass}` : ""; - return ``; - } - - function renderContainerCard(container) { - const name = normalizeName(container); - const stateClass = normalizeState(container); - const running = stateClass === "running"; - const id = shortId(container.Id || container.ID || container.Name || name); - const image = normalizeImage(container); - const status = normalizeStatus(container); - const ports = summarizePorts(container); - - return ` -
-
-
-
${escapeHtml(name)}
-
${escapeHtml(id)}
-
-
${escapeHtml(stateClass)}
-
-
-
Image
${escapeHtml(image)}
-
Status
${escapeHtml(status)}
-
Ports
${escapeHtml(ports)}
-
Created
${escapeHtml(formatCreated(container.Created))}
-
-
- ${running ? actionButton("stop", "Stop", container, "btn-danger") : actionButton("start", "Start", container, "btn-primary")} - ${actionButton("restart", "Restart", container, "btn-ghost")} - ${actionButton("inspect", "Inspect", container, "btn-ghost")} -
-
`; - } - - function formatCreated(value) { - if (!value) return "—"; - const numeric = Number(value); - if (!Number.isFinite(numeric) || numeric <= 0) return "—"; - const date = new Date(numeric * 1000); - if (Number.isNaN(date.getTime())) return "—"; - return date.toLocaleString(); - } - - function renderContainers() { - els.grid.innerHTML = state.containers.map(renderContainerCard).join(""); - els.empty.hidden = state.containers.length !== 0; - renderStats(); - } - - function renderDiagnostics(online) { - const version = state.version || {}; - const info = state.info || {}; - const items = []; - items.push(`API status: ${online ? "online" : "offline"}`); - if (version.Version) items.push(`Engine version: ${version.Version}`); - if (version.ApiVersion) items.push(`API version: ${version.ApiVersion}`); - if (version.Arch) items.push(`Architecture: ${version.Arch}`); - if (info.Containers !== undefined) items.push(`Info containers: ${info.Containers}`); - if (info.ContainersRunning !== undefined) items.push(`Info running: ${info.ContainersRunning}`); - if (!items.length) items.push("No diagnostic information available."); - els.diagnostics.innerHTML = items.map((item) => `
  • ${escapeHtml(item)}
  • `).join(""); - } - - async function refreshDashboard() { - setAlert(""); - els.refreshButton.disabled = true; - try { - const online = await checkDaemon(); - if (online) { - await loadInfo(); - await loadContainers(); - renderContainers(); - renderDiagnostics(true); - } else { - renderDiagnostics(false); - } - } catch (err) { - setText(els.consoleContainers, `failed: ${err.message}`); - setAlert(`Dashboard refresh failed: ${err.message}`); - renderDiagnostics(false); - } finally { - els.refreshButton.disabled = false; - } - } - - async function lifecycleAction(ref, action) { - const key = `${ref}:${action}`; - state.busyRefs.add(key); - renderContainers(); - try { - await apiFetch(`/containers/${encodeURIComponent(ref)}/${action}`, { method: "POST" }); - setAlert(`${action} sent to ${ref}`, true); - await loadContainers(); - renderContainers(); - } catch (err) { - if (err.status === 304) { - setAlert(`${ref} already in requested state`, true); - await loadContainers(); - renderContainers(); - } else { - setAlert(`${action} failed for ${ref}: ${err.message}`); - } - } finally { - state.busyRefs.delete(key); - renderContainers(); - } - } - - function detailValue(value) { - if (value === undefined || value === null || value === "") return "—"; - if (typeof value === "boolean") return value ? "true" : "false"; - return String(value); - } - - function summaryRow(label, value) { - return `
    ${escapeHtml(label)}
    ${escapeHtml(detailValue(value))}
    `; - } - - async function inspectContainer(ref) { - setAlert(""); - try { - const result = await apiFetch(`/containers/${encodeURIComponent(ref)}/json`); - const data = result.body || {}; - els.details.hidden = false; - setText(els.detailsTitle, `${ref} details`); - const stateData = data.State || {}; - const config = data.Config || {}; - const network = data.NetworkSettings || {}; - els.detailsSummary.innerHTML = [ - summaryRow("Name", (data.Name || ref).replace(/^\/+/, "")), - summaryRow("ID", shortId(data.Id || data.ID || ref)), - summaryRow("Image", config.Image || data.Image || data.ImageName), - summaryRow("Running", stateData.Running), - summaryRow("PID", stateData.Pid || data.Pid), - summaryRow("Status", stateData.Status || data.Status), - summaryRow("Hostname", config.Hostname || data.Hostname), - summaryRow("IP address", network.IPAddress || data.IPAddress) - ].join(""); - setText(els.detailsJson, JSON.stringify(data, null, 2)); - location.hash = "details"; - } catch (err) { - setAlert(`inspect failed for ${ref}: ${err.message}`); - } - } - - async function manualGet(path) { - let requestPath = path.trim(); - if (!requestPath.startsWith("/")) requestPath = `/${requestPath}`; - els.manualOutput.textContent = "Loading…"; - try { - const result = await apiFetch(requestPath); - if (typeof result.body === "string") { - els.manualOutput.textContent = result.body || ""; - } else { - els.manualOutput.textContent = JSON.stringify(result.body, null, 2); - } - } catch (err) { - els.manualOutput.textContent = `ERROR: ${err.message}`; - } - } - - function setupThemeToggle() { - const themeToggle = els.themeToggle; - const sunIcon = themeToggle.querySelector(".sun-icon"); - const moonIcon = themeToggle.querySelector(".moon-icon"); - const mediaQuery = window.matchMedia("(prefers-color-scheme: light)"); - - function updateIcons(theme) { - if (theme === "light") { - sunIcon.style.display = "none"; - moonIcon.style.display = "block"; - } else { - sunIcon.style.display = "block"; - moonIcon.style.display = "none"; - } - } - - function getEffectiveTheme() { - const saved = localStorage.getItem("theme"); - if (saved) return saved; - return mediaQuery.matches ? "light" : "dark"; - } - - function applyTheme(theme) { - document.documentElement.setAttribute("data-theme", theme); - updateIcons(theme); - } - - applyTheme(getEffectiveTheme()); - mediaQuery.addEventListener("change", (event) => { - if (!localStorage.getItem("theme")) applyTheme(event.matches ? "light" : "dark"); - }); - themeToggle.addEventListener("click", () => { - const next = getEffectiveTheme() === "dark" ? "light" : "dark"; - localStorage.setItem("theme", next); - applyTheme(next); - }); - } - - function bindEvents() { - els.refreshButton.addEventListener("click", refreshDashboard); - els.recheckButton.addEventListener("click", checkDaemon); - els.showAll.addEventListener("change", refreshDashboard); - els.grid.addEventListener("click", (event) => { - const button = event.target.closest("button[data-action]"); - if (!button) return; - const action = button.dataset.action; - const ref = button.dataset.ref; - if (!ref) return; - if (action === "inspect") inspectContainer(ref); - else lifecycleAction(ref, action); - }); - els.manualForm.addEventListener("submit", (event) => { - event.preventDefault(); - manualGet(els.manualPath.value); - }); - } - - document.addEventListener("DOMContentLoaded", () => { - setupThemeToggle(); - bindEvents(); - refreshDashboard(); - }); -})();