From dda1cfc6d440074bd6d07b60c6d0cdf05e5c07a8 Mon Sep 17 00:00:00 2001 From: sleepy-snowflake Date: Mon, 31 Aug 2026 23:01:35 +0500 Subject: [PATCH 1/3] Add initd service manager as Software Hub preset Addresses #20: installs EdwardLab/initd v0.0.2 as an opt-in preset to provide working systemctl in proot where systemd cannot run. - New preset 'initd_service_manager' (arm64/x86_64 only) - SHA256-pinned download from GitHub releases - POSIX shim replaces fake systemctl stub with real client - Shim intercepts reboot/poweroff/halt, fakes offline enable/disable - Autostart hook via /etc/profile.d/ for session-level daemon lifecycle - New tests for version pinning, shim POSIX compliance, and install command structure --- .../model/SoftwarePackage.kt | 194 ++++++++++++++++++ .../ui/components/SoftwareCard.kt | 1 + .../model/SoftwarePackageTest.kt | 50 ++++- 3 files changed, 244 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/devwithzachary/completelinuxinstaller/model/SoftwarePackage.kt b/app/src/main/java/com/devwithzachary/completelinuxinstaller/model/SoftwarePackage.kt index cf7329c..331ecea 100644 --- a/app/src/main/java/com/devwithzachary/completelinuxinstaller/model/SoftwarePackage.kt +++ b/app/src/main/java/com/devwithzachary/completelinuxinstaller/model/SoftwarePackage.kt @@ -101,6 +101,10 @@ data class SoftwarePackage( return "NGINX HTTP server listening on port $validPort. Test in your browser or with 'curl http://localhost:$validPort'." } + private const val INITD_VERSION = "0.0.2" + private const val INITD_SHA256_ARM64 = "0484f46990bf48b7b46223064d79c029d2a6789aa24e7fd7b82efea0e3d4634e" + private const val INITD_SHA256_AMD64 = "0dcd1f33ee224ab1f70d264db9378c434cde9bd8c82abba4009e008c5d85c801" + fun buildSshLaunchCommand(port: Int = 2222): String { val validPort = if (port in 1..65535) port else 2222 return "(sed -i 's/^Subsystem.*sftp/#&/' /etc/ssh/sshd_config 2>/dev/null || true); mkdir -p /run/sshd /var/run/sshd /var/empty && [ -e /dev/ptmx ] || (mknod -m 666 /dev/ptmx c 5 2 2>/dev/null || ln -s /dev/pts/ptmx /dev/ptmx 2>/dev/null || true) && chmod 666 /dev/ptmx 2>/dev/null || true && ssh-keygen -A 2>/dev/null || true && chmod 755 /etc/ssh /run/sshd /var/run/sshd /var/empty 2>/dev/null || true && (killall -9 sshd 2>/dev/null || true) && /usr/sbin/sshd -p $validPort" @@ -111,6 +115,178 @@ data class SoftwarePackage( return "SSH server listening on port $validPort. Connect via 'ssh @ -p $validPort' using your Linux password." } + fun buildInitdShim(): String = listOf( + "#!/bin/sh", + "# LinuxOnAndroid shim: initd-backed systemctl with automatic daemon recovery", + "CLIENT=/usr/local/lib/initd/systemctl", + "DAEMON=/usr/local/lib/initd/initd", + "SOCK=/run/initd.sock", + "LOCK=/run/.initd-start.lock", + "LOG=/var/log/initd.log", + "", + "case \"\$1\" in", + " reboot|poweroff|halt)", + " echo \"systemctl \$1 is not supported in LinuxOnAndroid.\" >&2", + " exit 1", + " ;;", + "esac", + "", + "alive() {", + " [ -S \"\$SOCK\" ] && \"\$CLIENT\" list-units >/dev/null 2>&1", + "}", + "", + "clear_stale_lock() {", + " if [ -d \"\$LOCK\" ] && ! find \"\$LOCK\" -maxdepth 0 -newermt '-10 seconds' >/dev/null 2>&1; then", + " rmdir \"\$LOCK\" 2>/dev/null", + " fi", + "}", + "", + "wait_alive() {", + " i=0", + " while [ \"\$i\" -lt 30 ]; do", + " if alive; then return 0; fi", + " sleep 0.1", + " i=\$((i + 1))", + " done", + " return 1", + "}", + "", + "start_daemon() {", + " clear_stale_lock", + " if mkdir \"\$LOCK\" 2>/dev/null; then", + " ( umask 000; nohup \"\$DAEMON\" --socket >\"\$LOG\" 2>&1 & sleep 1; rmdir \"\$LOCK\" 2>/dev/null ) &", + " fi", + " wait_alive", + "}", + "", + "offline() {", + " case \"\$1\" in", + " daemon-reload)", + " exit 0", + " ;;", + " is-enabled)", + " shift", + " rc=0", + " for u in \"\$@\"; do", + " n=\$(basename \"\$u\")", + " en=\"\"", + " for d in /etc/systemd/system/*.wants; do", + " if [ -e \"\$d/\$n\" ]; then en=1; fi", + " done", + " if [ -n \"\$en\" ]; then", + " echo \"enabled\"", + " else", + " echo \"disabled\"", + " rc=1", + " fi", + " done", + " exit \$rc", + " ;;", + " enable)", + " shift", + " for u in \"\$@\"; do", + " case \"\$u\" in", + " *.* ) ;;", + " *) u=\"\$u.service\" ;;", + " esac", + " src=\"\"", + " for b in /etc/systemd/system /lib/systemd/system /usr/lib/systemd/system; do", + " if [ -f \"\$b/\$u\" ]; then src=\"\$b/\$u\"; fi", + " done", + " if [ -z \"\$src\" ]; then", + " echo \"Unit \$u not found.\" >&2", + " exit 1", + " fi", + " want=\$(sed -n '/^\\[Install\\]/,/^\\[/s/^WantedBy=//p' \"\$src\" | head -n 1)", + " if [ -z \"\$want\" ]; then want=\"multi-user.target\"; fi", + " mkdir -p \"/etc/systemd/system/\$want.wants\"", + " ln -sf \"\$src\" \"/etc/systemd/system/\$want.wants/\$u\"", + " done", + " exit 0", + " ;;", + " disable)", + " shift", + " for u in \"\$@\"; do", + " case \"\$u\" in", + " *.* ) ;;", + " *) u=\"\$u.service\" ;;", + " esac", + " for d in /etc/systemd/system/*.wants; do", + " if [ -e \"\$d/\$u\" ]; then rm -f \"\$d/\$u\"; fi", + " done", + " done", + " exit 0", + " ;;", + " esac", + "}", + "", + "if alive; then", + " exec \"\$CLIENT\" \"\$@\"", + "fi", + "", + "start_daemon", + "", + "if alive; then", + " exec \"\$CLIENT\" \"\$@\"", + "fi", + "", + "offline \"\$@\"", + "", + "echo \"initd service manager is not running and 'systemctl \$1' requires the daemon.\" >&2", + "exit 1" + ).joinToString("\n") + + fun buildInitdAutostartHook(): String = listOf( + "#!/bin/sh", + "# LinuxOnAndroid: auto-start the initd service manager when a session opens", + "INITD_DAEMON=/usr/local/lib/initd/initd", + "INITD_CLIENT=/usr/local/lib/initd/systemctl", + "INITD_LOCK=/run/.initd-start.lock", + "if [ -x \"\$INITD_DAEMON\" ]; then", + " if ! \"\$INITD_CLIENT\" list-units >/dev/null 2>&1; then", + " if [ -d \"\$INITD_LOCK\" ] && ! find \"\$INITD_LOCK\" -maxdepth 0 -newermt '-10 seconds' >/dev/null 2>&1; then", + " rmdir \"\$INITD_LOCK\" 2>/dev/null", + " fi", + " if mkdir \"\$INITD_LOCK\" 2>/dev/null; then", + " ( umask 000; nohup \"\$INITD_DAEMON\" --socket >/var/log/initd.log 2>&1 & sleep 1; rmdir \"\$INITD_LOCK\" 2>/dev/null ) &", + " fi", + " fi", + " unset INITD_DAEMON INITD_CLIENT INITD_LOCK", + "fi" + ).joinToString("\n") + + fun buildInitdInstallCommand(): String { + val shim = buildInitdShim() + val hook = buildInitdAutostartHook() + return listOf( + "$NONINT_EXPORT && dpkg --configure -a && \\", + "{ command -v curl >/dev/null 2>&1 || { apt-get update -qq && apt-get install -y $DPKG_FLAGS curl ca-certificates; }; } && \\", + "{ command -v python3 >/dev/null 2>&1 || { echo 'ERROR: python3 is required to unpack the initd package.' >&2; exit 1; }; } && \\", + "INITD_ARCH=\$(uname -m) && \\", + "INITD_VERSION=$INITD_VERSION && \\", + "if [ \"\$INITD_ARCH\" = \"aarch64\" ] || [ \"\$INITD_ARCH\" = \"arm64\" ]; then INITD_ARCH=arm64; INITD_SHA=$INITD_SHA256_ARM64; elif [ \"\$INITD_ARCH\" = \"x86_64\" ]; then INITD_ARCH=amd64; INITD_SHA=$INITD_SHA256_AMD64; else echo \"ERROR: initd requires arm64 or x86_64 (detected: \$INITD_ARCH).\" >&2; exit 1; fi && \\", + "rm -rf /tmp/initd-pkg /tmp/initd-pkg.zip && mkdir -p /tmp/initd-pkg /usr/local/lib/initd /var/log && \\", + "curl -fSL --retry 3 --connect-timeout 20 -o /tmp/initd-pkg.zip \"https://github.com/EdwardLab/initd/releases/download/\${INITD_VERSION}/initd-v\${INITD_VERSION}-linux-\${INITD_ARCH}.zip\" && \\", + "echo \"\$INITD_SHA /tmp/initd-pkg.zip\" | sha256sum -c - && \\", + "python3 -m zipfile -e /tmp/initd-pkg.zip /tmp/initd-pkg && \\", + "[ -f /tmp/initd-pkg/initd ] && [ -f /tmp/initd-pkg/systemctl ] && \\", + "install -m 755 /tmp/initd-pkg/initd /usr/local/lib/initd/initd && \\", + "install -m 755 /tmp/initd-pkg/systemctl /usr/local/lib/initd/systemctl && \\", + "{ [ ! -e /usr/bin/systemctl ] || { [ -e /usr/bin/systemctl.loa-stub ] || mv /usr/bin/systemctl /usr/bin/systemctl.loa-stub; }; } && \\", + "ln -sf /usr/local/bin/systemctl /usr/bin/systemctl && \\", + "cat << 'SHIMEOF' > /usr/local/bin/systemctl", + shim, + "SHIMEOF", + "chmod 755 /usr/local/bin/systemctl && \\", + "cat << 'HOOKEOF' > /etc/profile.d/00-initd-autostart.sh", + hook, + "HOOKEOF", + "chmod 644 /etc/profile.d/00-initd-autostart.sh && \\", + "rm -rf /tmp/initd-pkg /tmp/initd-pkg.zip && \\", + "echo \"initd v\$INITD_VERSION (\$INITD_ARCH) installed successfully.\"" + ).joinToString("\n") + } + fun getPresets(sshPort: Int = 2222): List { val validPort = if (sshPort in 1..65535) sshPort else 2222 return listOf( @@ -194,6 +370,24 @@ data class SoftwarePackage( postInstallNotes = buildSshPostInstallNotes(validPort), expectedBinaries = listOf("usr/sbin/sshd", "usr/bin/ssh-keygen"), version = 3 + ), + SoftwarePackage( + id = "initd_service_manager", + name = "initd Service Manager", + category = SoftwareCategory.UTILITIES, + description = "Lightweight systemd-compatible init system. Enables systemctl start/stop/enable for services in proot where systemd cannot run.", + iconName = "Settings", + installCommand = buildInitdInstallCommand(), + launchCommand = "systemctl list-units", + postInstallNotes = "The service manager auto-starts with each session. Manage services with 'systemctl start/stop/enable '. 'systemctl reboot/poweroff/halt' is disabled.", + expectedBinaries = listOf( + "usr/local/lib/initd/initd", + "usr/local/lib/initd/systemctl", + "usr/local/bin/systemctl", + "usr/bin/systemctl", + "etc/profile.d/00-initd-autostart.sh" + ), + version = 1 ) ) } diff --git a/app/src/main/java/com/devwithzachary/completelinuxinstaller/ui/components/SoftwareCard.kt b/app/src/main/java/com/devwithzachary/completelinuxinstaller/ui/components/SoftwareCard.kt index 8304810..0b21de5 100644 --- a/app/src/main/java/com/devwithzachary/completelinuxinstaller/ui/components/SoftwareCard.kt +++ b/app/src/main/java/com/devwithzachary/completelinuxinstaller/ui/components/SoftwareCard.kt @@ -34,6 +34,7 @@ fun SoftwareCard( "Terminal" -> Icons.Default.Terminal "Security" -> Icons.Default.Security "Android" -> Icons.Default.Android + "Settings" -> Icons.Default.Settings else -> Icons.Default.Apps } diff --git a/app/src/test/java/com/devwithzachary/completelinuxinstaller/model/SoftwarePackageTest.kt b/app/src/test/java/com/devwithzachary/completelinuxinstaller/model/SoftwarePackageTest.kt index 5c55173..d661b71 100644 --- a/app/src/test/java/com/devwithzachary/completelinuxinstaller/model/SoftwarePackageTest.kt +++ b/app/src/test/java/com/devwithzachary/completelinuxinstaller/model/SoftwarePackageTest.kt @@ -12,7 +12,7 @@ class SoftwarePackageTest { fun testGetPresets_returnsNonEmptyList() { val presets = SoftwarePackage.getPresets() assertTrue("Preset package list should not be empty", presets.isNotEmpty()) - assertEquals(6, presets.size) + assertEquals(7, presets.size) } @Test @@ -156,4 +156,52 @@ class SoftwarePackageTest { tempDir.deleteRecursively() } } + + @Test + fun testPreset_initd_service_manager_pinsVersionAndHashes() { + val initd = SoftwarePackage.getPresets().find { it.id == "initd_service_manager" } + assertNotNull("initd_service_manager preset must exist", initd) + initd?.let { + assertTrue("installCommand must pin the release tag and asset version", it.installCommand.contains("releases/download/0.0.2/initd-v0.0.2-linux-")) + assertTrue("installCommand must contain the amd64 SHA256 hash", it.installCommand.contains("0dcd1f33ee224ab1f70d264db9378c434cde9bd8c82abba4009e008c5d85c801")) + assertTrue("installCommand must contain the arm64 SHA256 hash", it.installCommand.contains("0484f46990bf48b7b46223064d79c029d2a6789aa24e7fd7b82efea0e3d4634e")) + assertTrue("installCommand must verify SHA256 with sha256sum -c", it.installCommand.contains("sha256sum -c")) + assertTrue("installCommand must install initd to /usr/local/lib/initd/initd", it.installCommand.contains("/usr/local/lib/initd/initd")) + assertTrue("installCommand must install systemctl client to /usr/local/lib/initd/systemctl", it.installCommand.contains("/usr/local/lib/initd/systemctl")) + assertTrue("installCommand must install autostart hook to /etc/profile.d/", it.installCommand.contains("/etc/profile.d/00-initd-autostart.sh")) + assertTrue("installCommand must guard armv7 with uname -m arch check", it.installCommand.contains("uname -m")) + assertTrue("installCommand must reject non-arm64/amd64 architectures", it.installCommand.contains("x86_64")) + assertTrue("installCommand must use python3 to extract the zip (unzip absent in ubuntu base)", it.installCommand.contains("python3 -m zipfile")) + assertTrue("launchCommand must list units", it.launchCommand != null && it.launchCommand.contains("list-units")) + assertEquals( + "expectedBinaries must match installed artifacts", + setOf( + "usr/local/lib/initd/initd", + "usr/local/lib/initd/systemctl", + "usr/local/bin/systemctl", + "usr/bin/systemctl", + "etc/profile.d/00-initd-autostart.sh" + ), + it.expectedBinaries.toSet() + ) + assertEquals(1, it.version) + } + } + + @Test + fun testPreset_initd_shimInterceptsReboot() { + val shim = SoftwarePackage.buildInitdShim() + assertTrue("shim must intercept reboot", shim.contains("reboot|poweroff|halt")) + assertFalse("shim must be POSIX sh (no bashisms)", shim.contains("[[")) + assertTrue("shim must be POSIX sh shebang", shim.startsWith("#!/bin/sh")) + } + + @Test + fun testBuildInitdInstallCommand_containsDpkgAndPolicyRcD() { + val cmd = SoftwarePackage.buildInitdInstallCommand() + assertTrue("initd install must contain dpkg --configure -a", cmd.contains("dpkg --configure -a")) + assertTrue("initd install must contain policy-rc.d", cmd.contains("policy-rc.d")) + assertTrue("initd install must contain apt-get install -y for curl fallback", cmd.contains("apt-get install -y")) + assertTrue("initd install must contain messagebus system user initialization", cmd.contains("messagebus:")) + } } From e36b12b4195e84fe2025c5739ffc8321dbd66c9a Mon Sep 17 00:00:00 2001 From: sleepy-snowflake Date: Mon, 31 Aug 2026 23:16:59 +0500 Subject: [PATCH 2/3] Fix test: use Kotlin interpolation for version in install URL $INITD_VERSION produced literal shell ${INITD_VERSION} which the test assertion couldn't match against '0.0.2'. Use Kotlin interpolation to embed the pinned version directly into the curl URL and echo. --- .../completelinuxinstaller/model/SoftwarePackage.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/devwithzachary/completelinuxinstaller/model/SoftwarePackage.kt b/app/src/main/java/com/devwithzachary/completelinuxinstaller/model/SoftwarePackage.kt index 331ecea..99a8201 100644 --- a/app/src/main/java/com/devwithzachary/completelinuxinstaller/model/SoftwarePackage.kt +++ b/app/src/main/java/com/devwithzachary/completelinuxinstaller/model/SoftwarePackage.kt @@ -266,7 +266,7 @@ data class SoftwarePackage( "INITD_VERSION=$INITD_VERSION && \\", "if [ \"\$INITD_ARCH\" = \"aarch64\" ] || [ \"\$INITD_ARCH\" = \"arm64\" ]; then INITD_ARCH=arm64; INITD_SHA=$INITD_SHA256_ARM64; elif [ \"\$INITD_ARCH\" = \"x86_64\" ]; then INITD_ARCH=amd64; INITD_SHA=$INITD_SHA256_AMD64; else echo \"ERROR: initd requires arm64 or x86_64 (detected: \$INITD_ARCH).\" >&2; exit 1; fi && \\", "rm -rf /tmp/initd-pkg /tmp/initd-pkg.zip && mkdir -p /tmp/initd-pkg /usr/local/lib/initd /var/log && \\", - "curl -fSL --retry 3 --connect-timeout 20 -o /tmp/initd-pkg.zip \"https://github.com/EdwardLab/initd/releases/download/\${INITD_VERSION}/initd-v\${INITD_VERSION}-linux-\${INITD_ARCH}.zip\" && \\", + "curl -fSL --retry 3 --connect-timeout 20 -o /tmp/initd-pkg.zip \"https://github.com/EdwardLab/initd/releases/download/$INITD_VERSION/initd-v$INITD_VERSION-linux-\${INITD_ARCH}.zip\" && \\", "echo \"\$INITD_SHA /tmp/initd-pkg.zip\" | sha256sum -c - && \\", "python3 -m zipfile -e /tmp/initd-pkg.zip /tmp/initd-pkg && \\", "[ -f /tmp/initd-pkg/initd ] && [ -f /tmp/initd-pkg/systemctl ] && \\", @@ -283,7 +283,7 @@ data class SoftwarePackage( "HOOKEOF", "chmod 644 /etc/profile.d/00-initd-autostart.sh && \\", "rm -rf /tmp/initd-pkg /tmp/initd-pkg.zip && \\", - "echo \"initd v\$INITD_VERSION (\$INITD_ARCH) installed successfully.\"" + "echo \"initd v$INITD_VERSION (\$INITD_ARCH) installed successfully.\"" ).joinToString("\n") } From db34ac564003b6d0b3c58e1ff6e0f62b1cfc9dc5 Mon Sep 17 00:00:00 2001 From: sleepy-snowflake Date: Sat, 12 Sep 2026 16:25:44 +0500 Subject: [PATCH 3/3] add softwarePackageHidden to hide initd_service_manager on alpine and void --- .../completelinuxinstaller/model/DistroCatalog.kt | 9 ++++++--- .../completelinuxinstaller/ui/MainViewModel.kt | 4 +++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/devwithzachary/completelinuxinstaller/model/DistroCatalog.kt b/app/src/main/java/com/devwithzachary/completelinuxinstaller/model/DistroCatalog.kt index 83dcf1a..388cf05 100644 --- a/app/src/main/java/com/devwithzachary/completelinuxinstaller/model/DistroCatalog.kt +++ b/app/src/main/java/com/devwithzachary/completelinuxinstaller/model/DistroCatalog.kt @@ -40,7 +40,8 @@ data class DistroDefinition( val softwarePackageCommands: Map String> = emptyMap(), val softwarePackageLaunchCommands: Map String> = emptyMap(), val softwarePackageExpectedBinaries: Map> = emptyMap(), - val softwarePackageVersions: Map = emptyMap() + val softwarePackageVersions: Map = emptyMap(), + val softwarePackageHidden: Set = emptySet() ) { val expectedSizeMb: Int get() = downloadSizeMb @@ -288,7 +289,8 @@ object DistroCatalog { "openssh_server" to { port -> "apk update && apk add --no-cache openssh-server openssh ca-certificates && mkdir -p /run/sshd /var/run/sshd /var/empty && ssh-keygen -A 2>/dev/null || true && (sed -i 's/^#\\?PasswordAuthentication.*/PasswordAuthentication yes/' /etc/ssh/sshd_config 2>/dev/null || true) && (sed -i 's/^#\\?PermitRootLogin.*/PermitRootLogin yes/' /etc/ssh/sshd_config 2>/dev/null || true) && echo \"Port $port\" >> /etc/ssh/ssh_config && chmod 600 /etc/ssh/ssh_host_*_key 2>/dev/null || true" } - ) + ), + softwarePackageHidden = setOf("initd_service_manager") ) val ARCH_ARM = DistroDefinition( @@ -613,7 +615,8 @@ object DistroCatalog { ), softwarePackageVersions = mapOf( "xfce_desktop" to 5 - ) + ), + softwarePackageHidden = setOf("initd_service_manager") ) val ALL_DISTROS = listOf( diff --git a/app/src/main/java/com/devwithzachary/completelinuxinstaller/ui/MainViewModel.kt b/app/src/main/java/com/devwithzachary/completelinuxinstaller/ui/MainViewModel.kt index 7ef4f7c..c4b63a4 100644 --- a/app/src/main/java/com/devwithzachary/completelinuxinstaller/ui/MainViewModel.kt +++ b/app/src/main/java/com/devwithzachary/completelinuxinstaller/ui/MainViewModel.kt @@ -685,7 +685,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { val packageVersions = if (installed) RootfsMigrationManager.readPackageVersions(rootfsDir) else emptyMap() val distroDef = com.devwithzachary.completelinuxinstaller.model.DistroCatalog.getById(container.distroId.ifBlank { container.distroName }) - return _packages.value.map { pkg -> + return _packages.value + .filter { pkg -> pkg.id !in distroDef.softwarePackageHidden } + .map { pkg -> val effectiveLaunchCommand = distroDef.getSoftwarePackageLaunchCommand(pkg.id, _sshPort.value) ?: pkg.launchCommand val effectiveBinaries = distroDef.getSoftwarePackageExpectedBinaries(pkg.id) ?: pkg.expectedBinaries val effectiveVersion = distroDef.getSoftwarePackageVersion(pkg.id) ?: pkg.version