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,