mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-23 03:25:06 +03:00
Compare commits
6 Commits
@@ -9,7 +9,7 @@ inputs:
|
||||
outputs:
|
||||
cache-hit:
|
||||
description: Whether an exact cache key match was restored
|
||||
value: ${{ steps.cache.outputs.cache-hit }}
|
||||
value: ${{ steps.cache.outputs.cache-hit == 'true' || steps.cache-android.outputs.cache-hit == 'true' }}
|
||||
cache-save-scope:
|
||||
description: Scope to pass to the save action (always this job's scope, not a restored fallback key)
|
||||
value: ${{ inputs.scope }}
|
||||
@@ -18,6 +18,23 @@ runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: actions/cache/restore@v5
|
||||
if: inputs.scope == 'android'
|
||||
id: cache-android
|
||||
with:
|
||||
path: |
|
||||
.gradle
|
||||
build
|
||||
app/shared/build
|
||||
app/android/build
|
||||
utils/shared/build
|
||||
utils/android/build
|
||||
key: gradle-build-${{ runner.os }}-${{ runner.arch }}-v2-${{ inputs.scope }}-${{ hashFiles('gradle/libs.versions.toml', 'settings.gradle.kts', 'gradle.properties', 'build.gradle.kts', 'app/desktop/build.gradle.kts', 'app/desktop/proguard-rules.pro', 'app/shared/build.gradle.kts', 'app/android/build.gradle.kts', 'utils/shared/build.gradle.kts', 'utils/android/build.gradle.kts') }}
|
||||
restore-keys: |
|
||||
gradle-build-${{ runner.os }}-${{ runner.arch }}-v2-${{ inputs.scope }}-
|
||||
gradle-build-${{ runner.os }}-${{ runner.arch }}-v2-build-jar-
|
||||
gradle-build-${{ runner.os }}-${{ runner.arch }}-v2-
|
||||
- uses: actions/cache/restore@v5
|
||||
if: inputs.scope != 'android'
|
||||
id: cache
|
||||
with:
|
||||
path: |
|
||||
|
||||
@@ -16,6 +16,9 @@ runs:
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
app/desktop/windows-setup/target
|
||||
key: windows-rust-${{ runner.os }}-v2-${{ hashFiles('app/desktop/windows-setup/Cargo.lock', 'app/desktop/windows-setup/Cargo.toml', 'app/desktop/windows-setup/**/src/**', 'app/desktop/windows-setup/**/build.rs') }}
|
||||
key: windows-rust-${{ runner.os }}-v3-${{ hashFiles('app/desktop/windows-setup/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
windows-rust-${{ runner.os }}-v3-
|
||||
windows-rust-${{ runner.os }}-v2-
|
||||
windows-rust-tools-v1-
|
||||
cargo-registry-${{ runner.os }}-
|
||||
|
||||
@@ -9,7 +9,22 @@ inputs:
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: actions/cache/save@v5
|
||||
- name: Save Android Gradle build cache
|
||||
if: inputs.scope == 'android'
|
||||
uses: actions/cache/save@v5
|
||||
continue-on-error: true
|
||||
with:
|
||||
key: gradle-build-${{ runner.os }}-${{ runner.arch }}-v2-${{ inputs.scope }}-${{ hashFiles('gradle/libs.versions.toml', 'settings.gradle.kts', 'gradle.properties', 'build.gradle.kts', 'app/desktop/build.gradle.kts', 'app/desktop/proguard-rules.pro', 'app/shared/build.gradle.kts', 'app/android/build.gradle.kts', 'utils/shared/build.gradle.kts', 'utils/android/build.gradle.kts') }}
|
||||
path: |
|
||||
.gradle
|
||||
build
|
||||
app/shared/build
|
||||
app/android/build
|
||||
utils/shared/build
|
||||
utils/android/build
|
||||
- name: Save Gradle build cache
|
||||
if: inputs.scope != 'android'
|
||||
uses: actions/cache/save@v5
|
||||
continue-on-error: true
|
||||
with:
|
||||
key: gradle-build-${{ runner.os }}-${{ runner.arch }}-v2-${{ inputs.scope }}-${{ hashFiles('gradle/libs.versions.toml', 'settings.gradle.kts', 'gradle.properties', 'build.gradle.kts', 'app/desktop/build.gradle.kts', 'app/desktop/proguard-rules.pro', 'app/shared/build.gradle.kts', 'app/android/build.gradle.kts', 'utils/shared/build.gradle.kts', 'utils/android/build.gradle.kts') }}
|
||||
|
||||
@@ -7,7 +7,7 @@ runs:
|
||||
- uses: actions/cache/save@v5
|
||||
continue-on-error: true
|
||||
with:
|
||||
key: windows-rust-${{ runner.os }}-v2-${{ hashFiles('app/desktop/windows-setup/Cargo.lock', 'app/desktop/windows-setup/Cargo.toml', 'app/desktop/windows-setup/**/src/**', 'app/desktop/windows-setup/**/build.rs') }}
|
||||
key: windows-rust-${{ runner.os }}-v3-${{ hashFiles('app/desktop/windows-setup/Cargo.lock') }}
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
|
||||
@@ -28,7 +28,7 @@ on:
|
||||
default: "false"
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
group: release
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
@@ -414,6 +414,8 @@ jobs:
|
||||
id: gradle-build-cache
|
||||
with:
|
||||
scope: android
|
||||
- name: Prepare Android SDK cache dirs
|
||||
run: mkdir -p ~/.android/build-cache ~/.android/cache
|
||||
- name: Cache Android SDK extras
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
|
||||
Vendored
+7
@@ -1,6 +1,13 @@
|
||||
# Release shrinking: obfuscate/rename classes and members as aggressively as R8 allows.
|
||||
# proguard-android-optimize.txt (from build.gradle) supplies repackageclasses, overloadaggressively, etc.
|
||||
|
||||
# ru.fromchat: keep names for plugin hooks; allow unused code removal.
|
||||
-keepnames,allowshrinking class ru.fromchat.** { *; }
|
||||
-keepclassmembernames,allowshrinking class ru.fromchat.** { *; }
|
||||
|
||||
# Plugin SDK loaded from plugin artifacts.
|
||||
-keepnames class ru.fromchat.plugins.** { *; }
|
||||
|
||||
# Crash reports: keep real .kt file names and line numbers; class names stay obfuscated.
|
||||
-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import kotlinx.coroutines.launch
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.local.workers.AttachmentTransferBootstrap
|
||||
import ru.fromchat.notifications.MessageNotificationCoordinator
|
||||
import ru.fromchat.plugins.integration.AndroidPluginBootstrap
|
||||
|
||||
class App : Application() {
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
@@ -16,6 +17,7 @@ class App : Application() {
|
||||
super.onCreate()
|
||||
UtilsLibrary.init(this)
|
||||
MessageNotificationCoordinator.install()
|
||||
AndroidPluginBootstrap.init(this)
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
runCatching { ApiClient.loadPersistedData() }
|
||||
|
||||
@@ -142,6 +142,7 @@ private object DesktopApplicationBootstrap {
|
||||
scope.launch {
|
||||
runCatching { ApiClient.loadPersistedData() }
|
||||
runCatching { AttachmentTransferBootstrap.runColdStart() }
|
||||
runCatching { ru.fromchat.plugins.integration.DesktopPluginBootstrap.init() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,6 +165,7 @@ kotlin {
|
||||
}
|
||||
|
||||
androidMain.dependencies {
|
||||
implementation(project(":plugins:host"))
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.markdown.renderer.m3)
|
||||
implementation(libs.bouncycastle.bcprov)
|
||||
@@ -200,6 +201,7 @@ kotlin {
|
||||
} else {
|
||||
implementation(compose.desktop.currentOs)
|
||||
}
|
||||
implementation(project(":plugins:host"))
|
||||
implementation(libs.jetbrains.kotlinx.io.bytestring)
|
||||
implementation(libs.jetbrains.kotlinx.coroutines.core)
|
||||
implementation(libs.ktor.client.cio)
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package ru.fromchat.plugins.integration
|
||||
|
||||
import android.content.Context
|
||||
import ru.fromchat.Logger
|
||||
import ru.fromchat.plugins.AppEvent
|
||||
import ru.fromchat.plugins.host.FeatureGate
|
||||
import ru.fromchat.plugins.host.PluginEngine
|
||||
import ru.fromchat.plugins.host.PluginHookDispatcher
|
||||
import ru.fromchat.plugins.host.initAndroidPluginPlatform
|
||||
import ru.fromchat.plugins.host.ui.PluginBulletinStore
|
||||
import ru.fromchat.plugins.host.util.PluginLogger
|
||||
import java.io.File
|
||||
|
||||
object AndroidPluginBootstrap {
|
||||
fun init(context: Context) {
|
||||
initAndroidPluginPlatform(context)
|
||||
PluginLogger.sink = { tag, message -> Logger.d(tag, message) }
|
||||
PluginHookDispatcher.bulletinHandler = PluginBulletinStore::show
|
||||
PluginEngine.pluginsRootProvider = {
|
||||
File(context.filesDir, "plugins").apply { mkdirs() }
|
||||
}
|
||||
PluginEngine.appVersionProvider = { ru.fromchat.AppBuildInfo.version }
|
||||
PluginEngine.init()
|
||||
PluginEngine.dispatchAppEvent(AppEvent.START)
|
||||
FeatureGate.registerDefault("calls") { ru.fromchat.config.ServerConfig.callsEnabled }
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package ru.fromchat.plugins.integration
|
||||
|
||||
actual object PluginInstallSupport {
|
||||
actual fun installFromPicker(): Boolean = false
|
||||
}
|
||||
@@ -339,6 +339,27 @@
|
||||
<string name="debug_tools">Отладка API</string>
|
||||
<string name="debug_tools_d">Просмотр ответов API профиля и личных сообщений.</string>
|
||||
|
||||
<string name="plugins_title">Плагины</string>
|
||||
<string name="plugins_enable_system">Включить систему плагинов</string>
|
||||
<string name="plugins_engine">Движок плагинов</string>
|
||||
<string name="plugins_engine_d">Запуск Kotlin-плагинов для расширения FromChat.</string>
|
||||
<string name="plugins_developer_mode">Режим разработчика</string>
|
||||
<string name="plugins_developer_mode_d">Включает локальный DevServer на порту 42690.</string>
|
||||
<string name="plugins_installed">Установленные плагины</string>
|
||||
<string name="plugins_none_installed">Плагины ещё не установлены.</string>
|
||||
<string name="plugins_no_results">Нет плагинов по вашему запросу.</string>
|
||||
<string name="plugins_enabled">Включено</string>
|
||||
<string name="plugins_settings_title">Настройки плагина</string>
|
||||
<string name="plugins_search">Поиск плагинов</string>
|
||||
<string name="plugins_add">Добавить плагин</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_d">Тема и Material You</string>
|
||||
<string name="settings_category_server_tools">Сервер и инструменты</string>
|
||||
|
||||
@@ -370,6 +370,27 @@
|
||||
<string name="debug_tools">Debug API</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_enable_system">Enable plugin system</string>
|
||||
<string name="plugins_engine">Plugin engine</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_d">Enable the local DevServer on port 42690.</string>
|
||||
<string name="plugins_installed">Installed plugins</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_settings_title">Plugin settings</string>
|
||||
<string name="plugins_search">Search plugins</string>
|
||||
<string name="plugins_add">Add plugin</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 -->
|
||||
<string name="settings_category_appearance">Appearance</string>
|
||||
<string name="settings_category_appearance_d">Theme and Material You</string>
|
||||
|
||||
+19
-3
@@ -197,13 +197,21 @@ object OutgoingMessageCoordinator {
|
||||
clientMessageId: String,
|
||||
optimisticMessage: Message,
|
||||
) {
|
||||
val hooked = ru.fromchat.plugins.host.PluginHookDispatcher.interceptSendMessage(
|
||||
ru.fromchat.plugins.SendMessageHookContext(
|
||||
text = content,
|
||||
isDm = false,
|
||||
recipientId = null,
|
||||
),
|
||||
) ?: return
|
||||
val finalContent = hooked.text
|
||||
val instanceId = CacheContext.requireActiveInstanceId()
|
||||
val conversationId = conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID)
|
||||
markOutboundActive(clientMessageId)
|
||||
withContext(Dispatchers.Default) {
|
||||
MessageRepository.upsertPublicMessage(optimisticMessage)
|
||||
Logger.d("OutgoingMessageCoordinator", "enqueuePublicMessage: clientId=${clientMessageId.take(12)} contentLen=${content.length}")
|
||||
val payload = json.encodeToString(PublicOutboxPayload(content, replyToId))
|
||||
Logger.d("OutgoingMessageCoordinator", "enqueuePublicMessage: clientId=${clientMessageId.take(12)} contentLen=${finalContent.length}")
|
||||
val payload = json.encodeToString(PublicOutboxPayload(finalContent, replyToId))
|
||||
MessageDatabaseProvider.database.messageDatabaseQueries.upsertOutbox(
|
||||
instanceId = instanceId,
|
||||
clientMessageId = clientMessageId,
|
||||
@@ -227,12 +235,20 @@ object OutgoingMessageCoordinator {
|
||||
transportFiles: List<SendDmFile> = emptyList(),
|
||||
uploadedFileIds: List<String> = emptyList(),
|
||||
) {
|
||||
val hooked = ru.fromchat.plugins.host.PluginHookDispatcher.interceptSendMessage(
|
||||
ru.fromchat.plugins.SendMessageHookContext(
|
||||
text = plaintext,
|
||||
isDm = true,
|
||||
recipientId = recipientId,
|
||||
),
|
||||
) ?: return
|
||||
val finalPlaintext = hooked.text
|
||||
val instanceId = CacheContext.requireActiveInstanceId()
|
||||
val conversationId = conversationIdForDm(recipientId)
|
||||
markOutboundActive(clientMessageId)
|
||||
withContext(Dispatchers.Default) {
|
||||
MessageRepository.upsertDmMessage(recipientId, optimisticMessage)
|
||||
val outboundPlaintext = buildDmOutboundPlaintext(plaintext, replyToId)
|
||||
val outboundPlaintext = buildDmOutboundPlaintext(finalPlaintext, replyToId)
|
||||
val payload = json.encodeToString(
|
||||
DmOutboxPayload(
|
||||
recipientId = recipientId,
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package ru.fromchat.plugins.integration
|
||||
|
||||
expect object PluginInstallSupport {
|
||||
fun installFromPicker(): Boolean
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package ru.fromchat.plugins.integration
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Extension
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.pr0gramm3r101.components.ListItem
|
||||
import ru.fromchat.plugins.MenuItemType
|
||||
import ru.fromchat.plugins.host.FeatureGate
|
||||
import ru.fromchat.plugins.host.PluginHookDispatcher
|
||||
import ru.fromchat.plugins.host.ui.PluginOverlayStore
|
||||
import ru.fromchat.ui.components.Text
|
||||
|
||||
@Composable
|
||||
fun PluginOverlayBanner(
|
||||
slot: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val storeOverlays = PluginOverlayStore.overlays
|
||||
val overlays = storeOverlays.filter { it.slot == slot }
|
||||
if (overlays.isEmpty()) return
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
overlays.forEach { overlay ->
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
) {
|
||||
Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
|
||||
Text(
|
||||
text = overlay.title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
)
|
||||
Text(
|
||||
text = overlay.message,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.85f),
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PluginSettingsMenuItems(
|
||||
showDividerBeforeFirst: Boolean,
|
||||
showDividerAfterLast: Boolean,
|
||||
) {
|
||||
val items = PluginHookDispatcher.menuItemEntriesFor(MenuItemType.SETTINGS_ROW)
|
||||
items.forEachIndexed { index, (_, item) ->
|
||||
ListItem(
|
||||
headline = item.text,
|
||||
supportingText = item.subtext,
|
||||
leadingContent = { androidx.compose.material3.Icon(Icons.Default.Extension, contentDescription = null) },
|
||||
onClick = { PluginHookDispatcher.dispatchMenuItemClick(item.onClickKey) },
|
||||
divider = when {
|
||||
index < items.lastIndex -> true
|
||||
showDividerAfterLast -> true
|
||||
else -> false
|
||||
}.let { divider ->
|
||||
divider || (index == 0 && showDividerBeforeFirst)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun pluginFeatureEnabled(featureId: String, default: Boolean = true): Boolean =
|
||||
FeatureGate.isEnabled(featureId, default)
|
||||
@@ -534,6 +534,7 @@ fun App(
|
||||
}
|
||||
|
||||
ScreenSurface {
|
||||
ru.fromchat.plugins.host.ui.PluginUiHost {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
@@ -780,6 +781,7 @@ fun App(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsDetailBackStackObserver(
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.union
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Extension
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.outlined.BugReport
|
||||
@@ -36,6 +37,7 @@ import ru.fromchat.more
|
||||
import ru.fromchat.ui.LocalNavController
|
||||
import ru.fromchat.ui.components.Text
|
||||
import ru.fromchat.ui.extraStatusBars
|
||||
import ru.fromchat.plugins_title
|
||||
import ru.fromchat.ui.main.settings.SettingsRoutes
|
||||
|
||||
/** Margin between the window edge and the wide [AuthContentFrame] panel. */
|
||||
@@ -105,6 +107,16 @@ fun PreAuthOverflowMenu() {
|
||||
navController.navigate(SettingsRoutes.About)
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(Res.string.plugins_title)) },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.Extension, contentDescription = null)
|
||||
},
|
||||
onClick = {
|
||||
menuExpanded = false
|
||||
navController.navigate(SettingsRoutes.Plugins)
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(Res.string.logs_title)) },
|
||||
leadingIcon = {
|
||||
|
||||
@@ -55,6 +55,7 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import ru.fromchat.ui.components.Text
|
||||
import ru.fromchat.plugins.integration.pluginFeatureEnabled
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -568,6 +569,7 @@ fun ChatInput(
|
||||
.padding(horizontal = 6.dp, vertical = 0.dp),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
) {
|
||||
if (pluginFeatureEnabled("chat.emoji_picker")) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(vertical = ChatInputIconSlotVerticalInset)
|
||||
@@ -582,6 +584,7 @@ fun ChatInput(
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val inputTextStyle = MaterialTheme.typography.bodyLarge.merge(
|
||||
TextStyle(
|
||||
|
||||
@@ -162,6 +162,7 @@ import ru.fromchat.ui.chat.utils.visibleMessageIdsInChatList
|
||||
import ru.fromchat.ui.chat.utils.unobstructedVisibleMessageIdsInChatList
|
||||
import ru.fromchat.ui.components.Text
|
||||
import ru.fromchat.ui.components.SuspendedAccountSupportSheet
|
||||
import ru.fromchat.plugins.integration.PluginOverlayBanner
|
||||
import ru.fromchat.ui.extraStatusBars
|
||||
import ru.fromchat.utils.NetworkConnectivity
|
||||
import ru.fromchat.utils.formatLastSeen
|
||||
@@ -1410,6 +1411,14 @@ fun ChatScreen(
|
||||
item { Spacer(modifier.height(floatingHeaderClearance)) }
|
||||
}
|
||||
|
||||
PluginOverlayBanner(
|
||||
slot = "chat.banner",
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.zIndex(2f)
|
||||
.padding(top = innerPadding.calculateTopPadding()),
|
||||
)
|
||||
|
||||
val showScrollToBottomFab = !isNearBottom &&
|
||||
panelState.messages.isNotEmpty() &&
|
||||
!contextMenuState.isOpen
|
||||
|
||||
@@ -30,6 +30,7 @@ import androidx.compose.material.icons.rounded.Close
|
||||
import androidx.compose.material.icons.rounded.ContentCopy
|
||||
import androidx.compose.material.icons.rounded.Delete
|
||||
import androidx.compose.material.icons.rounded.Edit
|
||||
import androidx.compose.material.icons.rounded.Extension
|
||||
import androidx.compose.material.icons.rounded.Refresh
|
||||
import androidx.compose.material.icons.rounded.SaveAlt
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -70,6 +71,8 @@ import ru.fromchat.api.local.download.resolveSavableMessageFile
|
||||
import ru.fromchat.api.local.download.resolveSavableMessageImage
|
||||
import ru.fromchat.api.local.messages.isQueuedOutbound
|
||||
import ru.fromchat.api.schema.messages.Message
|
||||
import ru.fromchat.plugins.MenuItemType
|
||||
import ru.fromchat.plugins.host.PluginHookDispatcher
|
||||
import ru.fromchat.ui.components.Text
|
||||
|
||||
data class ContextMenuState(
|
||||
@@ -275,6 +278,7 @@ fun MessageContextMenu(
|
||||
onRetrySend(it)
|
||||
onDismiss()
|
||||
},
|
||||
onDismiss = onDismiss,
|
||||
modifier = modifier
|
||||
.then(
|
||||
if (lockedMenuWidthPx > 0) {
|
||||
@@ -327,6 +331,7 @@ private fun ContextMenuContent(
|
||||
onSave: (Message) -> Unit,
|
||||
onCancelSend: (Message) -> Unit,
|
||||
onRetrySend: (Message) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
isReadOnly: Boolean = false,
|
||||
modifier: Modifier,
|
||||
animated: Boolean,
|
||||
@@ -419,6 +424,16 @@ private fun ContextMenuContent(
|
||||
)
|
||||
}
|
||||
}
|
||||
PluginHookDispatcher.menuItemEntriesFor(MenuItemType.MESSAGE_CONTEXT).forEach { (_, item) ->
|
||||
ChatStyleContextMenuItem(
|
||||
icon = Icons.Rounded.Extension,
|
||||
text = item.text,
|
||||
onClick = {
|
||||
PluginHookDispatcher.dispatchMenuItemClick(item.onClickKey)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ import ru.fromchat.ui.main.settings.LOG_FILE_OPEN_RESULT_KEY
|
||||
import ru.fromchat.ui.main.settings.LogFilesScreen
|
||||
import ru.fromchat.ui.main.settings.LogsScreen
|
||||
import ru.fromchat.ui.main.settings.NotificationsScreen
|
||||
import ru.fromchat.ui.main.settings.PluginDetailScreen
|
||||
import ru.fromchat.ui.main.settings.PluginsScreen
|
||||
import ru.fromchat.ui.main.settings.SettingsRoutes
|
||||
import ru.fromchat.ui.main.settings.account.AccountScreen
|
||||
import ru.fromchat.ui.main.settings.account.changepassword.ChangePasswordScreen
|
||||
@@ -288,6 +290,22 @@ fun NavGraphBuilder.settingsDetailDestinations(
|
||||
composable(SettingsRoutes.ServerConfig) {
|
||||
ServerConfigScreen()
|
||||
}
|
||||
|
||||
composable(SettingsRoutes.Plugins) {
|
||||
PluginsScreen()
|
||||
}
|
||||
|
||||
composable(
|
||||
route = SettingsRoutes.PluginDetail,
|
||||
arguments = listOf(
|
||||
navArgument("pluginId") { type = NavType.StringType },
|
||||
),
|
||||
) { entry ->
|
||||
val pluginId = entry.savedStateHandle.get<String>("pluginId").orEmpty()
|
||||
if (pluginId.isNotBlank()) {
|
||||
PluginDetailScreen(pluginId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun NavBackStackEntry.pathInt(name: String): Int =
|
||||
|
||||
@@ -82,7 +82,9 @@ import ru.fromchat.ui.main.chats.ChatContextMenuOverlayHost
|
||||
import ru.fromchat.ui.main.chats.ChatsSearchScreen
|
||||
import ru.fromchat.ui.main.chats.ChatsTab
|
||||
import ru.fromchat.ui.main.settings.SettingsTab
|
||||
import ru.fromchat.plugins.host.PluginEngine
|
||||
import ru.fromchat.ui.profile.ProfileScreen
|
||||
import androidx.compose.runtime.collectAsState
|
||||
|
||||
const val MAIN_PAGE_CHATS = 0
|
||||
const val MAIN_PAGE_CONTACTS = 1
|
||||
@@ -259,6 +261,8 @@ fun MainScreen(
|
||||
)
|
||||
}
|
||||
|
||||
val pluginHostRevision by PluginEngine.hostRevision.collectAsState()
|
||||
|
||||
val chatsLabel = stringResource(Res.string.chats)
|
||||
val contactsLabel = stringResource(Res.string.contacts)
|
||||
val settingsLabel = stringResource(Res.string.settings)
|
||||
|
||||
@@ -0,0 +1,598 @@
|
||||
package ru.fromchat.ui.main.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||
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.PushPin
|
||||
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.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialShapes
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.TextButton
|
||||
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.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 kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
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.ExpressiveIconFrame
|
||||
import ru.fromchat.ui.components.SearchBar
|
||||
import ru.fromchat.ui.components.Text
|
||||
import ru.fromchat.plugins_add
|
||||
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_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
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun PluginsScreen() {
|
||||
val navController = LocalNavController.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val clipboard = supportClipboardManagerImpl
|
||||
val scrollBehavior = rememberSettingsCollapsingScrollBehavior()
|
||||
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<PluginManifest?>(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),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
stringResource(Res.string.plugins_engine_d),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
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),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
},
|
||||
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),
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
topBar = {
|
||||
SettingsDetailTopBar(
|
||||
title = {
|
||||
Text(
|
||||
stringResource(Res.string.plugins_title),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
onBack = { navController.navigateUp() },
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll()
|
||||
.padding(innerPadding)
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
SearchBar(
|
||||
query = searchQuery,
|
||||
onQueryChange = { searchQuery = it },
|
||||
onSearch = {},
|
||||
placeholder = stringResource(Res.string.plugins_search),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp, bottom = 16.dp),
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 8.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
FilledTonalIconButton(onClick = { PluginInstallSupport.installFromPicker() }) {
|
||||
Icon(
|
||||
Icons.Outlined.Add,
|
||||
contentDescription = stringResource(Res.string.plugins_add),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { showInfoDialog = true }) {
|
||||
Icon(
|
||||
Icons.Default.Info,
|
||||
contentDescription = stringResource(Res.string.plugins_info_title),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
PluginEngineMasterCard(
|
||||
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 = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(vertical = 24.dp),
|
||||
)
|
||||
} 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 PluginEngineMasterCard(
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp, vertical = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(Res.string.plugins_enable_system),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.string.plugins_engine_d),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f),
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = checked,
|
||||
onCheckedChange = onCheckedChange,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@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,
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
),
|
||||
) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
ExpressiveIconFrame(
|
||||
icon = Icons.Default.Extension,
|
||||
containerSize = 48.dp,
|
||||
iconSize = 24.dp,
|
||||
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
materialPolygon = MaterialShapes.SoftBurst,
|
||||
)
|
||||
Switch(
|
||||
checked = enabled,
|
||||
onCheckedChange = onEnabledChange,
|
||||
enabled = engineEnabled,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
Text(
|
||||
text = manifest.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
|
||||
val authorLabel = manifest.author.removePrefix("@")
|
||||
Text(
|
||||
text = if (authorLabel.isNotBlank()) {
|
||||
stringResource(
|
||||
Res.string.plugins_version_author,
|
||||
manifest.version,
|
||||
authorLabel,
|
||||
)
|
||||
} else {
|
||||
manifest.version
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
|
||||
if (manifest.description.isNotBlank()) {
|
||||
Text(
|
||||
text = manifest.description,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
val usage = manifest.usage.takeIf { it.isNotBlank() }
|
||||
if (usage != null) {
|
||||
Text(
|
||||
text = stringResource(Res.string.plugins_usage),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
Text(
|
||||
text = usage,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
FilledTonalIconButton(onClick = onShare) {
|
||||
Icon(Icons.Default.Share, contentDescription = null)
|
||||
}
|
||||
FilledTonalIconButton(onClick = onOpenSettings) {
|
||||
Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null)
|
||||
}
|
||||
FilledTonalIconButton(onClick = onTogglePin) {
|
||||
Icon(
|
||||
imageVector = if (pinned) Icons.Default.PushPin else Icons.Outlined.PushPin,
|
||||
contentDescription = null,
|
||||
tint = if (pinned) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
IconButton(
|
||||
onClick = onDelete,
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = stringResource(Res.string.plugins_delete),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun PluginDetailScreen(pluginId: String) {
|
||||
val navController = LocalNavController.current
|
||||
val scrollBehavior = rememberSettingsCollapsingScrollBehavior()
|
||||
val plugin = PluginEngine.loadedPlugin(pluginId)
|
||||
val settings = plugin?.instance?.createSettings().orEmpty()
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
topBar = {
|
||||
SettingsDetailTopBar(
|
||||
title = {
|
||||
Text(
|
||||
plugin?.manifest?.name ?: pluginId,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
onBack = { navController.navigateUp() },
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll()
|
||||
.padding(innerPadding),
|
||||
) {
|
||||
if (plugin != null) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
ExpressiveIconFrame(
|
||||
icon = Icons.Default.Extension,
|
||||
materialPolygon = MaterialShapes.Cookie6Sided,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = plugin.manifest.name,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
val authorLabel = plugin.manifest.author.removePrefix("@")
|
||||
if (authorLabel.isNotBlank()) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
Res.string.plugins_version_author,
|
||||
plugin.manifest.version,
|
||||
authorLabel,
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Category(
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
margin = PaddingValues(horizontal = 16.dp, vertical = 0.dp),
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.plugins_settings_title),
|
||||
modifier = Modifier.padding(horizontal = SettingsStepHorizontalPadding, vertical = 8.dp),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
settings.forEach { setting ->
|
||||
when (setting) {
|
||||
is PluginSetting.Header -> {
|
||||
Text(
|
||||
text = setting.text,
|
||||
modifier = Modifier.padding(horizontal = SettingsStepHorizontalPadding, vertical = 8.dp),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
}
|
||||
is PluginSetting.Text -> {
|
||||
ListItem(
|
||||
headline = setting.text,
|
||||
supportingText = setting.subtext,
|
||||
)
|
||||
}
|
||||
is PluginSetting.Switch -> {
|
||||
var checked by remember(pluginId, setting.key) {
|
||||
mutableStateOf(
|
||||
PluginEngine.getPluginSettingBoolean(pluginId, setting.key, setting.default),
|
||||
)
|
||||
}
|
||||
SwitchListItem(
|
||||
headline = setting.text,
|
||||
supportingText = setting.subtext,
|
||||
checked = checked,
|
||||
onCheckedChange = { value ->
|
||||
checked = value
|
||||
PluginEngine.setPluginSetting(pluginId, setting.key, value.toString())
|
||||
},
|
||||
)
|
||||
}
|
||||
is PluginSetting.Input -> {
|
||||
var text by remember(pluginId, setting.key) {
|
||||
mutableStateOf(
|
||||
PluginEngine.getPluginSettingString(
|
||||
pluginId,
|
||||
setting.key,
|
||||
setting.default,
|
||||
),
|
||||
)
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = { value ->
|
||||
text = value
|
||||
PluginEngine.setPluginSetting(pluginId, setting.key, value)
|
||||
},
|
||||
label = { Text(setting.text) },
|
||||
modifier = Modifier
|
||||
.padding(horizontal = SettingsStepHorizontalPadding, vertical = 8.dp)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,4 +20,8 @@ object SettingsRoutes {
|
||||
const val About = "about"
|
||||
const val Logs = "settings/logs"
|
||||
const val LogFiles = "settings/logs/files"
|
||||
const val Plugins = "settings/plugins"
|
||||
const val PluginDetail = "settings/plugins/{pluginId}"
|
||||
|
||||
fun pluginDetail(pluginId: String): String = "settings/plugins/$pluginId"
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AccountCircle
|
||||
import androidx.compose.material.icons.filled.Devices
|
||||
import androidx.compose.material.icons.filled.Extension
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
@@ -40,6 +41,8 @@ import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.change_server
|
||||
import ru.fromchat.change_server_d
|
||||
import ru.fromchat.logs_title
|
||||
import ru.fromchat.plugins_title
|
||||
import ru.fromchat.plugins_engine_d
|
||||
import ru.fromchat.profile
|
||||
import ru.fromchat.settings
|
||||
import ru.fromchat.settings_category_account
|
||||
@@ -61,6 +64,9 @@ import ru.fromchat.ui.main.LocalDesktopSettingsNavController
|
||||
import ru.fromchat.ui.main.LocalMainChromeInsets
|
||||
import ru.fromchat.ui.main.mainPagerBottomInset
|
||||
import ru.fromchat.ui.main.navigateReplacingMainDetail
|
||||
import ru.fromchat.plugins.integration.PluginOverlayBanner
|
||||
import ru.fromchat.plugins.integration.PluginSettingsMenuItems
|
||||
import ru.fromchat.plugins.integration.pluginFeatureEnabled
|
||||
|
||||
val SettingsStepHorizontalPadding = 24.dp
|
||||
|
||||
@@ -109,6 +115,8 @@ fun SettingsTab() {
|
||||
) {
|
||||
Spacer(Modifier.height(innerPadding.calculateTopPadding()))
|
||||
|
||||
PluginOverlayBanner(slot = "settings.banner")
|
||||
|
||||
if (isTwoPane) {
|
||||
Category(Modifier.padding(top = 16.dp)) {
|
||||
ListItem(
|
||||
@@ -132,6 +140,7 @@ fun SettingsTab() {
|
||||
divider = true
|
||||
)
|
||||
|
||||
if (pluginFeatureEnabled("settings.devices")) {
|
||||
ListItem(
|
||||
headline = stringResource(Res.string.settings_category_devices),
|
||||
supportingText = stringResource(Res.string.settings_category_devices_d),
|
||||
@@ -139,6 +148,7 @@ fun SettingsTab() {
|
||||
leadingContent = { Icon(Icons.Filled.Devices, null) },
|
||||
divider = true
|
||||
)
|
||||
}
|
||||
|
||||
ListItem(
|
||||
headline = stringResource(Res.string.settings_category_appearance),
|
||||
@@ -172,11 +182,26 @@ fun SettingsTab() {
|
||||
divider = true
|
||||
)
|
||||
|
||||
if (pluginFeatureEnabled("settings.logs")) {
|
||||
ListItem(
|
||||
headline = stringResource(Res.string.logs_title),
|
||||
supportingText = stringResource(Res.string.settings_hub_logs_sub),
|
||||
onClick = { openDetail(SettingsRoutes.Logs) },
|
||||
leadingContent = { Icon(Icons.Outlined.BugReport, null) }
|
||||
leadingContent = { Icon(Icons.Outlined.BugReport, null) },
|
||||
divider = true,
|
||||
)
|
||||
}
|
||||
|
||||
PluginSettingsMenuItems(
|
||||
showDividerBeforeFirst = true,
|
||||
showDividerAfterLast = true,
|
||||
)
|
||||
|
||||
ListItem(
|
||||
headline = stringResource(Res.string.plugins_title),
|
||||
supportingText = stringResource(Res.string.plugins_engine_d),
|
||||
onClick = { openDetail(SettingsRoutes.Plugins) },
|
||||
leadingContent = { Icon(Icons.Filled.Extension, null) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package ru.fromchat.plugins.integration
|
||||
|
||||
actual object PluginInstallSupport {
|
||||
actual fun installFromPicker(): Boolean = false
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package ru.fromchat.plugins.integration
|
||||
|
||||
import ru.fromchat.Logger
|
||||
import ru.fromchat.plugins.AppEvent
|
||||
import ru.fromchat.plugins.host.FeatureGate
|
||||
import ru.fromchat.plugins.host.PluginEngine
|
||||
import ru.fromchat.plugins.host.PluginHookDispatcher
|
||||
import ru.fromchat.plugins.host.ui.PluginBulletinStore
|
||||
import ru.fromchat.plugins.host.util.PluginLogger
|
||||
import java.io.File
|
||||
|
||||
object DesktopPluginBootstrap {
|
||||
fun init(classLoader: ClassLoader = DesktopPluginBootstrap::class.java.classLoader) {
|
||||
PluginLogger.sink = { tag, message -> Logger.d(tag, message) }
|
||||
PluginHookDispatcher.bulletinHandler = PluginBulletinStore::show
|
||||
PluginEngine.pluginsRootProvider = { desktopPluginsRoot().apply { mkdirs() } }
|
||||
PluginEngine.appVersionProvider = { ru.fromchat.AppBuildInfo.version }
|
||||
PluginEngine.init()
|
||||
installBundledPlugin(classLoader)
|
||||
PluginEngine.refreshInstalledList()
|
||||
PluginEngine.dispatchAppEvent(AppEvent.START)
|
||||
FeatureGate.registerDefault("calls") { ru.fromchat.config.ServerConfig.callsEnabled }
|
||||
}
|
||||
|
||||
private fun installBundledPlugin(classLoader: ClassLoader) {
|
||||
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 {
|
||||
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()
|
||||
}
|
||||
|
||||
private fun desktopPluginsRoot(): File {
|
||||
val home = System.getProperty("user.home")
|
||||
val os = System.getProperty("os.name").lowercase()
|
||||
return when {
|
||||
os.contains("mac") -> File(home, "Library/Application Support/FromChat/plugins")
|
||||
os.contains("win") -> File(System.getenv("APPDATA") ?: home, "FromChat/plugins")
|
||||
else -> File(home, ".local/share/FromChat/plugins")
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
@@ -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)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,35 @@
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.compose.multiplatform)
|
||||
alias(libs.plugins.compose.compiler)
|
||||
alias(libs.plugins.kotlin.multiplatform.library)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
android {
|
||||
namespace = "ru.fromchat.plugins.host"
|
||||
compileSdk = 37
|
||||
minSdk = 24
|
||||
}
|
||||
jvm()
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
api(project(":plugins:sdk"))
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.jetbrains.kotlinx.coroutines.core)
|
||||
implementation(libs.compose.runtime)
|
||||
implementation(libs.compose.foundation)
|
||||
implementation(libs.compose.material3)
|
||||
implementation(libs.compose.ui)
|
||||
}
|
||||
jvmMain.dependencies {
|
||||
implementation("net.bytebuddy:byte-buddy:1.15.11")
|
||||
implementation("net.bytebuddy:byte-buddy-agent:1.15.11")
|
||||
}
|
||||
androidMain.dependencies {
|
||||
implementation("top.canyie.pine:core:0.3.0")
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package ru.fromchat.plugins.host
|
||||
|
||||
actual object PluginDevServer {
|
||||
actual fun onDeveloperModeChanged(enabled: Boolean) {
|
||||
// Android DevServer wiring can mirror JVM; disabled in initial pass.
|
||||
}
|
||||
|
||||
actual fun stop() = Unit
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package ru.fromchat.plugins.host
|
||||
|
||||
import android.content.Context
|
||||
import dalvik.system.DexClassLoader
|
||||
import ru.fromchat.plugins.BasePlugin
|
||||
import ru.fromchat.plugins.PluginManifest
|
||||
import top.canyie.pine.Pine
|
||||
import top.canyie.pine.callback.MethodHook
|
||||
import java.io.File
|
||||
|
||||
private lateinit var appContext: Context
|
||||
|
||||
fun initAndroidPluginPlatform(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
}
|
||||
|
||||
actual object PluginPlatform {
|
||||
actual val current: PluginPlatform? get() = this
|
||||
|
||||
private val classLoaders = mutableMapOf<String, DexClassLoader>()
|
||||
private val pineHooks = mutableMapOf<String, MutableList<top.canyie.pine.Pine.HookRecord>>()
|
||||
|
||||
actual fun installSharedHook(registration: SharedHookRegistration) {
|
||||
val target = registration.target
|
||||
val clazz = Class.forName(target.className)
|
||||
val paramTypes = target.paramTypeNames.map { resolveJvmParameterType(it) }.toTypedArray()
|
||||
val method = clazz.getDeclaredMethod(target.methodName, *paramTypes)
|
||||
val record = Pine.hook(method, object : MethodHook() {
|
||||
override fun beforeCall(callFrame: Pine.CallFrame) {
|
||||
registration.before?.invoke(callFrame.args)?.let { result ->
|
||||
when (result.strategy) {
|
||||
ru.fromchat.plugins.HookStrategy.CANCEL -> callFrame.setResult(null)
|
||||
ru.fromchat.plugins.HookStrategy.MODIFY, ru.fromchat.plugins.HookStrategy.MODIFY_FINAL -> {
|
||||
result.value?.let { callFrame.args = it }
|
||||
}
|
||||
ru.fromchat.plugins.HookStrategy.DEFAULT -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun afterCall(callFrame: Pine.CallFrame) {
|
||||
registration.after?.invoke(callFrame.args, callFrame.result)?.let { result ->
|
||||
when (result.strategy) {
|
||||
ru.fromchat.plugins.HookStrategy.CANCEL -> callFrame.setResult(null)
|
||||
ru.fromchat.plugins.HookStrategy.MODIFY, ru.fromchat.plugins.HookStrategy.MODIFY_FINAL -> {
|
||||
result.value?.let { callFrame.setResult(it) }
|
||||
}
|
||||
ru.fromchat.plugins.HookStrategy.DEFAULT -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
pineHooks.getOrPut(registration.pluginId) { mutableListOf() } += record
|
||||
}
|
||||
|
||||
actual fun uninstallSharedHook(registration: SharedHookRegistration) {
|
||||
pineHooks.remove(registration.pluginId)?.forEach { Pine.unhook(it) }
|
||||
}
|
||||
|
||||
actual fun loadPlugin(manifest: PluginManifest, pluginDir: String): LoadedPlugin? {
|
||||
val artifact = manifest.androidArtifact ?: return null
|
||||
val dex = File(pluginDir, artifact)
|
||||
if (!dex.exists()) return null
|
||||
val optimizedDir = File(appContext.codeCacheDir, "plugins_opt").apply { mkdirs() }
|
||||
val loader = DexClassLoader(
|
||||
dex.absolutePath,
|
||||
optimizedDir.absolutePath,
|
||||
null,
|
||||
appContext.classLoader,
|
||||
)
|
||||
classLoaders[manifest.id] = loader
|
||||
val clazz = loader.loadClass(manifest.entryClass)
|
||||
val instance = clazz.getDeclaredConstructor().newInstance() as BasePlugin
|
||||
return LoadedPlugin(manifest, instance, pluginDir)
|
||||
}
|
||||
|
||||
actual fun unloadPlugin(pluginId: String) {
|
||||
pineHooks.remove(pluginId)?.forEach { Pine.unhook(it) }
|
||||
classLoaders.remove(pluginId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package ru.fromchat.plugins.host
|
||||
|
||||
internal fun resolveJvmParameterType(typeName: String): Class<*> =
|
||||
when (typeName) {
|
||||
"boolean" -> java.lang.Boolean.TYPE
|
||||
"byte" -> java.lang.Byte.TYPE
|
||||
"char" -> java.lang.Character.TYPE
|
||||
"short" -> java.lang.Short.TYPE
|
||||
"int" -> Integer.TYPE
|
||||
"long" -> java.lang.Long.TYPE
|
||||
"float" -> java.lang.Float.TYPE
|
||||
"double" -> java.lang.Double.TYPE
|
||||
"void" -> java.lang.Void.TYPE
|
||||
else -> Class.forName(typeName)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package ru.fromchat.plugins.host
|
||||
|
||||
import ru.fromchat.plugins.HookResult
|
||||
|
||||
data class SharedHookTarget(
|
||||
val className: String,
|
||||
val methodName: String,
|
||||
val paramTypeNames: Array<String>,
|
||||
)
|
||||
|
||||
object HookRegistry {
|
||||
const val LOGGER_DEBUG = "logger.debug"
|
||||
|
||||
val targets = mapOf(
|
||||
LOGGER_DEBUG to SharedHookTarget(
|
||||
className = "ru.fromchat.Logger",
|
||||
methodName = "d",
|
||||
paramTypeNames = arrayOf("java.lang.String", "java.lang.String", "java.lang.Throwable"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
data class SharedHookRegistration(
|
||||
val pluginId: String,
|
||||
val hookId: String,
|
||||
val target: SharedHookTarget,
|
||||
val priority: Int,
|
||||
val before: ((Array<Any?>) -> HookResult<Array<Any?>>)?,
|
||||
val after: ((Array<Any?>, Any?) -> HookResult<Any?>)?,
|
||||
)
|
||||
|
||||
object SharedMethodHookRegistry {
|
||||
private val registrations = mutableListOf<SharedHookRegistration>()
|
||||
|
||||
fun register(registration: SharedHookRegistration) {
|
||||
registrations += registration
|
||||
registrations.sortByDescending { it.priority }
|
||||
PluginPlatform.current?.installSharedHook(registration)
|
||||
}
|
||||
|
||||
fun unregisterPlugin(pluginId: String) {
|
||||
val removed = registrations.filter { it.pluginId == pluginId }
|
||||
registrations.removeAll { it.pluginId == pluginId }
|
||||
removed.forEach { PluginPlatform.current?.uninstallSharedHook(it) }
|
||||
}
|
||||
|
||||
fun targetFor(hookId: String): SharedHookTarget? = HookRegistry.targets[hookId]
|
||||
}
|
||||
|
||||
expect object PluginPlatform {
|
||||
val current: PluginPlatform?
|
||||
fun installSharedHook(registration: SharedHookRegistration)
|
||||
fun uninstallSharedHook(registration: SharedHookRegistration)
|
||||
fun loadPlugin(manifest: ru.fromchat.plugins.PluginManifest, pluginDir: String): LoadedPlugin?
|
||||
fun unloadPlugin(pluginId: String)
|
||||
}
|
||||
|
||||
data class LoadedPlugin(
|
||||
val manifest: ru.fromchat.plugins.PluginManifest,
|
||||
val instance: ru.fromchat.plugins.BasePlugin,
|
||||
val pluginDir: String,
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
package ru.fromchat.plugins.host
|
||||
|
||||
expect object PluginDevServer {
|
||||
fun onDeveloperModeChanged(enabled: Boolean)
|
||||
fun stop()
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
package ru.fromchat.plugins.host
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import ru.fromchat.plugins.AppEvent
|
||||
import ru.fromchat.plugins.BasePlugin
|
||||
import ru.fromchat.plugins.HookResult
|
||||
import ru.fromchat.plugins.MenuItemData
|
||||
import ru.fromchat.plugins.PluginHostBridge
|
||||
import ru.fromchat.plugins.PluginManifest
|
||||
import ru.fromchat.plugins.PluginSettingsReload
|
||||
import ru.fromchat.plugins.SendMessageHookContext
|
||||
import java.io.File
|
||||
|
||||
object PluginEngine : PluginHostBridge {
|
||||
private val _engineEnabled = MutableStateFlow(false)
|
||||
val engineEnabled: StateFlow<Boolean> = _engineEnabled.asStateFlow()
|
||||
|
||||
private val _developerMode = MutableStateFlow(false)
|
||||
val developerMode: StateFlow<Boolean> = _developerMode.asStateFlow()
|
||||
|
||||
private val _installed = MutableStateFlow<List<PluginManifest>>(emptyList())
|
||||
val installed: StateFlow<List<PluginManifest>> = _installed.asStateFlow()
|
||||
|
||||
private val _hostRevision = MutableStateFlow(0)
|
||||
val hostRevision: StateFlow<Int> = _hostRevision.asStateFlow()
|
||||
|
||||
private val enabledIds = linkedSetOf<String>()
|
||||
private val pinnedIds = linkedSetOf<String>()
|
||||
private val loaded = mutableMapOf<String, LoadedPlugin>()
|
||||
private val settings = mutableMapOf<String, MutableMap<String, String>>()
|
||||
private val sharedUnhooks = mutableMapOf<String, MutableList<() -> Unit>>()
|
||||
|
||||
var pluginsRootProvider: () -> File = { error("pluginsRootProvider not set") }
|
||||
var appVersionProvider: () -> String = { "0.0.0" }
|
||||
var onSettingsReload: ((String) -> Unit)? = null
|
||||
|
||||
fun init() {
|
||||
PluginSettingsReload.listener = { pluginId -> onSettingsReload?.invoke(pluginId) }
|
||||
refreshInstalledList()
|
||||
loadEnabledState()
|
||||
if (_engineEnabled.value) {
|
||||
enabledIds.forEach { loadPlugin(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun setEngineEnabled(enabled: Boolean) {
|
||||
if (_engineEnabled.value == enabled) return
|
||||
_engineEnabled.value = enabled
|
||||
persistEngineState()
|
||||
if (enabled) {
|
||||
enabledIds.forEach { loadPlugin(it) }
|
||||
} else {
|
||||
loaded.keys.toList().forEach { unloadPlugin(it) }
|
||||
}
|
||||
bumpHostRevision()
|
||||
}
|
||||
|
||||
fun setDeveloperMode(enabled: Boolean) {
|
||||
_developerMode.value = enabled
|
||||
persistEngineState()
|
||||
PluginDevServer.onDeveloperModeChanged(enabled)
|
||||
}
|
||||
|
||||
fun setPluginEnabled(pluginId: String, enabled: Boolean) {
|
||||
if (enabled) {
|
||||
enabledIds += pluginId
|
||||
if (_engineEnabled.value) loadPlugin(pluginId)
|
||||
} else {
|
||||
enabledIds -= pluginId
|
||||
unloadPlugin(pluginId)
|
||||
}
|
||||
persistEnabledState()
|
||||
refreshInstalledList()
|
||||
bumpHostRevision()
|
||||
}
|
||||
|
||||
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 getPluginSettingBoolean(pluginId: String, key: String, default: Boolean = false): Boolean =
|
||||
getSetting(pluginId, key, default)
|
||||
|
||||
fun getPluginSettingString(pluginId: String, key: String, default: String = ""): String =
|
||||
getSettingString(pluginId, key, default)
|
||||
|
||||
fun setPluginSetting(pluginId: String, key: String, value: String) {
|
||||
setSetting(pluginId, key, value)
|
||||
}
|
||||
|
||||
fun refreshInstalledList() {
|
||||
val root = pluginsRootProvider()
|
||||
if (!root.exists()) {
|
||||
root.mkdirs()
|
||||
}
|
||||
val manifests = root.listFiles()?.filter { it.isDirectory }?.mapNotNull { dir ->
|
||||
val manifestFile = dir.resolve("manifest.json")
|
||||
if (!manifestFile.exists()) return@mapNotNull null
|
||||
runCatching { PluginManifestParser.parse(manifestFile.readText()) }.getOrNull()
|
||||
}.orEmpty().sortedBy { it.name.lowercase() }
|
||||
_installed.value = manifests
|
||||
}
|
||||
|
||||
fun installFcPluginArchive(archiveFile: File) {
|
||||
val temp = pluginsRootProvider().resolve(".install-${System.currentTimeMillis()}")
|
||||
temp.mkdirs()
|
||||
projectZipExtract(archiveFile, temp)
|
||||
val manifestFile = temp.walkTopDown().firstOrNull { it.name == "manifest.json" }
|
||||
?: error("manifest.json not found in archive")
|
||||
val manifest = PluginManifestParser.parse(manifestFile.readText())
|
||||
val target = pluginsRootProvider().resolve(manifest.id)
|
||||
if (target.exists()) target.deleteRecursively()
|
||||
manifestFile.parentFile?.copyRecursively(target, overwrite = true)
|
||||
temp.deleteRecursively()
|
||||
refreshInstalledList()
|
||||
}
|
||||
|
||||
fun dispatchAppEvent(event: AppEvent) {
|
||||
loaded.values.forEach { runCatching { it.instance.onAppEvent(event) } }
|
||||
}
|
||||
|
||||
override fun log(pluginId: String, message: String) {
|
||||
ru.fromchat.plugins.host.util.PluginLogger.log(pluginId, message)
|
||||
}
|
||||
|
||||
override fun getSetting(pluginId: String, key: String, default: Boolean): Boolean =
|
||||
settings[pluginId]?.get(key)?.toBooleanStrictOrNull() ?: default
|
||||
|
||||
override fun getSettingString(pluginId: String, key: String, default: String): String =
|
||||
settings[pluginId]?.get(key) ?: default
|
||||
|
||||
override fun setSetting(pluginId: String, key: String, value: String) {
|
||||
val map = settings.getOrPut(pluginId) { loadSettingsFile(pluginId) }
|
||||
map[key] = value
|
||||
saveSettingsFile(pluginId, map)
|
||||
}
|
||||
|
||||
override fun registerSendMessageHook(
|
||||
pluginId: String,
|
||||
priority: Int,
|
||||
handler: (SendMessageHookContext) -> HookResult<SendMessageHookContext>,
|
||||
) {
|
||||
PluginHookDispatcher.registerSendHook(pluginId, priority, handler)
|
||||
}
|
||||
|
||||
override fun registerFeatureOverride(pluginId: String, featureId: String, provider: () -> Boolean?) {
|
||||
PluginHookDispatcher.registerFeatureOverride(pluginId, featureId, provider)
|
||||
}
|
||||
|
||||
override fun registerMenuItem(pluginId: String, item: MenuItemData) {
|
||||
PluginHookDispatcher.registerMenuItem(pluginId, item)
|
||||
}
|
||||
|
||||
override fun registerUiOverlay(pluginId: String, slot: String, title: String, message: String) {
|
||||
ru.fromchat.plugins.host.ui.PluginOverlayStore.register(pluginId, slot, title, message)
|
||||
}
|
||||
|
||||
override fun showBulletin(message: String) {
|
||||
PluginHookDispatcher.showBulletin(message)
|
||||
}
|
||||
|
||||
override fun hookSharedMethod(
|
||||
pluginId: String,
|
||||
hookId: String,
|
||||
priority: Int,
|
||||
before: ((Array<Any?>) -> HookResult<Array<Any?>>)?,
|
||||
after: ((Array<Any?>, Any?) -> HookResult<Any?>)?,
|
||||
): () -> Unit {
|
||||
val target = SharedMethodHookRegistry.targetFor(hookId)
|
||||
?: run {
|
||||
log(pluginId, "Unknown shared hook id: $hookId")
|
||||
return {}
|
||||
}
|
||||
return registerSharedHook(pluginId, hookId, target, priority, before, after)
|
||||
}
|
||||
|
||||
override fun hookRawMethod(
|
||||
pluginId: String,
|
||||
className: String,
|
||||
methodName: String,
|
||||
paramTypeNames: Array<String>,
|
||||
priority: Int,
|
||||
before: ((Array<Any?>) -> HookResult<Array<Any?>>)?,
|
||||
after: ((Array<Any?>, Any?) -> HookResult<Any?>)?,
|
||||
): () -> Unit {
|
||||
val hookId = "raw:$className#$methodName"
|
||||
val target = SharedHookTarget(className, methodName, paramTypeNames)
|
||||
return registerSharedHook(pluginId, hookId, target, priority, before, after)
|
||||
}
|
||||
|
||||
private fun registerSharedHook(
|
||||
pluginId: String,
|
||||
hookId: String,
|
||||
target: SharedHookTarget,
|
||||
priority: Int,
|
||||
before: ((Array<Any?>) -> HookResult<Array<Any?>>)?,
|
||||
after: ((Array<Any?>, Any?) -> HookResult<Any?>)?,
|
||||
): () -> Unit {
|
||||
val registration = SharedHookRegistration(pluginId, hookId, target, priority, before, after)
|
||||
SharedMethodHookRegistry.register(registration)
|
||||
val unhook = {
|
||||
SharedMethodHookRegistry.unregisterPlugin(pluginId)
|
||||
}
|
||||
sharedUnhooks.getOrPut(pluginId) { mutableListOf() } += unhook
|
||||
return unhook
|
||||
}
|
||||
|
||||
private fun loadPlugin(pluginId: String) {
|
||||
if (loaded.containsKey(pluginId)) return
|
||||
val dir = pluginsRootProvider().resolve(pluginId)
|
||||
val manifestFile = dir.resolve("manifest.json")
|
||||
if (!manifestFile.exists()) return
|
||||
val manifest = PluginManifestParser.parse(manifestFile.readText())
|
||||
val platform = PluginPlatform.current ?: return
|
||||
val loadedPlugin = platform.loadPlugin(manifest, dir.absolutePath) ?: return
|
||||
loadedPlugin.instance.attach(manifest.id, this)
|
||||
settings.putIfAbsent(manifest.id, loadSettingsFile(manifest.id))
|
||||
runCatching { loadedPlugin.instance.onPluginLoad() }
|
||||
.onFailure { log(manifest.id, "onPluginLoad failed: ${it.message}") }
|
||||
loaded[manifest.id] = loadedPlugin
|
||||
bumpHostRevision()
|
||||
}
|
||||
|
||||
private fun unloadPlugin(pluginId: String) {
|
||||
val plugin = loaded.remove(pluginId) ?: return
|
||||
runCatching { plugin.instance.onPluginUnload() }
|
||||
PluginHookDispatcher.unregisterPlugin(pluginId)
|
||||
sharedUnhooks.remove(pluginId)
|
||||
PluginPlatform.current?.unloadPlugin(pluginId)
|
||||
bumpHostRevision()
|
||||
}
|
||||
|
||||
private fun bumpHostRevision() {
|
||||
_hostRevision.value++
|
||||
}
|
||||
|
||||
private fun loadSettingsFile(pluginId: String): MutableMap<String, String> {
|
||||
val file = pluginsRootProvider().resolve(pluginId).resolve("settings.json")
|
||||
if (!file.exists()) return mutableMapOf()
|
||||
return runCatching {
|
||||
file.readLines()
|
||||
.mapNotNull { line ->
|
||||
val idx = line.indexOf('=')
|
||||
if (idx <= 0) null else line.substring(0, idx) to line.substring(idx + 1)
|
||||
}
|
||||
.toMap()
|
||||
.toMutableMap()
|
||||
}.getOrElse { mutableMapOf() }
|
||||
}
|
||||
|
||||
private fun saveSettingsFile(pluginId: String, map: Map<String, String>) {
|
||||
val file = pluginsRootProvider().resolve(pluginId).resolve("settings.json")
|
||||
file.writeText(map.entries.joinToString("\n") { "${it.key}=${it.value}" })
|
||||
}
|
||||
|
||||
private fun persistEngineState() {
|
||||
val file = pluginsRootProvider().resolve("engine_state.properties")
|
||||
file.parentFile?.mkdirs()
|
||||
file.writeText(
|
||||
buildString {
|
||||
appendLine("engineEnabled=${_engineEnabled.value}")
|
||||
appendLine("developerMode=${_developerMode.value}")
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun loadEnabledState() {
|
||||
val file = pluginsRootProvider().resolve("engine_state.properties")
|
||||
if (file.exists()) {
|
||||
file.readLines().forEach { line ->
|
||||
val parts = line.split("=", limit = 2)
|
||||
if (parts.size == 2) {
|
||||
when (parts[0]) {
|
||||
"engineEnabled" -> _engineEnabled.value = parts[1].toBooleanStrictOrNull() == true
|
||||
"developerMode" -> _developerMode.value = parts[1].toBooleanStrictOrNull() == true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val enabledFile = pluginsRootProvider().resolve("enabled_plugins.txt")
|
||||
if (enabledFile.exists()) {
|
||||
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 ->
|
||||
val out = dest.resolve(entry.name)
|
||||
if (entry.isDirectory) {
|
||||
out.mkdirs()
|
||||
} else {
|
||||
out.parentFile?.mkdirs()
|
||||
zip.getInputStream(entry).use { input -> out.outputStream().use { input.copyTo(it) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun ensureBundledPlugins(bundledDir: File) {
|
||||
if (!bundledDir.exists()) return
|
||||
bundledDir.listFiles()?.filter { it.extension == "fcplugin" }?.forEach { archive ->
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package ru.fromchat.plugins.host
|
||||
|
||||
import ru.fromchat.plugins.HookResult
|
||||
import ru.fromchat.plugins.HookStrategy
|
||||
import ru.fromchat.plugins.MenuItemData
|
||||
import ru.fromchat.plugins.SendMessageHookContext
|
||||
import ru.fromchat.plugins.host.ui.PluginOverlayStore
|
||||
|
||||
data class RegisteredSendHook(
|
||||
val pluginId: String,
|
||||
val priority: Int,
|
||||
val handler: (SendMessageHookContext) -> HookResult<SendMessageHookContext>,
|
||||
)
|
||||
|
||||
object PluginHookDispatcher {
|
||||
private val sendHooks = mutableListOf<RegisteredSendHook>()
|
||||
private val featureOverrides = mutableMapOf<String, MutableList<Pair<String, () -> Boolean?>>>()
|
||||
private val menuItems = mutableListOf<Pair<String, MenuItemData>>()
|
||||
var bulletinHandler: ((String) -> Unit)? = null
|
||||
|
||||
fun registerSendHook(pluginId: String, priority: Int, handler: (SendMessageHookContext) -> HookResult<SendMessageHookContext>) {
|
||||
sendHooks += RegisteredSendHook(pluginId, priority, handler)
|
||||
sendHooks.sortByDescending { it.priority }
|
||||
}
|
||||
|
||||
fun unregisterPlugin(pluginId: String) {
|
||||
sendHooks.removeAll { it.pluginId == pluginId }
|
||||
featureOverrides.values.forEach { list -> list.removeAll { it.first == pluginId } }
|
||||
menuItems.removeAll { it.first == pluginId }
|
||||
SharedMethodHookRegistry.unregisterPlugin(pluginId)
|
||||
PluginOverlayStore.unregisterPlugin(pluginId)
|
||||
}
|
||||
|
||||
fun registerFeatureOverride(pluginId: String, featureId: String, provider: () -> Boolean?) {
|
||||
featureOverrides.getOrPut(featureId) { mutableListOf() } += pluginId to provider
|
||||
}
|
||||
|
||||
fun registerMenuItem(pluginId: String, item: MenuItemData) {
|
||||
menuItems += pluginId to item
|
||||
}
|
||||
|
||||
fun menuItemEntriesFor(type: ru.fromchat.plugins.MenuItemType): List<Pair<String, MenuItemData>> =
|
||||
menuItems.filter { it.second.menuType == type }.sortedByDescending { it.second.priority }
|
||||
|
||||
fun menuItemsFor(type: ru.fromchat.plugins.MenuItemType): List<MenuItemData> =
|
||||
menuItemEntriesFor(type).map { it.second }
|
||||
|
||||
fun dispatchMenuItemClick(clickKey: String) {
|
||||
if (clickKey.isBlank()) return
|
||||
val owner = menuItems.firstOrNull { it.second.onClickKey == clickKey } ?: return
|
||||
PluginEngine.loadedPlugin(owner.first)?.instance?.onMenuItemClick(clickKey)
|
||||
}
|
||||
|
||||
fun featureOverrideValue(id: String): Boolean? {
|
||||
featureOverrides[id]?.asReversed()?.forEach { (_, provider) ->
|
||||
provider()?.let { return it }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun interceptSendMessage(context: SendMessageHookContext): SendMessageHookContext? {
|
||||
var current = context
|
||||
for (hook in sendHooks) {
|
||||
val result = hook.handler(current)
|
||||
when (result.strategy) {
|
||||
HookStrategy.DEFAULT -> Unit
|
||||
HookStrategy.CANCEL -> return null
|
||||
HookStrategy.MODIFY, HookStrategy.MODIFY_FINAL -> {
|
||||
result.value?.let { current = it }
|
||||
if (result.strategy == HookStrategy.MODIFY_FINAL) return current
|
||||
}
|
||||
}
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
fun showBulletin(message: String) {
|
||||
bulletinHandler?.invoke(message)
|
||||
}
|
||||
}
|
||||
|
||||
object FeatureGate {
|
||||
private val defaults = mutableMapOf<String, () -> Boolean>()
|
||||
var pluginResolver: ((String, Boolean) -> Boolean)? = null
|
||||
|
||||
fun registerDefault(id: String, provider: () -> Boolean) {
|
||||
defaults[id] = provider
|
||||
}
|
||||
|
||||
fun isEnabled(id: String, default: Boolean = true): Boolean {
|
||||
PluginHookDispatcher.featureOverrideValue(id)?.let { return it }
|
||||
return defaults[id]?.invoke() ?: default
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package ru.fromchat.plugins.host
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import ru.fromchat.plugins.PluginManifest
|
||||
|
||||
object PluginManifestParser {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
fun parse(raw: String): PluginManifest {
|
||||
val root = json.parseToJsonElement(raw).jsonObject
|
||||
val artifacts = root["artifacts"]?.jsonObject
|
||||
return PluginManifest(
|
||||
id = root.requireString("id"),
|
||||
name = root.requireString("name"),
|
||||
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"),
|
||||
androidArtifact = artifacts?.get("android")?.jsonPrimitive?.contentOrNull,
|
||||
desktopArtifact = artifacts?.get("desktop")?.jsonPrimitive?.contentOrNull,
|
||||
)
|
||||
}
|
||||
|
||||
private fun JsonObject.requireString(key: String): String =
|
||||
this[key]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }
|
||||
?: error("manifest missing $key")
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package ru.fromchat.plugins.host.ui
|
||||
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
|
||||
data class PluginUiOverlay(
|
||||
val pluginId: String,
|
||||
val slot: String,
|
||||
val title: String,
|
||||
val message: String,
|
||||
)
|
||||
|
||||
object PluginOverlayStore {
|
||||
val overlays = mutableStateListOf<PluginUiOverlay>()
|
||||
|
||||
fun register(pluginId: String, slot: String, title: String, message: String) {
|
||||
overlays.removeAll { it.pluginId == pluginId && it.slot == slot }
|
||||
overlays += PluginUiOverlay(pluginId, slot, title, message)
|
||||
}
|
||||
|
||||
fun unregisterPlugin(pluginId: String) {
|
||||
overlays.removeAll { it.pluginId == pluginId }
|
||||
}
|
||||
|
||||
fun overlaysFor(slot: String): List<PluginUiOverlay> =
|
||||
overlays.filter { it.slot == slot }
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package ru.fromchat.plugins.host.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
data class PluginBulletin(val message: String, val id: Long)
|
||||
|
||||
object PluginBulletinStore {
|
||||
val bulletins = mutableStateListOf<PluginBulletin>()
|
||||
private var nextId = 0L
|
||||
|
||||
fun show(message: String) {
|
||||
val id = ++nextId
|
||||
bulletins += PluginBulletin(message, id)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PluginUiHost(content: @Composable () -> Unit) {
|
||||
val bulletins = remember { PluginBulletinStore.bulletins }
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
content()
|
||||
bulletins.firstOrNull()?.let { bulletin ->
|
||||
LaunchedEffect(bulletin.id) {
|
||||
delay(4_000)
|
||||
if (bulletins.firstOrNull()?.id == bulletin.id) {
|
||||
bulletins.removeAt(0)
|
||||
}
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(16.dp),
|
||||
tonalElevation = 6.dp,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
) {
|
||||
Text(
|
||||
text = bulletin.message,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package ru.fromchat.plugins.host.util
|
||||
|
||||
object PluginLogger {
|
||||
var sink: ((tag: String, message: String) -> Unit)? = null
|
||||
|
||||
fun log(pluginId: String, message: String) {
|
||||
val tag = "Plugin:$pluginId"
|
||||
sink?.invoke(tag, message) ?: println("$tag $message")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package ru.fromchat.plugins.host
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.io.PrintWriter
|
||||
import java.net.InetAddress
|
||||
import java.net.ServerSocket
|
||||
import java.net.Socket
|
||||
|
||||
actual object PluginDevServer {
|
||||
private var serverJob: Job? = null
|
||||
private val scope = CoroutineScope(Dispatchers.IO)
|
||||
|
||||
actual fun onDeveloperModeChanged(enabled: Boolean) {
|
||||
if (enabled) start() else stop()
|
||||
}
|
||||
|
||||
actual fun stop() {
|
||||
serverJob?.cancel()
|
||||
serverJob = null
|
||||
}
|
||||
|
||||
private fun start() {
|
||||
if (serverJob != null) return
|
||||
serverJob = scope.launch {
|
||||
runCatching {
|
||||
ServerSocket(42690, 50, InetAddress.getByName("127.0.0.1")).use { server ->
|
||||
while (true) {
|
||||
val socket = server.accept()
|
||||
scope.launch { handleClient(socket) }
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
PluginEngine.log("devserver", "failed: ${it.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleClient(socket: Socket) {
|
||||
socket.use { client ->
|
||||
val reader = BufferedReader(InputStreamReader(client.getInputStream()))
|
||||
val writer = PrintWriter(client.getOutputStream(), true)
|
||||
val line = reader.readLine() ?: return
|
||||
val requestId = Regex("\"#\"\\s*:\\s*\"([^\"]+)\"").find(line)?.groupValues?.getOrNull(1) ?: "0"
|
||||
val command = Regex("\"@\"\\s*:\\s*\"([^\"]+)\"").find(line)?.groupValues?.getOrNull(1)
|
||||
when (command) {
|
||||
"ping" -> writer.println("""{"#":"$requestId","pong":true}""")
|
||||
"get_plugins" -> {
|
||||
val plugins = PluginEngine.installed.value.joinToString(",") { manifest ->
|
||||
"""{"id":"${manifest.id}","enabled":${PluginEngine.isPluginEnabled(manifest.id)}}"""
|
||||
}
|
||||
writer.println("""{"#":"$requestId","plugins":[$plugins]}""")
|
||||
}
|
||||
"enable_plugin" -> {
|
||||
Regex("\"plugin_id\"\\s*:\\s*\"([^\"]+)\"").find(line)?.groupValues?.getOrNull(1)?.let {
|
||||
PluginEngine.setPluginEnabled(it, true)
|
||||
}
|
||||
writer.println("""{"#":"$requestId","success":true}""")
|
||||
}
|
||||
"disable_plugin" -> {
|
||||
Regex("\"plugin_id\"\\s*:\\s*\"([^\"]+)\"").find(line)?.groupValues?.getOrNull(1)?.let {
|
||||
PluginEngine.setPluginEnabled(it, false)
|
||||
}
|
||||
writer.println("""{"#":"$requestId","success":true}""")
|
||||
}
|
||||
"reload_plugin" -> {
|
||||
Regex("\"plugin_id\"\\s*:\\s*\"([^\"]+)\"").find(line)?.groupValues?.getOrNull(1)?.let { id ->
|
||||
PluginEngine.setPluginEnabled(id, false)
|
||||
PluginEngine.setPluginEnabled(id, true)
|
||||
}
|
||||
writer.println("""{"#":"$requestId","success":true}""")
|
||||
}
|
||||
else -> writer.println("""{"#":"$requestId","error":"unknown command"}""")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun PluginEngine.log(tag: String, message: String) {
|
||||
ru.fromchat.plugins.host.util.PluginLogger.log(tag, message)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package ru.fromchat.plugins.host
|
||||
|
||||
import net.bytebuddy.ByteBuddy
|
||||
import net.bytebuddy.agent.ByteBuddyAgent
|
||||
import net.bytebuddy.asm.Advice
|
||||
import net.bytebuddy.dynamic.loading.ClassReloadingStrategy
|
||||
import net.bytebuddy.matcher.ElementMatchers
|
||||
import ru.fromchat.plugins.BasePlugin
|
||||
import ru.fromchat.plugins.PluginManifest
|
||||
import ru.fromchat.plugins.host.util.PluginLogger
|
||||
import java.io.File
|
||||
import java.lang.reflect.Method
|
||||
import java.net.URLClassLoader
|
||||
|
||||
actual object PluginPlatform {
|
||||
actual val current: PluginPlatform? get() = this
|
||||
|
||||
private val classLoaders = mutableMapOf<String, URLClassLoader>()
|
||||
private val instances = mutableMapOf<String, BasePlugin>()
|
||||
private val hookHandles = mutableMapOf<String, MutableList<SharedHookHandle>>()
|
||||
private var agentInstalled = false
|
||||
|
||||
actual fun installSharedHook(registration: SharedHookRegistration) {
|
||||
val handle = JvmSharedHookHandle(registration, registration.target)
|
||||
handle.install()
|
||||
hookHandles.getOrPut(registration.pluginId) { mutableListOf() } += handle
|
||||
}
|
||||
|
||||
actual fun uninstallSharedHook(registration: SharedHookRegistration) {
|
||||
hookHandles[registration.pluginId]?.removeAll { it.registration == registration }
|
||||
HookAdviceBindings.unbind(registration)
|
||||
}
|
||||
|
||||
actual fun loadPlugin(manifest: PluginManifest, pluginDir: String): LoadedPlugin? {
|
||||
val artifact = manifest.desktopArtifact ?: return null
|
||||
val jar = File(pluginDir, artifact)
|
||||
if (!jar.exists()) return null
|
||||
val loader = URLClassLoader(arrayOf(jar.toURI().toURL()), BasePlugin::class.java.classLoader)
|
||||
classLoaders[manifest.id] = loader
|
||||
val clazz = loader.loadClass(manifest.entryClass)
|
||||
val instance = clazz.getDeclaredConstructor().newInstance() as BasePlugin
|
||||
instances[manifest.id] = instance
|
||||
return LoadedPlugin(manifest, instance, pluginDir)
|
||||
}
|
||||
|
||||
actual fun unloadPlugin(pluginId: String) {
|
||||
hookHandles.remove(pluginId)?.forEach { it.uninstall() }
|
||||
instances.remove(pluginId)
|
||||
classLoaders.remove(pluginId)?.close()
|
||||
}
|
||||
|
||||
private fun ensureAgent() {
|
||||
if (agentInstalled) return
|
||||
ByteBuddyAgent.install()
|
||||
agentInstalled = true
|
||||
}
|
||||
|
||||
private interface SharedHookHandle {
|
||||
val registration: SharedHookRegistration
|
||||
fun install()
|
||||
fun uninstall()
|
||||
}
|
||||
|
||||
private class JvmSharedHookHandle(
|
||||
override val registration: SharedHookRegistration,
|
||||
private val target: SharedHookTarget,
|
||||
) : SharedHookHandle {
|
||||
private var installed = false
|
||||
private var boundMethod: Method? = null
|
||||
|
||||
override fun install() {
|
||||
if (installed) return
|
||||
runCatching {
|
||||
ensureAgent()
|
||||
val clazz = Class.forName(target.className)
|
||||
val paramTypes = target.paramTypeNames.map { resolveJvmParameterType(it) }.toTypedArray()
|
||||
val method = clazz.getDeclaredMethod(target.methodName, *paramTypes)
|
||||
HookAdviceBindings.bind(method, registration)
|
||||
boundMethod = method
|
||||
ByteBuddy()
|
||||
.redefine(clazz)
|
||||
.visit(Advice.to(SharedHookAdvice::class.java).on(ElementMatchers.`is`(method)))
|
||||
.make()
|
||||
.load(clazz.classLoader, ClassReloadingStrategy.fromInstalledAgent())
|
||||
installed = true
|
||||
}.onFailure {
|
||||
boundMethod?.let { HookAdviceBindings.unbind(registration) }
|
||||
boundMethod = null
|
||||
PluginLogger.log(
|
||||
registration.pluginId,
|
||||
"Raw hook install failed for ${target.className}.${target.methodName}: ${it.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun uninstall() {
|
||||
HookAdviceBindings.unbind(registration)
|
||||
boundMethod = null
|
||||
installed = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package ru.fromchat.plugins.host
|
||||
|
||||
import net.bytebuddy.asm.Advice
|
||||
import ru.fromchat.plugins.HookStrategy
|
||||
import java.lang.reflect.Method
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
internal object HookAdviceBindings {
|
||||
private val byMethodKey = ConcurrentHashMap<String, SharedHookRegistration>()
|
||||
|
||||
fun bind(method: Method, registration: SharedHookRegistration) {
|
||||
byMethodKey[key(method)] = registration
|
||||
}
|
||||
|
||||
fun unbind(registration: SharedHookRegistration) {
|
||||
byMethodKey.entries.removeIf { it.value == registration }
|
||||
}
|
||||
|
||||
fun registration(method: Method): SharedHookRegistration? = byMethodKey[key(method)]
|
||||
|
||||
private fun key(method: Method): String =
|
||||
buildString {
|
||||
append(method.declaringClass.name)
|
||||
append('#')
|
||||
append(method.name)
|
||||
append('(')
|
||||
method.parameterTypes.forEachIndexed { index, type ->
|
||||
if (index > 0) append(',')
|
||||
append(type.name)
|
||||
}
|
||||
append(')')
|
||||
}
|
||||
}
|
||||
|
||||
internal class SharedHookAdvice {
|
||||
companion object {
|
||||
@JvmStatic
|
||||
@Advice.OnMethodEnter(skipOn = Advice.OnNonDefaultValue::class)
|
||||
fun onEnter(
|
||||
@Advice.AllArguments arguments: Array<Any?>,
|
||||
@Advice.Origin method: Method,
|
||||
): Boolean {
|
||||
val registration = HookAdviceBindings.registration(method) ?: return false
|
||||
registration.before?.let { before ->
|
||||
when (before(arguments).strategy) {
|
||||
HookStrategy.CANCEL -> return true
|
||||
HookStrategy.MODIFY, HookStrategy.MODIFY_FINAL, HookStrategy.DEFAULT -> Unit
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@Advice.OnMethodExit(onThrowable = Throwable::class)
|
||||
fun onExit(
|
||||
@Advice.AllArguments arguments: Array<Any?>,
|
||||
@Advice.Origin method: Method,
|
||||
@Advice.Enter skipOriginal: Boolean,
|
||||
) {
|
||||
if (skipOriginal) return
|
||||
HookAdviceBindings.registration(method)?.after?.invoke(arguments, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
plugins {
|
||||
kotlin("jvm")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":plugins:sdk"))
|
||||
}
|
||||
|
||||
tasks.register("packageFcPlugin") {
|
||||
group = "plugins"
|
||||
description = "Build hello_world.fcplugin with desktop JAR"
|
||||
dependsOn(tasks.named("compileKotlin"))
|
||||
doLast {
|
||||
val buildDir = layout.buildDirectory.get().asFile
|
||||
val staging = buildDir.resolve("fcplugin-staging")
|
||||
staging.deleteRecursively()
|
||||
staging.mkdirs()
|
||||
staging.resolve("desktop").mkdirs()
|
||||
staging.resolve("android").mkdirs()
|
||||
|
||||
val jvmClasses = buildDir.resolve("classes/kotlin/main")
|
||||
val jarFile = staging.resolve("desktop/plugin.jar")
|
||||
val jarProcess = ProcessBuilder(
|
||||
"jar",
|
||||
"cf",
|
||||
jarFile.absolutePath,
|
||||
"-C",
|
||||
jvmClasses.absolutePath,
|
||||
".",
|
||||
).inheritIO().start()
|
||||
check(jarProcess.waitFor() == 0) { "jar failed" }
|
||||
|
||||
staging.resolve("manifest.json").writeText(
|
||||
"""
|
||||
{
|
||||
"id": "hello_world",
|
||||
"name": "Hello World",
|
||||
"description": "Demonstrates high-level send hooks, raw Logger.d hooks, and raw main-nav hooks.",
|
||||
"author": "FromChat",
|
||||
"version": "1.0.3",
|
||||
"usage": "Raw-hooks existing MainScreen/ContactsTab JVM methods (no app hook IDs). Also hides Devices/Logs settings and emoji; injects settings row, message menu item, and chat banner. Send .hello Name to test send hooks.",
|
||||
"app_version": ">=1.0.0",
|
||||
"sdk_version": ">=1.0.0",
|
||||
"entry_class": "ru.fromchat.plugins.sample.hello.HelloWorldPlugin",
|
||||
"artifacts": {
|
||||
"android": "android/plugin.dex",
|
||||
"desktop": "desktop/plugin.jar"
|
||||
}
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
val archive = buildDir.resolve("distributions/hello_world.fcplugin")
|
||||
archive.parentFile.mkdirs()
|
||||
if (archive.exists()) archive.delete()
|
||||
val zipProcess = ProcessBuilder("zip", "-r", archive.absolutePath, ".")
|
||||
.directory(staging)
|
||||
.inheritIO()
|
||||
.start()
|
||||
check(zipProcess.waitFor() == 0) { "zip failed" }
|
||||
println("Created ${archive.absolutePath}")
|
||||
}
|
||||
}
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
package ru.fromchat.plugins.sample.hello
|
||||
|
||||
import ru.fromchat.plugins.BasePlugin
|
||||
import ru.fromchat.plugins.HookResult
|
||||
import ru.fromchat.plugins.HookStrategy
|
||||
import ru.fromchat.plugins.MenuItemData
|
||||
import ru.fromchat.plugins.MenuItemType
|
||||
import ru.fromchat.plugins.PluginSetting
|
||||
import ru.fromchat.plugins.SendMessageHookContext
|
||||
|
||||
class HelloWorldPlugin : BasePlugin() {
|
||||
override fun onPluginLoad() {
|
||||
log("Hello World plugin loaded")
|
||||
showBulletin("Hello World plugin is active")
|
||||
registerUiOverrides()
|
||||
registerInjectedUi()
|
||||
registerRawHooks()
|
||||
addOnSendMessageHook { context ->
|
||||
onSendMessage(context)
|
||||
}
|
||||
hookShared(
|
||||
hookId = "logger.debug",
|
||||
before = { 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 onMenuItemClick(key: String) {
|
||||
when (key) {
|
||||
MENU_SETTINGS_DEMO -> showBulletin("Injected settings row clicked")
|
||||
MENU_MESSAGE_DEMO -> showBulletin("Injected message menu clicked")
|
||||
}
|
||||
}
|
||||
|
||||
override fun createSettings(): List<PluginSetting> = listOf(
|
||||
PluginSetting.Header("Hello World"),
|
||||
PluginSetting.Input(
|
||||
key = "template",
|
||||
text = "Greeting template",
|
||||
default = "Hello, {name}!",
|
||||
subtext = "Use {name} for the entered name",
|
||||
),
|
||||
PluginSetting.Text(
|
||||
text = "Send .hello Alice in a chat",
|
||||
subtext = "Example: .hello Alice -> Hello, Alice!",
|
||||
),
|
||||
)
|
||||
|
||||
private fun registerRawHooks() {
|
||||
hookRawMethod(
|
||||
className = "ru.fromchat.ui.main.MainScreenKt",
|
||||
methodName = "selectMainPage",
|
||||
paramTypeNames = arrayOf(
|
||||
"int",
|
||||
"com.pr0gramm3r101.utils.WindowWidthSizeClass",
|
||||
"kotlinx.coroutines.CoroutineScope",
|
||||
"androidx.compose.foundation.pager.PagerState",
|
||||
"androidx.navigation.NavController",
|
||||
),
|
||||
before = { args ->
|
||||
val page = (args.getOrNull(0) as? Number)?.toInt() ?: return@hookRawMethod HookResult()
|
||||
if (page == MAIN_PAGE_CONTACTS) {
|
||||
if (!contactsNavBlockedNotified) {
|
||||
contactsNavBlockedNotified = true
|
||||
showBulletin("Raw hook: blocked Contacts tab navigation")
|
||||
}
|
||||
HookResult(strategy = HookStrategy.CANCEL)
|
||||
} else {
|
||||
HookResult()
|
||||
}
|
||||
},
|
||||
)
|
||||
hookRawMethod(
|
||||
className = "ru.fromchat.ui.main.ContactsTabKt",
|
||||
methodName = "ContactsTab",
|
||||
paramTypeNames = arrayOf(
|
||||
"androidx.compose.runtime.Composer",
|
||||
"int",
|
||||
),
|
||||
before = { _ ->
|
||||
HookResult(strategy = HookStrategy.CANCEL)
|
||||
},
|
||||
)
|
||||
hookRawMethod(
|
||||
className = "ru.fromchat.ui.main.MainScreenKt",
|
||||
methodName = "MainScreen",
|
||||
paramTypeNames = arrayOf(
|
||||
"androidx.compose.animation.SharedTransitionScope",
|
||||
"androidx.compose.animation.AnimatedVisibilityScope",
|
||||
"androidx.compose.material3.SnackbarHostState",
|
||||
"boolean",
|
||||
"int",
|
||||
"boolean",
|
||||
"kotlin.jvm.functions.Function1",
|
||||
"androidx.compose.runtime.Composer",
|
||||
"int",
|
||||
"int",
|
||||
),
|
||||
before = { _ ->
|
||||
mainScreenNavItemOrdinal = 0
|
||||
HookResult()
|
||||
},
|
||||
)
|
||||
hookRawMethod(
|
||||
className = "androidx.compose.material3.NavigationBarKt",
|
||||
methodName = "NavigationBarItem",
|
||||
paramTypeNames = arrayOf(
|
||||
"androidx.compose.foundation.layout.RowScope",
|
||||
"boolean",
|
||||
"kotlin.jvm.functions.Function0",
|
||||
"kotlin.jvm.functions.Function2",
|
||||
"androidx.compose.ui.Modifier",
|
||||
"boolean",
|
||||
"kotlin.jvm.functions.Function2",
|
||||
"boolean",
|
||||
"androidx.compose.material3.NavigationBarItemColors",
|
||||
"androidx.compose.foundation.interaction.MutableInteractionSource",
|
||||
"androidx.compose.runtime.Composer",
|
||||
"int",
|
||||
"int",
|
||||
),
|
||||
before = { _ ->
|
||||
mainScreenNavItemOrdinal += 1
|
||||
if (mainScreenNavItemOrdinal == CONTACTS_NAV_ITEM_ORDINAL) {
|
||||
HookResult(strategy = HookStrategy.CANCEL)
|
||||
} else {
|
||||
HookResult()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun registerUiOverrides() {
|
||||
registerFeatureOverride(FEATURE_SETTINGS_DEVICES) { false }
|
||||
registerFeatureOverride(FEATURE_SETTINGS_LOGS) { false }
|
||||
registerFeatureOverride(FEATURE_CHAT_EMOJI) { false }
|
||||
}
|
||||
|
||||
private fun registerInjectedUi() {
|
||||
registerUiOverlay(
|
||||
slot = OVERLAY_CHAT_BANNER,
|
||||
title = "Hello World plugin UI",
|
||||
message = "This banner was injected by the plugin engine.",
|
||||
)
|
||||
addMenuItem(
|
||||
MenuItemData(
|
||||
menuType = MenuItemType.SETTINGS_ROW,
|
||||
text = "Hello World demo action",
|
||||
subtext = "Injected settings row from plugin",
|
||||
priority = 10,
|
||||
onClickKey = MENU_SETTINGS_DEMO,
|
||||
),
|
||||
)
|
||||
addMenuItem(
|
||||
MenuItemData(
|
||||
menuType = MenuItemType.MESSAGE_CONTEXT,
|
||||
text = "Hello World message action",
|
||||
subtext = "Injected message menu item",
|
||||
priority = 10,
|
||||
onClickKey = MENU_MESSAGE_DEMO,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun onSendMessage(context: SendMessageHookContext): HookResult<SendMessageHookContext> {
|
||||
val raw = context.text.trim()
|
||||
if (!raw.startsWith(".hello")) return HookResult()
|
||||
val parts = raw.split(" ", limit = 2)
|
||||
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)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MAIN_PAGE_CONTACTS = 1
|
||||
const val CONTACTS_NAV_ITEM_ORDINAL = 2
|
||||
const val FEATURE_SETTINGS_DEVICES = "settings.devices"
|
||||
const val FEATURE_SETTINGS_LOGS = "settings.logs"
|
||||
const val FEATURE_CHAT_EMOJI = "chat.emoji_picker"
|
||||
const val OVERLAY_CHAT_BANNER = "chat.banner"
|
||||
const val MENU_SETTINGS_DEMO = "hello_world.settings_demo"
|
||||
const val MENU_MESSAGE_DEMO = "hello_world.message_demo"
|
||||
|
||||
@Volatile
|
||||
var mainScreenNavItemOrdinal: Int = 0
|
||||
|
||||
@Volatile
|
||||
var contactsNavBlockedNotified: Boolean = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.kotlin.multiplatform.library)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvm()
|
||||
androidLibrary {
|
||||
namespace = "ru.fromchat.plugins.sdk"
|
||||
compileSdk = 37
|
||||
minSdk = 24
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package ru.fromchat.plugins
|
||||
|
||||
interface PluginHostBridge {
|
||||
fun log(pluginId: String, message: String)
|
||||
fun getSetting(pluginId: String, key: String, default: Boolean): Boolean
|
||||
fun getSettingString(pluginId: String, key: String, default: String): String
|
||||
fun setSetting(pluginId: String, key: String, value: String)
|
||||
fun registerSendMessageHook(pluginId: String, priority: Int, handler: (SendMessageHookContext) -> HookResult<SendMessageHookContext>)
|
||||
fun registerFeatureOverride(pluginId: String, featureId: String, provider: () -> Boolean?)
|
||||
fun registerMenuItem(pluginId: String, item: MenuItemData)
|
||||
fun registerUiOverlay(pluginId: String, slot: String, title: String, message: String)
|
||||
fun showBulletin(message: String)
|
||||
fun hookSharedMethod(
|
||||
pluginId: String,
|
||||
hookId: String,
|
||||
priority: Int,
|
||||
before: ((Array<Any?>) -> HookResult<Array<Any?>>)?,
|
||||
after: ((Array<Any?>, Any?) -> HookResult<Any?>)?,
|
||||
): () -> Unit
|
||||
|
||||
fun hookRawMethod(
|
||||
pluginId: String,
|
||||
className: String,
|
||||
methodName: String,
|
||||
paramTypeNames: Array<String>,
|
||||
priority: Int,
|
||||
before: ((Array<Any?>) -> HookResult<Array<Any?>>)?,
|
||||
after: ((Array<Any?>, Any?) -> HookResult<Any?>)?,
|
||||
): () -> Unit
|
||||
}
|
||||
|
||||
abstract class BasePlugin {
|
||||
lateinit var pluginId: String
|
||||
internal set
|
||||
private lateinit var bridge: PluginHostBridge
|
||||
|
||||
fun attach(id: String, hostBridge: PluginHostBridge) {
|
||||
pluginId = id
|
||||
bridge = hostBridge
|
||||
}
|
||||
|
||||
open fun onPluginLoad() {}
|
||||
open fun onPluginUnload() {}
|
||||
open fun onAppEvent(event: AppEvent) {}
|
||||
open fun onMenuItemClick(key: String) {}
|
||||
open fun createSettings(): List<PluginSetting> = emptyList()
|
||||
|
||||
protected fun log(message: String) = bridge.log(pluginId, message)
|
||||
|
||||
protected fun getSetting(key: String, default: Boolean = false): Boolean =
|
||||
bridge.getSetting(pluginId, key, default)
|
||||
|
||||
protected fun getSettingString(key: String, default: String = ""): String =
|
||||
bridge.getSettingString(pluginId, key, default)
|
||||
|
||||
protected fun setSetting(key: String, value: String, reloadSettings: Boolean = false) {
|
||||
bridge.setSetting(pluginId, key, value)
|
||||
if (reloadSettings) {
|
||||
PluginSettingsReload.request(pluginId)
|
||||
}
|
||||
}
|
||||
|
||||
protected fun addOnSendMessageHook(
|
||||
priority: Int = 0,
|
||||
handler: (SendMessageHookContext) -> HookResult<SendMessageHookContext>,
|
||||
) {
|
||||
bridge.registerSendMessageHook(pluginId, priority, handler)
|
||||
}
|
||||
|
||||
protected fun registerFeatureOverride(featureId: String, provider: () -> Boolean?) {
|
||||
bridge.registerFeatureOverride(pluginId, featureId, provider)
|
||||
}
|
||||
|
||||
protected fun addMenuItem(item: MenuItemData) {
|
||||
bridge.registerMenuItem(pluginId, item)
|
||||
}
|
||||
|
||||
protected fun registerUiOverlay(slot: String, title: String, message: String) {
|
||||
bridge.registerUiOverlay(pluginId, slot, title, message)
|
||||
}
|
||||
|
||||
protected fun showBulletin(message: String) {
|
||||
bridge.showBulletin(message)
|
||||
}
|
||||
|
||||
protected fun hookShared(
|
||||
hookId: String,
|
||||
priority: Int = 0,
|
||||
before: ((Array<Any?>) -> HookResult<Array<Any?>>)? = null,
|
||||
after: ((Array<Any?>, Any?) -> HookResult<Any?>)? = null,
|
||||
): () -> Unit = bridge.hookSharedMethod(pluginId, hookId, priority, before, after)
|
||||
|
||||
protected fun hookRawMethod(
|
||||
className: String,
|
||||
methodName: String,
|
||||
paramTypeNames: Array<String> = emptyArray(),
|
||||
priority: Int = 0,
|
||||
before: ((Array<Any?>) -> HookResult<Array<Any?>>)? = null,
|
||||
after: ((Array<Any?>, Any?) -> HookResult<Any?>)? = null,
|
||||
): () -> Unit = bridge.hookRawMethod(
|
||||
pluginId,
|
||||
className,
|
||||
methodName,
|
||||
paramTypeNames,
|
||||
priority,
|
||||
before,
|
||||
after,
|
||||
)
|
||||
}
|
||||
|
||||
object PluginSettingsReload {
|
||||
var listener: ((String) -> Unit)? = null
|
||||
fun request(pluginId: String) {
|
||||
listener?.invoke(pluginId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package ru.fromchat.plugins
|
||||
|
||||
enum class AppEvent {
|
||||
START,
|
||||
STOP,
|
||||
PAUSE,
|
||||
RESUME,
|
||||
}
|
||||
|
||||
enum class HookStrategy {
|
||||
DEFAULT,
|
||||
CANCEL,
|
||||
MODIFY,
|
||||
MODIFY_FINAL,
|
||||
}
|
||||
|
||||
data class HookResult<T>(
|
||||
val strategy: HookStrategy = HookStrategy.DEFAULT,
|
||||
val value: T? = null,
|
||||
)
|
||||
|
||||
data class SendMessageHookContext(
|
||||
var text: String,
|
||||
val isDm: Boolean,
|
||||
val recipientId: Int?,
|
||||
)
|
||||
|
||||
enum class MenuItemType {
|
||||
MESSAGE_CONTEXT,
|
||||
CHAT_ACTION,
|
||||
PROFILE_ACTION,
|
||||
SETTINGS_ROW,
|
||||
}
|
||||
|
||||
data class MenuItemData(
|
||||
val menuType: MenuItemType,
|
||||
val text: String,
|
||||
val subtext: String? = null,
|
||||
val priority: Int = 0,
|
||||
val onClickKey: String = "",
|
||||
)
|
||||
|
||||
sealed class PluginSetting {
|
||||
data class Header(val text: String) : PluginSetting()
|
||||
data class Switch(
|
||||
val key: String,
|
||||
val text: String,
|
||||
val default: Boolean = false,
|
||||
val subtext: String? = null,
|
||||
) : PluginSetting()
|
||||
data class Input(
|
||||
val key: String,
|
||||
val text: String,
|
||||
val default: String = "",
|
||||
val subtext: String? = null,
|
||||
) : PluginSetting()
|
||||
data class Text(
|
||||
val text: String,
|
||||
val subtext: String? = null,
|
||||
) : PluginSetting()
|
||||
}
|
||||
|
||||
data class PluginManifest(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val description: String = "",
|
||||
val author: String = "",
|
||||
val version: String = "1.0.0",
|
||||
val usage: String = "",
|
||||
val appVersion: String = "",
|
||||
val sdkVersion: String = "",
|
||||
val entryClass: String,
|
||||
val androidArtifact: String? = null,
|
||||
val desktopArtifact: String? = null,
|
||||
)
|
||||
+9
-1
@@ -42,5 +42,13 @@ dependencyResolutionManagement {
|
||||
}
|
||||
}
|
||||
|
||||
include(":app:shared", ":utils:shared", ":app:android", ":app:desktop")
|
||||
include(
|
||||
":app:shared",
|
||||
":utils:shared",
|
||||
":app:android",
|
||||
":app:desktop",
|
||||
":plugins:sdk",
|
||||
":plugins:host",
|
||||
":plugins:sample-hello",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user