mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-23 11:35:06 +03:00
Compare commits
6 Commits
@@ -9,7 +9,7 @@ inputs:
|
|||||||
outputs:
|
outputs:
|
||||||
cache-hit:
|
cache-hit:
|
||||||
description: Whether an exact cache key match was restored
|
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:
|
cache-save-scope:
|
||||||
description: Scope to pass to the save action (always this job's scope, not a restored fallback key)
|
description: Scope to pass to the save action (always this job's scope, not a restored fallback key)
|
||||||
value: ${{ inputs.scope }}
|
value: ${{ inputs.scope }}
|
||||||
@@ -18,6 +18,23 @@ runs:
|
|||||||
using: composite
|
using: composite
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/cache/restore@v5
|
- 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
|
id: cache
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ runs:
|
|||||||
~/.cargo/registry
|
~/.cargo/registry
|
||||||
~/.cargo/git
|
~/.cargo/git
|
||||||
app/desktop/windows-setup/target
|
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: |
|
restore-keys: |
|
||||||
|
windows-rust-${{ runner.os }}-v3-
|
||||||
windows-rust-${{ runner.os }}-v2-
|
windows-rust-${{ runner.os }}-v2-
|
||||||
|
windows-rust-tools-v1-
|
||||||
|
cargo-registry-${{ runner.os }}-
|
||||||
|
|||||||
@@ -9,7 +9,22 @@ inputs:
|
|||||||
runs:
|
runs:
|
||||||
using: composite
|
using: composite
|
||||||
steps:
|
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
|
continue-on-error: true
|
||||||
with:
|
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') }}
|
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
|
- uses: actions/cache/save@v5
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
with:
|
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: |
|
path: |
|
||||||
~/.cargo/registry
|
~/.cargo/registry
|
||||||
~/.cargo/git
|
~/.cargo/git
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ on:
|
|||||||
default: "false"
|
default: "false"
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: release-${{ github.ref }}
|
group: release
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
env:
|
env:
|
||||||
@@ -414,6 +414,8 @@ jobs:
|
|||||||
id: gradle-build-cache
|
id: gradle-build-cache
|
||||||
with:
|
with:
|
||||||
scope: android
|
scope: android
|
||||||
|
- name: Prepare Android SDK cache dirs
|
||||||
|
run: mkdir -p ~/.android/build-cache ~/.android/cache
|
||||||
- name: Cache Android SDK extras
|
- name: Cache Android SDK extras
|
||||||
uses: actions/cache@v5
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
|
|||||||
Vendored
+7
@@ -1,6 +1,13 @@
|
|||||||
# Release shrinking: obfuscate/rename classes and members as aggressively as R8 allows.
|
# Release shrinking: obfuscate/rename classes and members as aggressively as R8 allows.
|
||||||
# proguard-android-optimize.txt (from build.gradle) supplies repackageclasses, overloadaggressively, etc.
|
# 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.
|
# Crash reports: keep real .kt file names and line numbers; class names stay obfuscated.
|
||||||
-keepattributes SourceFile,LineNumberTable
|
-keepattributes SourceFile,LineNumberTable
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import kotlinx.coroutines.launch
|
|||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
import ru.fromchat.api.local.workers.AttachmentTransferBootstrap
|
import ru.fromchat.api.local.workers.AttachmentTransferBootstrap
|
||||||
import ru.fromchat.notifications.MessageNotificationCoordinator
|
import ru.fromchat.notifications.MessageNotificationCoordinator
|
||||||
|
import ru.fromchat.plugins.integration.AndroidPluginBootstrap
|
||||||
|
|
||||||
class App : Application() {
|
class App : Application() {
|
||||||
@OptIn(DelicateCoroutinesApi::class)
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
@@ -16,6 +17,7 @@ class App : Application() {
|
|||||||
super.onCreate()
|
super.onCreate()
|
||||||
UtilsLibrary.init(this)
|
UtilsLibrary.init(this)
|
||||||
MessageNotificationCoordinator.install()
|
MessageNotificationCoordinator.install()
|
||||||
|
AndroidPluginBootstrap.init(this)
|
||||||
|
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
runCatching { ApiClient.loadPersistedData() }
|
runCatching { ApiClient.loadPersistedData() }
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ private object DesktopApplicationBootstrap {
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching { ApiClient.loadPersistedData() }
|
runCatching { ApiClient.loadPersistedData() }
|
||||||
runCatching { AttachmentTransferBootstrap.runColdStart() }
|
runCatching { AttachmentTransferBootstrap.runColdStart() }
|
||||||
|
runCatching { ru.fromchat.plugins.integration.DesktopPluginBootstrap.init() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ kotlin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
androidMain.dependencies {
|
androidMain.dependencies {
|
||||||
|
implementation(project(":plugins:host"))
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.markdown.renderer.m3)
|
implementation(libs.markdown.renderer.m3)
|
||||||
implementation(libs.bouncycastle.bcprov)
|
implementation(libs.bouncycastle.bcprov)
|
||||||
@@ -200,6 +201,7 @@ kotlin {
|
|||||||
} else {
|
} else {
|
||||||
implementation(compose.desktop.currentOs)
|
implementation(compose.desktop.currentOs)
|
||||||
}
|
}
|
||||||
|
implementation(project(":plugins:host"))
|
||||||
implementation(libs.jetbrains.kotlinx.io.bytestring)
|
implementation(libs.jetbrains.kotlinx.io.bytestring)
|
||||||
implementation(libs.jetbrains.kotlinx.coroutines.core)
|
implementation(libs.jetbrains.kotlinx.coroutines.core)
|
||||||
implementation(libs.ktor.client.cio)
|
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">Отладка API</string>
|
||||||
<string name="debug_tools_d">Просмотр ответов 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">Оформление</string>
|
||||||
<string name="settings_category_appearance_d">Тема и Material You</string>
|
<string name="settings_category_appearance_d">Тема и Material You</string>
|
||||||
<string name="settings_category_server_tools">Сервер и инструменты</string>
|
<string name="settings_category_server_tools">Сервер и инструменты</string>
|
||||||
|
|||||||
@@ -370,6 +370,27 @@
|
|||||||
<string name="debug_tools">Debug API</string>
|
<string name="debug_tools">Debug API</string>
|
||||||
<string name="debug_tools_d">Inspect profile and DM endpoints used by the client.</string>
|
<string name="debug_tools_d">Inspect profile and DM endpoints used by the client.</string>
|
||||||
|
|
||||||
|
<string name="plugins_title">Plugins</string>
|
||||||
|
<string name="plugins_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 -->
|
<!-- Settings categories -->
|
||||||
<string name="settings_category_appearance">Appearance</string>
|
<string name="settings_category_appearance">Appearance</string>
|
||||||
<string name="settings_category_appearance_d">Theme and Material You</string>
|
<string name="settings_category_appearance_d">Theme and Material You</string>
|
||||||
|
|||||||
+19
-3
@@ -197,13 +197,21 @@ object OutgoingMessageCoordinator {
|
|||||||
clientMessageId: String,
|
clientMessageId: String,
|
||||||
optimisticMessage: Message,
|
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 instanceId = CacheContext.requireActiveInstanceId()
|
||||||
val conversationId = conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID)
|
val conversationId = conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID)
|
||||||
markOutboundActive(clientMessageId)
|
markOutboundActive(clientMessageId)
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
MessageRepository.upsertPublicMessage(optimisticMessage)
|
MessageRepository.upsertPublicMessage(optimisticMessage)
|
||||||
Logger.d("OutgoingMessageCoordinator", "enqueuePublicMessage: clientId=${clientMessageId.take(12)} contentLen=${content.length}")
|
Logger.d("OutgoingMessageCoordinator", "enqueuePublicMessage: clientId=${clientMessageId.take(12)} contentLen=${finalContent.length}")
|
||||||
val payload = json.encodeToString(PublicOutboxPayload(content, replyToId))
|
val payload = json.encodeToString(PublicOutboxPayload(finalContent, replyToId))
|
||||||
MessageDatabaseProvider.database.messageDatabaseQueries.upsertOutbox(
|
MessageDatabaseProvider.database.messageDatabaseQueries.upsertOutbox(
|
||||||
instanceId = instanceId,
|
instanceId = instanceId,
|
||||||
clientMessageId = clientMessageId,
|
clientMessageId = clientMessageId,
|
||||||
@@ -227,12 +235,20 @@ object OutgoingMessageCoordinator {
|
|||||||
transportFiles: List<SendDmFile> = emptyList(),
|
transportFiles: List<SendDmFile> = emptyList(),
|
||||||
uploadedFileIds: List<String> = 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 instanceId = CacheContext.requireActiveInstanceId()
|
||||||
val conversationId = conversationIdForDm(recipientId)
|
val conversationId = conversationIdForDm(recipientId)
|
||||||
markOutboundActive(clientMessageId)
|
markOutboundActive(clientMessageId)
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
MessageRepository.upsertDmMessage(recipientId, optimisticMessage)
|
MessageRepository.upsertDmMessage(recipientId, optimisticMessage)
|
||||||
val outboundPlaintext = buildDmOutboundPlaintext(plaintext, replyToId)
|
val outboundPlaintext = buildDmOutboundPlaintext(finalPlaintext, replyToId)
|
||||||
val payload = json.encodeToString(
|
val payload = json.encodeToString(
|
||||||
DmOutboxPayload(
|
DmOutboxPayload(
|
||||||
recipientId = recipientId,
|
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 {
|
ScreenSurface {
|
||||||
|
ru.fromchat.plugins.host.ui.PluginUiHost {
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -774,6 +775,7 @@ fun App(
|
|||||||
ReleaseNotesDialog(onDismiss = { showReleaseNotesPrompt = false })
|
ReleaseNotesDialog(onDismiss = { showReleaseNotesPrompt = false })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.union
|
|||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.material.icons.Icons
|
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.Info
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
import androidx.compose.material.icons.outlined.BugReport
|
import androidx.compose.material.icons.outlined.BugReport
|
||||||
@@ -36,6 +37,7 @@ import ru.fromchat.more
|
|||||||
import ru.fromchat.ui.LocalNavController
|
import ru.fromchat.ui.LocalNavController
|
||||||
import ru.fromchat.ui.components.Text
|
import ru.fromchat.ui.components.Text
|
||||||
import ru.fromchat.ui.extraStatusBars
|
import ru.fromchat.ui.extraStatusBars
|
||||||
|
import ru.fromchat.plugins_title
|
||||||
import ru.fromchat.ui.main.settings.SettingsRoutes
|
import ru.fromchat.ui.main.settings.SettingsRoutes
|
||||||
|
|
||||||
/** Margin between the window edge and the wide [AuthContentFrame] panel. */
|
/** Margin between the window edge and the wide [AuthContentFrame] panel. */
|
||||||
@@ -105,6 +107,16 @@ fun PreAuthOverflowMenu() {
|
|||||||
navController.navigate(SettingsRoutes.About)
|
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(
|
DropdownMenuItem(
|
||||||
text = { Text(stringResource(Res.string.logs_title)) },
|
text = { Text(stringResource(Res.string.logs_title)) },
|
||||||
leadingIcon = {
|
leadingIcon = {
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import androidx.compose.material3.IconButton
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import ru.fromchat.ui.components.Text
|
import ru.fromchat.ui.components.Text
|
||||||
|
import ru.fromchat.plugins.integration.pluginFeatureEnabled
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -568,19 +569,21 @@ fun ChatInput(
|
|||||||
.padding(horizontal = 6.dp, vertical = 0.dp),
|
.padding(horizontal = 6.dp, vertical = 0.dp),
|
||||||
verticalAlignment = Alignment.Bottom,
|
verticalAlignment = Alignment.Bottom,
|
||||||
) {
|
) {
|
||||||
Box(
|
if (pluginFeatureEnabled("chat.emoji_picker")) {
|
||||||
modifier = Modifier
|
Box(
|
||||||
.padding(vertical = ChatInputIconSlotVerticalInset)
|
modifier = Modifier
|
||||||
.size(ChatInputIconSlotSize)
|
.padding(vertical = ChatInputIconSlotVerticalInset)
|
||||||
.clip(CircleShape)
|
.size(ChatInputIconSlotSize)
|
||||||
.clickable(onClick = { /* emoji picker to be wired */ }),
|
.clip(CircleShape)
|
||||||
contentAlignment = Alignment.Center,
|
.clickable(onClick = { /* emoji picker to be wired */ }),
|
||||||
) {
|
contentAlignment = Alignment.Center,
|
||||||
Icon(
|
) {
|
||||||
imageVector = Icons.Rounded.SentimentSatisfied,
|
Icon(
|
||||||
contentDescription = cdEmoji,
|
imageVector = Icons.Rounded.SentimentSatisfied,
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
contentDescription = cdEmoji,
|
||||||
)
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val inputTextStyle = MaterialTheme.typography.bodyLarge.merge(
|
val inputTextStyle = MaterialTheme.typography.bodyLarge.merge(
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ import ru.fromchat.ui.chat.utils.visibleMessageIdsInChatList
|
|||||||
import ru.fromchat.ui.chat.utils.unobstructedVisibleMessageIdsInChatList
|
import ru.fromchat.ui.chat.utils.unobstructedVisibleMessageIdsInChatList
|
||||||
import ru.fromchat.ui.components.Text
|
import ru.fromchat.ui.components.Text
|
||||||
import ru.fromchat.ui.components.SuspendedAccountSupportSheet
|
import ru.fromchat.ui.components.SuspendedAccountSupportSheet
|
||||||
|
import ru.fromchat.plugins.integration.PluginOverlayBanner
|
||||||
import ru.fromchat.ui.extraStatusBars
|
import ru.fromchat.ui.extraStatusBars
|
||||||
import ru.fromchat.utils.NetworkConnectivity
|
import ru.fromchat.utils.NetworkConnectivity
|
||||||
import ru.fromchat.utils.formatLastSeen
|
import ru.fromchat.utils.formatLastSeen
|
||||||
@@ -1410,6 +1411,14 @@ fun ChatScreen(
|
|||||||
item { Spacer(modifier.height(floatingHeaderClearance)) }
|
item { Spacer(modifier.height(floatingHeaderClearance)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PluginOverlayBanner(
|
||||||
|
slot = "chat.banner",
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
.zIndex(2f)
|
||||||
|
.padding(top = innerPadding.calculateTopPadding()),
|
||||||
|
)
|
||||||
|
|
||||||
val showScrollToBottomFab = !isNearBottom &&
|
val showScrollToBottomFab = !isNearBottom &&
|
||||||
panelState.messages.isNotEmpty() &&
|
panelState.messages.isNotEmpty() &&
|
||||||
!contextMenuState.isOpen
|
!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.ContentCopy
|
||||||
import androidx.compose.material.icons.rounded.Delete
|
import androidx.compose.material.icons.rounded.Delete
|
||||||
import androidx.compose.material.icons.rounded.Edit
|
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.Refresh
|
||||||
import androidx.compose.material.icons.rounded.SaveAlt
|
import androidx.compose.material.icons.rounded.SaveAlt
|
||||||
import androidx.compose.material3.Icon
|
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.download.resolveSavableMessageImage
|
||||||
import ru.fromchat.api.local.messages.isQueuedOutbound
|
import ru.fromchat.api.local.messages.isQueuedOutbound
|
||||||
import ru.fromchat.api.schema.messages.Message
|
import ru.fromchat.api.schema.messages.Message
|
||||||
|
import ru.fromchat.plugins.MenuItemType
|
||||||
|
import ru.fromchat.plugins.host.PluginHookDispatcher
|
||||||
import ru.fromchat.ui.components.Text
|
import ru.fromchat.ui.components.Text
|
||||||
|
|
||||||
data class ContextMenuState(
|
data class ContextMenuState(
|
||||||
@@ -275,6 +278,7 @@ fun MessageContextMenu(
|
|||||||
onRetrySend(it)
|
onRetrySend(it)
|
||||||
onDismiss()
|
onDismiss()
|
||||||
},
|
},
|
||||||
|
onDismiss = onDismiss,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.then(
|
.then(
|
||||||
if (lockedMenuWidthPx > 0) {
|
if (lockedMenuWidthPx > 0) {
|
||||||
@@ -327,6 +331,7 @@ private fun ContextMenuContent(
|
|||||||
onSave: (Message) -> Unit,
|
onSave: (Message) -> Unit,
|
||||||
onCancelSend: (Message) -> Unit,
|
onCancelSend: (Message) -> Unit,
|
||||||
onRetrySend: (Message) -> Unit,
|
onRetrySend: (Message) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
isReadOnly: Boolean = false,
|
isReadOnly: Boolean = false,
|
||||||
modifier: Modifier,
|
modifier: Modifier,
|
||||||
animated: Boolean,
|
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.LogFilesScreen
|
||||||
import ru.fromchat.ui.main.settings.LogsScreen
|
import ru.fromchat.ui.main.settings.LogsScreen
|
||||||
import ru.fromchat.ui.main.settings.NotificationsScreen
|
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.SettingsRoutes
|
||||||
import ru.fromchat.ui.main.settings.account.AccountScreen
|
import ru.fromchat.ui.main.settings.account.AccountScreen
|
||||||
import ru.fromchat.ui.main.settings.account.changepassword.ChangePasswordScreen
|
import ru.fromchat.ui.main.settings.account.changepassword.ChangePasswordScreen
|
||||||
@@ -288,6 +290,22 @@ fun NavGraphBuilder.settingsDetailDestinations(
|
|||||||
composable(SettingsRoutes.ServerConfig) {
|
composable(SettingsRoutes.ServerConfig) {
|
||||||
ServerConfigScreen()
|
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 =
|
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.ChatsSearchScreen
|
||||||
import ru.fromchat.ui.main.chats.ChatsTab
|
import ru.fromchat.ui.main.chats.ChatsTab
|
||||||
import ru.fromchat.ui.main.settings.SettingsTab
|
import ru.fromchat.ui.main.settings.SettingsTab
|
||||||
|
import ru.fromchat.plugins.host.PluginEngine
|
||||||
import ru.fromchat.ui.profile.ProfileScreen
|
import ru.fromchat.ui.profile.ProfileScreen
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
|
||||||
const val MAIN_PAGE_CHATS = 0
|
const val MAIN_PAGE_CHATS = 0
|
||||||
const val MAIN_PAGE_CONTACTS = 1
|
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 chatsLabel = stringResource(Res.string.chats)
|
||||||
val contactsLabel = stringResource(Res.string.contacts)
|
val contactsLabel = stringResource(Res.string.contacts)
|
||||||
val settingsLabel = stringResource(Res.string.settings)
|
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 About = "about"
|
||||||
const val Logs = "settings/logs"
|
const val Logs = "settings/logs"
|
||||||
const val LogFiles = "settings/logs/files"
|
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.Icons
|
||||||
import androidx.compose.material.icons.filled.AccountCircle
|
import androidx.compose.material.icons.filled.AccountCircle
|
||||||
import androidx.compose.material.icons.filled.Devices
|
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.Info
|
||||||
import androidx.compose.material.icons.filled.Notifications
|
import androidx.compose.material.icons.filled.Notifications
|
||||||
import androidx.compose.material.icons.filled.Palette
|
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
|
||||||
import ru.fromchat.change_server_d
|
import ru.fromchat.change_server_d
|
||||||
import ru.fromchat.logs_title
|
import ru.fromchat.logs_title
|
||||||
|
import ru.fromchat.plugins_title
|
||||||
|
import ru.fromchat.plugins_engine_d
|
||||||
import ru.fromchat.profile
|
import ru.fromchat.profile
|
||||||
import ru.fromchat.settings
|
import ru.fromchat.settings
|
||||||
import ru.fromchat.settings_category_account
|
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.LocalMainChromeInsets
|
||||||
import ru.fromchat.ui.main.mainPagerBottomInset
|
import ru.fromchat.ui.main.mainPagerBottomInset
|
||||||
import ru.fromchat.ui.main.navigateReplacingMainDetail
|
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
|
val SettingsStepHorizontalPadding = 24.dp
|
||||||
|
|
||||||
@@ -109,6 +115,8 @@ fun SettingsTab() {
|
|||||||
) {
|
) {
|
||||||
Spacer(Modifier.height(innerPadding.calculateTopPadding()))
|
Spacer(Modifier.height(innerPadding.calculateTopPadding()))
|
||||||
|
|
||||||
|
PluginOverlayBanner(slot = "settings.banner")
|
||||||
|
|
||||||
if (isTwoPane) {
|
if (isTwoPane) {
|
||||||
Category(Modifier.padding(top = 16.dp)) {
|
Category(Modifier.padding(top = 16.dp)) {
|
||||||
ListItem(
|
ListItem(
|
||||||
@@ -132,13 +140,15 @@ fun SettingsTab() {
|
|||||||
divider = true
|
divider = true
|
||||||
)
|
)
|
||||||
|
|
||||||
ListItem(
|
if (pluginFeatureEnabled("settings.devices")) {
|
||||||
headline = stringResource(Res.string.settings_category_devices),
|
ListItem(
|
||||||
supportingText = stringResource(Res.string.settings_category_devices_d),
|
headline = stringResource(Res.string.settings_category_devices),
|
||||||
onClick = { openDetail(SettingsRoutes.Devices) },
|
supportingText = stringResource(Res.string.settings_category_devices_d),
|
||||||
leadingContent = { Icon(Icons.Filled.Devices, null) },
|
onClick = { openDetail(SettingsRoutes.Devices) },
|
||||||
divider = true
|
leadingContent = { Icon(Icons.Filled.Devices, null) },
|
||||||
)
|
divider = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
ListItem(
|
ListItem(
|
||||||
headline = stringResource(Res.string.settings_category_appearance),
|
headline = stringResource(Res.string.settings_category_appearance),
|
||||||
@@ -172,11 +182,26 @@ fun SettingsTab() {
|
|||||||
divider = true
|
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) },
|
||||||
|
divider = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
PluginSettingsMenuItems(
|
||||||
|
showDividerBeforeFirst = true,
|
||||||
|
showDividerAfterLast = true,
|
||||||
|
)
|
||||||
|
|
||||||
ListItem(
|
ListItem(
|
||||||
headline = stringResource(Res.string.logs_title),
|
headline = stringResource(Res.string.plugins_title),
|
||||||
supportingText = stringResource(Res.string.settings_hub_logs_sub),
|
supportingText = stringResource(Res.string.plugins_engine_d),
|
||||||
onClick = { openDetail(SettingsRoutes.Logs) },
|
onClick = { openDetail(SettingsRoutes.Plugins) },
|
||||||
leadingContent = { Icon(Icons.Outlined.BugReport, null) }
|
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