Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,26 @@ class AutoMobileAccessibilityService : AccessibilityService() {
}
}

private val screenStateReceiver =
object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
Intent.ACTION_SCREEN_ON -> {
Log.i(TAG, "Screen turned ON, triggering hierarchy extraction")
if (::hierarchyDebouncer.isInitialized) {
hierarchyDebouncer.extractNow()
}
}
Intent.ACTION_SCREEN_OFF -> {
Log.i(TAG, "Screen turned OFF, triggering hierarchy extraction")
if (::hierarchyDebouncer.isInitialized) {
hierarchyDebouncer.extractNow()
}
}
}
}
}

private val anrReceiver =
object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
Expand Down Expand Up @@ -575,6 +595,14 @@ class AutoMobileAccessibilityService : AccessibilityService() {
}
Log.d(TAG, "ANR receiver registered")

val screenStateFilter =
IntentFilter().apply {
addAction(Intent.ACTION_SCREEN_ON)
addAction(Intent.ACTION_SCREEN_OFF)
}
registerReceiver(screenStateReceiver, screenStateFilter)
Log.d(TAG, "Screen state receiver registered")

// Initialize the navigation event accumulator
navigationEventAccumulator.initialize()
Log.d(TAG, "Navigation event accumulator initialized")
Expand Down Expand Up @@ -814,6 +842,12 @@ class AutoMobileAccessibilityService : AccessibilityService() {
Log.e(TAG, "Error unregistering ANR receiver", e)
}

try {
unregisterReceiver(screenStateReceiver)
} catch (e: Exception) {
Log.e(TAG, "Error unregistering screen state receiver", e)
}

if (::overlayDrawer.isInitialized) {
overlayDrawer.destroy()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,24 +234,12 @@ class GitWorktreeLister : WorktreeLister {
}
}

class DefaultPortScanner : PortScanner {
override suspend fun scanListeningPorts(): Set<Int> =
withContext(Dispatchers.IO) {
val osName = System.getProperty("os.name", "").lowercase()
val outputs = mutableListOf<String>()

if (osName.contains("win")) {
runCommand(listOf("netstat", "-ano", "-p", "tcp"))?.let { outputs.add(it) }
} else {
runCommand(listOf("lsof", "-iTCP", "-sTCP:LISTEN", "-n", "-P"))?.let { outputs.add(it) }
runCommand(listOf("ss", "-ltn"))?.let { outputs.add(it) }
runCommand(listOf("netstat", "-an"))?.let { outputs.add(it) }
}

outputs.flatMap { parsePorts(it) }.toSet()
}
interface PortCommandRunner {
fun runCommand(command: List<String>): String?
}

private fun runCommand(command: List<String>): String? {
class SystemPortCommandRunner : PortCommandRunner {
override fun runCommand(command: List<String>): String? {
return try {
val process = ProcessBuilder(command).redirectErrorStream(true).start()
val completed = process.waitFor(2, TimeUnit.SECONDS)
Expand All @@ -267,8 +255,50 @@ class DefaultPortScanner : PortScanner {
null
}
}
}

interface PlatformInfo {
val osName: String
}

object SystemPlatformInfo : PlatformInfo {
override val osName: String
get() = System.getProperty("os.name", "").lowercase()
}

class DefaultPortScanner(
private val commandRunner: PortCommandRunner = SystemPortCommandRunner(),
private val platformInfo: PlatformInfo = SystemPlatformInfo,
) : PortScanner {
override suspend fun scanListeningPorts(): Set<Int> = coroutineScope {
val osName = platformInfo.osName
val commands = buildCommandList(osName)

val outputs = commands
.map { cmd -> async(Dispatchers.IO) { commandRunner.runCommand(cmd) } }
.awaitAll()
.filterNotNull()

outputs.flatMap { parsePorts(it) }.toSet()
}

internal fun buildCommandList(osName: String): List<List<String>> {
return if (osName.contains("win")) {
listOf(listOf("netstat", "-ano", "-p", "tcp"))
} else {
val commands = mutableListOf(
listOf("lsof", "-iTCP", "-sTCP:LISTEN", "-n", "-P"),
)
// ss is not available on macOS — skip it to avoid a 2s timeout
if (!osName.contains("mac") && !osName.contains("darwin")) {
commands.add(listOf("ss", "-ltn"))
}
commands.add(listOf("netstat", "-an"))
commands
}
}

private fun parsePorts(output: String): List<Int> {
internal fun parsePorts(output: String): List<Int> {
val ports = mutableListOf<Int>()
val listenRegex = Regex(":(\\d+)(?:\\s|\\)|$)")
for (line in output.lineSequence()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
Expand All @@ -33,7 +32,9 @@ import androidx.compose.ui.unit.sp
import dev.jasonpearson.automobile.ide.datasource.DataSourceMode
import dev.jasonpearson.automobile.ide.mcp.McpProcess
import dev.jasonpearson.automobile.ide.mcp.McpConnectionType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import org.jetbrains.jewel.foundation.theme.JewelTheme
import org.jetbrains.jewel.ui.component.Text
import java.io.File
Expand All @@ -50,18 +51,15 @@ fun DiagnosticsDashboard(
) {
val colors = JewelTheme.globalColors

// Auto-refresh diagnostics every 2 seconds
var refreshTrigger by remember { mutableLongStateOf(0L) }
// Run system checks off the UI thread and refresh every 30 seconds
var systemChecks by remember { mutableStateOf(emptyList<DiagnosticCheck>()) }
LaunchedEffect(Unit) {
while (true) {
delay(2000)
refreshTrigger = System.currentTimeMillis()
systemChecks = withContext(Dispatchers.IO) { runSystemChecks() }
delay(30_000)
}
}

// System checks
val systemChecks = remember(refreshTrigger) { runSystemChecks() }

Column(
modifier = modifier
.fillMaxSize()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,54 @@ class FakeMcpProcessDetector : McpProcessDetector {
)
}

/**
* Runs a subprocess and returns its stdout lines, or null on failure.
*/
interface ProcessRunner {
fun runAndReadLines(command: List<String>): List<String>?
}

class SystemProcessRunner : ProcessRunner {
override fun runAndReadLines(command: List<String>): List<String>? {
return try {
val pb = ProcessBuilder(command)
pb.redirectErrorStream(true)
val process = pb.start()
val lines = BufferedReader(InputStreamReader(process.inputStream)).use { reader ->
reader.readLines()
}
process.waitFor()
lines
} catch (e: Exception) {
null
}
}
}

/**
* Checks whether daemon socket files exist in /tmp.
*/
interface SocketFileChecker {
fun findDaemonSocketFiles(): List<String>
}

class RealSocketFileChecker : SocketFileChecker {
override fun findDaemonSocketFiles(): List<String> {
val tmpDir = File("/tmp")
if (!tmpDir.isDirectory) return emptyList()
return tmpDir.listFiles { f ->
f.name.startsWith("auto-mobile-daemon") && f.name.endsWith(".sock")
}?.map { it.absolutePath } ?: emptyList()
}
}

/**
* Real implementation that detects actual MCP processes on the system
*/
class RealMcpProcessDetector(
private val timeProvider: TimeProvider = SystemTimeProvider,
private val processRunner: ProcessRunner = SystemProcessRunner(),
private val socketFileChecker: SocketFileChecker = RealSocketFileChecker(),
) : McpProcessDetector {

override fun detectProcesses(): List<McpProcess> {
Expand All @@ -72,15 +115,19 @@ class RealMcpProcessDetector(
// Find auto-mobile processes via ps
val psProcesses = findAutoMobileProcesses()

// Fast-path: check if any daemon socket files exist at all
val socketFilesExist = socketFileChecker.findDaemonSocketFiles().isNotEmpty()

// Match processes with their connection types
psProcesses.forEach { (pid, name, startTime, cmdLine) ->
val uptimeMs = timeProvider.currentTimeMillis() - startTime

// Determine connection type based on what THIS specific process is actually doing
val (connectionType, port, socketPath) = when {
// Check if THIS process is listening on a Unix socket
isListeningOnSocket(pid) != null -> {
Triple(McpConnectionType.UnixSocket, null, isListeningOnSocket(pid))
val socketPath = if (socketFilesExist) isListeningOnSocket(pid) else null

val (connectionType, port, resolvedSocketPath) = when {
socketPath != null -> {
Triple(McpConnectionType.UnixSocket, null, socketPath)
}
// Check if THIS process is the daemon running in HTTP mode
// Only --daemon-mode is the actual daemon; --daemon start is just the manager
Expand All @@ -103,7 +150,7 @@ class RealMcpProcessDetector(
name = name,
connectionType = connectionType,
port = port,
socketPath = socketPath,
socketPath = resolvedSocketPath,
uptimeMs = uptimeMs,
commandLine = cmdLine,
)
Expand All @@ -114,33 +161,15 @@ class RealMcpProcessDetector(
}

private fun findAutoMobileProcesses(): List<ProcessInfo> {
val processes = mutableListOf<ProcessInfo>()
val lines = processRunner.runAndReadLines(listOf("ps", "-eo", "pid,lstart,command"))
?: return emptyList()

try {
// Use ps to find auto-mobile processes with start time
// Format: pid, lstart (full date), command
val pb = ProcessBuilder("ps", "-eo", "pid,lstart,command")
pb.redirectErrorStream(true)
val process = pb.start()

BufferedReader(InputStreamReader(process.inputStream)).use { reader ->
reader.lineSequence()
.filter { it.contains("auto-mobile") && !it.contains("grep") }
.forEach { line ->
parseProcessLine(line)?.let { processes.add(it) }
}
}

process.waitFor()
} catch (e: Exception) {
// Log error but don't crash
e.printStackTrace()
}

return processes
return lines
.filter { it.contains("auto-mobile") && !it.contains("grep") }
.mapNotNull { line -> parseProcessLine(line) }
}

private fun parseProcessLine(line: String): ProcessInfo? {
internal fun parseProcessLine(line: String): ProcessInfo? {
// Parse: " PID STARTED COMMAND"
// Example: "97956 Wed Jan 22 11:00:00 2025 bun /path/to/auto-mobile"
val trimmed = line.trim()
Expand Down Expand Up @@ -169,64 +198,54 @@ class RealMcpProcessDetector(
}
}

private fun extractProcessName(command: String): String {
// Extract meaningful name from command line
internal fun extractProcessName(command: String): String {
return when {
command.contains("auto-mobile-daemon") -> "auto-mobile-daemon"
command.contains("auto-mobile") -> "auto-mobile"
else -> command.substringAfterLast('/').substringBefore(' ')
}
}

private fun extractPort(cmdLine: String): Int? {
// Try to extract port from command line
internal fun extractPort(cmdLine: String): Int? {
val portRegex = Regex("--port[=\\s](\\d+)|:(\\d{4,5})")
val match = portRegex.find(cmdLine)
return match?.groupValues?.drop(1)?.firstOrNull { it.isNotEmpty() }?.toIntOrNull()
}


private fun isProcessRunning(pid: Int): Boolean {
return try {
// Send signal 0 to check if process exists (doesn't actually send a signal)
val pb = ProcessBuilder("kill", "-0", pid.toString())
pb.redirectErrorStream(true)
val process = pb.start()
val exitCode = process.waitFor()
exitCode == 0
} catch (e: Exception) {
false
internal fun classifyConnection(
socketPath: String?,
cmdLine: String,
): Triple<McpConnectionType, Int?, String?> {
return when {
socketPath != null -> {
Triple(McpConnectionType.UnixSocket, null, socketPath)
}
cmdLine.contains("--daemon-mode") -> {
Triple(McpConnectionType.StreamableHttp, extractPort(cmdLine) ?: 3000, null)
}
cmdLine.contains("--port") || cmdLine.contains(":3000") || cmdLine.contains("http") -> {
Triple(McpConnectionType.StreamableHttp, extractPort(cmdLine) ?: 3000, null)
}
else -> {
Triple(McpConnectionType.Stdio, null, null)
}
}
}

/**
* Check if a process is listening on a daemon Unix socket.
* Returns the socket path if found, null otherwise.
*/
private fun isListeningOnSocket(pid: Int): String? {
return try {
// Use lsof to find Unix sockets for this process
val pb = ProcessBuilder("lsof", "-p", pid.toString(), "-a", "-U")
pb.redirectErrorStream(true)
val process = pb.start()

BufferedReader(InputStreamReader(process.inputStream)).use { reader ->
reader.lineSequence()
.filter { it.contains("/tmp/auto-mobile-daemon") && it.contains(".sock") }
.mapNotNull { line ->
// Extract socket path from lsof output
// Format: "bun 3122 jason 17u unix 0x... 0t0 /tmp/auto-mobile-daemon-501.sock"
val parts = line.trim().split(Regex("\\s+"))
parts.lastOrNull()?.takeIf { it.startsWith("/tmp/auto-mobile-daemon") }
}
.firstOrNull()
val lines = processRunner.runAndReadLines(listOf("lsof", "-p", pid.toString(), "-a", "-U"))
?: return null

return lines
.filter { it.contains("/tmp/auto-mobile-daemon") && it.contains(".sock") }
.mapNotNull { line ->
val parts = line.trim().split(Regex("\\s+"))
parts.lastOrNull()?.takeIf { it.startsWith("/tmp/auto-mobile-daemon") }
}
} catch (e: Exception) {
null
}
.firstOrNull()
}

private data class ProcessInfo(
internal data class ProcessInfo(
val pid: Int,
val name: String,
val startTime: Long,
Expand Down
Loading
Loading