Skip to content

Commit a43de5a

Browse files
authored
fix: Close adb when app closes (#153)
The gui now properly closes the adb server when it's being closed, if it started it. Won't touch it if other process started it.
1 parent bd9b931 commit a43de5a

11 files changed

Lines changed: 468 additions & 9 deletions

File tree

src/main/kotlin/app/morphe/gui/App.kt

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,21 @@ val LocalModeState = staticCompositionLocalOf<ModeState> {
5151
error("No ModeState provided")
5252
}
5353

54+
/**
55+
* Auto-start ADB preference. Exposed as a composition local so the
56+
* SettingsDialog (writer) and DeviceIndicator + install buttons (readers)
57+
* can react without prop-drilling through Voyager screens. App-level
58+
* lifecycle (start/stop the daemon when this flips) is handled in [App.kt].
59+
*/
60+
data class AdbPreferenceState(
61+
val enabled: Boolean,
62+
val onChange: (Boolean) -> Unit,
63+
)
64+
65+
val LocalAdbPreference = staticCompositionLocalOf<AdbPreferenceState> {
66+
error("No AdbPreferenceState provided")
67+
}
68+
5469
@Composable
5570
fun App(
5671
initialSimplifiedMode: Boolean = true
@@ -76,6 +91,7 @@ private fun AppContent(
7691

7792
var themePreference by remember { mutableStateOf(ThemePreference.SYSTEM) }
7893
var isSimplifiedMode by remember { mutableStateOf(initialSimplifiedMode) }
94+
var autoStartAdb by remember { mutableStateOf(false) }
7995
var isLoading by remember { mutableStateOf(true) }
8096

8197
// Initialize PatchSourceManager and load config on startup
@@ -84,6 +100,7 @@ private fun AppContent(
84100
val config = configRepository.loadConfig()
85101
themePreference = config.getThemePreference()
86102
isSimplifiedMode = config.useSimplifiedMode
103+
autoStartAdb = config.autoStartAdb
87104
isLoading = false
88105
}
89106

@@ -105,6 +122,23 @@ private fun AppContent(
105122
}
106123
}
107124

125+
// Callback for the auto-start ADB toggle. Persists the preference AND
126+
// applies the change immediately: ON spins up DeviceMonitor (which
127+
// explicitly start-server's adb and records ownership); OFF cancels
128+
// polling and kill-server's the daemon if Morphe owns it.
129+
val onAutoStartAdbChange: (Boolean) -> Unit = { enabled ->
130+
autoStartAdb = enabled
131+
scope.launch {
132+
configRepository.setAutoStartAdb(enabled)
133+
if (enabled) {
134+
DeviceMonitor.startMonitoring()
135+
} else {
136+
DeviceMonitor.stopMonitoringAndKillIfOwned()
137+
}
138+
Logger.info("Auto-start ADB ${if (enabled) "enabled" else "disabled"}")
139+
}
140+
}
141+
108142
val themeState = ThemeState(
109143
current = themePreference,
110144
onChange = onThemeChange
@@ -115,9 +149,24 @@ private fun AppContent(
115149
onChange = onModeChange
116150
)
117151

118-
// Start/stop DeviceMonitor with app lifecycle
152+
val adbPreferenceState = AdbPreferenceState(
153+
enabled = autoStartAdb,
154+
onChange = onAutoStartAdbChange
155+
)
156+
157+
// Initial DeviceMonitor start. Gated on autoStartAdb so users who left
158+
// the toggle OFF don't spawn an unwanted adb daemon at launch. Runs once
159+
// after config finishes loading. Subsequent live toggles go through
160+
// [onAutoStartAdbChange], not this effect.
161+
LaunchedEffect(isLoading, autoStartAdb) {
162+
if (!isLoading && autoStartAdb) {
163+
DeviceMonitor.startMonitoring()
164+
}
165+
}
166+
// On Compose teardown (window close → exitApplication), cancel polling.
167+
// The kill-if-owned half runs from the JVM shutdown hook in [GuiMain.kt]
168+
// so it works even when the user quits via Cmd+Q without disposing.
119169
DisposableEffect(Unit) {
120-
DeviceMonitor.startMonitoring()
121170
onDispose {
122171
DeviceMonitor.stopMonitoring()
123172
}
@@ -126,7 +175,8 @@ private fun AppContent(
126175
MorpheTheme(themePreference = themePreference) {
127176
CompositionLocalProvider(
128177
LocalThemeState provides themeState,
129-
LocalModeState provides modeState
178+
LocalModeState provides modeState,
179+
LocalAdbPreference provides adbPreferenceState
130180
) {
131181
// Tint the OS title bar (Windows DWM caption color, macOS traffic
132182
// light contrast) to match the active theme's surface color.

src/main/kotlin/app/morphe/gui/GuiMain.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import androidx.compose.ui.window.WindowPosition
1818
import androidx.compose.ui.window.application
1919
import androidx.compose.ui.window.rememberWindowState
2020
import app.morphe.gui.data.model.AppConfig
21+
import app.morphe.gui.util.DeviceMonitor
22+
import kotlinx.coroutines.runBlocking
2123
import kotlinx.serialization.json.Json
2224
import org.jetbrains.skia.Image
2325
import app.morphe.gui.util.FileUtils
@@ -34,6 +36,18 @@ fun launchGui(args: Array<String>) = application {
3436
else -> loadConfigSync().useSimplifiedMode
3537
}
3638

39+
// Belt-and-braces: on any JVM-normal exit path (window close, Cmd+Q,
40+
// SIGTERM), kill the ADB daemon if Morphe spawned it. Compose's
41+
// DisposableEffect already cancels polling; this hook covers shutdown
42+
// routes where Compose teardown doesn't reach the suspend kill call.
43+
remember {
44+
Runtime.getRuntime().addShutdownHook(Thread {
45+
runCatching {
46+
runBlocking { DeviceMonitor.stopMonitoringAndKillIfOwned() }
47+
}
48+
})
49+
}
50+
3751
val windowState = rememberWindowState(
3852
size = DpSize(1024.dp, 768.dp),
3953
position = WindowPosition(Alignment.Center)

src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,12 @@ data class AppConfig(
7676
// after upgrading to multi-source builds. Flips to true once the user dismisses
7777
// the banner, never resets.
7878
val multiSourceHintDismissed: Boolean = false,
79+
// Whether Morphe should auto-start the ADB daemon at GUI launch to monitor
80+
// connected devices. Default OFF — many users never push patched APKs to a
81+
// device, so spawning a long-lived adb server unprompted is unwanted noise.
82+
// When ON, DeviceMonitor polls devices; if Morphe was the one that started
83+
// the daemon, it's killed on toggle-OFF and on window close.
84+
val autoStartAdb: Boolean = false,
7985
) {
8086

8187
fun getUpdateChannelPreference(): UpdateChannelPreference? {

src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,14 @@ class ConfigRepository {
296296
saveConfig(current.copy(patchSource = updatedSources))
297297
}
298298

299+
/**
300+
* Update whether Morphe auto-starts the ADB daemon at GUI launch.
301+
*/
302+
suspend fun setAutoStartAdb(enabled: Boolean) {
303+
val current = loadConfig()
304+
saveConfig(current.copy(autoStartAdb = enabled))
305+
}
306+
299307
/**
300308
* Mark the multi-source upgrade hint as dismissed. One-shot — never resets.
301309
*/

src/main/kotlin/app/morphe/gui/ui/components/DeviceIndicator.kt

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import androidx.compose.material.icons.Icons
2020
import androidx.compose.material.icons.filled.ArrowDropDown
2121
import androidx.compose.material.icons.filled.PhoneAndroid
2222
import androidx.compose.material.icons.filled.Info
23+
import androidx.compose.material.icons.filled.PowerSettingsNew
2324
import androidx.compose.material.icons.filled.UsbOff
2425
import androidx.compose.material3.*
2526
import androidx.compose.runtime.*
@@ -31,6 +32,7 @@ import androidx.compose.ui.text.font.FontWeight
3132
import androidx.compose.ui.text.style.TextOverflow
3233
import androidx.compose.ui.unit.dp
3334
import androidx.compose.ui.unit.sp
35+
import app.morphe.gui.LocalAdbPreference
3436
import app.morphe.gui.ui.theme.LocalMorpheAccents
3537
import app.morphe.gui.ui.theme.LocalMorpheFont
3638
import app.morphe.gui.ui.theme.LocalMorpheCorners
@@ -42,8 +44,10 @@ fun DeviceIndicator(modifier: Modifier = Modifier) {
4244
val corners = LocalMorpheCorners.current
4345
val mono = LocalMorpheFont.current
4446
val accents = LocalMorpheAccents.current
47+
val adbPreference = LocalAdbPreference.current
4548
val monitorState by DeviceMonitor.state.collectAsState()
4649

50+
val isAdbDisabledByUser = !adbPreference.enabled
4751
val isAdbAvailable = monitorState.isAdbAvailable
4852
val readyDevices = monitorState.devices.filter { it.isReady }
4953
val unauthorizedDevices = monitorState.devices.filter { it.status == DeviceStatus.UNAUTHORIZED }
@@ -55,6 +59,7 @@ fun DeviceIndicator(modifier: Modifier = Modifier) {
5559
val isHovered by hoverInteraction.collectIsHoveredAsState()
5660

5761
val dotColor = when {
62+
isAdbDisabledByUser -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.25f)
5863
isAdbAvailable == false -> MaterialTheme.colorScheme.error.copy(alpha = 0.7f)
5964
selectedDevice != null && selectedDevice.isReady -> accents.secondary
6065
unauthorizedDevices.isNotEmpty() -> accents.warning
@@ -94,6 +99,7 @@ fun DeviceIndicator(modifier: Modifier = Modifier) {
9499
)
95100

96101
val displayText = when {
102+
isAdbDisabledByUser -> "ADB OFF"
97103
isAdbAvailable == null -> "Checking…"
98104
isAdbAvailable == false -> "No ADB"
99105
selectedDevice != null -> {
@@ -110,6 +116,7 @@ fun DeviceIndicator(modifier: Modifier = Modifier) {
110116
fontWeight = FontWeight.Medium,
111117
fontFamily = mono,
112118
color = when {
119+
isAdbDisabledByUser -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
113120
isAdbAvailable == false -> MaterialTheme.colorScheme.error.copy(alpha = 0.7f)
114121
selectedDevice != null -> MaterialTheme.colorScheme.onSurface
115122
unauthorizedDevices.isNotEmpty() -> accents.warning
@@ -138,6 +145,67 @@ fun DeviceIndicator(modifier: Modifier = Modifier) {
138145
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.12f))
139146
) {
140147
when {
148+
isAdbDisabledByUser -> {
149+
DropdownMenuItem(
150+
text = {
151+
Row(
152+
verticalAlignment = Alignment.CenterVertically,
153+
horizontalArrangement = Arrangement.spacedBy(8.dp)
154+
) {
155+
Icon(
156+
imageVector = Icons.Default.PowerSettingsNew,
157+
contentDescription = null,
158+
modifier = Modifier.size(14.dp),
159+
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
160+
)
161+
Column {
162+
Text(
163+
text = "ADB is off",
164+
fontSize = 12.sp,
165+
fontWeight = FontWeight.SemiBold,
166+
fontFamily = mono,
167+
color = MaterialTheme.colorScheme.onSurface
168+
)
169+
Text(
170+
text = "Morphe is not monitoring connected devices",
171+
fontSize = 10.sp,
172+
fontFamily = mono,
173+
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
174+
)
175+
}
176+
}
177+
},
178+
onClick = { showPopup = false }
179+
)
180+
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
181+
DropdownMenuItem(
182+
text = {
183+
Row(
184+
verticalAlignment = Alignment.CenterVertically,
185+
horizontalArrangement = Arrangement.spacedBy(8.dp)
186+
) {
187+
Icon(
188+
imageVector = Icons.Default.PowerSettingsNew,
189+
contentDescription = null,
190+
modifier = Modifier.size(14.dp),
191+
tint = accents.primary
192+
)
193+
Text(
194+
text = "Enable ADB",
195+
fontSize = 11.sp,
196+
fontWeight = FontWeight.Medium,
197+
fontFamily = mono,
198+
color = accents.primary
199+
)
200+
}
201+
},
202+
onClick = {
203+
adbPreference.onChange(true)
204+
showPopup = false
205+
}
206+
)
207+
}
208+
141209
isAdbAvailable == false -> {
142210
DropdownMenuItem(
143211
text = {

src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
package app.morphe.gui.ui.components
77

8+
import app.morphe.gui.LocalAdbPreference
89
import app.morphe.gui.LocalModeState
910
import androidx.compose.animation.animateColorAsState
1011
import androidx.compose.animation.core.tween
@@ -60,6 +61,7 @@ fun SettingsButton(
6061
val corners = LocalMorpheCorners.current
6162
val themeState = LocalThemeState.current
6263
val modeState = LocalModeState.current
64+
val adbPreference = LocalAdbPreference.current
6365
val configRepository: ConfigRepository = koinInject()
6466
val patchSourceManager: PatchSourceManager = koinInject()
6567
val updateCheckRepository: UpdateCheckRepository = koinInject()
@@ -194,6 +196,8 @@ fun SettingsButton(
194196
}
195197
}
196198
},
199+
autoStartAdb = adbPreference.enabled,
200+
onAutoStartAdbChange = { adbPreference.onChange(it) },
197201
collapsibleSectionStates = collapsibleSectionStates,
198202
onCollapsibleSectionToggle = { id, expanded ->
199203
collapsibleSectionStates = collapsibleSectionStates + (id to expanded)

src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,8 @@ fun SettingsDialog(
107107
onKeepArchitecturesChange: (Set<String>) -> Unit = {},
108108
updateChannelPreference: app.morphe.gui.data.model.UpdateChannelPreference = app.morphe.gui.data.model.UpdateChannelPreference.STABLE,
109109
onUpdateChannelChange: (app.morphe.gui.data.model.UpdateChannelPreference) -> Unit = {},
110+
autoStartAdb: Boolean = false,
111+
onAutoStartAdbChange: (Boolean) -> Unit = {},
110112
collapsibleSectionStates: Map<String, Boolean> = emptyMap(),
111113
onCollapsibleSectionToggle: (id: String, expanded: Boolean) -> Unit = { _, _ -> }
112114
) {
@@ -277,6 +279,20 @@ fun SettingsDialog(
277279

278280
SettingsDivider(borderColor)
279281

282+
// ── Auto-start ADB ──
283+
SettingToggleRow(
284+
label = "Auto-start ADB",
285+
description = "Spawn the ADB daemon on launch so connected devices are monitored. " +
286+
"When off, Morphe never starts the server, and install/push features are disabled.",
287+
checked = autoStartAdb,
288+
onCheckedChange = onAutoStartAdbChange,
289+
accentColor = accents.primary,
290+
mono = mono,
291+
enabled = !isPatching
292+
)
293+
294+
SettingsDivider(borderColor)
295+
280296
// ── Patched App Runtime Logs ──
281297
PatchedAppRuntimeLogsSection(
282298
mono = mono,

src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import cafe.adriel.voyager.core.screen.Screen
3939
import app.morphe.morphe_cli.generated.resources.Res
4040
import app.morphe.morphe_cli.generated.resources.morphe_dark
4141
import app.morphe.morphe_cli.generated.resources.morphe_light
42+
import app.morphe.gui.LocalAdbPreference
4243
import app.morphe.gui.data.model.Patch
4344
import app.morphe.gui.data.model.SupportedApp
4445
import app.morphe.gui.data.repository.ConfigRepository
@@ -1189,6 +1190,8 @@ private fun CompletedContent(
11891190
val scope = rememberCoroutineScope()
11901191
val adbManager = remember { AdbManager() }
11911192
val monitorState by DeviceMonitor.state.collectAsState()
1193+
val adbPreference = LocalAdbPreference.current
1194+
val isAdbDisabledByUser = !adbPreference.enabled
11921195
var isInstalling by remember { mutableStateOf(false) }
11931196
var installError by remember { mutableStateOf<String?>(null) }
11941197
var installSuccess by remember { mutableStateOf(false) }
@@ -1326,8 +1329,44 @@ private fun CompletedContent(
13261329
}
13271330
}
13281331

1329-
// ADB install
1330-
if (monitorState.isAdbAvailable == true) {
1332+
// ADB install — when the user has the toggle off, render a compact
1333+
// "ADB OFF" hint with an inline enable button rather than hiding the
1334+
// affordance entirely (otherwise users wonder where install went).
1335+
if (isAdbDisabledByUser) {
1336+
Spacer(modifier = Modifier.height(12.dp))
1337+
val enableHover = remember { MutableInteractionSource() }
1338+
val enableHovered by enableHover.collectIsHoveredAsState()
1339+
Box(
1340+
modifier = Modifier
1341+
.widthIn(max = 480.dp)
1342+
.fillMaxWidth()
1343+
.height(38.dp)
1344+
.hoverable(enableHover)
1345+
.clip(RoundedCornerShape(corners.small))
1346+
.border(
1347+
1.dp,
1348+
if (enableHovered) accents.primary.copy(alpha = 0.5f)
1349+
else accents.primary.copy(alpha = 0.25f),
1350+
RoundedCornerShape(corners.small)
1351+
)
1352+
.background(
1353+
if (enableHovered) accents.primary.copy(alpha = 0.08f)
1354+
else Color.Transparent,
1355+
RoundedCornerShape(corners.small)
1356+
)
1357+
.clickable { adbPreference.onChange(true) },
1358+
contentAlignment = Alignment.Center
1359+
) {
1360+
Text(
1361+
text = "ADB OFF · ENABLE TO INSTALL",
1362+
fontSize = 11.sp,
1363+
fontWeight = FontWeight.Bold,
1364+
fontFamily = mono,
1365+
color = accents.primary,
1366+
letterSpacing = 0.5.sp
1367+
)
1368+
}
1369+
} else if (monitorState.isAdbAvailable == true) {
13311370
Spacer(modifier = Modifier.height(12.dp))
13321371

13331372
val readyDevices = monitorState.devices.filter { it.isReady }

0 commit comments

Comments
 (0)