Redesign plugins UI and demo raw + high-level hooks

- Match exteraGram-style card layout with master toggle and plugin actions
- Add usage manifest field, pin/delete/removePlugin engine APIs
- Hello World plugin hooks Logger.d (raw) and send pipeline (high-level)
- Bundle updated hello_world.fcplugin; stop debug auto-enable

Co-authored-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
Cursor Agent
2026-09-16 22:35:49 +00:00
Unverified
parent ad7b4d01b9
commit efbb5de9c6
14 changed files with 541 additions and 67 deletions
@@ -0,0 +1,5 @@
package ru.fromchat.plugins.integration
actual object PluginInstallSupport {
actual fun installFromPicker(): Boolean = false
}
@@ -340,14 +340,24 @@
<string name="debug_tools_d">Просмотр ответов API профиля и личных сообщений.</string> <string name="debug_tools_d">Просмотр ответов API профиля и личных сообщений.</string>
<string name="plugins_title">Плагины</string> <string name="plugins_title">Плагины</string>
<string name="plugins_enable_system">Включить систему плагинов</string>
<string name="plugins_engine">Движок плагинов</string> <string name="plugins_engine">Движок плагинов</string>
<string name="plugins_engine_d">Запуск Kotlin-плагинов для расширения FromChat.</string> <string name="plugins_engine_d">Запуск Kotlin-плагинов для расширения FromChat.</string>
<string name="plugins_developer_mode">Режим разработчика</string> <string name="plugins_developer_mode">Режим разработчика</string>
<string name="plugins_developer_mode_d">Включает локальный DevServer на порту 42690.</string> <string name="plugins_developer_mode_d">Включает локальный DevServer на порту 42690.</string>
<string name="plugins_installed">Установленные плагины</string> <string name="plugins_installed">Установленные плагины</string>
<string name="plugins_none_installed">Плагины ещё не установлены.</string> <string name="plugins_none_installed">Плагины ещё не установлены.</string>
<string name="plugins_no_results">Нет плагинов по вашему запросу.</string>
<string name="plugins_enabled">Включено</string> <string name="plugins_enabled">Включено</string>
<string name="plugins_settings_title">Настройки плагина</string> <string name="plugins_settings_title">Настройки плагина</string>
<string name="plugins_search">Поиск плагинов</string>
<string name="plugins_usage">Использование:</string>
<string name="plugins_version_author">Версия %1$s • @%2$s</string>
<string name="plugins_delete">Удалить</string>
<string name="plugins_delete_confirm">Удалить «%1$s» и все его данные?</string>
<string name="plugins_share_copied">Информация о плагине скопирована.</string>
<string name="plugins_info_title">О плагинах</string>
<string name="plugins_info_body">Плагины могут менять интерфейс, перехватывать сообщения и подключаться к внутренним методам приложения. Устанавливайте плагины только из проверенных источников.</string>
<string name="settings_category_appearance">Оформление</string> <string name="settings_category_appearance">Оформление</string>
<string name="settings_category_appearance_d">Тема и Material You</string> <string name="settings_category_appearance_d">Тема и Material You</string>
@@ -371,14 +371,24 @@
<string name="debug_tools_d">Inspect profile and DM endpoints used by the client.</string> <string name="debug_tools_d">Inspect profile and DM endpoints used by the client.</string>
<string name="plugins_title">Plugins</string> <string name="plugins_title">Plugins</string>
<string name="plugins_enable_system">Enable plugin system</string>
<string name="plugins_engine">Plugin engine</string> <string name="plugins_engine">Plugin engine</string>
<string name="plugins_engine_d">Run Kotlin plugins that extend FromChat.</string> <string name="plugins_engine_d">Run Kotlin plugins that extend FromChat.</string>
<string name="plugins_developer_mode">Developer mode</string> <string name="plugins_developer_mode">Developer mode</string>
<string name="plugins_developer_mode_d">Enable the local DevServer on port 42690.</string> <string name="plugins_developer_mode_d">Enable the local DevServer on port 42690.</string>
<string name="plugins_installed">Installed plugins</string> <string name="plugins_installed">Installed plugins</string>
<string name="plugins_none_installed">No plugins installed yet.</string> <string name="plugins_none_installed">No plugins installed yet.</string>
<string name="plugins_no_results">No plugins match your search.</string>
<string name="plugins_enabled">Enabled</string> <string name="plugins_enabled">Enabled</string>
<string name="plugins_settings_title">Plugin settings</string> <string name="plugins_settings_title">Plugin settings</string>
<string name="plugins_search">Search plugins</string>
<string name="plugins_usage">Usage:</string>
<string name="plugins_version_author">Version %1$s • @%2$s</string>
<string name="plugins_delete">Delete</string>
<string name="plugins_delete_confirm">Remove \"%1$s\" and all its data?</string>
<string name="plugins_share_copied">Plugin info copied to clipboard.</string>
<string name="plugins_info_title">About plugins</string>
<string name="plugins_info_body">Plugins can modify UI, intercept messages, and hook internal app methods. Only install plugins from sources you trust.</string>
<!-- Settings categories --> <!-- Settings categories -->
<string name="settings_category_appearance">Appearance</string> <string name="settings_category_appearance">Appearance</string>
@@ -0,0 +1,5 @@
package ru.fromchat.plugins.integration
expect object PluginInstallSupport {
fun installFromPicker(): Boolean
}
@@ -1,80 +1,126 @@
package ru.fromchat.ui.main.settings package ru.fromchat.ui.main.settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Extension import androidx.compose.material.icons.filled.Extension
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.OpenInNew
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.PushPin
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MediumTopAppBar import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.pr0gramm3r101.components.Category import com.pr0gramm3r101.components.Category
import com.pr0gramm3r101.components.ListItem import com.pr0gramm3r101.components.ListItem
import com.pr0gramm3r101.components.SwitchListItem import com.pr0gramm3r101.components.SwitchListItem
import com.pr0gramm3r101.utils.supportClipboardManagerImpl
import com.pr0gramm3r101.utils.verticalScroll import com.pr0gramm3r101.utils.verticalScroll
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import kotlinx.coroutines.launch
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.back
import ru.fromchat.cancel
import ru.fromchat.plugins.PluginManifest
import ru.fromchat.plugins.PluginSetting import ru.fromchat.plugins.PluginSetting
import ru.fromchat.plugins.host.PluginEngine import ru.fromchat.plugins.host.PluginEngine
import ru.fromchat.plugins.integration.PluginInstallSupport
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import ru.fromchat.plugins_title import ru.fromchat.plugins_delete
import ru.fromchat.plugins_engine import ru.fromchat.plugins_delete_confirm
import ru.fromchat.plugins_engine_d
import ru.fromchat.plugins_developer_mode import ru.fromchat.plugins_developer_mode
import ru.fromchat.plugins_developer_mode_d import ru.fromchat.plugins_developer_mode_d
import ru.fromchat.plugins_installed import ru.fromchat.plugins_enable_system
import ru.fromchat.plugins_engine_d
import ru.fromchat.plugins_info_body
import ru.fromchat.plugins_info_title
import ru.fromchat.plugins_no_results
import ru.fromchat.plugins_none_installed import ru.fromchat.plugins_none_installed
import ru.fromchat.plugins_search
import ru.fromchat.plugins_settings_title import ru.fromchat.plugins_settings_title
import ru.fromchat.plugins_share_copied
import ru.fromchat.plugins_title
import ru.fromchat.plugins_usage
import ru.fromchat.plugins_version_author
private val PluginMasterBlue = Color(0xFF3D85C6)
private val PluginCardBackground = Color(0xFF1A1A1A)
private val PluginDestructiveRed = Color(0xFFD9534F)
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun PluginsScreen() { fun PluginsScreen() {
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
val navController = LocalNavController.current val navController = LocalNavController.current
val scope = rememberCoroutineScope()
val clipboard = supportClipboardManagerImpl
val engineEnabled by PluginEngine.engineEnabled.collectAsState() val engineEnabled by PluginEngine.engineEnabled.collectAsState()
val developerMode by PluginEngine.developerMode.collectAsState() val developerMode by PluginEngine.developerMode.collectAsState()
val installed by PluginEngine.installed.collectAsState() val installed by PluginEngine.installed.collectAsState()
var searchQuery by remember { mutableStateOf("") }
var showInfoDialog by remember { mutableStateOf(false) }
var deleteTarget by remember { mutableStateOf<PluginManifest?>(null) }
var shareNotice by remember { mutableStateOf(false) }
Scaffold( val filtered = remember(installed, searchQuery) {
modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection), val sorted = PluginEngine.sortedInstalled()
topBar = { if (searchQuery.isBlank()) {
MediumTopAppBar( sorted
title = { } else {
Text(stringResource(Res.string.plugins_title), maxLines = 1, overflow = TextOverflow.Ellipsis) val query = searchQuery.trim().lowercase()
}, sorted.filter {
scrollBehavior = scrollBehavior, it.name.lowercase().contains(query) ||
) it.description.lowercase().contains(query) ||
}, it.author.lowercase().contains(query) ||
) { innerPadding -> it.id.lowercase().contains(query)
Column( }
Modifier }
.fillMaxSize() }
.verticalScroll()
.padding(innerPadding), if (showInfoDialog) {
) { AlertDialog(
Category(Modifier.padding(top = 16.dp)) { onDismissRequest = { showInfoDialog = false },
SwitchListItem( title = { Text(stringResource(Res.string.plugins_info_title)) },
headline = stringResource(Res.string.plugins_engine), text = {
supportingText = stringResource(Res.string.plugins_engine_d), Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
checked = engineEnabled, Text(stringResource(Res.string.plugins_info_body))
onCheckedChange = PluginEngine::setEngineEnabled, Text(stringResource(Res.string.plugins_engine_d))
divider = true,
)
SwitchListItem( SwitchListItem(
headline = stringResource(Res.string.plugins_developer_mode), headline = stringResource(Res.string.plugins_developer_mode),
supportingText = stringResource(Res.string.plugins_developer_mode_d), supportingText = stringResource(Res.string.plugins_developer_mode_d),
@@ -83,50 +129,339 @@ fun PluginsScreen() {
enabled = engineEnabled, enabled = engineEnabled,
) )
} }
Category(Modifier.padding(top = 8.dp)) { },
Text( confirmButton = {
text = stringResource(Res.string.plugins_installed), TextButton(onClick = { showInfoDialog = false }) {
modifier = Modifier.padding(horizontal = SettingsStepHorizontalPadding, vertical = 8.dp), Text(stringResource(Res.string.back))
}
},
) )
if (installed.isEmpty()) { }
ListItem(
headline = stringResource(Res.string.plugins_none_installed), deleteTarget?.let { manifest ->
leadingContent = { Icon(Icons.Default.Extension, contentDescription = null) }, AlertDialog(
onDismissRequest = { deleteTarget = null },
title = { Text(stringResource(Res.string.plugins_delete)) },
text = { Text(stringResource(Res.string.plugins_delete_confirm, manifest.name)) },
confirmButton = {
TextButton(
onClick = {
PluginEngine.removePlugin(manifest.id)
deleteTarget = null
},
) {
Text(stringResource(Res.string.plugins_delete))
}
},
dismissButton = {
TextButton(onClick = { deleteTarget = null }) {
Text(stringResource(Res.string.cancel))
}
},
)
}
if (shareNotice) {
AlertDialog(
onDismissRequest = { shareNotice = false },
text = { Text(stringResource(Res.string.plugins_share_copied)) },
confirmButton = {
TextButton(onClick = { shareNotice = false }) {
Text(stringResource(Res.string.back))
}
},
)
}
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = {
Text(
stringResource(Res.string.plugins_title),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
navigationIcon = {
IconButton(onClick = { navController.navigateUp() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.back))
}
},
actions = {
IconButton(onClick = { /* search toggled via field below */ }) {
Icon(Icons.Default.Search, contentDescription = stringResource(Res.string.plugins_search))
}
IconButton(onClick = { PluginInstallSupport.installFromPicker() }) {
Icon(Icons.Outlined.Add, contentDescription = null)
}
IconButton(onClick = { showInfoDialog = true }) {
Icon(Icons.Default.Info, contentDescription = stringResource(Res.string.plugins_info_title))
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Black,
titleContentColor = Color.White,
navigationIconContentColor = Color.White,
actionIconContentColor = Color.White,
),
)
},
containerColor = Color.Black,
) { innerPadding ->
Column(
Modifier
.fillMaxSize()
.verticalScroll()
.padding(innerPadding)
.padding(horizontal = 16.dp),
) {
OutlinedTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp, bottom = 12.dp),
placeholder = { Text(stringResource(Res.string.plugins_search)) },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
singleLine = true,
)
PluginMasterToggle(
checked = engineEnabled,
onCheckedChange = PluginEngine::setEngineEnabled,
)
Spacer(Modifier.height(16.dp))
if (filtered.isEmpty()) {
Text(
text = if (installed.isEmpty()) {
stringResource(Res.string.plugins_none_installed)
} else {
stringResource(Res.string.plugins_no_results)
},
color = Color.White.copy(alpha = 0.7f),
modifier = Modifier.padding(vertical = 24.dp),
) )
} else { } else {
installed.forEachIndexed { index, manifest -> filtered.forEach { manifest ->
SwitchListItem( PluginCard(
headline = manifest.name, manifest = manifest,
supportingText = manifest.description.ifBlank { manifest.id }, engineEnabled = engineEnabled,
leadingContent = { Icon(Icons.Default.Extension, contentDescription = null) }, pinned = PluginEngine.isPluginPinned(manifest.id),
checked = PluginEngine.isPluginEnabled(manifest.id), enabled = PluginEngine.isPluginEnabled(manifest.id),
onCheckedChange = { enabled -> onEnabledChange = { PluginEngine.setPluginEnabled(manifest.id, it) },
PluginEngine.setPluginEnabled(manifest.id, enabled) onOpenSettings = {
},
enabled = engineEnabled,
divider = index < installed.lastIndex,
listItemOnClick = {
navController.navigate(SettingsRoutes.pluginDetail(manifest.id)) navController.navigate(SettingsRoutes.pluginDetail(manifest.id))
}, },
onShare = {
scope.launch {
clipboard.setText("${manifest.name} (${manifest.id}) v${manifest.version}")
shareNotice = true
}
},
onTogglePin = {
PluginEngine.setPluginPinned(manifest.id, !PluginEngine.isPluginPinned(manifest.id))
},
onDelete = { deleteTarget = manifest },
modifier = Modifier.padding(bottom = 12.dp),
)
}
}
Spacer(Modifier.height(24.dp))
}
}
}
@Composable
private fun PluginMasterToggle(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable { onCheckedChange(!checked) },
shape = RoundedCornerShape(12.dp),
color = PluginMasterBlue,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(Res.string.plugins_enable_system),
color = Color.White,
style = MaterialTheme.typography.titleMedium,
)
Switch(
checked = checked,
onCheckedChange = onCheckedChange,
colors = SwitchDefaults.colors(
checkedThumbColor = Color.White,
checkedTrackColor = Color.White.copy(alpha = 0.35f),
uncheckedThumbColor = Color.White,
uncheckedTrackColor = Color.Black.copy(alpha = 0.25f),
),
)
}
}
}
@Composable
private fun PluginCard(
manifest: PluginManifest,
engineEnabled: Boolean,
pinned: Boolean,
enabled: Boolean,
onEnabledChange: (Boolean) -> Unit,
onOpenSettings: () -> Unit,
onShare: () -> Unit,
onTogglePin: () -> Unit,
onDelete: () -> Unit,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
color = PluginCardBackground,
) {
Column(Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Icon(
imageVector = Icons.Default.Extension,
contentDescription = null,
tint = PluginMasterBlue,
modifier = Modifier.size(40.dp),
)
Switch(
checked = enabled,
onCheckedChange = onEnabledChange,
enabled = engineEnabled,
colors = SwitchDefaults.colors(
checkedThumbColor = PluginMasterBlue,
checkedTrackColor = PluginMasterBlue.copy(alpha = 0.45f),
),
)
}
Spacer(Modifier.height(8.dp))
Text(
text = manifest.name,
color = Color.White,
style = MaterialTheme.typography.titleMedium,
)
val authorLabel = manifest.author.removePrefix("@")
if (authorLabel.isNotBlank()) {
Text(
text = stringResource(
Res.string.plugins_version_author,
manifest.version,
authorLabel,
),
color = Color.White.copy(alpha = 0.55f),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 4.dp),
)
} else {
Text(
text = manifest.version,
color = Color.White.copy(alpha = 0.55f),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 4.dp),
)
}
if (manifest.description.isNotBlank()) {
Text(
text = manifest.description,
color = Color.White.copy(alpha = 0.85f),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 12.dp),
)
}
val usage = manifest.usage.ifBlank {
manifest.description
}
if (usage.isNotBlank()) {
Text(
text = stringResource(Res.string.plugins_usage),
color = Color.White,
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(top = 12.dp),
)
Text(
text = usage,
color = Color.White.copy(alpha = 0.75f),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 4.dp),
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
PluginCardAction(Icons.Default.Share, onShare)
PluginCardAction(Icons.Default.OpenInNew, onOpenSettings)
PluginCardAction(
imageVector = if (pinned) Icons.Default.PushPin else Icons.Outlined.PushPin,
onClick = onTogglePin,
)
Spacer(Modifier.weight(1f))
IconButton(onClick = onDelete) {
Icon(
Icons.Default.Delete,
contentDescription = stringResource(Res.string.plugins_delete),
tint = PluginDestructiveRed,
) )
} }
} }
} }
} }
} }
@Composable
private fun PluginCardAction(
imageVector: androidx.compose.ui.graphics.vector.ImageVector,
onClick: () -> Unit,
) {
IconButton(onClick = onClick) {
Icon(
imageVector = imageVector,
contentDescription = null,
tint = Color.White.copy(alpha = 0.85f),
)
}
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun PluginDetailScreen(pluginId: String) { fun PluginDetailScreen(pluginId: String) {
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) val navController = LocalNavController.current
val plugin = PluginEngine.loadedPlugin(pluginId) val plugin = PluginEngine.loadedPlugin(pluginId)
val settings = plugin?.instance?.createSettings().orEmpty() val settings = plugin?.instance?.createSettings().orEmpty()
Scaffold( Scaffold(
modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection), modifier = Modifier.fillMaxSize(),
topBar = { topBar = {
MediumTopAppBar( TopAppBar(
title = { title = {
Text( Text(
plugin?.manifest?.name ?: pluginId, plugin?.manifest?.name ?: pluginId,
@@ -134,7 +469,11 @@ fun PluginDetailScreen(pluginId: String) {
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
}, },
scrollBehavior = scrollBehavior, navigationIcon = {
IconButton(onClick = { navController.navigateUp() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.back))
}
},
) )
}, },
) { innerPadding -> ) { innerPadding ->
@@ -189,7 +528,7 @@ fun PluginDetailScreen(pluginId: String) {
), ),
) )
} }
OutlinedTextField( androidx.compose.material3.OutlinedTextField(
value = text, value = text,
onValueChange = { value -> onValueChange = { value ->
text = value text = value
@@ -198,7 +537,7 @@ fun PluginDetailScreen(pluginId: String) {
label = { Text(setting.text) }, label = { Text(setting.text) },
modifier = Modifier modifier = Modifier
.padding(horizontal = SettingsStepHorizontalPadding, vertical = 8.dp) .padding(horizontal = SettingsStepHorizontalPadding, vertical = 8.dp)
.fillMaxSize(), .fillMaxWidth(),
) )
} }
} }
@@ -0,0 +1,5 @@
package ru.fromchat.plugins.integration
actual object PluginInstallSupport {
actual fun installFromPicker(): Boolean = false
}
@@ -17,22 +17,20 @@ object DesktopPluginBootstrap {
PluginEngine.appVersionProvider = { ru.fromchat.AppBuildInfo.version } PluginEngine.appVersionProvider = { ru.fromchat.AppBuildInfo.version }
installBundledPlugin(classLoader) installBundledPlugin(classLoader)
PluginEngine.init() PluginEngine.init()
if (ru.fromchat.AppBuildInfo.isDebug) {
PluginEngine.setEngineEnabled(true)
PluginEngine.setPluginEnabled("hello_world", true)
}
PluginEngine.dispatchAppEvent(AppEvent.START) PluginEngine.dispatchAppEvent(AppEvent.START)
FeatureGate.registerDefault("calls") { ru.fromchat.config.ServerConfig.callsEnabled } FeatureGate.registerDefault("calls") { ru.fromchat.config.ServerConfig.callsEnabled }
} }
private fun installBundledPlugin(classLoader: ClassLoader) { private fun installBundledPlugin(classLoader: ClassLoader) {
val installed = desktopPluginsRoot().resolve("hello_world")
if (installed.exists()) return
val stream = classLoader.getResourceAsStream("bundled_plugins/hello_world.fcplugin") ?: return val stream = classLoader.getResourceAsStream("bundled_plugins/hello_world.fcplugin") ?: return
val temp = File.createTempFile("hello_world", ".fcplugin") val temp = File.createTempFile("hello_world", ".fcplugin")
temp.outputStream().use { out -> stream.copyTo(out) } temp.outputStream().use { out -> stream.copyTo(out) }
runCatching { PluginEngine.installFcPluginArchive(temp) } runCatching {
.onFailure { Logger.w("Plugins", "Bundled plugin install failed: ${it.message}") } val bundledDir = temp.parentFile!!.resolve("bundled")
bundledDir.mkdirs()
temp.copyTo(bundledDir.resolve("hello_world.fcplugin"), overwrite = true)
PluginEngine.ensureBundledPlugins(bundledDir)
}.onFailure { Logger.w("Plugins", "Bundled plugin install failed: ${it.message}") }
temp.delete() temp.delete()
} }
@@ -0,0 +1,23 @@
package ru.fromchat.plugins.integration
import ru.fromchat.plugins.host.PluginEngine
import java.awt.FileDialog
import java.awt.Frame
import java.io.File
actual object PluginInstallSupport {
actual fun installFromPicker(): Boolean {
val dialog = FileDialog(null as Frame?, "Install .fcplugin", FileDialog.LOAD).apply {
file = "*.fcplugin"
isVisible = true
}
val selected = dialog.file ?: return false
val directory = dialog.directory ?: return false
val file = File(directory, selected)
if (!file.exists() || file.extension.lowercase() != "fcplugin") return false
return runCatching {
PluginEngine.installFcPluginArchive(file)
true
}.getOrDefault(false)
}
}
@@ -24,6 +24,7 @@ object PluginEngine : PluginHostBridge {
val installed: StateFlow<List<PluginManifest>> = _installed.asStateFlow() val installed: StateFlow<List<PluginManifest>> = _installed.asStateFlow()
private val enabledIds = linkedSetOf<String>() private val enabledIds = linkedSetOf<String>()
private val pinnedIds = linkedSetOf<String>()
private val loaded = mutableMapOf<String, LoadedPlugin>() private val loaded = mutableMapOf<String, LoadedPlugin>()
private val settings = mutableMapOf<String, MutableMap<String, String>>() private val settings = mutableMapOf<String, MutableMap<String, String>>()
private val sharedUnhooks = mutableMapOf<String, MutableList<() -> Unit>>() private val sharedUnhooks = mutableMapOf<String, MutableList<() -> Unit>>()
@@ -72,6 +73,40 @@ object PluginEngine : PluginHostBridge {
fun isPluginEnabled(pluginId: String): Boolean = pluginId in enabledIds fun isPluginEnabled(pluginId: String): Boolean = pluginId in enabledIds
fun isPluginPinned(pluginId: String): Boolean = pluginId in pinnedIds
fun setPluginPinned(pluginId: String, pinned: Boolean) {
if (pinned) {
pinnedIds += pluginId
} else {
pinnedIds -= pluginId
}
persistPinnedState()
refreshInstalledList()
}
fun sortedInstalled(): List<PluginManifest> {
val manifests = _installed.value
return manifests.sortedWith(
compareByDescending<PluginManifest> { it.id in pinnedIds }
.thenBy { it.name.lowercase() },
)
}
fun removePlugin(pluginId: String) {
unloadPlugin(pluginId)
enabledIds -= pluginId
pinnedIds -= pluginId
settings.remove(pluginId)
val dir = pluginsRootProvider().resolve(pluginId)
if (dir.exists()) {
dir.deleteRecursively()
}
persistEnabledState()
persistPinnedState()
refreshInstalledList()
}
fun loadedPlugin(pluginId: String): LoadedPlugin? = loaded[pluginId] fun loadedPlugin(pluginId: String): LoadedPlugin? = loaded[pluginId]
fun getPluginSettingBoolean(pluginId: String, key: String, default: Boolean = false): Boolean = fun getPluginSettingBoolean(pluginId: String, key: String, default: Boolean = false): Boolean =
@@ -238,12 +273,21 @@ object PluginEngine : PluginHostBridge {
enabledIds.clear() enabledIds.clear()
enabledIds += enabledFile.readLines().map { it.trim() }.filter { it.isNotEmpty() } enabledIds += enabledFile.readLines().map { it.trim() }.filter { it.isNotEmpty() }
} }
val pinnedFile = pluginsRootProvider().resolve("pinned_plugins.txt")
if (pinnedFile.exists()) {
pinnedIds.clear()
pinnedIds += pinnedFile.readLines().map { it.trim() }.filter { it.isNotEmpty() }
}
} }
private fun persistEnabledState() { private fun persistEnabledState() {
pluginsRootProvider().resolve("enabled_plugins.txt").writeText(enabledIds.joinToString("\n")) pluginsRootProvider().resolve("enabled_plugins.txt").writeText(enabledIds.joinToString("\n"))
} }
private fun persistPinnedState() {
pluginsRootProvider().resolve("pinned_plugins.txt").writeText(pinnedIds.joinToString("\n"))
}
private fun projectZipExtract(archive: File, dest: File) { private fun projectZipExtract(archive: File, dest: File) {
java.util.zip.ZipFile(archive).use { zip -> java.util.zip.ZipFile(archive).use { zip ->
zip.entries().asSequence().forEach { entry -> zip.entries().asSequence().forEach { entry ->
@@ -261,10 +305,30 @@ object PluginEngine : PluginHostBridge {
fun ensureBundledPlugins(bundledDir: File) { fun ensureBundledPlugins(bundledDir: File) {
if (!bundledDir.exists()) return if (!bundledDir.exists()) return
bundledDir.listFiles()?.filter { it.extension == "fcplugin" }?.forEach { archive -> bundledDir.listFiles()?.filter { it.extension == "fcplugin" }?.forEach { archive ->
val installed = pluginsRootProvider().resolve("hello_world") val manifestInArchive = readManifestFromArchive(archive) ?: return@forEach
if (!installed.exists()) { val installedDir = pluginsRootProvider().resolve(manifestInArchive.id)
val installedManifest = installedDir.resolve("manifest.json")
val shouldInstall = when {
!installedManifest.exists() -> true
else -> {
val current = runCatching {
PluginManifestParser.parse(installedManifest.readText())
}.getOrNull()
current == null || current.version != manifestInArchive.version
}
}
if (shouldInstall) {
runCatching { installFcPluginArchive(archive) } runCatching { installFcPluginArchive(archive) }
} }
} }
} }
private fun readManifestFromArchive(archive: File): PluginManifest? {
return runCatching {
java.util.zip.ZipFile(archive).use { zip ->
val entry = zip.getEntry("manifest.json") ?: return null
zip.getInputStream(entry).bufferedReader().use { PluginManifestParser.parse(it.readText()) }
}
}.getOrNull()
}
} }
@@ -19,6 +19,7 @@ object PluginManifestParser {
description = root["description"]?.jsonPrimitive?.contentOrNull.orEmpty(), description = root["description"]?.jsonPrimitive?.contentOrNull.orEmpty(),
author = root["author"]?.jsonPrimitive?.contentOrNull.orEmpty(), author = root["author"]?.jsonPrimitive?.contentOrNull.orEmpty(),
version = root["version"]?.jsonPrimitive?.contentOrNull ?: "1.0.0", version = root["version"]?.jsonPrimitive?.contentOrNull ?: "1.0.0",
usage = root["usage"]?.jsonPrimitive?.contentOrNull.orEmpty(),
appVersion = root["app_version"]?.jsonPrimitive?.contentOrNull.orEmpty(), appVersion = root["app_version"]?.jsonPrimitive?.contentOrNull.orEmpty(),
sdkVersion = root["sdk_version"]?.jsonPrimitive?.contentOrNull.orEmpty(), sdkVersion = root["sdk_version"]?.jsonPrimitive?.contentOrNull.orEmpty(),
entryClass = root.requireString("entry_class"), entryClass = root.requireString("entry_class"),
+3 -2
View File
@@ -35,9 +35,10 @@ tasks.register("packageFcPlugin") {
{ {
"id": "hello_world", "id": "hello_world",
"name": "Hello World", "name": "Hello World",
"description": "Rewrites .hello commands into a friendly greeting", "description": "Demonstrates high-level send hooks and raw Logger.d hooks.",
"author": "FromChat", "author": "FromChat",
"version": "1.0.0", "version": "1.0.1",
"usage": "Send .hello Name in any chat to rewrite the message. Raw hook shows a bulletin when a message is queued for sending.",
"app_version": ">=1.0.0", "app_version": ">=1.0.0",
"sdk_version": ">=1.0.0", "sdk_version": ">=1.0.0",
"entry_class": "ru.fromchat.plugins.sample.hello.HelloWorldPlugin", "entry_class": "ru.fromchat.plugins.sample.hello.HelloWorldPlugin",
@@ -13,6 +13,17 @@ class HelloWorldPlugin : BasePlugin() {
addOnSendMessageHook { context -> addOnSendMessageHook { context ->
onSendMessage(context) onSendMessage(context)
} }
hookShared(
hookId = "logger.debug",
after = { args, _ ->
val tag = args.getOrNull(0) as? String ?: return@hookShared HookResult()
val message = args.getOrNull(1) as? String ?: return@hookShared HookResult()
if (tag == "OutgoingMessageCoordinator" && message.contains("enqueue")) {
showBulletin("Raw hook: intercepted outgoing message pipeline")
}
HookResult()
},
)
} }
override fun createSettings(): List<PluginSetting> = listOf( override fun createSettings(): List<PluginSetting> = listOf(
@@ -36,6 +47,7 @@ class HelloWorldPlugin : BasePlugin() {
val name = parts.getOrNull(1)?.trim().orEmpty().ifEmpty { "World" } val name = parts.getOrNull(1)?.trim().orEmpty().ifEmpty { "World" }
val template = getSettingString("template", "Hello, {name}!") val template = getSettingString("template", "Hello, {name}!")
context.text = template.replace("{name}", name) context.text = template.replace("{name}", name)
showBulletin("High-level hook: rewrote .hello command")
return HookResult(strategy = HookStrategy.MODIFY, value = context) return HookResult(strategy = HookStrategy.MODIFY, value = context)
} }
} }
@@ -66,6 +66,7 @@ data class PluginManifest(
val description: String = "", val description: String = "",
val author: String = "", val author: String = "",
val version: String = "1.0.0", val version: String = "1.0.0",
val usage: String = "",
val appVersion: String = "", val appVersion: String = "",
val sdkVersion: String = "", val sdkVersion: String = "",
val entryClass: String, val entryClass: String,