diff --git a/Android/app/src/main/assets/export_container.sh b/Android/app/src/main/assets/export_container.sh index b9cae376..3f7b79f3 100755 --- a/Android/app/src/main/assets/export_container.sh +++ b/Android/app/src/main/assets/export_container.sh @@ -20,6 +20,7 @@ error() { echo "[ERROR] $1"; } # --- Cleanup state --- TEMP_MOUNT="" +TEMP_CONFIG_DIR="" CLEANUP_DONE=0 cleanup() { @@ -41,6 +42,9 @@ cleanup() { if [ -n "$TEMP_MOUNT" ] && [ -d "$TEMP_MOUNT" ]; then rmdir "$TEMP_MOUNT" 2>/dev/null || true fi + if [ -n "$TEMP_CONFIG_DIR" ]; then + "$BUSYBOX" rm -rf "$TEMP_CONFIG_DIR" + fi } # Always cleanup on exit (covers both success and failure) @@ -93,8 +97,10 @@ if [ ! -f "$CONFIG_FILE" ]; then exit 1 fi -# Parse rootfs_path from config -ROOTFS_PATH=$(grep "rootfs_path=" "$CONFIG_FILE" | cut -d'=' -f2) +# Snapshot the current host config, including edits made since this rootfs was imported. +TEMP_CONFIG_DIR=$("$BUSYBOX" mktemp -d "${CONTAINER_DIR}/.export-config.XXXXXX") +"$BUSYBOX" cp "$CONFIG_FILE" "$TEMP_CONFIG_DIR/container.config" +ROOTFS_PATH=$("$BUSYBOX" sed -n 's/^rootfs_path=//p' "$TEMP_CONFIG_DIR/container.config") if [ -z "$ROOTFS_PATH" ]; then error "Could not extract rootfs_path from $CONFIG_FILE" @@ -179,7 +185,18 @@ fi # --- Create archive --- log "Creating archive... (this may take a while)" -if ! "$BUSYBOX" tar -czf "$OUTPUT_PATH" -C "$TAR_ROOT" . 2>&1; then +# BusyBox applies only the last -C, so prepend a small config tar without its end +# blocks. Both rootfs modes stay read-only, and no uncompressed rootfs copy is needed. +"$BUSYBOX" tar -cf "$TEMP_CONFIG_DIR/config.tar" -C "$TEMP_CONFIG_DIR" container.config +CONFIG_SIZE=$("$BUSYBOX" stat -c %s "$TEMP_CONFIG_DIR/container.config") +CONFIG_BLOCKS=$((1 + (CONFIG_SIZE + 511) / 512)) +if ! ( + set -o pipefail + { + "$BUSYBOX" dd if="$TEMP_CONFIG_DIR/config.tar" bs=512 count="$CONFIG_BLOCKS" 2>/dev/null && + "$BUSYBOX" tar -cf - --exclude='./container.config' -C "$TAR_ROOT" . + } | "$BUSYBOX" gzip > "$OUTPUT_PATH" +); then error "tar failed. Removing incomplete archive." rm -f "$OUTPUT_PATH" 2>/dev/null || true exit 1 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 index 925b553e..13cf4386 100644 --- 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 @@ -632,7 +632,7 @@ fun ContainerConfigForm( val isSeccompDisabled = state.privileged.contains("noseccomp") || state.privileged.contains("full") val usernsSupported = ok("user_ns") - // One pass: drop what the kernel cannot do, then the seccomp rule, then a + // One pass: apply capability adjustments, then the seccomp rule, then a // single state write so the Edit screen sees one change, not several. LaunchedEffect(caps, isSeccompDisabled, state.netMode) { var s = caps?.coerce(state) ?: state 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 d7df6927..310ef2d4 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 @@ -51,6 +51,11 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.ui.Modifier import com.droidspaces.app.ui.util.LoadingIndicator import com.droidspaces.app.ui.util.LoadingSize +import com.droidspaces.app.ui.util.FullScreenLoading +import com.droidspaces.app.ui.util.ErrorLogsDialog +import com.droidspaces.app.ui.component.HardwareAccessDialog +import com.droidspaces.app.ui.component.PrivilegedModeDialog +import com.droidspaces.app.R import androidx.lifecycle.viewmodel.compose.viewModel import com.droidspaces.app.ui.viewmodel.AppStateViewModel import androidx.activity.ComponentActivity @@ -352,21 +357,40 @@ fun DroidspacesNavigation( val tarballUri = Uri.parse(tarballUriString) LaunchedEffect(tarballUri) { - viewModel.setTarball(tarballUri) + viewModel.setTarball(context, tarballUri) } - ContainerNameScreen( - initialName = viewModel.containerName, - initialHostname = viewModel.hostname, - existingContainerNames = sharedContainerViewModel.containerList.map { it.name }, - onNext = { name, hostname -> - viewModel.setName(name, hostname) - navController.navigate(Screen.ContainerConfig.route) - }, - onClose = { - navController.popBackStack() + if (viewModel.preparingTarball) { + FullScreenLoading(context.getString(R.string.rootfs_config_loading)) + } else if (viewModel.preparationError != null) { + ErrorLogsDialog(listOf(viewModel.preparationError.orEmpty())) { navController.popBackStack() } + } else { + if (viewModel.recommendedHwAccess) { + HardwareAccessDialog( + onConfirm = { viewModel.confirmRecommendedHwAccess(true) }, + onDismiss = { viewModel.confirmRecommendedHwAccess(false) } + ) + } else if (viewModel.recommendedPrivileged.isNotEmpty()) { + PrivilegedModeDialog( + initialPrivileged = viewModel.recommendedPrivileged, + onConfirm = viewModel::confirmRecommendedPrivileged, + onDismiss = { viewModel.confirmRecommendedPrivileged("") } + ) } - ) + ContainerNameScreen( + initialName = viewModel.containerName, + initialHostname = viewModel.hostname, + recommendationNotice = viewModel.recommendationNotice, + existingContainerNames = sharedContainerViewModel.containerList.map { it.name }, + onNext = { name, hostname -> + viewModel.setName(name, hostname) + navController.navigate(Screen.ContainerConfig.route) + }, + onClose = { + navController.popBackStack() + } + ) + } } composable( @@ -470,6 +494,7 @@ fun DroidspacesNavigation( InstallationProgressScreen( tarballUri = tarballUri, config = config, + preparedTarball = viewModel.preparedTarball, onSuccess = { viewModel.reset() // Trigger container list refresh before navigating back 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 17104440..4a2d5e4b 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 @@ -38,6 +38,7 @@ fun ContainerNameScreen( initialName: String = "", initialHostname: String = "", existingContainerNames: List = emptyList(), + recommendationNotice: String? = null, onNext: (String, String) -> Unit, onClose: () -> Unit ) { @@ -148,6 +149,14 @@ fun ContainerNameScreen( fontWeight = FontWeight.Bold ) + recommendationNotice?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } + // Container Name OutlinedTextField( value = containerName, @@ -202,4 +211,3 @@ fun ContainerNameScreen( } } } - diff --git a/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationProgressScreen.kt b/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationProgressScreen.kt index 6bcea82a..eba21ca4 100644 --- a/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationProgressScreen.kt +++ b/Android/app/src/main/java/com/droidspaces/app/ui/screen/InstallationProgressScreen.kt @@ -27,6 +27,7 @@ import com.droidspaces.app.util.ContainerLogger import com.droidspaces.app.util.FilePickerUtils import com.droidspaces.app.util.ViewModelLogger import kotlinx.coroutines.launch +import java.io.File enum class InstallationState { INSTALLING, @@ -39,6 +40,7 @@ enum class InstallationState { fun InstallationProgressScreen( tarballUri: Uri, config: ContainerInfo, + preparedTarball: File? = null, onSuccess: () -> Unit, onError: () -> Unit ) { @@ -80,7 +82,8 @@ fun InstallationProgressScreen( context = context, tarballUri = tarballUri, config = config, - logger = logger + logger = logger, + preparedTarball = preparedTarball ) installationState = if (result.isSuccess) { @@ -206,4 +209,3 @@ fun InstallationProgressScreen( } } } - 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 7b6a0fc8..f56932aa 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 @@ -1,11 +1,15 @@ package com.droidspaces.app.ui.viewmodel +import android.content.Context import android.net.Uri import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.droidspaces.app.R import com.droidspaces.app.util.ContainerInfo +import com.droidspaces.app.util.ContainerInstaller import com.droidspaces.app.util.ContainerManager import com.droidspaces.app.util.ContainerStatus import com.droidspaces.app.util.Constants @@ -14,11 +18,31 @@ import com.droidspaces.app.util.ContainerConfigState import com.droidspaces.app.util.ValidationUtils import com.droidspaces.app.util.withConfig import com.droidspaces.app.util.HostCapabilities +import com.droidspaces.app.util.RootfsConfig +import com.droidspaces.app.util.toConfigState +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File class ContainerInstallationViewModel : ViewModel() { var tarballUri: Uri? by mutableStateOf(null) private set + var preparedTarball: File? = null + private set + var preparingTarball by mutableStateOf(true) + private set + var preparationError: String? by mutableStateOf(null) + private set + var recommendationNotice: String? by mutableStateOf(null) + private set + var recommendedHwAccess by mutableStateOf(false) + private set + var recommendedPrivileged by mutableStateOf("") + private set + var containerName: String by mutableStateOf("") private set @@ -42,8 +66,56 @@ class ContainerInstallationViewModel : ViewModel() { var configState: ContainerConfigState by mutableStateOf(ContainerConfigState()) private set - fun setTarball(uri: Uri) { + fun setTarball(context: Context, uri: Uri) { + // Returning to the first wizard page must not reapply defaults over user edits. + if (tarballUri == uri) return tarballUri = uri + val appContext = context.applicationContext + viewModelScope.launch { + try { + val extension = ContainerInstaller.getTarballExtension(appContext, uri) + val archive = File.createTempFile("rootfs_", ".tar$extension", appContext.cacheDir) + preparedTarball = archive + ContainerInstaller.copyTarball(appContext, uri, archive) + try { + val recommended = withContext(Dispatchers.IO) { RootfsConfig.read(archive) } + if (recommended != null) { + containerName = ValidationUtils.normalizeContainerName(recommended.name) + hostname = recommended.hostname + useSparseImage = recommended.useSparseImage + sparseImageSizeGB = recommended.sparseImageSizeGB ?: 8 + val state = recommended.toConfigState().let { HostCapabilities.state.value?.coerce(it) ?: it } + recommendedHwAccess = state.enableHwAccess + recommendedPrivileged = state.privileged + // These two settings keep the same confirmation gates as manual setup. + configState = state.copy(enableHwAccess = false, privileged = "") + recommendationNotice = appContext.getString(R.string.rootfs_config_loaded) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + recommendationNotice = appContext.getString(R.string.rootfs_config_invalid, e.message.orEmpty()) + } + } catch (e: CancellationException) { + preparedTarball?.delete() + throw e + } catch (e: Exception) { + preparationError = e.message ?: appContext.getString(R.string.operation_failed_title) + preparedTarball?.delete() + } finally { + preparingTarball = false + } + } + } + + fun confirmRecommendedHwAccess(enabled: Boolean) { + configState = configState.copy(enableHwAccess = enabled) + recommendedHwAccess = false + } + + fun confirmRecommendedPrivileged(tags: String) { + configState = configState.copy(privileged = tags) + recommendedPrivileged = "" } fun setName(name: String, hostname: String) { @@ -80,7 +152,14 @@ class ContainerInstallationViewModel : ViewModel() { } fun reset() { + preparedTarball?.delete() + preparedTarball = null tarballUri = null + preparingTarball = true + preparationError = null + recommendationNotice = null + recommendedHwAccess = false + recommendedPrivileged = "" containerName = "" hostname = "" useSparseImage = true @@ -88,5 +167,9 @@ class ContainerInstallationViewModel : ViewModel() { storageDir = null configState = ContainerConfigState() } -} + override fun onCleared() { + preparedTarball?.delete() + super.onCleared() + } +} 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 eb621cfb..aa0d4bf0 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 @@ -5,6 +5,8 @@ import android.net.Uri import com.topjohnwu.superuser.Shell import com.topjohnwu.superuser.io.SuFile import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.withContext import java.io.File import java.io.FileOutputStream @@ -26,7 +28,8 @@ object ContainerInstaller { context: Context, tarballUri: Uri, config: ContainerInfo, - logger: ContainerLogger + logger: ContainerLogger, + preparedTarball: File? = null ): Result = withContext(Dispatchers.IO) { // Use sanitized name for directory (spaces -> dashes) val sanitizedName = ContainerManager.sanitizeContainerName(config.name) @@ -39,6 +42,7 @@ object ContainerInstaller { val isExternal = rootfsParent != containerPath val configFilePath = "$containerPath/${Constants.CONTAINER_CONFIG_FILE}" var createdPaths = mutableListOf() + var tempTarball = preparedTarball try { // Reject control chars in single-line config values. @@ -106,27 +110,26 @@ object ContainerInstaller { createdPaths.add(rootfsParent) } - // Step 4: Copy tarball to temp location - logger.i("Copying tarball to temporary location...") - val tarballExtension = getTarballExtension(context, tarballUri) - val tempTarball = File("${context.cacheDir}/container_${sanitizedName}.tar$tarballExtension") - context.contentResolver.openInputStream(tarballUri)?.use { inputStream -> - FileOutputStream(tempTarball).use { outputStream -> - inputStream.copyTo(outputStream) - } - } ?: throw Exception("Failed to open tarball input stream") + // Reuse the wizard's snapshot so the settings and payload come from the same archive. + logger.i("Preparing installation archive...") + val archive = tempTarball ?: File.createTempFile( + "rootfs_", ".tar${getTarballExtension(context, tarballUri)}", context.cacheDir + ).also { + tempTarball = it + copyTarball(context, tarballUri, it) + } - logger.i("Tarball copied: ${tempTarball.absolutePath}") + logger.i("Tarball ready: ${archive.absolutePath}") // Step 4.5: Verify the tarball is actually a Linux rootfs before we // extract anything, so users can't install arbitrary archives. - validateRootfsTarball(context, tempTarball, logger) + validateRootfsTarball(context, archive, logger) // Step 5: Extract tarball (either to directory or sparse image) if (config.useSparseImage) { SparseImageInstaller.extract( context = context, - tarball = tempTarball, + tarball = archive, imgPath = rootfsPath, mountPoint = "${containerPath}/rootfs", sizeGB = config.sparseImageSizeGB ?: 8, @@ -142,11 +145,11 @@ object ContainerInstaller { } logger.i("Extracting tarball to $rootfsPath...") - val isXz = tempTarball.name.lowercase().endsWith(".xz") + val isXz = archive.name.lowercase().endsWith(".xz") val extractCmd = if (isXz) { - "cd ${quote(rootfsPath)} && $BUSYBOX_PATH xzcat ${quote(tempTarball.absolutePath)} | $BUSYBOX_PATH tar -xpf - 2>&1" + "cd ${quote(rootfsPath)} && $BUSYBOX_PATH xzcat ${quote(archive.absolutePath)} | $BUSYBOX_PATH tar -xpf - 2>&1" } else { - "cd ${quote(rootfsPath)} && $BUSYBOX_PATH tar -xzpf ${quote(tempTarball.absolutePath)} 2>&1" + "cd ${quote(rootfsPath)} && $BUSYBOX_PATH tar -xzpf ${quote(archive.absolutePath)} 2>&1" } val extractResult = Shell.cmd(extractCmd).exec() @@ -249,8 +252,7 @@ object ContainerInstaller { } finally { // Clean up temp tarball try { - File("${context.cacheDir}/container_${sanitizedName}.tar.xz").delete() - File("${context.cacheDir}/container_${sanitizedName}.tar.gz").delete() + tempTarball?.delete() } catch (e: Exception) { // Ignore cleanup errors } @@ -261,7 +263,7 @@ object ContainerInstaller { * Get the tarball extension (.xz or .gz) from the URI. * Uses FilePickerUtils.getFileName() to reliably get the filename even for recent files. */ - private suspend fun getTarballExtension(context: Context, uri: Uri): String = withContext(Dispatchers.IO) { + internal suspend fun getTarballExtension(context: Context, uri: Uri): String = withContext(Dispatchers.IO) { // First, try to get the filename using FilePickerUtils (handles content URIs) val fileName = FilePickerUtils.getFileName(context, uri) @@ -286,6 +288,20 @@ object ContainerInstaller { } } + internal suspend fun copyTarball(context: Context, uri: Uri, destination: File) = withContext(Dispatchers.IO) { + context.contentResolver.openInputStream(uri)?.use { input -> + destination.outputStream().use { output -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + currentCoroutineContext().ensureActive() + val count = input.read(buffer) + if (count < 0) break + output.write(buffer, 0, count) + } + } + } ?: throw Exception("Failed to open tarball input stream") + } + /** @@ -431,4 +447,3 @@ object ContainerInstaller { } } } - 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 d5d7cbd8..0cd7866c 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 @@ -327,23 +327,23 @@ object ContainerManager { /** * Parse container configuration from string content. */ - fun parseConfig(configContent: String, defaultName: String): ContainerInfo? { - try { - val configMap = mutableMapOf() - - // Parse config file (key=value format) - configContent.lines().forEach { line -> - val trimmed = line.trim() - // Skip comments and empty lines - if (trimmed.isEmpty() || trimmed.startsWith("#")) { - return@forEach - } - + internal fun parseConfigValues(configContent: String): Map = buildMap { + configContent.lineSequence().forEach { line -> + val trimmed = line.trim() + if (trimmed.isNotEmpty() && !trimmed.startsWith("#")) { val parts = trimmed.split("=", limit = 2) - if (parts.size == 2) { - configMap[parts[0].trim()] = parts[1].trim() - } + if (parts.size == 2) put(parts[0].trim(), parts[1].trim()) } + } + } + + fun parseConfig( + configContent: String, + defaultName: String, + loadEnvironment: Boolean = true + ): ContainerInfo? { + try { + val configMap = parseConfigValues(configContent) // Build ContainerInfo from config val containerName = configMap["name"] ?: defaultName @@ -412,7 +412,7 @@ object ContainerManager { status = ContainerStatus.STOPPED, useSparseImage = useSparseImage, sparseImageSizeGB = sparseImageSizeGB, - envFileContent = loadEnvFileContent(containerName), + envFileContent = if (loadEnvironment) loadEnvFileContent(containerName) else null, upstreamInterfaces = upstreamInterfaces, portForwards = portForwards, forceCgroupv1 = configMap["force_cgroupv1"] == "1", diff --git a/Android/app/src/main/java/com/droidspaces/app/util/HostCapabilities.kt b/Android/app/src/main/java/com/droidspaces/app/util/HostCapabilities.kt index f5fe3e0b..17b53260 100644 --- a/Android/app/src/main/java/com/droidspaces/app/util/HostCapabilities.kt +++ b/Android/app/src/main/java/com/droidspaces/app/util/HostCapabilities.kt @@ -38,7 +38,8 @@ data class HostCapabilities( /** * Every correction collected into one copy: unsupported features off, IPv6 * forced off in NAT without IPv6 NAT, an unsupported network mode back to host. - * Limit values are left alone, the backend skips what it cannot apply. + * Limits and the requested user-namespace setting are retained. The form shows + * the latter checked but disabled when the kernel cannot provide it. */ fun coerce(s: ContainerConfigState): ContainerConfigState { val mode = if (s.netMode in supportedNetModes()) s.netMode else "host" @@ -46,7 +47,6 @@ data class HostCapabilities( netMode = mode, enableHwAccess = s.enableHwAccess && has("devtmpfs"), volatileMode = s.volatileMode && has("overlayfs"), - allowSandboxing = s.allowSandboxing && has("user_ns"), forceCgroupv1 = s.forceCgroupv1 && has("cgroup2"), disableIPv6 = s.disableIPv6 || (mode == "nat" && !has("ipv6_nat")) ) diff --git a/Android/app/src/main/java/com/droidspaces/app/util/RootfsConfig.kt b/Android/app/src/main/java/com/droidspaces/app/util/RootfsConfig.kt new file mode 100644 index 00000000..6cdc4262 --- /dev/null +++ b/Android/app/src/main/java/com/droidspaces/app/util/RootfsConfig.kt @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package com.droidspaces.app.util + +import com.topjohnwu.superuser.Shell +import java.io.File + +/** Reads the optional container.config using the existing container config parser. */ +object RootfsConfig { + fun read(tarball: File): ContainerInfo? { + val bb = Constants.BUSYBOX_BINARY_PATH + val decompress = if (tarball.name.endsWith(".xz")) "xzcat" else "zcat" + val input = "$bb $decompress ${ContainerCommandBuilder.quote(tarball.absolutePath)}" + // Ordinary rootfs archives should open the default wizard without a full scan. + val first = Shell.cmd( + "$input 2>/dev/null | $bb head -c 512 | " + + "$bb tar -tvf - container.config ./container.config 2>/dev/null" + ).exec().out + val entry = first.firstNotNullOfOrNull { + Regex("^-[rwxStTs-]{9}\\s+\\S+\\s+(\\d+)\\s+\\S+\\s+\\S+\\s+(\\./)?container\\.config$") + .matchEntire(it) + } ?: return null + val size = entry.groupValues[1].toLongOrNull() ?: return null + val member = entry.groupValues[2] + Constants.CONTAINER_CONFIG_FILE + // Closing the prefix reader gives the decompressor SIGPIPE, which is expected. + val source = + "(set +o pipefail; $input 2>/dev/null | $bb head -c ${512 + ((size + 511) / 512) * 512})" + val result = Shell.cmd( + "(set -o pipefail; $source | $bb tar -xOf - ${ContainerCommandBuilder.quote(member)} 2>/dev/null)" + ).exec() + check(result.isSuccess) { "Could not read recommended configuration" } + return parse(result.out.joinToString("\n")) + } + + internal fun parse(content: String): ContainerInfo? { + val values = ContainerManager.parseConfigValues(content) + // These defaults belong to the installation wizard, not an existing container. + val defaults = "use_sparse_image=1\nsparse_image_size_gb=8\n" + return ContainerManager.parseConfig(defaults + content, "Container", loadEnvironment = false)?.copy( + name = values["name"].orEmpty(), + hostname = values["hostname"].orEmpty() + ) + } +} 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 fb435625..915c5425 100644 --- a/Android/app/src/main/res/values-zh-rCN/strings.xml +++ b/Android/app/src/main/res/values-zh-rCN/strings.xml @@ -215,6 +215,9 @@ 添加绑定挂载点 内核 容器信息 + 正在准备 rootfs 并读取推荐配置… + 已加载推荐配置,你可以在后续步骤中检查和修改。 + 无法加载推荐配置,请手动配置此容器。%1$s 选择用户 容器设置 未找到本地用户 diff --git a/Android/app/src/main/res/values/strings.xml b/Android/app/src/main/res/values/strings.xml index 045b1900..788261d1 100644 --- a/Android/app/src/main/res/values/strings.xml +++ b/Android/app/src/main/res/values/strings.xml @@ -371,6 +371,9 @@ Container Setup Container Information + Preparing rootfs and reading recommended settings… + Recommended settings loaded. You can review and change them in the next steps. + Could not load recommended settings. Configure this container manually. %1$s Container Name * my-container Leave empty for default diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a6e76b37..192d2104 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -381,6 +381,7 @@ boundary. | --- | --- | --- | | `ContainerManager` | `util/ContainerManager.kt` | Container discovery, config parsing, status, updates, uninstall | | `ContainerInfo`, `BindMount`, `PortForward`, `ContainerStatus` | same | The container model. `ContainerInfo.toConfigContent()` is the single config serializer, never hand-write config lines | +| `RootfsConfig.read(tarball)` | `util/RootfsConfig.kt` | Reading optional root-level `container.config` recommendations before installation. Uses the shared config parser without loading host environment files | | `DaemonModeRepository` | `util/DaemonModeRepository.kt` | Reading and writing the daemon mode flag | | `RootfsRepository.fetchAllAssets(context)` + `RootfsAsset` | `util/RootfsRepository.kt` | Fetching official and user rootfs repos | | `PreferencesManager.getInstance(context)` | `util/PreferencesManager.kt` | All settings persistence. Collect `daemonModeFlow` and `symlinkEnabledFlow` rather than registering your own preference listener | diff --git a/Documentation/Installation-Android.md b/Documentation/Installation-Android.md index dc82ae72..50ea5131 100644 --- a/Documentation/Installation-Android.md +++ b/Documentation/Installation-Android.md @@ -61,6 +61,25 @@ If you already have a `.tar.xz` or `.tar.gz` rootfs file on the device: 2. **Select your tarball** from storage. 3. Follow the same **configuration wizard** steps as above. +An archive can include a regular `container.config` file at its root, alongside `bin/`, +`etc/` and `usr/`. The app reads its usual `key=value` settings and prefills the wizard. +The file may contain the complete config from an installed container or only the settings +you want to recommend. Missing settings use the wizard defaults. You can +change the loaded values before installing; hardware access and privileged mode keep +their confirmation dialogs. Recommendations use the existing config parser without +additional field filtering. Rootfs validation still runs before extraction. + +Exporting a container includes its current host-side `container.config` as the first +archive member, replacing any old copy in the rootfs. This lets the app read exported +recommendations without decompressing the rest of the archive. If the first member is +not `container.config` or `./container.config`, the app skips recommendations and opens +the default configuration wizard. It does not search later entries. +The new container name and storage location come from the wizard. Environment files +are not imported. Bind mounts use the existing +`bind_mounts=source:destination[:ro],...` format and can be reviewed in the wizard. +A requested user-namespace setting stays checked but disabled when the kernel does +not support it. The rootfs itself is not modified during export. + > [!NOTE] > > Both methods end in the same wizard. The only difference is where the tarball comes from. diff --git a/Documentation/zh-CN/Installation-Android.md b/Documentation/zh-CN/Installation-Android.md index 9905104c..983e1b9b 100644 --- a/Documentation/zh-CN/Installation-Android.md +++ b/Documentation/zh-CN/Installation-Android.md @@ -59,6 +59,20 @@ Droidspaces 在 Android 上旨在提供"零终端"体验。从首次安装到运 2. 从存储中**选择你的 tarball 文件**。 3. 按照上述相同的**配置向导**步骤操作即可。 +归档顶层可以包含一个普通文件 `container.config`,与 `bin/`、`etc/`、`usr/` +并列。应用会读取原有的 `key=value` 格式并自动填入向导。可以使用已安装容器的完整 +配置,也可以只填写需要推荐的选项。未填写的选项沿用向导 +默认值。安装前仍可修改,硬件访问和特权模式保留原有确认对话框。推荐配置直接复用 +已有解析,不另加字段过滤。解包前的 rootfs 校验仍然执行。 + +导出容器会把宿主侧当前的 `container.config` 放在归档最前面,替换 rootfs 中可能 +残留的旧副本,因此再次导入时不必解压扫描整个归档来读取推荐值。如果第一个成员 +不是 `container.config` 或 `./container.config`,应用直接跳过推荐配置,进入默认配置 +向导,不再查找后面的成员。新容器的名称和存储位置由向导决定, +环境变量文件不导入。绑定挂载沿用 `bind_mounts=源路径:目标路径[:ro],...` 格式, +可在向导中检查和修改。内核不支持用户命名空间时,推荐的开启状态仍会显示, +但开关灰显且不可操作。导出过程不会修改实际 rootfs。 + > [!NOTE] > 两种方式最终都会进入同一个向导——唯一的区别是 tarball 的来源不同。