diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/plugins/integration/PluginInstallSupport.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/plugins/integration/PluginInstallSupport.android.kt
new file mode 100644
index 0000000..2a79b51
--- /dev/null
+++ b/app/shared/src/androidMain/kotlin/ru/fromchat/plugins/integration/PluginInstallSupport.android.kt
@@ -0,0 +1,5 @@
+package ru.fromchat.plugins.integration
+
+actual object PluginInstallSupport {
+ actual fun installFromPicker(): Boolean = false
+}
diff --git a/app/shared/src/commonMain/composeResources/values-ru/strings.xml b/app/shared/src/commonMain/composeResources/values-ru/strings.xml
index 48927e6..dd188ea 100644
--- a/app/shared/src/commonMain/composeResources/values-ru/strings.xml
+++ b/app/shared/src/commonMain/composeResources/values-ru/strings.xml
@@ -340,14 +340,24 @@
Просмотр ответов API профиля и личных сообщений.
Плагины
+ Включить систему плагинов
Движок плагинов
Запуск Kotlin-плагинов для расширения FromChat.
Режим разработчика
Включает локальный DevServer на порту 42690.
Установленные плагины
Плагины ещё не установлены.
+ Нет плагинов по вашему запросу.
Включено
Настройки плагина
+ Поиск плагинов
+ Использование:
+ Версия %1$s • @%2$s
+ Удалить
+ Удалить «%1$s» и все его данные?
+ Информация о плагине скопирована.
+ О плагинах
+ Плагины могут менять интерфейс, перехватывать сообщения и подключаться к внутренним методам приложения. Устанавливайте плагины только из проверенных источников.
Оформление
Тема и Material You
diff --git a/app/shared/src/commonMain/composeResources/values/strings.xml b/app/shared/src/commonMain/composeResources/values/strings.xml
index 6f7e9f6..545f029 100644
--- a/app/shared/src/commonMain/composeResources/values/strings.xml
+++ b/app/shared/src/commonMain/composeResources/values/strings.xml
@@ -371,14 +371,24 @@
Inspect profile and DM endpoints used by the client.
Plugins
+ Enable plugin system
Plugin engine
Run Kotlin plugins that extend FromChat.
Developer mode
Enable the local DevServer on port 42690.
Installed plugins
No plugins installed yet.
+ No plugins match your search.
Enabled
Plugin settings
+ Search plugins
+ Usage:
+ Version %1$s • @%2$s
+ Delete
+ Remove \"%1$s\" and all its data?
+ Plugin info copied to clipboard.
+ About plugins
+ Plugins can modify UI, intercept messages, and hook internal app methods. Only install plugins from sources you trust.
Appearance
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/plugins/integration/PluginInstallSupport.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/plugins/integration/PluginInstallSupport.kt
new file mode 100644
index 0000000..4fbcfc5
--- /dev/null
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/plugins/integration/PluginInstallSupport.kt
@@ -0,0 +1,5 @@
+package ru.fromchat.plugins.integration
+
+expect object PluginInstallSupport {
+ fun installFromPicker(): Boolean
+}
diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/PluginsScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/PluginsScreen.kt
index 49aa26a..2fe68ee 100644
--- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/PluginsScreen.kt
+++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/PluginsScreen.kt
@@ -1,132 +1,467 @@
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.Row
+import androidx.compose.foundation.layout.Spacer
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.size
+import androidx.compose.foundation.shape.RoundedCornerShape
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.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.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.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.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
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.unit.dp
import com.pr0gramm3r101.components.Category
import com.pr0gramm3r101.components.ListItem
import com.pr0gramm3r101.components.SwitchListItem
+import com.pr0gramm3r101.utils.supportClipboardManagerImpl
import com.pr0gramm3r101.utils.verticalScroll
import org.jetbrains.compose.resources.stringResource
+import kotlinx.coroutines.launch
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.host.PluginEngine
+import ru.fromchat.plugins.integration.PluginInstallSupport
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.components.Text
-import ru.fromchat.plugins_title
-import ru.fromchat.plugins_engine
-import ru.fromchat.plugins_engine_d
+import ru.fromchat.plugins_delete
+import ru.fromchat.plugins_delete_confirm
import ru.fromchat.plugins_developer_mode
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_search
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)
@Composable
fun PluginsScreen() {
- val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
val navController = LocalNavController.current
+ val scope = rememberCoroutineScope()
+ val clipboard = supportClipboardManagerImpl
val engineEnabled by PluginEngine.engineEnabled.collectAsState()
val developerMode by PluginEngine.developerMode.collectAsState()
val installed by PluginEngine.installed.collectAsState()
+ var searchQuery by remember { mutableStateOf("") }
+ var showInfoDialog by remember { mutableStateOf(false) }
+ var deleteTarget by remember { mutableStateOf(null) }
+ var shareNotice by remember { mutableStateOf(false) }
+
+ val filtered = remember(installed, searchQuery) {
+ val sorted = PluginEngine.sortedInstalled()
+ if (searchQuery.isBlank()) {
+ sorted
+ } else {
+ val query = searchQuery.trim().lowercase()
+ sorted.filter {
+ it.name.lowercase().contains(query) ||
+ it.description.lowercase().contains(query) ||
+ it.author.lowercase().contains(query) ||
+ it.id.lowercase().contains(query)
+ }
+ }
+ }
+
+ if (showInfoDialog) {
+ AlertDialog(
+ onDismissRequest = { showInfoDialog = false },
+ title = { Text(stringResource(Res.string.plugins_info_title)) },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ Text(stringResource(Res.string.plugins_info_body))
+ Text(stringResource(Res.string.plugins_engine_d))
+ SwitchListItem(
+ headline = stringResource(Res.string.plugins_developer_mode),
+ supportingText = stringResource(Res.string.plugins_developer_mode_d),
+ checked = developerMode,
+ onCheckedChange = PluginEngine::setDeveloperMode,
+ enabled = engineEnabled,
+ )
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = { showInfoDialog = false }) {
+ Text(stringResource(Res.string.back))
+ }
+ },
+ )
+ }
+
+ deleteTarget?.let { manifest ->
+ 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().nestedScroll(scrollBehavior.nestedScrollConnection),
+ modifier = Modifier.fillMaxSize(),
topBar = {
- MediumTopAppBar(
+ TopAppBar(
title = {
- Text(stringResource(Res.string.plugins_title), maxLines = 1, overflow = TextOverflow.Ellipsis)
+ Text(
+ stringResource(Res.string.plugins_title),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
},
- scrollBehavior = scrollBehavior,
+ 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(innerPadding)
+ .padding(horizontal = 16.dp),
) {
- Category(Modifier.padding(top = 16.dp)) {
- SwitchListItem(
- headline = stringResource(Res.string.plugins_engine),
- supportingText = stringResource(Res.string.plugins_engine_d),
- checked = engineEnabled,
- onCheckedChange = PluginEngine::setEngineEnabled,
- divider = true,
+ 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),
)
- SwitchListItem(
- headline = stringResource(Res.string.plugins_developer_mode),
- supportingText = stringResource(Res.string.plugins_developer_mode_d),
- checked = developerMode,
- onCheckedChange = PluginEngine::setDeveloperMode,
+ } else {
+ filtered.forEach { manifest ->
+ PluginCard(
+ manifest = manifest,
+ engineEnabled = engineEnabled,
+ pinned = PluginEngine.isPluginPinned(manifest.id),
+ enabled = PluginEngine.isPluginEnabled(manifest.id),
+ onEnabledChange = { PluginEngine.setPluginEnabled(manifest.id, it) },
+ onOpenSettings = {
+ 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),
+ ),
)
}
- Category(Modifier.padding(top = 8.dp)) {
+
+ 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_installed),
- modifier = Modifier.padding(horizontal = SettingsStepHorizontalPadding, vertical = 8.dp),
+ 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),
)
- if (installed.isEmpty()) {
- ListItem(
- headline = stringResource(Res.string.plugins_none_installed),
- leadingContent = { Icon(Icons.Default.Extension, contentDescription = null) },
+ } 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,
)
- } else {
- installed.forEachIndexed { index, manifest ->
- SwitchListItem(
- headline = manifest.name,
- supportingText = manifest.description.ifBlank { manifest.id },
- leadingContent = { Icon(Icons.Default.Extension, contentDescription = null) },
- checked = PluginEngine.isPluginEnabled(manifest.id),
- onCheckedChange = { enabled ->
- PluginEngine.setPluginEnabled(manifest.id, enabled)
- },
- enabled = engineEnabled,
- divider = index < installed.lastIndex,
- listItemOnClick = {
- navController.navigate(SettingsRoutes.pluginDetail(manifest.id))
- },
- )
- }
}
}
}
}
}
+@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)
@Composable
fun PluginDetailScreen(pluginId: String) {
- val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
+ val navController = LocalNavController.current
val plugin = PluginEngine.loadedPlugin(pluginId)
val settings = plugin?.instance?.createSettings().orEmpty()
Scaffold(
- modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection),
+ modifier = Modifier.fillMaxSize(),
topBar = {
- MediumTopAppBar(
+ TopAppBar(
title = {
Text(
plugin?.manifest?.name ?: pluginId,
@@ -134,7 +469,11 @@ fun PluginDetailScreen(pluginId: String) {
overflow = TextOverflow.Ellipsis,
)
},
- scrollBehavior = scrollBehavior,
+ navigationIcon = {
+ IconButton(onClick = { navController.navigateUp() }) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.back))
+ }
+ },
)
},
) { innerPadding ->
@@ -189,7 +528,7 @@ fun PluginDetailScreen(pluginId: String) {
),
)
}
- OutlinedTextField(
+ androidx.compose.material3.OutlinedTextField(
value = text,
onValueChange = { value ->
text = value
@@ -198,7 +537,7 @@ fun PluginDetailScreen(pluginId: String) {
label = { Text(setting.text) },
modifier = Modifier
.padding(horizontal = SettingsStepHorizontalPadding, vertical = 8.dp)
- .fillMaxSize(),
+ .fillMaxWidth(),
)
}
}
diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/plugins/integration/PluginInstallSupport.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/plugins/integration/PluginInstallSupport.ios.kt
new file mode 100644
index 0000000..2a79b51
--- /dev/null
+++ b/app/shared/src/iosMain/kotlin/ru/fromchat/plugins/integration/PluginInstallSupport.ios.kt
@@ -0,0 +1,5 @@
+package ru.fromchat.plugins.integration
+
+actual object PluginInstallSupport {
+ actual fun installFromPicker(): Boolean = false
+}
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/plugins/integration/DesktopPluginBootstrap.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/plugins/integration/DesktopPluginBootstrap.kt
index 2907eed..2d21dfb 100644
--- a/app/shared/src/jvmMain/kotlin/ru/fromchat/plugins/integration/DesktopPluginBootstrap.kt
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/plugins/integration/DesktopPluginBootstrap.kt
@@ -17,22 +17,20 @@ object DesktopPluginBootstrap {
PluginEngine.appVersionProvider = { ru.fromchat.AppBuildInfo.version }
installBundledPlugin(classLoader)
PluginEngine.init()
- if (ru.fromchat.AppBuildInfo.isDebug) {
- PluginEngine.setEngineEnabled(true)
- PluginEngine.setPluginEnabled("hello_world", true)
- }
PluginEngine.dispatchAppEvent(AppEvent.START)
FeatureGate.registerDefault("calls") { ru.fromchat.config.ServerConfig.callsEnabled }
}
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 temp = File.createTempFile("hello_world", ".fcplugin")
temp.outputStream().use { out -> stream.copyTo(out) }
- runCatching { PluginEngine.installFcPluginArchive(temp) }
- .onFailure { Logger.w("Plugins", "Bundled plugin install failed: ${it.message}") }
+ runCatching {
+ 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()
}
diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/plugins/integration/PluginInstallSupport.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/plugins/integration/PluginInstallSupport.jvm.kt
new file mode 100644
index 0000000..9d1992b
--- /dev/null
+++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/plugins/integration/PluginInstallSupport.jvm.kt
@@ -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)
+ }
+}
diff --git a/app/shared/src/jvmMain/resources/bundled_plugins/hello_world.fcplugin b/app/shared/src/jvmMain/resources/bundled_plugins/hello_world.fcplugin
new file mode 100644
index 0000000..bf6cc82
Binary files /dev/null and b/app/shared/src/jvmMain/resources/bundled_plugins/hello_world.fcplugin differ
diff --git a/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/PluginEngine.kt b/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/PluginEngine.kt
index e66cb7f..2d8f004 100644
--- a/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/PluginEngine.kt
+++ b/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/PluginEngine.kt
@@ -24,6 +24,7 @@ object PluginEngine : PluginHostBridge {
val installed: StateFlow> = _installed.asStateFlow()
private val enabledIds = linkedSetOf()
+ private val pinnedIds = linkedSetOf()
private val loaded = mutableMapOf()
private val settings = mutableMapOf>()
private val sharedUnhooks = mutableMapOf Unit>>()
@@ -72,6 +73,40 @@ object PluginEngine : PluginHostBridge {
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 {
+ val manifests = _installed.value
+ return manifests.sortedWith(
+ compareByDescending { 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 getPluginSettingBoolean(pluginId: String, key: String, default: Boolean = false): Boolean =
@@ -238,12 +273,21 @@ object PluginEngine : PluginHostBridge {
enabledIds.clear()
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() {
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) {
java.util.zip.ZipFile(archive).use { zip ->
zip.entries().asSequence().forEach { entry ->
@@ -261,10 +305,30 @@ object PluginEngine : PluginHostBridge {
fun ensureBundledPlugins(bundledDir: File) {
if (!bundledDir.exists()) return
bundledDir.listFiles()?.filter { it.extension == "fcplugin" }?.forEach { archive ->
- val installed = pluginsRootProvider().resolve("hello_world")
- if (!installed.exists()) {
+ val manifestInArchive = readManifestFromArchive(archive) ?: return@forEach
+ 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) }
}
}
}
+
+ 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()
+ }
}
diff --git a/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/PluginManifestParser.kt b/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/PluginManifestParser.kt
index 8747e5d..d2c3178 100644
--- a/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/PluginManifestParser.kt
+++ b/plugins/host/src/commonMain/kotlin/ru/fromchat/plugins/host/PluginManifestParser.kt
@@ -19,6 +19,7 @@ object PluginManifestParser {
description = root["description"]?.jsonPrimitive?.contentOrNull.orEmpty(),
author = root["author"]?.jsonPrimitive?.contentOrNull.orEmpty(),
version = root["version"]?.jsonPrimitive?.contentOrNull ?: "1.0.0",
+ usage = root["usage"]?.jsonPrimitive?.contentOrNull.orEmpty(),
appVersion = root["app_version"]?.jsonPrimitive?.contentOrNull.orEmpty(),
sdkVersion = root["sdk_version"]?.jsonPrimitive?.contentOrNull.orEmpty(),
entryClass = root.requireString("entry_class"),
diff --git a/plugins/sample-hello/build.gradle.kts b/plugins/sample-hello/build.gradle.kts
index 7e06f64..d22c9a2 100644
--- a/plugins/sample-hello/build.gradle.kts
+++ b/plugins/sample-hello/build.gradle.kts
@@ -35,9 +35,10 @@ tasks.register("packageFcPlugin") {
{
"id": "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",
- "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",
"sdk_version": ">=1.0.0",
"entry_class": "ru.fromchat.plugins.sample.hello.HelloWorldPlugin",
diff --git a/plugins/sample-hello/src/main/kotlin/ru/fromchat/plugins/sample/hello/HelloWorldPlugin.kt b/plugins/sample-hello/src/main/kotlin/ru/fromchat/plugins/sample/hello/HelloWorldPlugin.kt
index 2be48ab..e8af3a8 100644
--- a/plugins/sample-hello/src/main/kotlin/ru/fromchat/plugins/sample/hello/HelloWorldPlugin.kt
+++ b/plugins/sample-hello/src/main/kotlin/ru/fromchat/plugins/sample/hello/HelloWorldPlugin.kt
@@ -13,6 +13,17 @@ class HelloWorldPlugin : BasePlugin() {
addOnSendMessageHook { 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 = listOf(
@@ -36,6 +47,7 @@ class HelloWorldPlugin : BasePlugin() {
val name = parts.getOrNull(1)?.trim().orEmpty().ifEmpty { "World" }
val template = getSettingString("template", "Hello, {name}!")
context.text = template.replace("{name}", name)
+ showBulletin("High-level hook: rewrote .hello command")
return HookResult(strategy = HookStrategy.MODIFY, value = context)
}
}
diff --git a/plugins/sdk/src/commonMain/kotlin/ru/fromchat/plugins/PluginTypes.kt b/plugins/sdk/src/commonMain/kotlin/ru/fromchat/plugins/PluginTypes.kt
index 493a1ad..c7a6b65 100644
--- a/plugins/sdk/src/commonMain/kotlin/ru/fromchat/plugins/PluginTypes.kt
+++ b/plugins/sdk/src/commonMain/kotlin/ru/fromchat/plugins/PluginTypes.kt
@@ -66,6 +66,7 @@ data class PluginManifest(
val description: String = "",
val author: String = "",
val version: String = "1.0.0",
+ val usage: String = "",
val appVersion: String = "",
val sdkVersion: String = "",
val entryClass: String,