diff --git a/src/main/kotlin/app/morphe/gui/App.kt b/src/main/kotlin/app/morphe/gui/App.kt index 4ccd9d25..1fba76c6 100644 --- a/src/main/kotlin/app/morphe/gui/App.kt +++ b/src/main/kotlin/app/morphe/gui/App.kt @@ -51,6 +51,21 @@ val LocalModeState = staticCompositionLocalOf { error("No ModeState provided") } +/** + * Auto-start ADB preference. Exposed as a composition local so the + * SettingsDialog (writer) and DeviceIndicator + install buttons (readers) + * can react without prop-drilling through Voyager screens. App-level + * lifecycle (start/stop the daemon when this flips) is handled in [App.kt]. + */ +data class AdbPreferenceState( + val enabled: Boolean, + val onChange: (Boolean) -> Unit, +) + +val LocalAdbPreference = staticCompositionLocalOf { + error("No AdbPreferenceState provided") +} + @Composable fun App( initialSimplifiedMode: Boolean = true @@ -76,6 +91,7 @@ private fun AppContent( var themePreference by remember { mutableStateOf(ThemePreference.SYSTEM) } var isSimplifiedMode by remember { mutableStateOf(initialSimplifiedMode) } + var autoStartAdb by remember { mutableStateOf(false) } var isLoading by remember { mutableStateOf(true) } // Initialize PatchSourceManager and load config on startup @@ -84,6 +100,7 @@ private fun AppContent( val config = configRepository.loadConfig() themePreference = config.getThemePreference() isSimplifiedMode = config.useSimplifiedMode + autoStartAdb = config.autoStartAdb isLoading = false } @@ -105,6 +122,23 @@ private fun AppContent( } } + // Callback for the auto-start ADB toggle. Persists the preference AND + // applies the change immediately: ON spins up DeviceMonitor (which + // explicitly start-server's adb and records ownership); OFF cancels + // polling and kill-server's the daemon if Morphe owns it. + val onAutoStartAdbChange: (Boolean) -> Unit = { enabled -> + autoStartAdb = enabled + scope.launch { + configRepository.setAutoStartAdb(enabled) + if (enabled) { + DeviceMonitor.startMonitoring() + } else { + DeviceMonitor.stopMonitoringAndKillIfOwned() + } + Logger.info("Auto-start ADB ${if (enabled) "enabled" else "disabled"}") + } + } + val themeState = ThemeState( current = themePreference, onChange = onThemeChange @@ -115,9 +149,24 @@ private fun AppContent( onChange = onModeChange ) - // Start/stop DeviceMonitor with app lifecycle + val adbPreferenceState = AdbPreferenceState( + enabled = autoStartAdb, + onChange = onAutoStartAdbChange + ) + + // Initial DeviceMonitor start. Gated on autoStartAdb so users who left + // the toggle OFF don't spawn an unwanted adb daemon at launch. Runs once + // after config finishes loading. Subsequent live toggles go through + // [onAutoStartAdbChange], not this effect. + LaunchedEffect(isLoading, autoStartAdb) { + if (!isLoading && autoStartAdb) { + DeviceMonitor.startMonitoring() + } + } + // On Compose teardown (window close → exitApplication), cancel polling. + // The kill-if-owned half runs from the JVM shutdown hook in [GuiMain.kt] + // so it works even when the user quits via Cmd+Q without disposing. DisposableEffect(Unit) { - DeviceMonitor.startMonitoring() onDispose { DeviceMonitor.stopMonitoring() } @@ -126,7 +175,8 @@ private fun AppContent( MorpheTheme(themePreference = themePreference) { CompositionLocalProvider( LocalThemeState provides themeState, - LocalModeState provides modeState + LocalModeState provides modeState, + LocalAdbPreference provides adbPreferenceState ) { // Tint the OS title bar (Windows DWM caption color, macOS traffic // light contrast) to match the active theme's surface color. diff --git a/src/main/kotlin/app/morphe/gui/GuiMain.kt b/src/main/kotlin/app/morphe/gui/GuiMain.kt index 002e2c9a..6e1b77cd 100644 --- a/src/main/kotlin/app/morphe/gui/GuiMain.kt +++ b/src/main/kotlin/app/morphe/gui/GuiMain.kt @@ -18,6 +18,8 @@ import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState import app.morphe.gui.data.model.AppConfig +import app.morphe.gui.util.DeviceMonitor +import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json import org.jetbrains.skia.Image import app.morphe.gui.util.FileUtils @@ -34,6 +36,18 @@ fun launchGui(args: Array) = application { else -> loadConfigSync().useSimplifiedMode } + // Belt-and-braces: on any JVM-normal exit path (window close, Cmd+Q, + // SIGTERM), kill the ADB daemon if Morphe spawned it. Compose's + // DisposableEffect already cancels polling; this hook covers shutdown + // routes where Compose teardown doesn't reach the suspend kill call. + remember { + Runtime.getRuntime().addShutdownHook(Thread { + runCatching { + runBlocking { DeviceMonitor.stopMonitoringAndKillIfOwned() } + } + }) + } + val windowState = rememberWindowState( size = DpSize(1024.dp, 768.dp), position = WindowPosition(Alignment.Center) diff --git a/src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt b/src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt index 0abd82ca..4ef8ed1a 100644 --- a/src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt +++ b/src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt @@ -76,6 +76,12 @@ data class AppConfig( // after upgrading to multi-source builds. Flips to true once the user dismisses // the banner, never resets. val multiSourceHintDismissed: Boolean = false, + // Whether Morphe should auto-start the ADB daemon at GUI launch to monitor + // connected devices. Default OFF — many users never push patched APKs to a + // device, so spawning a long-lived adb server unprompted is unwanted noise. + // When ON, DeviceMonitor polls devices; if Morphe was the one that started + // the daemon, it's killed on toggle-OFF and on window close. + val autoStartAdb: Boolean = false, ) { fun getUpdateChannelPreference(): UpdateChannelPreference? { diff --git a/src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt b/src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt index 70310071..a0477ffa 100644 --- a/src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt +++ b/src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt @@ -296,6 +296,14 @@ class ConfigRepository { saveConfig(current.copy(patchSource = updatedSources)) } + /** + * Update whether Morphe auto-starts the ADB daemon at GUI launch. + */ + suspend fun setAutoStartAdb(enabled: Boolean) { + val current = loadConfig() + saveConfig(current.copy(autoStartAdb = enabled)) + } + /** * Mark the multi-source upgrade hint as dismissed. One-shot — never resets. */ diff --git a/src/main/kotlin/app/morphe/gui/ui/components/DeviceIndicator.kt b/src/main/kotlin/app/morphe/gui/ui/components/DeviceIndicator.kt index 1b3c0bd5..a4498f97 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/DeviceIndicator.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/DeviceIndicator.kt @@ -20,6 +20,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.PhoneAndroid import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.PowerSettingsNew import androidx.compose.material.icons.filled.UsbOff import androidx.compose.material3.* import androidx.compose.runtime.* @@ -31,6 +32,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import app.morphe.gui.LocalAdbPreference import app.morphe.gui.ui.theme.LocalMorpheAccents import app.morphe.gui.ui.theme.LocalMorpheFont import app.morphe.gui.ui.theme.LocalMorpheCorners @@ -42,8 +44,10 @@ fun DeviceIndicator(modifier: Modifier = Modifier) { val corners = LocalMorpheCorners.current val mono = LocalMorpheFont.current val accents = LocalMorpheAccents.current + val adbPreference = LocalAdbPreference.current val monitorState by DeviceMonitor.state.collectAsState() + val isAdbDisabledByUser = !adbPreference.enabled val isAdbAvailable = monitorState.isAdbAvailable val readyDevices = monitorState.devices.filter { it.isReady } val unauthorizedDevices = monitorState.devices.filter { it.status == DeviceStatus.UNAUTHORIZED } @@ -55,6 +59,7 @@ fun DeviceIndicator(modifier: Modifier = Modifier) { val isHovered by hoverInteraction.collectIsHoveredAsState() val dotColor = when { + isAdbDisabledByUser -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.25f) isAdbAvailable == false -> MaterialTheme.colorScheme.error.copy(alpha = 0.7f) selectedDevice != null && selectedDevice.isReady -> accents.secondary unauthorizedDevices.isNotEmpty() -> accents.warning @@ -94,6 +99,7 @@ fun DeviceIndicator(modifier: Modifier = Modifier) { ) val displayText = when { + isAdbDisabledByUser -> "ADB OFF" isAdbAvailable == null -> "Checking…" isAdbAvailable == false -> "No ADB" selectedDevice != null -> { @@ -110,6 +116,7 @@ fun DeviceIndicator(modifier: Modifier = Modifier) { fontWeight = FontWeight.Medium, fontFamily = mono, color = when { + isAdbDisabledByUser -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) isAdbAvailable == false -> MaterialTheme.colorScheme.error.copy(alpha = 0.7f) selectedDevice != null -> MaterialTheme.colorScheme.onSurface unauthorizedDevices.isNotEmpty() -> accents.warning @@ -138,6 +145,67 @@ fun DeviceIndicator(modifier: Modifier = Modifier) { border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.12f)) ) { when { + isAdbDisabledByUser -> { + DropdownMenuItem( + text = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.PowerSettingsNew, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + Column { + Text( + text = "ADB is off", + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "Morphe is not monitoring connected devices", + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + } + } + }, + onClick = { showPopup = false } + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + DropdownMenuItem( + text = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.PowerSettingsNew, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = accents.primary + ) + Text( + text = "Enable ADB", + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + fontFamily = mono, + color = accents.primary + ) + } + }, + onClick = { + adbPreference.onChange(true) + showPopup = false + } + ) + } + isAdbAvailable == false -> { DropdownMenuItem( text = { diff --git a/src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt b/src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt index 3428ce1d..daa15b8f 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt @@ -5,6 +5,7 @@ package app.morphe.gui.ui.components +import app.morphe.gui.LocalAdbPreference import app.morphe.gui.LocalModeState import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.tween @@ -60,6 +61,7 @@ fun SettingsButton( val corners = LocalMorpheCorners.current val themeState = LocalThemeState.current val modeState = LocalModeState.current + val adbPreference = LocalAdbPreference.current val configRepository: ConfigRepository = koinInject() val patchSourceManager: PatchSourceManager = koinInject() val updateCheckRepository: UpdateCheckRepository = koinInject() @@ -194,6 +196,8 @@ fun SettingsButton( } } }, + autoStartAdb = adbPreference.enabled, + onAutoStartAdbChange = { adbPreference.onChange(it) }, collapsibleSectionStates = collapsibleSectionStates, onCollapsibleSectionToggle = { id, expanded -> collapsibleSectionStates = collapsibleSectionStates + (id to expanded) diff --git a/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt b/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt index ddb62275..db4e5f10 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt @@ -107,6 +107,8 @@ fun SettingsDialog( onKeepArchitecturesChange: (Set) -> Unit = {}, updateChannelPreference: app.morphe.gui.data.model.UpdateChannelPreference = app.morphe.gui.data.model.UpdateChannelPreference.STABLE, onUpdateChannelChange: (app.morphe.gui.data.model.UpdateChannelPreference) -> Unit = {}, + autoStartAdb: Boolean = false, + onAutoStartAdbChange: (Boolean) -> Unit = {}, collapsibleSectionStates: Map = emptyMap(), onCollapsibleSectionToggle: (id: String, expanded: Boolean) -> Unit = { _, _ -> } ) { @@ -277,6 +279,20 @@ fun SettingsDialog( SettingsDivider(borderColor) + // ── Auto-start ADB ── + SettingToggleRow( + label = "Auto-start ADB", + description = "Spawn the ADB daemon on launch so connected devices are monitored. " + + "When off, Morphe never starts the server, and install/push features are disabled.", + checked = autoStartAdb, + onCheckedChange = onAutoStartAdbChange, + accentColor = accents.primary, + mono = mono, + enabled = !isPatching + ) + + SettingsDivider(borderColor) + // ── Patched App Runtime Logs ── PatchedAppRuntimeLogsSection( mono = mono, diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt index 40699f36..60791398 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt @@ -39,6 +39,7 @@ import cafe.adriel.voyager.core.screen.Screen import app.morphe.morphe_cli.generated.resources.Res import app.morphe.morphe_cli.generated.resources.morphe_dark import app.morphe.morphe_cli.generated.resources.morphe_light +import app.morphe.gui.LocalAdbPreference import app.morphe.gui.data.model.Patch import app.morphe.gui.data.model.SupportedApp import app.morphe.gui.data.repository.ConfigRepository @@ -1189,6 +1190,8 @@ private fun CompletedContent( val scope = rememberCoroutineScope() val adbManager = remember { AdbManager() } val monitorState by DeviceMonitor.state.collectAsState() + val adbPreference = LocalAdbPreference.current + val isAdbDisabledByUser = !adbPreference.enabled var isInstalling by remember { mutableStateOf(false) } var installError by remember { mutableStateOf(null) } var installSuccess by remember { mutableStateOf(false) } @@ -1326,8 +1329,44 @@ private fun CompletedContent( } } - // ADB install - if (monitorState.isAdbAvailable == true) { + // ADB install — when the user has the toggle off, render a compact + // "ADB OFF" hint with an inline enable button rather than hiding the + // affordance entirely (otherwise users wonder where install went). + if (isAdbDisabledByUser) { + Spacer(modifier = Modifier.height(12.dp)) + val enableHover = remember { MutableInteractionSource() } + val enableHovered by enableHover.collectIsHoveredAsState() + Box( + modifier = Modifier + .widthIn(max = 480.dp) + .fillMaxWidth() + .height(38.dp) + .hoverable(enableHover) + .clip(RoundedCornerShape(corners.small)) + .border( + 1.dp, + if (enableHovered) accents.primary.copy(alpha = 0.5f) + else accents.primary.copy(alpha = 0.25f), + RoundedCornerShape(corners.small) + ) + .background( + if (enableHovered) accents.primary.copy(alpha = 0.08f) + else Color.Transparent, + RoundedCornerShape(corners.small) + ) + .clickable { adbPreference.onChange(true) }, + contentAlignment = Alignment.Center + ) { + Text( + text = "ADB OFF · ENABLE TO INSTALL", + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accents.primary, + letterSpacing = 0.5.sp + ) + } + } else if (monitorState.isAdbAvailable == true) { Spacer(modifier = Modifier.height(12.dp)) val readyDevices = monitorState.devices.filter { it.isReady } diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/result/ResultScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/result/ResultScreen.kt index fbce3149..64b0d6c8 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/result/ResultScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/result/ResultScreen.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.unit.sp import cafe.adriel.voyager.core.screen.Screen import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow +import app.morphe.gui.LocalAdbPreference import app.morphe.gui.data.repository.ConfigRepository import kotlinx.coroutines.launch import org.koin.compose.koinInject @@ -85,6 +86,8 @@ fun ResultScreenContent(outputPath: String) { // ADB state from DeviceMonitor val monitorState by DeviceMonitor.state.collectAsState() + val adbPreference = LocalAdbPreference.current + val isAdbDisabledByUser = !adbPreference.enabled var isInstalling by remember { mutableStateOf(false) } var installProgress by remember { mutableStateOf("") } var installError by remember { mutableStateOf(null) } @@ -352,7 +355,14 @@ fun ResultScreenContent(outputPath: String) { } // ADB Install section - if (monitorState.isAdbAvailable == true) { + if (isAdbDisabledByUser) { + AdbDisabledHint( + corners = corners, + mono = mono, + borderColor = borderColor, + onEnableClick = { adbPreference.onChange(true) } + ) + } else if (monitorState.isAdbAvailable == true) { AdbInstallSection( devices = monitorState.devices, selectedDevice = monitorState.selectedDevice, @@ -393,8 +403,10 @@ fun ResultScreenContent(outputPath: String) { ) } - // ADB help text - if (monitorState.isAdbAvailable == false) { + // ADB help text — only when the toggle is ON but the binary is + // missing. When the toggle is OFF, AdbDisabledHint above carries + // the explanation; suppress the duplicate "ADB not found" text. + if (!isAdbDisabledByUser && monitorState.isAdbAvailable == false) { Text( text = "ADB not found. Install Android SDK Platform Tools to enable direct installation.", fontSize = 10.sp, @@ -868,6 +880,87 @@ private fun CleanupSection( } } +/** + * Replaces [AdbInstallSection] when the user has the auto-start ADB toggle off. + * Mirrors the bordered card layout so the result screen doesn't collapse — + * but the install button is replaced with a clearly-disabled "ENABLE ADB" + * hint that flips the toggle in one click. + */ +@Composable +private fun AdbDisabledHint( + corners: app.morphe.gui.ui.theme.MorpheCornerStyle, + mono: androidx.compose.ui.text.font.FontFamily, + borderColor: Color, + onEnableClick: () -> Unit, +) { + val accents = LocalMorpheAccents.current + val hover = remember { MutableInteractionSource() } + val isHovered by hover.collectIsHoveredAsState() + + Box( + modifier = Modifier + .widthIn(max = 520.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(corners.medium)) + .border(1.dp, borderColor, RoundedCornerShape(corners.medium)) + .background(MaterialTheme.colorScheme.surface) + ) { + Column(modifier = Modifier.fillMaxWidth().padding(20.dp)) { + Text( + text = "ADB INSTALL", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + letterSpacing = 1.5.sp + ) + Spacer(Modifier.height(12.dp)) + Text( + text = "ADB is off. Install-on-device is disabled.", + fontSize = 12.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = "Enable ADB in Settings to push patched APKs directly.", + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f), + ) + Spacer(Modifier.height(14.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + .height(36.dp) + .hoverable(hover) + .clip(RoundedCornerShape(corners.small)) + .border( + 1.dp, + if (isHovered) accents.primary.copy(alpha = 0.5f) + else accents.primary.copy(alpha = 0.25f), + RoundedCornerShape(corners.small) + ) + .background( + if (isHovered) accents.primary.copy(alpha = 0.08f) + else Color.Transparent + ) + .clickable(onClick = onEnableClick), + contentAlignment = Alignment.Center + ) { + Text( + text = "ENABLE ADB", + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accents.primary, + letterSpacing = 1.sp + ) + } + } + } +} + private fun formatFileSize(bytes: Long): String { return when { bytes < 1024 -> "$bytes B" diff --git a/src/main/kotlin/app/morphe/gui/util/AdbManager.kt b/src/main/kotlin/app/morphe/gui/util/AdbManager.kt index d345989c..b8fa6e1c 100644 --- a/src/main/kotlin/app/morphe/gui/util/AdbManager.kt +++ b/src/main/kotlin/app/morphe/gui/util/AdbManager.kt @@ -8,6 +8,8 @@ package app.morphe.gui.util import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File +import java.net.InetSocketAddress +import java.net.Socket /** * Manages ADB (Android Debug Bridge) operations for installing APKs. @@ -17,6 +19,49 @@ class AdbManager { private var adbPath: String? = null + /** + * Set to true once [startServer] confirms Morphe was the process that + * spawned the ADB daemon (vs. attaching to one that was already running — + * Android Studio, scrcpy, a prior shell session). Gates [killServerIfOwned] + * so we never nuke a daemon someone else is depending on. + * + * Updated on every [startServer] call: if we attach to a pre-existing + * daemon and that daemon later dies + our next [startServer] tick + * respawns it, ownership flips from false → true. Without that, the + * polling loop's implicit respawns would leak a daemon Morphe is + * actively maintaining. + */ + @Volatile + var weStartedDaemon: Boolean = false + private set + + /** + * One-shot log dedup for the "we attached to a pre-existing daemon" + * message — without this, the polling loop would log it every 5s. + * Reset by [killServerIfOwned] so a re-attach after a kill cycle + * re-logs once. + */ + @Volatile + private var loggedAttachOnce: Boolean = false + + /** + * Cheap probe to check if the ADB daemon is listening on its conventional + * loopback port (5037). Used by [startServer] to detect ownership without + * relying on adb's stderr output (which varies across versions and can be + * suppressed when invoked programmatically). + * + * Short timeout keeps the polling loop snappy; localhost connects in <1ms + * when alive, refuses immediately when down. + */ + private fun isDaemonAlive(): Boolean = try { + Socket().use { socket -> + socket.connect(InetSocketAddress("127.0.0.1", 5037), 250) + } + true + } catch (_: Exception) { + false + } + /** * Find ADB binary in common locations or PATH. * Returns the path to ADB if found, null otherwise. @@ -106,6 +151,102 @@ class AdbManager { */ suspend fun isAdbAvailable(): Boolean = findAdb() != null + /** + * Ensure the ADB daemon is running, and record whether Morphe was the + * process that spawned the *current* daemon. Idempotent and cheap — safe + * to call on every poll tick. + * + * Detection is a TCP probe of 127.0.0.1:5037 (adb's conventional listen + * port). Before: alive? After invoking `adb start-server`: alive? + * - was-down + now-up → we own it (set [weStartedDaemon] = true). + * - was-up → no-op; ownership flag unchanged. + * + * Re-detection on every call matters: if Morphe initially attached to a + * pre-existing daemon (flag = false) and that daemon dies mid-session, + * the *next* tick's call will spawn a fresh one and flip the flag to + * true — so a subsequent [killServerIfOwned] correctly tears it down. + */ + suspend fun startServer(): Result = withContext(Dispatchers.IO) { + val adb = findAdb() ?: return@withContext Result.failure( + AdbException("ADB not found. Please install Android SDK Platform Tools.") + ) + + try { + if (isDaemonAlive()) { + // Daemon already up — Morphe is attaching, not spawning. + // Don't touch the ownership flag (a prior tick may have set + // it to true and the daemon is still ours). + if (!weStartedDaemon && !loggedAttachOnce) { + Logger.info("ADB daemon was already running — leaving it alone on shutdown") + loggedAttachOnce = true + } + return@withContext Result.success(Unit) + } + + // Daemon is down. Spawn it. + val process = ProcessBuilder(adb, "start-server") + .redirectErrorStream(true) + .start() + process.inputStream.bufferedReader().readText() // drain so the child exits cleanly + val exitCode = process.waitFor() + if (exitCode != 0) { + return@withContext Result.failure( + AdbException("adb start-server exited with code $exitCode") + ) + } + + if (isDaemonAlive()) { + if (!weStartedDaemon) { + Logger.info("ADB daemon spawned by Morphe — will kill on shutdown") + } + weStartedDaemon = true + loggedAttachOnce = false + } else { + Logger.warn("adb start-server returned success but port 5037 is still closed") + } + Result.success(Unit) + } catch (e: Exception) { + Logger.error("Failed to start ADB server", e) + Result.failure(AdbException("Failed to start ADB server: ${e.message}")) + } + } + + /** + * Kill the ADB server, but only if [weStartedDaemon] — i.e. Morphe was + * the one that spawned it. Refusing to kill a daemon we attached to + * keeps Android Studio / scrcpy / other concurrent users alive. + * + * Clears [weStartedDaemon] on success so repeated calls are idempotent. + */ + suspend fun killServerIfOwned(): Result = withContext(Dispatchers.IO) { + if (!weStartedDaemon) { + Logger.debug("Skipping adb kill-server — daemon wasn't started by Morphe") + return@withContext Result.success(false) + } + val adb = findAdb() ?: return@withContext Result.success(false) + + try { + val process = ProcessBuilder(adb, "kill-server") + .redirectErrorStream(true) + .start() + val output = process.inputStream.bufferedReader().readText() + val exitCode = process.waitFor() + if (exitCode != 0) { + Logger.warn("adb kill-server exited with code $exitCode: $output") + return@withContext Result.failure( + AdbException("adb kill-server exited with code $exitCode") + ) + } + weStartedDaemon = false + loggedAttachOnce = false // next attach (if any) re-logs once + Logger.info("ADB daemon killed by Morphe") + Result.success(true) + } catch (e: Exception) { + Logger.error("Failed to kill ADB server", e) + Result.failure(AdbException("Failed to kill ADB server: ${e.message}")) + } + } + /** * Get list of connected devices. * Returns list of device IDs and their status. diff --git a/src/main/kotlin/app/morphe/gui/util/DeviceMonitor.kt b/src/main/kotlin/app/morphe/gui/util/DeviceMonitor.kt index 1ce204e0..cc8a3fa0 100644 --- a/src/main/kotlin/app/morphe/gui/util/DeviceMonitor.kt +++ b/src/main/kotlin/app/morphe/gui/util/DeviceMonitor.kt @@ -34,8 +34,14 @@ object DeviceMonitor { if (!adbAvailable) return@launch - // Poll every 5 seconds + // Re-detect ownership on every poll. startServer() is cheap when + // the daemon's already alive (TCP port probe + early-return), + // and when the daemon has died externally + Morphe respawns it, + // ownership correctly flips to true — so a later kill on toggle + // OFF / window close tears down the daemon Morphe is actively + // keeping alive instead of leaking it. while (isActive) { + adbManager.startServer() refreshDevices() delay(5000) } @@ -47,6 +53,20 @@ object DeviceMonitor { pollingJob = null } + /** + * Stop polling AND kill the ADB daemon if Morphe owns it. Use this when + * the user toggles auto-start ADB OFF or closes the window. The owned-check + * lives in [AdbManager.killServerIfOwned] — if the daemon was already + * running when Morphe attached, this is a no-op. + * + * Clears device state immediately so UI doesn't flash stale entries. + */ + suspend fun stopMonitoringAndKillIfOwned() { + stopMonitoring() + _state.value = DeviceMonitorState(isAdbAvailable = _state.value.isAdbAvailable) + adbManager.killServerIfOwned() + } + fun selectDevice(device: AdbDevice) { _state.value = _state.value.copy(selectedDevice = device) }