Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 53 additions & 3 deletions src/main/kotlin/app/morphe/gui/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,21 @@ val LocalModeState = staticCompositionLocalOf<ModeState> {
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<AdbPreferenceState> {
error("No AdbPreferenceState provided")
}

@Composable
fun App(
initialSimplifiedMode: Boolean = true
Expand All @@ -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
Expand All @@ -84,6 +100,7 @@ private fun AppContent(
val config = configRepository.loadConfig()
themePreference = config.getThemePreference()
isSimplifiedMode = config.useSimplifiedMode
autoStartAdb = config.autoStartAdb
isLoading = false
}

Expand All @@ -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
Expand All @@ -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()
}
Expand All @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions src/main/kotlin/app/morphe/gui/GuiMain.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -34,6 +36,18 @@ fun launchGui(args: Array<String>) = 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)
Expand Down
6 changes: 6 additions & 0 deletions src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
68 changes: 68 additions & 0 deletions src/main/kotlin/app/morphe/gui/ui/components/DeviceIndicator.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand All @@ -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
Expand All @@ -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 }
Expand All @@ -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
Expand Down Expand Up @@ -94,6 +99,7 @@ fun DeviceIndicator(modifier: Modifier = Modifier) {
)

val displayText = when {
isAdbDisabledByUser -> "ADB OFF"
isAdbAvailable == null -> "Checking…"
isAdbAvailable == false -> "No ADB"
selectedDevice != null -> {
Expand All @@ -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
Expand Down Expand Up @@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -194,6 +196,8 @@ fun SettingsButton(
}
}
},
autoStartAdb = adbPreference.enabled,
onAutoStartAdbChange = { adbPreference.onChange(it) },
collapsibleSectionStates = collapsibleSectionStates,
onCollapsibleSectionToggle = { id, expanded ->
collapsibleSectionStates = collapsibleSectionStates + (id to expanded)
Expand Down
16 changes: 16 additions & 0 deletions src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ fun SettingsDialog(
onKeepArchitecturesChange: (Set<String>) -> 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<String, Boolean> = emptyMap(),
onCollapsibleSectionToggle: (id: String, expanded: Boolean) -> Unit = { _, _ -> }
) {
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<String?>(null) }
var installSuccess by remember { mutableStateOf(false) }
Expand Down Expand Up @@ -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 }
Expand Down
Loading
Loading