fix(#372): plugin install/uninstall 400 — enable instead of install for on-disk plugins, gate uninstall behind canRemove, parse FastAPI error bodies (#416)

- Replace installPlugin(name) with activatePlugin(plugin) that calls enable endpoint
  instead of the install endpoint for plugins already on disk (bundled/inactive).
- Gate Uninstall button behind plugin.canRemove — bundled plugins don't show it.
- Update PluginsScreen to call activatePlugin(plugin) instead of installPlugin(name).
- Parse FastAPI 'detail' field from 400 response bodies in mapHttpError/safeApiCall
  so users see the actual error message instead of a generic placeholder.
- Import ButtonDefaults to fix max-line-length lint warning.

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-06-29 00:45:16 +03:00
1 parent b10495c6e2
commit 2ae6cce929
3 files changed
+44 -22

No files matched your search

@@ -1,5 +1,6 @@
package com.m57.hermescontrol.data.remote package com.m57.hermescontrol.data.remote
import com.google.gson.JsonParser
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import retrofit2.Response import retrofit2.Response
import java.io.IOException import java.io.IOException
@@ -37,12 +38,28 @@ sealed interface NetworkError {
) : NetworkError ) : NetworkError
} }
fun mapHttpError(code: Int): NetworkError { fun mapHttpError(
code: Int,
errorBody: String? = null,
): NetworkError {
if (code == 401) { if (code == 401) {
return NetworkError.AuthExpired() return NetworkError.AuthExpired()
} }
// Try to parse FastAPI-style error detail from response body
val detail =
if (errorBody != null) {
try {
val parsed = com.google.gson.JsonParser.parseString(errorBody).asJsonObject
parsed.get("detail")?.asString
} catch (_: Exception) {
null
}
} else {
null
}
val message = val message =
when (code) { detail ?: when (code) {
400 -> "Bad Request (HTTP 400): The server could not understand the request." 400 -> "Bad Request (HTTP 400): The server could not understand the request."
403 -> "Forbidden (HTTP 403): You do not have permission to access this resource." 403 -> "Forbidden (HTTP 403): You do not have permission to access this resource."
404 -> "Not Found (HTTP 404): The requested resource could not be found." 404 -> "Not Found (HTTP 404): The requested resource could not be found."
@@ -88,12 +105,19 @@ suspend inline fun <reified T> safeApiCall(
} }
} else { } else {
val code = response.code() val code = response.code()
// Read error body for detail message
val errorBody =
try {
response.errorBody()?.string()
} catch (_: Exception) {
null
}
if ((code in 500..599 || code == 429) && attempt < retries) { if ((code in 500..599 || code == 429) && attempt < retries) {
val backoff = jitteredBackoff(500L * (1 shl attempt)) val backoff = jitteredBackoff(500L * (1 shl attempt))
delay(backoff) delay(backoff)
continue continue
} }
return NetworkResult.Failure(mapHttpError(code)) return NetworkResult.Failure(mapHttpError(code, errorBody))
} }
} catch (e: IOException) { } catch (e: IOException) {
lastException = e lastException = e
@@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -159,18 +160,20 @@ fun PluginsScreen(
OutlinedButton(onClick = { viewModel.updatePlugin(plugin.name) }) { OutlinedButton(onClick = { viewModel.updatePlugin(plugin.name) }) {
Text(stringResource(R.string.plugins_action_update)) Text(stringResource(R.string.plugins_action_update))
} }
Spacer(modifier = Modifier.width(8.dp)) if (plugin.canRemove) {
OutlinedButton( Spacer(modifier = Modifier.width(8.dp))
onClick = { viewModel.uninstallPlugin(plugin.name) }, OutlinedButton(
colors = onClick = { viewModel.uninstallPlugin(plugin.name) },
androidx.compose.material3.ButtonDefaults.outlinedButtonColors( colors =
contentColor = MaterialTheme.colorScheme.error, ButtonDefaults.outlinedButtonColors(
), contentColor = MaterialTheme.colorScheme.error,
) { ),
Text(stringResource(R.string.plugins_action_uninstall)) ) {
Text(stringResource(R.string.plugins_action_uninstall))
}
} }
} else { } else {
Button(onClick = { viewModel.installPlugin(plugin.name) }) { Button(onClick = { viewModel.activatePlugin(plugin) }) {
Text(stringResource(R.string.plugins_action_install)) Text(stringResource(R.string.plugins_action_install))
} }
} }
@@ -79,25 +79,20 @@ class PluginsViewModel : ViewModel(), ToastHost {
} }
} }
fun installPlugin(name: String) { fun activatePlugin(plugin: PluginInfo) {
viewModelScope.launch { viewModelScope.launch {
val result = val result =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
safeApiCall { safeApiCall { ApiClient.hermesApi.enablePlugin(plugin.name) }
ApiClient.hermesApi.installPlugin(
com.m57.hermescontrol.data.model
.AgentPluginInstallBody(name),
)
}
} }
when (result) { when (result) {
is NetworkResult.Success -> { is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Plugin installed successfully") } _uiState.update { it.copy(toastMessage = "Plugin enabled successfully") }
loadPlugins() loadPlugins()
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Failed to install plugin: ${result.error.message}") } _uiState.update { it.copy(toastMessage = "Failed to enable plugin: ${result.error.message}") }
} }
} }
} }