6 files changed
+247
-16
No files matched your search
@@ -81,6 +81,26 @@ data class PluginsHubResponse(
|
||||
val providers: PluginsHubProviders? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PluginUpdateRequest(
|
||||
@SerialName("accept_capabilities") val acceptCapabilities: Boolean,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PluginUpdateResult(
|
||||
val ok: Boolean = false,
|
||||
val unchanged: Boolean = false,
|
||||
@SerialName("consent_required") val consentRequired: Boolean = false,
|
||||
val delta: Map<String, List<String>>? = null,
|
||||
@SerialName("delta_lines") val deltaLines: List<String>? = null,
|
||||
val error: String? = null,
|
||||
) {
|
||||
val capabilityDelta: List<String>
|
||||
get() =
|
||||
deltaLines?.takeIf { it.isNotEmpty() }
|
||||
?: delta.orEmpty().map { (surface, added) -> "$surface: ${added.joinToString() }" }
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class AgentPluginInstallBody(
|
||||
val identifier: String = "",
|
||||
|
||||
@@ -670,7 +670,8 @@ interface HermesApiService : KanbanApiService {
|
||||
@POST("api/dashboard/agent-plugins/{name}/update")
|
||||
suspend fun updatePlugin(
|
||||
@Path("name", encoded = true) name: String,
|
||||
): Response<Unit>
|
||||
@Body body: com.m57.hermescontrol.data.model.PluginUpdateRequest,
|
||||
): Response<com.m57.hermescontrol.data.model.PluginUpdateResult>
|
||||
|
||||
@POST("api/dashboard/agent-plugins/{name}/enable")
|
||||
suspend fun enablePlugin(
|
||||
|
||||
@@ -13,6 +13,8 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.InstallDesktop
|
||||
@@ -120,6 +122,37 @@ fun PluginsScreen(
|
||||
|
||||
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
|
||||
|
||||
state.updateConsent?.let { consent ->
|
||||
AlertDialog(
|
||||
onDismissRequest = viewModel::cancelPluginUpdate,
|
||||
title = { Text(stringResource(R.string.plugins_update_consent_title)) },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.plugins_update_consent_text, consent.name))
|
||||
val lines = consent.result.capabilityDelta
|
||||
if (lines.isEmpty()) {
|
||||
Text(consent.result.error ?: stringResource(R.string.plugins_update_consent_missing))
|
||||
} else {
|
||||
lines.forEach { Text(it) }
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = viewModel::confirmPluginUpdate, enabled = state.rowBusy == null) {
|
||||
Text(stringResource(R.string.plugins_update_accept))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = viewModel::cancelPluginUpdate) {
|
||||
Text(stringResource(R.string.common_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Remove confirmation dialog
|
||||
if (state.removeConfirmPlugin != null) {
|
||||
AlertDialog(
|
||||
|
||||
@@ -9,6 +9,8 @@ import com.m57.hermescontrol.data.model.AgentPluginInstallBody
|
||||
import com.m57.hermescontrol.data.model.PluginCatalogEntry
|
||||
import com.m57.hermescontrol.data.model.PluginInfo
|
||||
import com.m57.hermescontrol.data.model.PluginProvidersPutRequest
|
||||
import com.m57.hermescontrol.data.model.PluginUpdateRequest
|
||||
import com.m57.hermescontrol.data.model.PluginUpdateResult
|
||||
import com.m57.hermescontrol.data.model.PluginsHubResponse
|
||||
import com.m57.hermescontrol.data.model.ProviderOption
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
@@ -33,6 +35,12 @@ enum class PluginsTab {
|
||||
CATALOG,
|
||||
}
|
||||
|
||||
data class PluginUpdateConsent(
|
||||
val name: String,
|
||||
val result: PluginUpdateResult,
|
||||
val scope: DataScope?,
|
||||
)
|
||||
|
||||
data class PluginsUiState(
|
||||
val selectedTab: PluginsTab = PluginsTab.INSTALLED,
|
||||
val isLoading: Boolean = false,
|
||||
@@ -55,6 +63,7 @@ data class PluginsUiState(
|
||||
val rowBusy: String? = null,
|
||||
// Confirm remove dialog
|
||||
val removeConfirmPlugin: String? = null,
|
||||
val updateConsent: PluginUpdateConsent? = null,
|
||||
// Rescan
|
||||
val rescanBusy: Boolean = false,
|
||||
// Catalog state
|
||||
@@ -99,6 +108,7 @@ class PluginsViewModel(
|
||||
providerBusy = false,
|
||||
rowBusy = null,
|
||||
removeConfirmPlugin = null,
|
||||
updateConsent = null,
|
||||
rescanBusy = false,
|
||||
)
|
||||
}
|
||||
@@ -450,23 +460,83 @@ class PluginsViewModel(
|
||||
}
|
||||
|
||||
fun updatePlugin(name: String) {
|
||||
viewModelScope.launch {
|
||||
setRowBusy(name)
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
safeApiCall { ApiClient.hermesApi.updatePlugin(name) }
|
||||
}
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
_uiState.update { it.copy(toastMessage = "Plugin updated successfully") }
|
||||
clearRowBusy(name)
|
||||
loadPlugins(forceRefresh = true)
|
||||
}
|
||||
if (_uiState.value.updateConsent != null) return
|
||||
performPluginUpdate(name, acceptCapabilities = false)
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update { it.copy(toastMessage = "Failed to update plugin: ${result.error.message}") }
|
||||
clearRowBusy(name)
|
||||
fun cancelPluginUpdate() {
|
||||
_uiState.update { it.copy(updateConsent = null) }
|
||||
}
|
||||
|
||||
fun confirmPluginUpdate() {
|
||||
val consent = _uiState.value.updateConsent ?: return
|
||||
if (_uiState.value.rowBusy != null) return
|
||||
cancelPluginUpdate()
|
||||
if (consent.scope != runCatching { AuthManager.currentDataScope() }.getOrNull()) return
|
||||
performPluginUpdate(consent.name, acceptCapabilities = true)
|
||||
}
|
||||
|
||||
private fun performPluginUpdate(
|
||||
name: String,
|
||||
acceptCapabilities: Boolean,
|
||||
) {
|
||||
if (_uiState.value.rowBusy != null) return
|
||||
val generation = scopeGeneration
|
||||
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
val api = ApiClient.hermesApi
|
||||
setRowBusy(name)
|
||||
_uiState.update { it.copy(toastMessage = null) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val result = safeApiCall { api.updatePlugin(name, PluginUpdateRequest(acceptCapabilities)) }
|
||||
if (generation != scopeGeneration ||
|
||||
requestScope != runCatching { AuthManager.currentDataScope() }.getOrNull()
|
||||
) {
|
||||
return@launch
|
||||
}
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
val update = result.data
|
||||
// #1282: HTTP success is not update success; consent must be explicit.
|
||||
when {
|
||||
update.consentRequired -> {
|
||||
_uiState.update {
|
||||
it.copy(updateConsent = PluginUpdateConsent(name, update, requestScope))
|
||||
}
|
||||
}
|
||||
|
||||
!update.ok -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
toastMessage = "Failed to update plugin: ${update.error ?: "Update refused"}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
toastMessage =
|
||||
if (update.unchanged) {
|
||||
"Plugin is already up to date"
|
||||
} else {
|
||||
"Plugin updated successfully"
|
||||
},
|
||||
)
|
||||
}
|
||||
loadPlugins(forceRefresh = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is NetworkResult.Failure -> {
|
||||
_uiState.update {
|
||||
it.copy(toastMessage = "Failed to update plugin: ${result.error.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (generation == scopeGeneration) clearRowBusy(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user