diff --git a/kotlin/src/kawaii/packetik/catalog/CatalogChromeNative.kt b/kotlin/src/kawaii/packetik/catalog/CatalogChromeNative.kt
new file mode 100644
index 00000000..57747690
--- /dev/null
+++ b/kotlin/src/kawaii/packetik/catalog/CatalogChromeNative.kt
@@ -0,0 +1,291 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+package kawaii.packetik.catalog
+
+import android.content.Context
+import android.content.res.ColorStateList
+import android.graphics.Typeface
+import android.graphics.drawable.Drawable
+import android.graphics.drawable.GradientDrawable
+import android.graphics.drawable.RippleDrawable
+import android.util.TypedValue
+import android.view.Gravity
+import android.view.View
+import android.widget.FrameLayout
+import android.widget.ImageView
+import android.widget.LinearLayout
+import android.widget.ScrollView
+import android.widget.TextView
+
+/**
+ * Static chrome skeleton of the plugins catalog, built entirely on the java
+ * side: the same tree costs hundreds of python->java bridge calls when built
+ * from Python and used to stall catalog entry for ~half a second.
+ *
+ * Python receives the tree, looks up the interactive children by tag
+ * (search_slot, clear_btn, search_btn, ai_pill, subtitle, tag_filter_btn,
+ * sort_btn, scroll), inserts the telegram EditTextBoldCursor into
+ * search_slot and wires all listeners/logic itself.
+ */
+object CatalogChromeNative {
+
+    private fun dp(ctx: Context, v: Float): Int =
+        (ctx.resources.displayMetrics.density * v + 0.5f).toInt()
+
+    private fun rounded(color: Int, radiusPx: Float, strokeW: Int = 0, strokeColor: Int = 0): GradientDrawable {
+        val d = GradientDrawable()
+        d.shape = GradientDrawable.RECTANGLE
+        d.cornerRadius = radiusPx
+        d.setColor(color)
+        if (strokeW > 0) d.setStroke(strokeW, strokeColor)
+        return d
+    }
+
+    private fun selector(base: Int, pressed: Int, radiusPx: Float): Drawable {
+        val content = rounded(base, radiusPx)
+        return try {
+            RippleDrawable(ColorStateList.valueOf(pressed), content, null)
+        } catch (t: Throwable) {
+            content
+        }
+    }
+
+    private fun iconButton(
+        ctx: Context, bg: Drawable, iconRes: Int, iconTint: Int, padPx: Int, tag: String
+    ): FrameLayout {
+        val btn = FrameLayout(ctx)
+        btn.tag = tag
+        btn.isClickable = true
+        btn.isFocusable = true
+        btn.background = bg
+        btn.setPadding(padPx, padPx, padPx, padPx)
+        val icon = ImageView(ctx)
+        if (iconRes != 0) icon.setImageResource(iconRes)
+        icon.setColorFilter(iconTint)
+        icon.scaleType = ImageView.ScaleType.CENTER
+        btn.addView(icon, FrameLayout.LayoutParams(dp(ctx, 20f), dp(ctx, 20f), Gravity.CENTER))
+        return btn
+    }
+
+    private fun searchPill(
+        ctx: Context, cardBg: Int, accent: Int, accentPressed: Int, buttonText: Int,
+        textColor: Int, iconClear: Int, iconSearch: Int, showSearchBtn: Boolean
+    ): FrameLayout {
+        val searchContainer = FrameLayout(ctx)
+        searchContainer.background =
+            rounded(cardBg, dp(ctx, 50f).toFloat(), dp(ctx, 2f), accent)
+        searchContainer.setPadding(dp(ctx, 16f), dp(ctx, 5f), dp(ctx, 8f), dp(ctx, 5f))
+
+        val searchRow = LinearLayout(ctx)
+        searchRow.orientation = LinearLayout.HORIZONTAL
+        searchRow.gravity = Gravity.CENTER_VERTICAL
+
+        val searchSlot = FrameLayout(ctx)
+        searchSlot.tag = "search_slot"
+        searchRow.addView(searchSlot, LinearLayout.LayoutParams(-1, dp(ctx, 36f), 1f))
+
+        val clearBtn = iconButton(
+            ctx, selector(0x00000000, 0x1F000000, dp(ctx, 25f).toFloat()),
+            iconClear, textColor, dp(ctx, 8f), "clear_btn"
+        )
+        clearBtn.visibility = View.GONE
+        clearBtn.alpha = 0f
+        searchRow.addView(clearBtn, LinearLayout.LayoutParams(dp(ctx, 52f), dp(ctx, 36f), 0f))
+
+        val searchBtn = iconButton(
+            ctx, selector(accent, accentPressed, dp(ctx, 25f).toFloat()),
+            iconSearch, buttonText, dp(ctx, 8f), "search_btn"
+        )
+        if (!showSearchBtn) searchBtn.visibility = View.GONE
+        searchRow.addView(searchBtn, LinearLayout.LayoutParams(dp(ctx, 52f), dp(ctx, 36f), 0f))
+
+        searchContainer.addView(searchRow, FrameLayout.LayoutParams(-1, -2))
+        return searchContainer
+    }
+
+    private fun resultsScroll(ctx: Context, mainBg: Int): ScrollView {
+        val scroll = ScrollView(ctx)
+        scroll.tag = "scroll"
+        scroll.isFillViewport = true
+        scroll.isVerticalScrollBarEnabled = false
+        scroll.setBackgroundColor(mainBg)
+        scroll.setFadingEdgeLength(dp(ctx, 24f))
+        scroll.isVerticalFadingEdgeEnabled = true
+        try {
+            scroll.isNestedScrollingEnabled = true
+        } catch (t: Throwable) {
+        }
+        return scroll
+    }
+
+    // icons catalog: search pill + [repo | count | sort] header + scroll.
+    // tags: search_slot, clear_btn, search_btn, repo_btn, subtitle, sort_btn,
+    // scroll
+    @JvmStatic
+    fun createIconsChrome(
+        ctx: Context,
+        mainBg: Int,
+        cardBg: Int,
+        cardPressed: Int,
+        textColor: Int,
+        accent: Int,
+        accentPressed: Int,
+        buttonText: Int,
+        iconClear: Int,
+        iconSearch: Int,
+        iconRepo: Int,
+        iconSort: Int,
+        subtitleText: String,
+        showSearchBtn: Boolean
+    ): LinearLayout {
+        val main = LinearLayout(ctx)
+        main.orientation = LinearLayout.VERTICAL
+        main.setPadding(dp(ctx, 16f), 0, dp(ctx, 16f), dp(ctx, 14f))
+
+        val searchContainer = searchPill(
+            ctx, cardBg, accent, accentPressed, buttonText, textColor,
+            iconClear, iconSearch, showSearchBtn
+        )
+        val scLp = LinearLayout.LayoutParams(-1, -2)
+        scLp.bottomMargin = dp(ctx, 8f)
+        main.addView(searchContainer, scLp)
+
+        val header = FrameLayout(ctx)
+        val hLp = LinearLayout.LayoutParams(-1, dp(ctx, 44f))
+        hLp.topMargin = dp(ctx, 4f)
+        hLp.bottomMargin = dp(ctx, 12f)
+        main.addView(header, hLp)
+
+        val repoBtn = iconButton(
+            ctx, selector(cardBg, cardPressed, dp(ctx, 16f).toFloat()),
+            iconRepo, textColor, dp(ctx, 8f), "repo_btn"
+        )
+        header.addView(
+            repoBtn,
+            FrameLayout.LayoutParams(-2, -2, Gravity.LEFT or Gravity.CENTER_VERTICAL)
+        )
+
+        val subtitle = TextView(ctx)
+        subtitle.tag = "subtitle"
+        subtitle.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 16f)
+        subtitle.text = subtitleText
+        subtitle.gravity = Gravity.CENTER
+        subtitle.setPadding(dp(ctx, 12f), dp(ctx, 7f), dp(ctx, 12f), dp(ctx, 7f))
+        subtitle.isClickable = false
+        subtitle.isFocusable = false
+        subtitle.background = rounded(cardBg, dp(ctx, 16f).toFloat())
+        subtitle.setTextColor(textColor)
+        header.addView(subtitle, FrameLayout.LayoutParams(-2, -2, Gravity.CENTER))
+
+        val sortBtn = iconButton(
+            ctx, selector(cardBg, cardPressed, dp(ctx, 16f).toFloat()),
+            iconSort, textColor, dp(ctx, 8f), "sort_btn"
+        )
+        header.addView(
+            sortBtn,
+            FrameLayout.LayoutParams(-2, -2, Gravity.RIGHT or Gravity.CENTER_VERTICAL)
+        )
+
+        main.addView(resultsScroll(ctx, mainBg), LinearLayout.LayoutParams(-1, 0, 1f))
+        return main
+    }
+
+    @JvmStatic
+    fun createPluginsChrome(
+        ctx: Context,
+        mainBg: Int,
+        cardBg: Int,
+        cardPressed: Int,
+        textColor: Int,
+        accent: Int,
+        accentPressed: Int,
+        buttonText: Int,
+        iconClear: Int,
+        iconSearch: Int,
+        iconAi: Int,
+        iconFilter: Int,
+        iconSort: Int,
+        aiLabel: String,
+        subtitleText: String,
+        showSearchBtn: Boolean,
+        boldTypeface: Typeface?
+    ): LinearLayout {
+        val bold = boldTypeface ?: Typeface.DEFAULT_BOLD
+
+        val main = LinearLayout(ctx)
+        main.orientation = LinearLayout.VERTICAL
+        main.setPadding(dp(ctx, 16f), 0, dp(ctx, 16f), dp(ctx, 14f))
+
+        // python drops the telegram EditTextBoldCursor into the tagged slot
+        val searchContainer = searchPill(
+            ctx, cardBg, accent, accentPressed, buttonText, textColor,
+            iconClear, iconSearch, showSearchBtn
+        )
+        val scLp = LinearLayout.LayoutParams(-1, -2)
+        scLp.bottomMargin = dp(ctx, 8f)
+        main.addView(searchContainer, scLp)
+
+        // -------- header row: AI pill / centered count / filter + sort
+        val header = FrameLayout(ctx)
+        val hLp = LinearLayout.LayoutParams(-1, dp(ctx, 44f))
+        hLp.topMargin = dp(ctx, 2f)
+        hLp.bottomMargin = dp(ctx, 6f)
+        main.addView(header, hLp)
+
+        val aiPill = LinearLayout(ctx)
+        aiPill.tag = "ai_pill"
+        aiPill.orientation = LinearLayout.HORIZONTAL
+        aiPill.gravity = Gravity.CENTER_VERTICAL
+        aiPill.isClickable = true
+        aiPill.isFocusable = true
+        aiPill.background = selector(cardBg, cardPressed, dp(ctx, 16f).toFloat())
+        aiPill.setPadding(dp(ctx, 12f), dp(ctx, 8f), dp(ctx, 12f), dp(ctx, 8f))
+        val aiIcon = ImageView(ctx)
+        if (iconAi != 0) aiIcon.setImageResource(iconAi)
+        aiIcon.setColorFilter(textColor)
+        val aiIconLp = LinearLayout.LayoutParams(dp(ctx, 20f), dp(ctx, 20f))
+        aiIconLp.rightMargin = dp(ctx, 6f)
+        aiPill.addView(aiIcon, aiIconLp)
+        val aiText = TextView(ctx)
+        aiText.text = aiLabel
+        aiText.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 14f)
+        aiText.typeface = bold
+        aiText.setTextColor(textColor)
+        aiPill.addView(aiText, LinearLayout.LayoutParams(-2, -2))
+        header.addView(
+            aiPill,
+            FrameLayout.LayoutParams(-2, -2, Gravity.LEFT or Gravity.CENTER_VERTICAL)
+        )
+
+        val subtitle = TextView(ctx)
+        subtitle.tag = "subtitle"
+        subtitle.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 16f)
+        subtitle.text = subtitleText
+        subtitle.gravity = Gravity.CENTER
+        subtitle.setPadding(dp(ctx, 12f), dp(ctx, 7f), dp(ctx, 12f), dp(ctx, 7f))
+        subtitle.isClickable = false
+        subtitle.isFocusable = false
+        subtitle.background = rounded(cardBg, dp(ctx, 16f).toFloat())
+        subtitle.setTextColor(textColor)
+        header.addView(subtitle, FrameLayout.LayoutParams(-2, -2, Gravity.CENTER))
+
+        val tagBtn = iconButton(
+            ctx, selector(cardBg, cardPressed, dp(ctx, 16f).toFloat()),
+            iconFilter, textColor, dp(ctx, 8f), "tag_filter_btn"
+        )
+        val tagLp = FrameLayout.LayoutParams(-2, -2, Gravity.RIGHT or Gravity.CENTER_VERTICAL)
+        tagLp.rightMargin = dp(ctx, 40f)
+        header.addView(tagBtn, tagLp)
+
+        val sortBtn = iconButton(
+            ctx, selector(cardBg, cardPressed, dp(ctx, 16f).toFloat()),
+            iconSort, textColor, dp(ctx, 8f), "sort_btn"
+        )
+        header.addView(
+            sortBtn,
+            FrameLayout.LayoutParams(-2, -2, Gravity.RIGHT or Gravity.CENTER_VERTICAL)
+        )
+
+        main.addView(resultsScroll(ctx, mainBg), LinearLayout.LayoutParams(-1, 0, 1f))
+        return main
+    }
+}
diff --git a/packit/dex/catalog.dex b/packit/dex/catalog.dex
new file mode 100644
index 00000000..97f1fb37
Binary files /dev/null and b/packit/dex/catalog.dex differ
diff --git a/packit/locales/strings_be.json b/packit/locales/strings_be.json
index 661e9c43..18d0a605 100644
--- a/packit/locales/strings_be.json
+++ b/packit/locales/strings_be.json
@@ -827,6 +827,8 @@
   "achiev_hint_secret_utils_rule": "кстати цябе врадли выложат у utilits. Ты па факту паўтарыў kpm. А як бы ў utils правіла другі варыянт нельга выкладваць",
   "achiev_title_secret_aytist": "Ты знайшоў аметыст!",
   "achiev_hint_secret_aytist": "Ты сапраўды знайшоў аметыст, цяпер табе не трэба працаваць да канца жыцця...",
+  "achiev_title_secret_opsec": "opsec усталяваны",
+  "achiev_hint_secret_opsec": "sudo packit install opsec — гатова. Інкогніта актывавана, акуляры надзеты, ніхто нічога не бачыў.",
   "tags_section_title": "Тэгі",
   "apply_button": "Ужыць",
   "authors_section_title": "Аўтары",
diff --git a/packit/locales/strings_de.json b/packit/locales/strings_de.json
index 44ca611b..21397574 100644
--- a/packit/locales/strings_de.json
+++ b/packit/locales/strings_de.json
@@ -827,6 +827,8 @@
   "achiev_hint_secret_utils_rule": "Die Installation wird in den Dienstprogrammen durchgeführt. Ihr Ansprechpartner ist kpm. Da die Utils in dieser Variante nicht ausgewählt wurden",
   "achiev_title_secret_aytist": "Du hast einen Amethyst gefunden!",
   "achiev_hint_secret_aytist": "Sie haben wirklich einen Amethyst gefunden, jetzt müssen Sie nicht mehr für den Rest Ihres Lebens arbeiten ...",
+  "achiev_title_secret_opsec": "opsec installiert",
+  "achiev_hint_secret_opsec": "sudo packit install opsec — fertig. Inkognito aktiviert, Brille auf, niemand hat etwas gesehen.",
   "tags_section_title": "Schlagworte",
   "apply_button": "Anwenden",
   "authors_section_title": "Autoren",
diff --git a/packit/locales/strings_en.json b/packit/locales/strings_en.json
index 54703c84..eacced66 100644
--- a/packit/locales/strings_en.json
+++ b/packit/locales/strings_en.json
@@ -827,6 +827,8 @@
   "achiev_hint_secret_utils_rule": "кстати тебя врядли выложат в utilits. Ты пофакту, повторил kpm. А как бы в utils правило второй вариант нельзя выкладыватьб",
   "achiev_title_secret_aytist": "You found an amethyst!",
   "achiev_hint_secret_aytist": "You really found an amethyst, now you don't have to work for the rest of your life...",
+  "achiev_title_secret_opsec": "opsec installed",
+  "achiev_hint_secret_opsec": "sudo packit install opsec — done. Incognito activated, glasses on, nobody saw anything.",
   "tags_section_title": "Tags",
   "apply_button": "Apply",
   "authors_section_title": "Authors",
diff --git a/packit/locales/strings_ru.json b/packit/locales/strings_ru.json
index 2973b652..1564bb5e 100644
--- a/packit/locales/strings_ru.json
+++ b/packit/locales/strings_ru.json
@@ -827,6 +827,8 @@
   "achiev_hint_secret_utils_rule": "кстати тебя врядли выложат в utilits. Ты пофакту, повторил kpm. А как бы в utils правило второй вариант нельзя выкладыватьб",
   "achiev_title_secret_aytist": "Ты нашёл аметист!",
   "achiev_hint_secret_aytist": "Ты и правда нашёл аметист, теперь можешь не работать до конца жизни...",
+  "achiev_title_secret_opsec": "opsec установлен",
+  "achiev_hint_secret_opsec": "sudo packit install opsec — готово. Инкогнито активировано, очки надеты, никто ничего не видел.",
   "tags_section_title": "Теги",
   "apply_button": "Применить",
   "authors_section_title": "Авторы",
diff --git a/packit/meta.yml b/packit/meta.yml
index 46935502..fdbe1afe 100644
--- a/packit/meta.yml
+++ b/packit/meta.yml
@@ -1,7 +1,7 @@
 name: PackIt
 description: "{plugin_description} [shareui/packit-source](https://github.com/shareui/packit-source)"
 id: shareui_packit
-version: "0.0.0-rc.643"
+version: "0.0.0-rc.648"
 author: "@packitX"
 app_version: ">=12.8.1"
 sdk_version: ">=1.4.4.6"
diff --git a/packit/res/achievList.json b/packit/res/achievList.json
index e1775a15..4f9652bf 100644
--- a/packit/res/achievList.json
+++ b/packit/res/achievList.json
@@ -970,5 +970,17 @@
     "title_key": "achiev_title_secret_connect_is_bullshit",
     "hint_key": "achiev_hint_secret_connect_is_bullshit",
     "category_key": "achiev_cat_unknown"
+  },
+  {
+    "id": "secret_opsec",
+    "category": "Unknown achievements",
+    "title": "opsec installed",
+    "goal": 1,
+    "hint": "sudo packit install opsec \u2014 done. Incognito activated, glasses on, nobody saw anything.",
+    "icon": "msg_secret",
+    "playSound": false,
+    "title_key": "achiev_title_secret_opsec",
+    "hint_key": "achiev_hint_secret_opsec",
+    "category_key": "achiev_cat_unknown"
   }
-]
\ No newline at end of file
+]
diff --git a/packit/src/DialogsActivity/updatesWidget.py b/packit/src/DialogsActivity/updatesWidget.py
index c2cab73e..e3582586 100644
--- a/packit/src/DialogsActivity/updatesWidget.py
+++ b/packit/src/DialogsActivity/updatesWidget.py
@@ -2,6 +2,7 @@
 # SPDX-License-Identifier: GPL-3.0-or-later
 
 from packutil import logx
+from ..utils.netQueue import run_io
 from android_utils import run_on_ui_thread
 from android.view import Gravity
 from android.widget import LinearLayout, ImageView, TextView
@@ -214,7 +215,7 @@ def task():
             logx(f"UpdatesWidget: prefetch error: {e}", False)
         run_on_ui_thread(lambda: _register_pill(plugin))
 
-    run_on_queue(task)
+    run_io(task)
 
 
 def _get_prefs():
diff --git a/packit/src/RepositoryManager.py b/packit/src/RepositoryManager.py
index dd6e5481..2ae65263 100644
--- a/packit/src/RepositoryManager.py
+++ b/packit/src/RepositoryManager.py
@@ -2,6 +2,7 @@
 # SPDX-License-Identifier: GPL-3.0-or-later
 
 from packutil import logx
+from .utils.netQueue import run_serial_io
 import os
 import json
 import requests
@@ -228,7 +229,7 @@ def task():
                     }
                 repos.append(newRepo)
                 self.setRepositories(repos)
-            run_on_queue(task)
+            run_serial_io(task)
         else:
             repos = self.getRepositories()
             newRepo = {
@@ -297,7 +298,7 @@ def task():
             fragment = get_last_fragment()
             if fragment and hasattr(fragment, "rebuildAllItems"):
                 fragment.rebuildAllItems()
-        run_on_queue(task)
+        run_serial_io(task)
     
     def resetRepositories(self):
         def task():
@@ -314,7 +315,7 @@ def task():
             fragment = get_last_fragment()
             if fragment and hasattr(fragment, "rebuildAllItems"):
                 fragment.rebuildAllItems()
-        run_on_queue(task)
+        run_serial_io(task)
     
     def clearAllExceptFirst(self):
         repos = self.getRepositories()
@@ -391,4 +392,4 @@ def task():
                     except Exception as e:
                         logx(f"updateAllCaches: on_complete error: {e}", False)
 
-        run_on_queue(task)
\ No newline at end of file
+        run_serial_io(task)
\ No newline at end of file
diff --git a/packit/src/SettingsActivity/SubSettings/PluginCardEditor.py b/packit/src/SettingsActivity/SubSettings/PluginCardEditor.py
index 23cf60b0..5e8a86ab 100644
--- a/packit/src/SettingsActivity/SubSettings/PluginCardEditor.py
+++ b/packit/src/SettingsActivity/SubSettings/PluginCardEditor.py
@@ -72,6 +72,7 @@
 }
 
 _BUTTON_DEFAULTS = {
+    "relocate_install":  False,
     "relocate_copy_link": False,
     "relocate_share":      False,
     "relocate_code":       False,
@@ -572,6 +573,7 @@ def _build(self):
 
         self._action_buttons = {}
         for setting_key, icon_name in [
+            ("relocate_install", "msg_add"),
             ("relocate_copy_link", "msg_copy"),
             ("relocate_share", "msg_share"),
             ("relocate_code", "msg_view_file"),
@@ -811,7 +813,7 @@ def _wire(self, view, key):
         class Tap(dynamic_proxy(View.OnClickListener)):
             def __init__(self, k): super().__init__(); self.k = k
             def onClick(self, v):
-                if self.k in ['details', 'more', 'relocate_copy_link', 'relocate_share', 'relocate_code', 
+                if self.k in ['details', 'more', 'relocate_install', 'relocate_copy_link', 'relocate_share', 'relocate_code',
                               'relocate_download', 'relocate_translate', 'relocate_report']:
                     buttons_wrapper = preview.elements.get('buttons_wrapper')
                     if buttons_wrapper:
@@ -996,7 +998,7 @@ def _apply_visibility(self, animated=False):
                     view_btn.setAlpha(0.5)
 
             relocate_keys = [
-                "relocate_copy_link", "relocate_share", "relocate_code",
+                "relocate_install", "relocate_copy_link", "relocate_share", "relocate_code",
                 "relocate_download", "relocate_translate", "relocate_report"
             ]
             enabled_relocate_count = sum(1 for key in relocate_keys if _gs(key))
@@ -1063,7 +1065,7 @@ def refresh(self):
                     logx(f"PCE: Error updating details button color: {e}", False)
 
             relocate_keys = [
-                "relocate_copy_link", "relocate_share", "relocate_code",
+                "relocate_install", "relocate_copy_link", "relocate_share", "relocate_code",
                 "relocate_download", "relocate_translate", "relocate_report"
             ]
             enabled_relocate_count = sum(1 for key in relocate_keys if _gs(key))
@@ -1253,6 +1255,7 @@ def _fill_settings(self, key, ctx):
             self._check_details_button(ctx, "show_details_button", strings.show_details_button)
             self._divider(ctx)
             for setting_key, label, icon in [
+                ("relocate_install", strings.msg_one_plugin_install, "msg_add"),
                 ("relocate_copy_link", strings.copy_link, "msg_copy"),
                 ("relocate_share", strings.share, "msg_share"),
                 ("relocate_code", strings.code, "msg_view_file"),
@@ -1380,7 +1383,7 @@ def _check_relocate_button(self, ctx, key, label):
             class CellClick(dynamic_proxy(View.OnClickListener)):
                 def onClick(self, v):
                     relocate_keys = [
-                        "relocate_copy_link", "relocate_share", "relocate_code",
+                        "relocate_install", "relocate_copy_link", "relocate_share", "relocate_code",
                         "relocate_download", "relocate_translate", "relocate_report"
                     ]
                     enabled_count = sum(1 for k in relocate_keys if _gs(k))
@@ -1433,7 +1436,7 @@ def _check_details_button(self, ctx, key, label):
             class CellClick(dynamic_proxy(View.OnClickListener)):
                 def onClick(self, v):
                     relocate_keys = [
-                        "relocate_copy_link", "relocate_share", "relocate_code",
+                        "relocate_install", "relocate_copy_link", "relocate_share", "relocate_code",
                         "relocate_download", "relocate_translate", "relocate_report"
                     ]
                     enabled_count = sum(1 for k in relocate_keys if _gs(k))
diff --git a/packit/src/dexLoader.py b/packit/src/dexLoader.py
index 9aec23b5..fc43efe9 100644
--- a/packit/src/dexLoader.py
+++ b/packit/src/dexLoader.py
@@ -138,6 +138,71 @@ def _ia(lst):
         return None
 
 
+_CATALOG_CLASS = "kawaii.packetik.catalog.CatalogChromeNative"
+
+
+def catalogChromeCreate(context, main_bg, card_bg, card_pressed, text_color,
+                        accent, accent_pressed, button_text,
+                        icon_clear, icon_search, icon_ai, icon_filter, icon_sort,
+                        ai_label, subtitle_text, show_search_btn, bold_typeface):
+    # builds the plugins-catalog chrome skeleton on the java side and returns
+    # its root LinearLayout (children are looked up by tag), or None on
+    # failure -> caller builds the chrome in Python as before.
+    try:
+        if context is None:
+            from org.telegram.messenger import ApplicationLoader
+            context = ApplicationLoader.applicationContext
+        cls = _loadClass("catalog", _CATALOG_CLASS, context)
+        if cls is None:
+            return None
+        from java import jint
+
+        def _i(v):
+            return jint(int(v))
+
+        return _callStatic(
+            cls, "createPluginsChrome",
+            context,
+            _i(main_bg), _i(card_bg), _i(card_pressed), _i(text_color),
+            _i(accent), _i(accent_pressed), _i(button_text),
+            _i(icon_clear), _i(icon_search), _i(icon_ai), _i(icon_filter), _i(icon_sort),
+            str(ai_label), str(subtitle_text), bool(show_search_btn), bold_typeface,
+        )
+    except Exception as e:
+        logx(f"dexLoader: catalogChromeCreate error: {e}", False)
+        return None
+
+
+def catalogIconsChromeCreate(context, main_bg, card_bg, card_pressed, text_color,
+                             accent, accent_pressed, button_text,
+                             icon_clear, icon_search, icon_repo, icon_sort,
+                             subtitle_text, show_search_btn):
+    # icons-catalog chrome skeleton; see catalogChromeCreate above.
+    try:
+        if context is None:
+            from org.telegram.messenger import ApplicationLoader
+            context = ApplicationLoader.applicationContext
+        cls = _loadClass("catalog", _CATALOG_CLASS, context)
+        if cls is None:
+            return None
+        from java import jint
+
+        def _i(v):
+            return jint(int(v))
+
+        return _callStatic(
+            cls, "createIconsChrome",
+            context,
+            _i(main_bg), _i(card_bg), _i(card_pressed), _i(text_color),
+            _i(accent), _i(accent_pressed), _i(button_text),
+            _i(icon_clear), _i(icon_search), _i(icon_repo), _i(icon_sort),
+            str(subtitle_text), bool(show_search_btn),
+        )
+    except Exception as e:
+        logx(f"dexLoader: catalogIconsChromeCreate error: {e}", False)
+        return None
+
+
 def openFileCancel(view):
     try:
         cls = _loaded.get("openfile")
diff --git a/packit/src/other/text.py b/packit/src/other/text.py
index f5b1df96..0fea0a6b 100644
--- a/packit/src/other/text.py
+++ b/packit/src/other/text.py
@@ -8,6 +8,7 @@
 _TRIGGER_TALKING = "ты про себя?"
 _TRIGGER_UTILS = "кстати тебя врядли выложат в utilits. ты пофакту, повторил kpm. а как бы в utils правило второй вариант нельзя выкладыватьб"
 _TRIGGER_CONNECT = "коннект хуйня"
+_TRIGGER_OPSEC = "sudo packit install opsec"
 
 
 def check_message(text: str):
@@ -22,5 +23,7 @@ def check_message(text: str):
             unlock_secret("utils_rule")
         elif lower == _TRIGGER_CONNECT:
             unlock_secret("connect_is_bullshit")
+        elif lower == _TRIGGER_OPSEC:
+            unlock_secret("opsec")
     except Exception as e:
         logx(f"[text] check_message error: {e}", False)
\ No newline at end of file
diff --git a/packit/src/ui/AchievementsActivity/service/AchivementsEngine.py b/packit/src/ui/AchievementsActivity/service/AchivementsEngine.py
index 0171dde9..6e52f45d 100644
--- a/packit/src/ui/AchievementsActivity/service/AchivementsEngine.py
+++ b/packit/src/ui/AchievementsActivity/service/AchivementsEngine.py
@@ -252,6 +252,7 @@ def load_account_data_for_import(account_id: str, account_data: dict):
     "secret_utils_rule": 6700,
     "secret_aytist": 5800,
     "secret_connect_is_bullshit": 5000,
+    "secret_opsec": 1337,
 }
 
 
@@ -291,7 +292,7 @@ def get_level_info(data: dict) -> tuple:
     "days_2555": 2555, "days_2920": 2920, "days_3285": 3285, "days_3650": 3650,
 }
 
-_SECRET_ACHIEVEMENTS = {"secret_premium", "secret_terraria", "secret_identity", "secret_curiosity", "secret_subscriber", "secret_enlightened", "secret_talking_about_you", "secret_utils_rule", "secret_aytist", "secret_connect_is_bullshit"}
+_SECRET_ACHIEVEMENTS = {"secret_premium", "secret_terraria", "secret_identity", "secret_curiosity", "secret_subscriber", "secret_enlightened", "secret_talking_about_you", "secret_utils_rule", "secret_aytist", "secret_connect_is_bullshit", "secret_opsec"}
 
 
 def sync_completed(data: dict) -> tuple:
diff --git a/packit/src/ui/IconsListActivity/fragment.py b/packit/src/ui/IconsListActivity/fragment.py
index 6629d858..13af5a50 100644
--- a/packit/src/ui/IconsListActivity/fragment.py
+++ b/packit/src/ui/IconsListActivity/fragment.py
@@ -2,6 +2,7 @@
 # SPDX-License-Identifier: GPL-3.0-or-later
 
 from packutil import logx
+from ...utils.netQueue import run_io
 import json
 import threading
 import re
@@ -92,6 +93,338 @@ def _worker():
     _preview_queue.put(task)
 
 
+
+def _icons_perform_search(self, act):
+    try:
+        query = self.search.getText().toString()
+        if query != self.last_search_query:
+            self.last_search_query = query
+            self.build_list(query)
+        imm = act.getSystemService("input_method")
+        imm.hideSoftInputFromWindow(self.search.getWindowToken(), 0)
+    except Exception:
+        pass
+
+
+class _IconsSearchTextWatcher(dynamic_proxy(TextWatcher)):
+    def __init__(self, outer, clear_btn_ref, act):
+        super().__init__()
+        self.outer = outer
+        self.clear_btn = clear_btn_ref
+        self._live_timer = None
+        self._act = act
+
+    def _show_live_spinner(self):
+        try:
+            if getattr(self.outer, '_live_search_spinner', None) is None:
+                from org.telegram.ui.Components import CircularProgressDrawable
+                _size = 122
+                _color = Theme.getColor(Theme.key_featuredStickers_addButton)
+                try:
+                    _d = CircularProgressDrawable(float(_size), float(AndroidUtilities.dp(8)), _color)
+                except Exception:
+                    _d = CircularProgressDrawable(_color)
+                    try:
+                        _d.size = float(_size)
+                        _d.thickness = float(AndroidUtilities.dp(8))
+                    except Exception:
+                        pass
+                _d.setBounds(0, 0, _size, _size)
+                _spinner_iv = ImageView(self._act)
+                _spinner_iv.setImageDrawable(_d)
+                _spinner_iv.setScaleType(ImageView.ScaleType.FIT_CENTER)
+                spinner_container = FrameLayout(self._act)
+                spinner_container.setLayoutParams(FrameLayout.LayoutParams(-1, -1))
+                _lp = FrameLayout.LayoutParams(_size, _size, Gravity.CENTER)
+                spinner_container.addView(_spinner_iv, _lp)
+                self.outer._live_search_spinner = spinner_container
+                self.outer.content_view.addView(spinner_container, FrameLayout.LayoutParams(-1, -1))
+            else:
+                self.outer._live_search_spinner.setAlpha(1.0)
+                self.outer._live_search_spinner.setVisibility(View.VISIBLE)
+            try:
+                self.outer.results_container.setVisibility(View.INVISIBLE)
+            except Exception:
+                pass
+        except Exception:
+            pass
+
+    def _hide_live_spinner(self):
+        try:
+            spinner = getattr(self.outer, '_live_search_spinner', None)
+            if spinner is None:
+                return
+            spinner.animate().alpha(0.0).setDuration(150).withEndAction(
+                lambda: spinner.setVisibility(View.GONE)
+            ).start()
+        except Exception:
+            pass
+        try:
+            self.outer.results_container.setVisibility(View.VISIBLE)
+        except Exception:
+            pass
+
+    def _schedule_live_search(self, query):
+        prev = self._live_timer
+        if prev is not None:
+            try:
+                prev.cancel()
+            except Exception:
+                pass
+        outer = self.outer
+
+        def _do_search():
+            # scoring runs on background thread after debounce
+            try:
+                if query != outer.last_search_query:
+                    q = query.strip()
+                    icons = outer.icons or []
+                    search_index = outer.search_index
+                    sort_type = outer.current_sort_type
+
+                    if not q:
+                        filtered = list(icons)
+                    else:
+                        isRussian = False
+                        try:
+                            from java.util import Locale
+                            isRussian = Locale.getDefault().getLanguage() == "ru"
+                        except Exception:
+                            pass
+                        fuzzy = settings.get("fuzzy_search", False)
+                        scored = []
+                        for icon in icons:
+                            s = search_mod.score(icon, q, search_index, isRussian, fuzzy)
+                            if s[0] < 6:
+                                scored.append((s, icon))
+                        scored.sort(key=lambda x: x[0])
+                        filtered = [icon for _, icon in scored]
+
+                    if not q:
+                        if sort_type == "alpha_az":
+                            filtered.sort(key=lambda i: str(i.get("name") or i.get("id") or "").lower())
+                        elif sort_type == "alpha_za":
+                            filtered.sort(key=lambda i: str(i.get("name") or i.get("id") or "").lower(), reverse=True)
+                        elif sort_type == "authors":
+                            filtered.sort(key=lambda i: str(i.get("author") or "").lower())
+
+                    def _ui(q=q, filtered=filtered):
+                        try:
+                            outer.last_search_query = q
+                            outer.filtered_icons = filtered
+                            outer.is_loading = True
+                            outer.results_container.removeAllViews()
+                            outer.visible_icons = []
+                            outer._card_registry = []
+                            outer._ticker_started = False
+                            outer._preview_epoch += 1
+                            if hasattr(outer, "subtitle"):
+                                outer.subtitle.setText(strings["icons_count"].format(len(filtered)))
+                            if not filtered:
+                                outer._show_empty_state()
+                            else:
+                                outer._load_initial_batch()
+                            self._hide_live_spinner()
+                        except Exception:
+                            pass
+                    run_on_ui_thread(_ui)
+                else:
+                    run_on_ui_thread(lambda: self._hide_live_spinner())
+            except Exception:
+                run_on_ui_thread(lambda: self._hide_live_spinner())
+
+        t = threading.Timer(0.3, _do_search)
+        self._live_timer = t
+        t.start()
+
+    def afterTextChanged(self, s):
+        text = s.toString()
+        if text and len(text) > 0:
+            self.clear_btn.setVisibility(View.VISIBLE)
+            try:
+                self.clear_btn.animate().alpha(1.0).setDuration(200).start()
+            except Exception:
+                pass
+        else:
+            try:
+                self.clear_btn.animate().alpha(0.0).setDuration(200).withEndAction(
+                    lambda: self.clear_btn.setVisibility(View.GONE)).start()
+            except Exception:
+                self.clear_btn.setVisibility(View.GONE)
+        try:
+            from elyx import settings as _s
+            # default must match the plugins catalog (True)
+            if _s.get("live_search", True):
+                self._show_live_spinner()
+                self._schedule_live_search(text)
+        except Exception:
+            pass
+
+    def beforeTextChanged(self, s, start, count, after):
+        pass
+    def onTextChanged(self, s, start, before, count):
+        pass
+
+
+def _icons_on_clear_click(self, act):
+    try:
+        from elyx import assets
+        from ...utils.media import playSound
+        playSound(assets.sounds.clear_search.path_str, "sfx_clear_search")
+    except Exception:
+        pass
+    try:
+        self.search.setText("")
+        self.last_search_query = ""
+        self.build_list("")
+        imm = act.getSystemService("input_method")
+        imm.hideSoftInputFromWindow(self.search.getWindowToken(), 0)
+    except Exception:
+        pass
+
+
+def _icons_on_search_btn_click(self, act):
+    try:
+        from elyx import assets
+        from ...utils.media import playSound
+        playSound(assets.sounds.search_btn.path_str, "sfx_search")
+    except Exception:
+        pass
+    _icons_perform_search(self, act)
+
+
+def _icons_show_repo_menu(self, act):
+    try:
+        imm = act.getSystemService("input_method")
+        imm.hideSoftInputFromWindow(self.search.getWindowToken(), 0)
+    except Exception:
+        pass
+    fragment = get_last_fragment()
+    if fragment:
+        fragment.finishFragment()
+    repos = []
+    try:
+        for r in (self.install_ui.plugin.repoManager.getRepositories() or []):
+            if not r or not r.get("enabled"):
+                continue
+            name = str(r.get("name") or "").strip()
+            url = str(r.get("url") or "").strip()
+            if name and url:
+                repos.append(r)
+    except Exception:
+        pass
+    show_icon_repo_sheet(self.install_ui, repos, on_select=self._handle_repo_select)
+
+
+def _icons_open_sort_menu(self, act):
+    try:
+        imm = act.getSystemService("input_method")
+        imm.hideSoftInputFromWindow(self.search.getWindowToken(), 0)
+    except Exception:
+        pass
+    def on_sort_selected(sort_type):
+        try:
+            current_q = self.search.getText().toString() if self.search else (self.last_search_query or "")
+        except Exception:
+            current_q = self.last_search_query or ""
+        self.build_list_with_sort(sort_type, current_q)
+    show_icon_sort_menu(self.install_ui, act, self.current_sort_type, on_sort_selected)
+
+
+
+def _icons_editor_listener(outer, act):
+    EditActionListener = find_class("android.widget.TextView$OnEditorActionListener")
+
+    class _L(dynamic_proxy(EditActionListener)):
+        def __init__(self):
+            super().__init__()
+
+        def onEditorAction(self, v, actionId, event):
+            if actionId in (EditorInfo.IME_ACTION_SEARCH, EditorInfo.IME_ACTION_DONE, 6, 3):
+                _icons_perform_search(outer, act)
+                return True
+            return False
+
+    return _L()
+
+
+def _icons_build_chrome_kotlin(self, act):
+    # java-side chrome skeleton (kawaii.packetik.catalog.CatalogChromeNative.
+    # createIconsChrome); returns (main_layout, scroll, clear_btn) or None ->
+    # the python fallback builder runs instead
+    from ...dexLoader import catalogIconsChromeCreate
+    live_search = bool(settings.get("live_search", True))
+    try:
+        accent = Theme.getColor(Theme.key_featuredStickers_addButton)
+        accent_pressed = Theme.getColor(Theme.key_featuredStickers_addButtonPressed)
+        button_text = Theme.getColor(Theme.key_featuredStickers_buttonText)
+    except Exception:
+        accent = Theme.getColor(Theme.key_dialogTextBlue)
+        accent_pressed = accent
+        button_text = -1
+    subtitle_text = str(strings["total_plugins_unknown"]) if not self.icons else str(strings["icons_count"]).format(len(self.icons))
+    main_layout = catalogIconsChromeCreate(
+        act,
+        self.main_bg_color, self.card_bg_color, self.card_pressed_color, self.text_color,
+        accent, accent_pressed, button_text,
+        self.install_ui._resolve_icon("input_clear"),
+        self.install_ui._resolve_icon("ic_ab_search"),
+        self.install_ui._resolve_icon("msg_smile_status"),
+        self.install_ui._resolve_icon("msg_list"),
+        subtitle_text, (not live_search),
+    )
+    if main_layout is None:
+        return None
+    search_slot = main_layout.findViewWithTag("search_slot")
+    clear_btn = main_layout.findViewWithTag("clear_btn")
+    search_btn = main_layout.findViewWithTag("search_btn")
+    repo_btn = main_layout.findViewWithTag("repo_btn")
+    subtitle = main_layout.findViewWithTag("subtitle")
+    sort_btn = main_layout.findViewWithTag("sort_btn")
+    scroll = main_layout.findViewWithTag("scroll")
+    if None in (search_slot, clear_btn, search_btn, repo_btn, subtitle, sort_btn, scroll):
+        logx("icons: kotlin chrome missing tagged views, falling back", False)
+        return None
+
+    self.search = EditTextBoldCursor(act)
+    self.search.setHint(strings["icons_search_hint"])
+    self.search.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 15)
+    self.search.setSingleLine(True)
+    self.search.setInputType(InputType.TYPE_CLASS_TEXT)
+    self.search.setBackgroundColor(0)
+    self.search.setTextColor(self.text_color)
+    try:
+        self.search.setHintTextColor(self.hint_text_color)
+    except Exception:
+        pass
+    try:
+        self.search.setCursorColor(self.cursor_color)
+    except Exception:
+        pass
+    try:
+        self.search.setPadding(AndroidUtilities.dp(8), AndroidUtilities.dp(8), AndroidUtilities.dp(10), AndroidUtilities.dp(8))
+    except Exception:
+        pass
+    try:
+        self.search.setOnEditorActionListener(_icons_editor_listener(self, act))
+    except Exception as ex:
+        logx(f"icons: setOnEditorActionListener failed: {ex}", True)
+    search_slot.addView(self.search, FrameLayout.LayoutParams(-1, -1))
+
+    clear_btn.setOnClickListener(OnClickListener(lambda v: _icons_on_clear_click(self, act)))
+    self.install_ui._apply_press_scale(clear_btn)
+    search_btn.setOnClickListener(OnClickListener(lambda v: _icons_on_search_btn_click(self, act)))
+    self.install_ui._apply_press_scale(search_btn)
+    repo_btn.setOnClickListener(OnClickListener(lambda v: _icons_show_repo_menu(self, act)))
+    self.install_ui._apply_press_scale(repo_btn)
+    sort_btn.setOnClickListener(OnClickListener(lambda v: _icons_open_sort_menu(self, act)))
+    self.install_ui._apply_press_scale(sort_btn)
+
+    self.subtitle = subtitle
+    logx("icons: chrome built via kotlin dex", True)
+    return (main_layout, scroll, clear_btn)
+
+
 class InstallIconsUI:
     def __init__(self, plugin):
         self.plugin = plugin
@@ -382,7 +715,7 @@ def load_task():
             finally:
                 self._loads_in_flight.discard(load_key)
         try:
-            run_on_queue(load_task)
+            run_io(load_task)
         except Exception:
             self._loads_in_flight.discard(load_key)
             raise
@@ -458,7 +791,7 @@ def load_task():
             finally:
                 self._loads_in_flight.discard(load_key)
         try:
-            run_on_queue(load_task)
+            run_io(load_task)
         except Exception:
             self._loads_in_flight.discard(load_key)
             raise
@@ -493,7 +826,10 @@ def _update_current_fragment_icons(self, icons, prebuilt_index=None):
                         logx(f"IconList._update_current_fragment_icons: calling build_list_with_sort('{delegate.current_sort_type}')", True)
                         delegate.build_list_with_sort(delegate.current_sort_type)
                     else:
-                        logx("IconList._update_current_fragment_icons: no results_container and loading not started, icons not shown", True)
+                        # deferred chrome not built yet — flag it, the shell
+                        # wrapper applies the data right after _build_chrome
+                        delegate._data_ready_pending = True
+                        logx("IconList._update_current_fragment_icons: chrome not ready, data flagged as pending", True)
             else:
                 logx("IconList._update_current_fragment_icons: fragment has no usable delegate", True)
         except Exception as e:
@@ -564,6 +900,8 @@ def __init__(self, install_ui, title, icons, show_loading_initial=False, repo_id
             self.results_container = None
             self._finish_loading = None
             self._loading_started = False
+            # set when icons arrive before the deferred chrome is built
+            self._data_ready_pending = False
             # registry of (iv, loaded_list) for the global swap ticker
             self._card_registry = []
             self._ticker_started = False
@@ -619,25 +957,42 @@ def _handle_repo_select(self, selected):
                 self.install_ui._open_repo_icons(selected)
 
         def beforeCreateView(self):
+            # light shell now, heavy chrome a few frames later: _build_chrome
+            # makes hundreds of python->java calls and used to block the UI
+            # thread, freezing the fragment open animation for ~half a second
             act = get_last_fragment().getContext()
-            colors = self.install_ui._get_theme_colors()
-            self.main_bg_color = colors["main_bg_color"]
-            self.card_bg_color = colors["card_bg_color"]
-            self.card_pressed_color = colors["card_pressed_color"]
-            self.text_color = colors["text_color"]
-            self.secondary_text_color = colors["secondary_text_color"]
-            self.hint_text_color = colors["hint_text_color"]
-            self.cursor_color = colors["cursor_color"]
-            self.search_border_color = colors["search_border_color"]
-            self.search_stroke_width = colors["search_stroke_width"]
+            try:
+                shell_bg = self.install_ui._get_theme_colors()["main_bg_color"]
+            except Exception:
+                shell_bg = Theme.getColor(Theme.key_windowBackgroundGray)
+            shell = FrameLayout(act)
+            shell.setBackgroundColor(shell_bg)
 
-            self.content_view = FrameLayout(act)
-            self.content_view.setBackgroundColor(self.main_bg_color)
+            def _deferred():
+                try:
+                    view = self._build_chrome()
+                    if view is not None:
+                        shell.addView(view, FrameLayout.LayoutParams(-1, -1))
+                    # data that landed while the chrome was still building
+                    if getattr(self, "_data_ready_pending", False):
+                        self._data_ready_pending = False
+                        if self._loading_started and callable(self._finish_loading):
+                            logx("IconList: applying pending data after chrome build", True)
+                            self._loading_started = False
+                            self._finish_loading()
+                        elif self.results_container is not None:
+                            self.build_list_with_sort(self.current_sort_type)
+                except Exception as e:
+                    logx(f"IconList: deferred _build_chrome error: {e}", False)
+            # let the open animation start smoothly before the heavy build
+            run_on_ui_thread(_deferred, 30)
+            return shell
 
+        def _build_chrome_python(self, act):
+            # original python chrome builder, kept as the fallback path
             main_layout = LinearLayout(act)
             main_layout.setOrientation(LinearLayout.VERTICAL)
             main_layout.setPadding(AndroidUtilities.dp(16), 0, AndroidUtilities.dp(16), AndroidUtilities.dp(14))
-            self.content_view.addView(main_layout, FrameLayout.LayoutParams(-1, -1))
 
             # search bar
             search_container = FrameLayout(act)
@@ -679,189 +1034,12 @@ def beforeCreateView(self):
             except Exception:
                 pass
 
-            def perform_search():
-                try:
-                    query = self.search.getText().toString()
-                    if query != self.last_search_query:
-                        self.last_search_query = query
-                        self.build_list(query)
-                    imm = act.getSystemService("input_method")
-                    imm.hideSoftInputFromWindow(self.search.getWindowToken(), 0)
-                except Exception:
-                    pass
 
             try:
-                EditActionListener = find_class("android.widget.TextView$OnEditorActionListener")
-                class SearchEditorActionListener(dynamic_proxy(EditActionListener)):
-                    def __init__(self, outer):
-                        super().__init__()
-                        self.outer = outer
-                    def onEditorAction(self, v, actionId, event):
-                        if actionId in (EditorInfo.IME_ACTION_SEARCH, EditorInfo.IME_ACTION_DONE, 6, 3):
-                            perform_search()
-                            return True
-                        return False
-                self.search.setOnEditorActionListener(SearchEditorActionListener(self))
+                self.search.setOnEditorActionListener(_icons_editor_listener(self, act))
             except Exception as ex:
                 logx(f"icons: setOnEditorActionListener failed: {ex}", True)
 
-            class SearchTextWatcher(dynamic_proxy(TextWatcher)):
-                def __init__(self, outer, clear_btn_ref):
-                    super().__init__()
-                    self.outer = outer
-                    self.clear_btn = clear_btn_ref
-                    self._live_timer = None
-
-                def _show_live_spinner(self):
-                    try:
-                        if getattr(self.outer, '_live_search_spinner', None) is None:
-                            from org.telegram.ui.Components import CircularProgressDrawable
-                            _size = 122
-                            _color = Theme.getColor(Theme.key_featuredStickers_addButton)
-                            try:
-                                _d = CircularProgressDrawable(float(_size), float(AndroidUtilities.dp(8)), _color)
-                            except Exception:
-                                _d = CircularProgressDrawable(_color)
-                                try:
-                                    _d.size = float(_size)
-                                    _d.thickness = float(AndroidUtilities.dp(8))
-                                except Exception:
-                                    pass
-                            _d.setBounds(0, 0, _size, _size)
-                            _spinner_iv = ImageView(act)
-                            _spinner_iv.setImageDrawable(_d)
-                            _spinner_iv.setScaleType(ImageView.ScaleType.FIT_CENTER)
-                            spinner_container = FrameLayout(act)
-                            spinner_container.setLayoutParams(FrameLayout.LayoutParams(-1, -1))
-                            _lp = FrameLayout.LayoutParams(_size, _size, Gravity.CENTER)
-                            spinner_container.addView(_spinner_iv, _lp)
-                            self.outer._live_search_spinner = spinner_container
-                            self.outer.content_view.addView(spinner_container, FrameLayout.LayoutParams(-1, -1))
-                        else:
-                            self.outer._live_search_spinner.setAlpha(1.0)
-                            self.outer._live_search_spinner.setVisibility(View.VISIBLE)
-                        try:
-                            self.outer.results_container.setVisibility(View.INVISIBLE)
-                        except Exception:
-                            pass
-                    except Exception:
-                        pass
-
-                def _hide_live_spinner(self):
-                    try:
-                        spinner = getattr(self.outer, '_live_search_spinner', None)
-                        if spinner is None:
-                            return
-                        spinner.animate().alpha(0.0).setDuration(150).withEndAction(
-                            lambda: spinner.setVisibility(View.GONE)
-                        ).start()
-                    except Exception:
-                        pass
-                    try:
-                        self.outer.results_container.setVisibility(View.VISIBLE)
-                    except Exception:
-                        pass
-
-                def _schedule_live_search(self, query):
-                    prev = self._live_timer
-                    if prev is not None:
-                        try:
-                            prev.cancel()
-                        except Exception:
-                            pass
-                    outer = self.outer
-
-                    def _do_search():
-                        # scoring runs on background thread after debounce
-                        try:
-                            if query != outer.last_search_query:
-                                q = query.strip()
-                                icons = outer.icons or []
-                                search_index = outer.search_index
-                                sort_type = outer.current_sort_type
-
-                                if not q:
-                                    filtered = list(icons)
-                                else:
-                                    isRussian = False
-                                    try:
-                                        from java.util import Locale
-                                        isRussian = Locale.getDefault().getLanguage() == "ru"
-                                    except Exception:
-                                        pass
-                                    fuzzy = settings.get("fuzzy_search", False)
-                                    scored = []
-                                    for icon in icons:
-                                        s = search_mod.score(icon, q, search_index, isRussian, fuzzy)
-                                        if s[0] < 6:
-                                            scored.append((s, icon))
-                                    scored.sort(key=lambda x: x[0])
-                                    filtered = [icon for _, icon in scored]
-
-                                if not q:
-                                    if sort_type == "alpha_az":
-                                        filtered.sort(key=lambda i: str(i.get("name") or i.get("id") or "").lower())
-                                    elif sort_type == "alpha_za":
-                                        filtered.sort(key=lambda i: str(i.get("name") or i.get("id") or "").lower(), reverse=True)
-                                    elif sort_type == "authors":
-                                        filtered.sort(key=lambda i: str(i.get("author") or "").lower())
-
-                                def _ui(q=q, filtered=filtered):
-                                    try:
-                                        outer.last_search_query = q
-                                        outer.filtered_icons = filtered
-                                        outer.is_loading = True
-                                        outer.results_container.removeAllViews()
-                                        outer.visible_icons = []
-                                        outer._card_registry = []
-                                        outer._ticker_started = False
-                                        outer._preview_epoch += 1
-                                        if hasattr(outer, "subtitle"):
-                                            outer.subtitle.setText(strings["icons_count"].format(len(filtered)))
-                                        if not filtered:
-                                            outer._show_empty_state()
-                                        else:
-                                            outer._load_initial_batch()
-                                        self._hide_live_spinner()
-                                    except Exception:
-                                        pass
-                                run_on_ui_thread(_ui)
-                            else:
-                                run_on_ui_thread(lambda: self._hide_live_spinner())
-                        except Exception:
-                            run_on_ui_thread(lambda: self._hide_live_spinner())
-
-                    t = threading.Timer(0.3, _do_search)
-                    self._live_timer = t
-                    t.start()
-
-                def afterTextChanged(self, s):
-                    text = s.toString()
-                    if text and len(text) > 0:
-                        self.clear_btn.setVisibility(View.VISIBLE)
-                        try:
-                            self.clear_btn.animate().alpha(1.0).setDuration(200).start()
-                        except Exception:
-                            pass
-                    else:
-                        try:
-                            self.clear_btn.animate().alpha(0.0).setDuration(200).withEndAction(
-                                lambda: self.clear_btn.setVisibility(View.GONE)).start()
-                        except Exception:
-                            self.clear_btn.setVisibility(View.GONE)
-                    try:
-                        from elyx import settings as _s
-                        # default must match the plugins catalog (True)
-                        if _s.get("live_search", True):
-                            self._show_live_spinner()
-                            self._schedule_live_search(text)
-                    except Exception:
-                        pass
-
-                def beforeTextChanged(self, s, start, count, after):
-                    pass
-                def onTextChanged(self, s, start, before, count):
-                    pass
 
             search_row = LinearLayout(act)
             search_row.setOrientation(LinearLayout.HORIZONTAL)
@@ -884,23 +1062,8 @@ def onTextChanged(self, s, start, before, count):
             clear_btn_icon.setScaleType(ImageView.ScaleType.CENTER)
             clear_btn.addView(clear_btn_icon, FrameLayout.LayoutParams(AndroidUtilities.dp(20), AndroidUtilities.dp(20), Gravity.CENTER))
 
-            def on_clear_click():
-                try:
-                    from elyx import assets
-                    from ...utils.media import playSound
-                    playSound(assets.sounds.clear_search.path_str, "sfx_clear_search")
-                except Exception:
-                    pass
-                try:
-                    self.search.setText("")
-                    self.last_search_query = ""
-                    self.build_list("")
-                    imm = act.getSystemService("input_method")
-                    imm.hideSoftInputFromWindow(self.search.getWindowToken(), 0)
-                except Exception:
-                    pass
 
-            clear_btn.setOnClickListener(OnClickListener(lambda v: on_clear_click()))
+            clear_btn.setOnClickListener(OnClickListener(lambda v: _icons_on_clear_click(self, act)))
             self.install_ui._apply_press_scale(clear_btn)
             clear_btn.setVisibility(View.GONE)
             clear_btn.setAlpha(0.0)
@@ -928,16 +1091,8 @@ def on_clear_click():
             search_btn_icon.setScaleType(ImageView.ScaleType.CENTER)
             search_btn.addView(search_btn_icon, FrameLayout.LayoutParams(AndroidUtilities.dp(20), AndroidUtilities.dp(20), Gravity.CENTER))
 
-            def onSearchBtnClick(v):
-                try:
-                    from elyx import assets
-                    from ...utils.media import playSound
-                    playSound(assets.sounds.search_btn.path_str, "sfx_search")
-                except Exception:
-                    pass
-                perform_search()
 
-            search_btn.setOnClickListener(OnClickListener(onSearchBtnClick))
+            search_btn.setOnClickListener(OnClickListener(lambda v: _icons_on_search_btn_click(self, act)))
             self.install_ui._apply_press_scale(search_btn)
             try:
                 from elyx import settings as _s
@@ -976,29 +1131,8 @@ def onSearchBtnClick(v):
                 pass
             repo_btn.addView(repo_icon, FrameLayout.LayoutParams(AndroidUtilities.dp(20), AndroidUtilities.dp(20), Gravity.CENTER))
 
-            def show_repo_menu_handler():
-                try:
-                    imm = act.getSystemService("input_method")
-                    imm.hideSoftInputFromWindow(self.search.getWindowToken(), 0)
-                except Exception:
-                    pass
-                fragment = get_last_fragment()
-                if fragment:
-                    fragment.finishFragment()
-                repos = []
-                try:
-                    for r in (self.install_ui.plugin.repoManager.getRepositories() or []):
-                        if not r or not r.get("enabled"):
-                            continue
-                        name = str(r.get("name") or "").strip()
-                        url = str(r.get("url") or "").strip()
-                        if name and url:
-                            repos.append(r)
-                except Exception:
-                    pass
-                show_icon_repo_sheet(self.install_ui, repos, on_select=self._handle_repo_select)
 
-            repo_btn.setOnClickListener(OnClickListener(lambda v: show_repo_menu_handler()))
+            repo_btn.setOnClickListener(OnClickListener(lambda v: _icons_show_repo_menu(self, act)))
             self.install_ui._apply_press_scale(repo_btn)
             header_row.addView(repo_btn, FrameLayout.LayoutParams(-2, -2, Gravity.LEFT | Gravity.CENTER_VERTICAL))
 
@@ -1040,21 +1174,8 @@ def show_repo_menu_handler():
                 pass
             sort_btn.addView(sort_icon, FrameLayout.LayoutParams(AndroidUtilities.dp(20), AndroidUtilities.dp(20), Gravity.CENTER))
 
-            def show_sort_menu_handler():
-                try:
-                    imm = act.getSystemService("input_method")
-                    imm.hideSoftInputFromWindow(self.search.getWindowToken(), 0)
-                except Exception:
-                    pass
-                def on_sort_selected(sort_type):
-                    try:
-                        current_q = self.search.getText().toString() if self.search else (self.last_search_query or "")
-                    except Exception:
-                        current_q = self.last_search_query or ""
-                    self.build_list_with_sort(sort_type, current_q)
-                show_icon_sort_menu(self.install_ui, act, self.current_sort_type, on_sort_selected)
 
-            sort_btn.setOnClickListener(OnClickListener(lambda v: show_sort_menu_handler()))
+            sort_btn.setOnClickListener(OnClickListener(lambda v: _icons_open_sort_menu(self, act)))
             self.install_ui._apply_press_scale(sort_btn)
             header_row.addView(sort_btn, FrameLayout.LayoutParams(-2, -2, Gravity.RIGHT | Gravity.CENTER_VERTICAL))
 
@@ -1069,6 +1190,35 @@ def on_sort_selected(sort_type):
             except Exception:
                 pass
 
+            return main_layout, scroll, clear_btn
+
+        def _build_chrome(self):
+            act = get_last_fragment().getContext()
+            colors = self.install_ui._get_theme_colors()
+            self.main_bg_color = colors["main_bg_color"]
+            self.card_bg_color = colors["card_bg_color"]
+            self.card_pressed_color = colors["card_pressed_color"]
+            self.text_color = colors["text_color"]
+            self.secondary_text_color = colors["secondary_text_color"]
+            self.hint_text_color = colors["hint_text_color"]
+            self.cursor_color = colors["cursor_color"]
+            self.search_border_color = colors["search_border_color"]
+            self.search_stroke_width = colors["search_stroke_width"]
+
+            self.content_view = FrameLayout(act)
+            self.content_view.setBackgroundColor(self.main_bg_color)
+
+            chrome = None
+            try:
+                chrome = _icons_build_chrome_kotlin(self, act)
+            except Exception as e:
+                logx(f"icons: kotlin chrome error: {e}", False)
+            if chrome is None:
+                main_layout, scroll, clear_btn = self._build_chrome_python(act)
+            else:
+                main_layout, scroll, clear_btn = chrome
+            self.content_view.addView(main_layout, 0, FrameLayout.LayoutParams(-1, -1))
+
             self.results_container = LinearLayout(act)
             self.results_container.setOrientation(LinearLayout.VERTICAL)
             self.results_container.setPadding(0, 0, 0, AndroidUtilities.dp(10))
@@ -1189,8 +1339,9 @@ def onScrollChange(self, v, scrollX, scrollY, oldScrollX, oldScrollY):
             except Exception:
                 pass
 
-            main_layout.addView(scroll, LinearLayout.LayoutParams(-1, 0, 1.0))
-            self.search.addTextChangedListener(SearchTextWatcher(self, clear_btn))
+            if scroll.getParent() is None:
+                main_layout.addView(scroll, LinearLayout.LayoutParams(-1, 0, 1.0))
+            self.search.addTextChangedListener(_IconsSearchTextWatcher(self, clear_btn, act))
             try:
                 from ..viewUtils import applyFontToTree
                 applyFontToTree(self.content_view)
diff --git a/packit/src/ui/PluginListActivity/card.py b/packit/src/ui/PluginListActivity/card.py
index 41903529..54978947 100644
--- a/packit/src/ui/PluginListActivity/card.py
+++ b/packit/src/ui/PluginListActivity/card.py
@@ -540,6 +540,27 @@ def create_icon_pill(icon_name, handler):
         copyLinkSoundPath = None
     act_for_share = fragment.getParentActivity() if hasattr(fragment, "getParentActivity") else None
 
+    def do_install():
+        # install straight from the catalog card; stat increments happen
+        # inside the install pipeline, no manual bump here
+        if not is_available:
+            try:
+                from ui.bulletin import BulletinHelper
+                BulletinHelper.show_error(str(strings["plugin_version_below_min"]))
+            except Exception:
+                pass
+            return
+        try:
+            from ...core import install_plugin
+            install_plugin(
+                p,
+                install_ui=self.install_ui,
+                all_plugins=self.plugins,
+                rm_rid=self.repo_id or str(p.get("_repo_id") or ""),
+            )
+        except Exception as e:
+            logx(f"card: install from card error: {e}", False)
+
     def do_download_relocated():
         download_plugin_file(p)
         try:
@@ -587,6 +608,7 @@ def do_report_relocated():
     buttons.addView(spacer, LayoutHelper.createLinear(0, 0, 1.0))
 
     relocate_actions = [
+        ("_s_relocate_install",   "msg_add",       do_install),
         ("_s_relocate_copy",      "msg_copy",      do_copy_relocated),
         ("_s_relocate_share",     "msg_share",     do_share_relocated),
         ("_s_relocate_code",      "msg_view_file", do_code_relocated),
@@ -647,6 +669,7 @@ def do_report():
                     pass
 
             show_plugin_context_menu(anchor_view.getRootView(), anchor_view, [
+                {"icon": "msg_add",       "text": str(strings["msg_one_plugin_install"]), "action": do_install, "show": not getattr(self, "_s_relocate_install", False)},
                 {"icon": "msg_copy",      "text": str(strings["copy_link"]), "action": do_copy,      "show": not getattr(self, "_s_relocate_copy",      False)},
                 {"icon": "msg_share",     "text": str(strings["share"]),     "action": do_share,     "show": not getattr(self, "_s_relocate_share",     False)},
                 {"icon": "msg_view_file", "text": str(strings["code"]),      "action": do_code,      "show": not getattr(self, "_s_relocate_code",      False)},
diff --git a/packit/src/ui/PluginListActivity/fragment.py b/packit/src/ui/PluginListActivity/fragment.py
index c0490953..d07b7dad 100644
--- a/packit/src/ui/PluginListActivity/fragment.py
+++ b/packit/src/ui/PluginListActivity/fragment.py
@@ -2,6 +2,7 @@
 # SPDX-License-Identifier: GPL-3.0-or-later
 
 from packutil import logx
+from ...utils.netQueue import run_io
 import re
 import json
 import threading
@@ -295,7 +296,7 @@ def load_task():
                 run_on_ui_thread(lambda: self._update_current_fragment_plugins([]))
             finally:
                 self._reload_in_flight.discard(reload_key)
-        run_on_queue(load_task)
+        run_io(load_task)
 
     def _open_all_repos_plugins(self):
         fragment = get_last_fragment()
@@ -408,7 +409,8 @@ def __init__(self, install_ui, title, plugins, show_loading_initial=False, repo_
             self.repo_id = repo_id
             self.plugins = _filter_unavailable(plugins)
             self.show_loading_initial = show_loading_initial
-            self.search_index = search_mod.build_index(self.plugins)
+            # skip the pointless empty-index build (json+dlopen on the click path)
+            self.search_index = search_mod.build_index(self.plugins) if self.plugins else None
             self.last_search_query = None
             self.filtered_plugins = []
             self.visible_plugins = []
@@ -522,8 +524,32 @@ def _handle_repo_select(self, selected):
                 self.install_ui._open_repo_plugins(selected)
 
         def beforeCreateView(self):
+            # light shell now, heavy chrome a few frames later: build_list_view
+            # makes hundreds of python->java calls and used to block the UI
+            # thread, freezing the fragment open animation for ~half a second.
+            # Data arriving before the chrome is already handled by the
+            # _data_ready_before_view flag consumed inside build_list_view.
             from . import listView as _lv
-            return _lv.build_list_view(self)
+            from android_utils import run_on_ui_thread
+            act = get_last_fragment().getContext()
+            try:
+                bg = self.install_ui._get_theme_colors()["main_bg_color"]
+            except Exception:
+                from org.telegram.ui.ActionBar import Theme
+                bg = Theme.getColor(Theme.key_windowBackgroundGray)
+            shell = FrameLayout(act)
+            shell.setBackgroundColor(bg)
+
+            def _deferred():
+                try:
+                    view = _lv.build_list_view(self)
+                    if view is not None:
+                        shell.addView(view, FrameLayout.LayoutParams(-1, -1))
+                except Exception as e:
+                    logx(f"InstallUI: deferred build_list_view error: {e}", False)
+            # let the open animation start smoothly before the heavy build
+            run_on_ui_thread(_deferred, 30)
+            return shell
 
         def getTitle(self):
             return self.title
@@ -596,6 +622,7 @@ def _cache_settings(self):
             self._s_chip_deps_size = float(settings.get("chip_deps_size", 11))
             self._s_chip_size_size = float(settings.get("chip_size_size", 11))
             self._s_fuzzy_search = settings.get("fuzzy_search", False)
+            self._s_relocate_install = settings.get("relocate_install", False)
             self._s_relocate_copy = settings.get("relocate_copy_link", False)
             self._s_relocate_share = settings.get("relocate_share", False)
             self._s_relocate_code = settings.get("relocate_code", False)
diff --git a/packit/src/ui/PluginListActivity/listView.py b/packit/src/ui/PluginListActivity/listView.py
index 0cd31021..1c95d5bb 100644
--- a/packit/src/ui/PluginListActivity/listView.py
+++ b/packit/src/ui/PluginListActivity/listView.py
@@ -40,36 +40,329 @@
 from .helpers.utils import _build_plugin_count_label
 
 
-def build_list_view(self) -> View:
-    logx(f"InstallUI: build_list_view enter id={id(self)} show_loading_initial={self.show_loading_initial} loading_container={getattr(self, 'loading_container', None) is not None} data_ready_before_view={getattr(self, '_data_ready_before_view', False)}", True)
-    # save scroll position before rebuilding view
-    _saved_scroll_y = 0
+
+def _perform_search(self, act):
     try:
-        if hasattr(self, '_scroll_view') and self._scroll_view:
-            _saved_scroll_y = self._scroll_view.getScrollY()
+        query = self.search.getText().toString()
+        if query != self.last_search_query:
+            self.last_search_query = query
+            self.build_list(query)
+        imm = act.getSystemService("input_method")
+        imm.hideSoftInputFromWindow(self.search.getWindowToken(), 0)
     except Exception:
         pass
 
-    act = get_last_fragment().getContext()
-    colors = self.install_ui._get_theme_colors()
-    self.main_bg_color = colors["main_bg_color"]
-    self.card_bg_color = colors["card_bg_color"]
-    self.card_pressed_color = colors["card_pressed_color"]
-    self.text_color = colors["text_color"]
-    self.secondary_text_color = colors["secondary_text_color"]
-    self.hint_text_color = colors["hint_text_color"]
-    self.cursor_color = colors["cursor_color"]
-    self.search_border_color = colors["search_border_color"]
-    self.search_stroke_width = colors["search_stroke_width"]
 
-    self.content_view = FrameLayout(act)
-    self.content_view.setBackgroundColor(self.main_bg_color)
-    from ...ui.AchievementsActivity.service.AchivementsEngine import register_bulletin_container
-    register_bulletin_container(self.content_view)
+class _SearchTextWatcherWithClear(dynamic_proxy(TextWatcher)):
+    def __init__(self, outer, clear_btn_ref):
+        super().__init__()
+        self.outer = outer
+        self.clear_btn = clear_btn_ref
+        self._live_timer = None
+
+    def _show_live_spinner(self):
+        try:
+            if getattr(self.outer, '_live_search_spinner', None) is None:
+                spinner_container, spinner_view = self.outer.install_ui._create_center_loading_animation(self.outer.content_view)
+                if spinner_container is None:
+                    return
+                self.outer._live_search_spinner = spinner_container
+                self.outer._live_search_spinner_view = spinner_view
+                self.outer.content_view.addView(spinner_container, FrameLayout.LayoutParams(-1, -1))
+            else:
+                self.outer._live_search_spinner.setAlpha(1.0)
+                self.outer._live_search_spinner.setVisibility(View.VISIBLE)
+            # hide cards while spinner is shown
+            try:
+                self.outer.results_container.setVisibility(View.INVISIBLE)
+            except Exception:
+                pass
+        except Exception:
+            pass
+
+    def _hide_live_spinner(self):
+        try:
+            spinner = getattr(self.outer, '_live_search_spinner', None)
+            if spinner is None:
+                return
+            spinner.animate().alpha(0.0).setDuration(150).withEndAction(
+                lambda: spinner.setVisibility(View.GONE)
+            ).start()
+        except Exception:
+            pass
+        try:
+            self.outer.results_container.setVisibility(View.VISIBLE)
+        except Exception:
+            pass
+
+    def _schedule_live_search(self, query):
+        # cancel previous pending timer
+        prev = self._live_timer
+        if prev is not None:
+            try:
+                prev.cancel()
+            except Exception:
+                pass
+        outer = self.outer
+
+        def _do_search():
+            def _ui():
+                try:
+                    if query != outer.last_search_query:
+                        outer.last_search_query = query
+                        outer.build_list(query)
+                    self._hide_live_spinner()
+                except Exception:
+                    pass
+            run_on_ui_thread(_ui)
+
+        t = threading.Timer(0.3, _do_search)
+        self._live_timer = t
+        t.start()
+
+    def afterTextChanged(self, s):
+        text = s.toString()
+        if text and len(text) > 0:
+            self.clear_btn.setVisibility(View.VISIBLE)
+            try:
+                self.clear_btn.animate().alpha(1.0).setDuration(200).start()
+            except Exception:
+                pass
+        else:
+            try:
+                self.clear_btn.animate().alpha(0.0).setDuration(200).withEndAction(
+                    lambda: self.clear_btn.setVisibility(View.GONE)).start()
+            except Exception:
+                self.clear_btn.setVisibility(View.GONE)
+        try:
+            from elyx import settings as _s
+            if _s.get("live_search", True) and not getattr(self.outer, "_ai_result_active", False):
+                self._show_live_spinner()
+                self._schedule_live_search(text)
+        except Exception:
+            pass
+
+    def beforeTextChanged(self, s, start, count, after):
+        pass
+    def onTextChanged(self, s, start, before, count):
+        pass
+
+
+def _on_clear_click(self, act):
+    try:
+        from elyx import assets
+        playSound(assets.sounds.clear_search.path_str, "sfx_clear_search")
+    except Exception:
+        pass
+    try:
+        self._ai_result_active = False
+        self._ai_result_plugins = []
+        self.search.setText("")
+        self.last_search_query = ""
+        self.build_list("")
+        imm = act.getSystemService("input_method")
+        imm.hideSoftInputFromWindow(self.search.getWindowToken(), 0)
+    except Exception:
+        pass
+
+
+def _on_search_btn_click(self, act):
+    try:
+        from elyx import assets
+        playSound(assets.sounds.search_btn.path_str, "sfx_search")
+    except Exception:
+        pass
+    _perform_search(self, act)
+
+
+def _on_ai_pill_click(self, act):
+    try:
+        def _on_ai_results(names, query):
+            # set search field text and filter list by AI-returned plugin names
+            try:
+                ai_marker = "%ai response%"
+                # filter visible plugins to only those returned by AI, preserving order
+                name_set = set(n.lower() for n in names)
+                ordered = []
+                for name in names:
+                    for p in self.plugins:
+                        pname = str(p.get("name") or p.get("id") or "").strip()
+                        if pname.lower() == name.lower():
+                            ordered.append(p)
+                            break
+                # fallback: include any plugin whose name is in name_set but not yet matched
+                matched_names = set(str(p.get("name") or p.get("id") or "").strip().lower() for p in ordered)
+                for p in self.plugins:
+                    pname = str(p.get("name") or p.get("id") or "").strip().lower()
+                    if pname in name_set and pname not in matched_names:
+                        ordered.append(p)
+                        matched_names.add(pname)
+                self._ai_result_plugins = ordered
+                # set flag before setText so watcher skips live search
+                self._ai_result_active = True
+                self.last_search_query = ai_marker
+                self.search.setText(ai_marker)
+                self.filtered_plugins = ordered
+                self.visible_plugins = []
+                self.lazy_load_queue = deque()
+                self.results_container.removeAllViews()
+                if hasattr(self, "subtitle"):
+                    total = len(self.plugins)
+                    self.subtitle.setText(f"{len(ordered)}/{_build_plugin_count_label(total)}")
+                self._load_initial_batch()
+            except Exception as e:
+                logx(f"listView: on_ai_results error: {e}", False)
+
+        show_ai_search_sheet(self.install_ui, act, on_ai_results=_on_ai_results)
+    except Exception as e:
+        logx(f"listView: ai search sheet error: {e}", False)
+
+
+def _show_tag_filter(self, act):
+    try:
+        imm = act.getSystemService("input_method")
+        imm.hideSoftInputFromWindow(self.search.getWindowToken(), 0)
+    except Exception:
+        pass
+    def on_apply(tags, authors, app_versions, saved):
+        try:
+            self.selected_tags = tags
+            self.selected_authors = authors
+            self.selected_app_versions = app_versions
+            self.selected_saved = saved
+            current_q = self.search.getText().toString() if self.search else (self.last_search_query or "")
+            self.build_list_with_sort(self.current_sort_type, current_q)
+        except Exception:
+            pass
+    self._active_drawer = show_tag_drawer(act, self.content_view, self.plugins, self.selected_tags, on_apply,
+                                          self.selected_authors, self.selected_app_versions, self.selected_saved)
+
+
+def _open_sort_menu(self, act):
+    try:
+        imm = act.getSystemService("input_method")
+        imm.hideSoftInputFromWindow(self.search.getWindowToken(), 0)
+    except Exception:
+        pass
+    def on_sort_selected(sort_type):
+        try:
+            current_q = self.search.getText().toString() if self.search else (self.last_search_query or "")
+        except Exception:
+            current_q = self.last_search_query or ""
+        self.build_list_with_sort(sort_type, current_q)
+    show_sort_menu(self.install_ui, act, self.current_sort_type, on_sort_selected)
+
+
+
+def _make_editor_action_listener(outer, act):
+    EditActionListener = find_class("android.widget.TextView$OnEditorActionListener")
+
+    class _L(dynamic_proxy(EditActionListener)):
+        def __init__(self):
+            super().__init__()
+
+        def onEditorAction(self, v, actionId, event):
+            if actionId == EditorInfo.IME_ACTION_SEARCH or actionId == EditorInfo.IME_ACTION_DONE or actionId == 6 or actionId == 3:
+                _perform_search(outer, act)
+                return True
+            return False
+
+    return _L()
+
+
+def _build_chrome_kotlin(self, act):
+    # java-side chrome skeleton (kawaii.packetik.catalog.CatalogChromeNative):
+    # the same tree costs hundreds of bridge calls from python. Returns
+    # (main_layout, scroll, clear_btn) or None -> python fallback below.
+    from ...dexLoader import catalogChromeCreate
+    from elyx import settings as _s
+    live_search = bool(_s.get("live_search", True))
+    try:
+        accent = Theme.getColor(Theme.key_featuredStickers_addButton)
+        accent_pressed = Theme.getColor(Theme.key_featuredStickers_addButtonPressed)
+        button_text = Theme.getColor(Theme.key_featuredStickers_buttonText)
+    except Exception:
+        accent = Theme.getColor(Theme.key_dialogTextBlue)
+        accent_pressed = accent
+        button_text = -1
+    try:
+        bold = AndroidUtilities.bold()
+    except Exception:
+        bold = None
+    subtitle_text = _build_plugin_count_label(len(self.plugins)) if self.plugins else str(strings["total_plugins_unknown"])
+    main_layout = catalogChromeCreate(
+        act,
+        self.main_bg_color, self.card_bg_color, self.card_pressed_color, self.text_color,
+        accent, accent_pressed, button_text,
+        self.install_ui._resolve_icon("input_clear"),
+        self.install_ui._resolve_icon("ic_ab_search"),
+        self.install_ui._resolve_icon("msg_search"),
+        self.install_ui._resolve_icon("msg_list"),
+        self.install_ui._resolve_icon("msg_topics"),
+        "AI", subtitle_text, (not live_search), bold,
+    )
+    if main_layout is None:
+        return None
+    search_slot = main_layout.findViewWithTag("search_slot")
+    clear_btn = main_layout.findViewWithTag("clear_btn")
+    search_btn = main_layout.findViewWithTag("search_btn")
+    ai_pill = main_layout.findViewWithTag("ai_pill")
+    subtitle = main_layout.findViewWithTag("subtitle")
+    tag_filter_btn = main_layout.findViewWithTag("tag_filter_btn")
+    sort_btn = main_layout.findViewWithTag("sort_btn")
+    scroll = main_layout.findViewWithTag("scroll")
+    if None in (search_slot, clear_btn, search_btn, ai_pill, subtitle, tag_filter_btn, sort_btn, scroll):
+        logx("listView: kotlin chrome missing tagged views, falling back", False)
+        return None
+
+    # the only telegram-specific view is created here on the python side
+    self.search = EditTextBoldCursor(act)
+    self.search.setHint(strings["search_hint"])
+    self.search.setTextSize(TypedValue.COMPLEX_UNIT_DIP, 15)
+    self.search.setSingleLine(True)
+    self.search.setInputType(InputType.TYPE_CLASS_TEXT)
+    self.search.setBackgroundColor(0)
+    self.search.setTextColor(self.text_color)
+    try:
+        self.search.setHintTextColor(self.hint_text_color)
+    except Exception:
+        pass
+    try:
+        self.search.setCursorColor(self.cursor_color)
+    except Exception:
+        pass
+    try:
+        self.search.setPadding(AndroidUtilities.dp(8), AndroidUtilities.dp(8), AndroidUtilities.dp(10), AndroidUtilities.dp(8))
+    except Exception:
+        pass
+    try:
+        self.search.setOnEditorActionListener(_make_editor_action_listener(self, act))
+    except Exception:
+        pass
+    search_slot.addView(self.search, FrameLayout.LayoutParams(-1, -1))
+
+    clear_btn.setOnClickListener(OnClickListener(lambda v: _on_clear_click(self, act)))
+    self.install_ui._apply_press_scale(clear_btn)
+    search_btn.setOnClickListener(OnClickListener(lambda v: _on_search_btn_click(self, act)))
+    self.install_ui._apply_press_scale(search_btn)
+    ai_pill.setOnClickListener(OnClickListener(lambda v: _on_ai_pill_click(self, act)))
+    self.install_ui._apply_press_scale(ai_pill)
+    tag_filter_btn.setOnClickListener(OnClickListener(lambda v: _show_tag_filter(self, act)))
+    self.install_ui._apply_press_scale(tag_filter_btn)
+    sort_btn.setOnClickListener(OnClickListener(lambda v: _open_sort_menu(self, act)))
+    self.install_ui._apply_press_scale(sort_btn)
+
+    self.subtitle = subtitle
+    self._scroll_view = scroll
+    logx("listView: chrome built via kotlin dex", True)
+    return (main_layout, scroll, clear_btn)
+
+
+def _build_chrome_python(self, act):
+    # original python chrome builder, kept as the fallback path
     main_layout = LinearLayout(act)
     main_layout.setOrientation(LinearLayout.VERTICAL)
     main_layout.setPadding(AndroidUtilities.dp(16), 0, AndroidUtilities.dp(16), AndroidUtilities.dp(14))
-    self.content_view.addView(main_layout, FrameLayout.LayoutParams(-1, -1))
 
     search_container = FrameLayout(act)
     pill = GradientDrawable()
@@ -109,125 +402,12 @@ def build_list_view(self) -> View:
     except Exception:
         pass
 
-    def perform_search():
-        try:
-            query = self.search.getText().toString()
-            if query != self.last_search_query:
-                self.last_search_query = query
-                self.build_list(query)
-            imm = act.getSystemService("input_method")
-            imm.hideSoftInputFromWindow(self.search.getWindowToken(), 0)
-        except Exception:
-            pass
 
     try:
-        EditActionListener = find_class("android.widget.TextView$OnEditorActionListener")
-        class SearchEditorActionListener(dynamic_proxy(EditActionListener)):
-            def __init__(self, outer):
-                super().__init__()
-                self.outer = outer
-            def onEditorAction(self, v, actionId, event):
-                if actionId == EditorInfo.IME_ACTION_SEARCH or actionId == EditorInfo.IME_ACTION_DONE or actionId == 6 or actionId == 3:
-                    perform_search()
-                    return True
-                return False
-        self.search.setOnEditorActionListener(SearchEditorActionListener(self))
+        self.search.setOnEditorActionListener(_make_editor_action_listener(self, act))
     except Exception:
         pass
 
-    class SearchTextWatcherWithClear(dynamic_proxy(TextWatcher)):
-        def __init__(self, outer, clear_btn_ref):
-            super().__init__()
-            self.outer = outer
-            self.clear_btn = clear_btn_ref
-            self._live_timer = None
-
-        def _show_live_spinner(self):
-            try:
-                if getattr(self.outer, '_live_search_spinner', None) is None:
-                    spinner_container, spinner_view = self.outer.install_ui._create_center_loading_animation(self.outer.content_view)
-                    if spinner_container is None:
-                        return
-                    self.outer._live_search_spinner = spinner_container
-                    self.outer._live_search_spinner_view = spinner_view
-                    self.outer.content_view.addView(spinner_container, FrameLayout.LayoutParams(-1, -1))
-                else:
-                    self.outer._live_search_spinner.setAlpha(1.0)
-                    self.outer._live_search_spinner.setVisibility(View.VISIBLE)
-                # hide cards while spinner is shown
-                try:
-                    self.outer.results_container.setVisibility(View.INVISIBLE)
-                except Exception:
-                    pass
-            except Exception:
-                pass
-
-        def _hide_live_spinner(self):
-            try:
-                spinner = getattr(self.outer, '_live_search_spinner', None)
-                if spinner is None:
-                    return
-                spinner.animate().alpha(0.0).setDuration(150).withEndAction(
-                    lambda: spinner.setVisibility(View.GONE)
-                ).start()
-            except Exception:
-                pass
-            try:
-                self.outer.results_container.setVisibility(View.VISIBLE)
-            except Exception:
-                pass
-
-        def _schedule_live_search(self, query):
-            # cancel previous pending timer
-            prev = self._live_timer
-            if prev is not None:
-                try:
-                    prev.cancel()
-                except Exception:
-                    pass
-            outer = self.outer
-
-            def _do_search():
-                def _ui():
-                    try:
-                        if query != outer.last_search_query:
-                            outer.last_search_query = query
-                            outer.build_list(query)
-                        self._hide_live_spinner()
-                    except Exception:
-                        pass
-                run_on_ui_thread(_ui)
-
-            t = threading.Timer(0.3, _do_search)
-            self._live_timer = t
-            t.start()
-
-        def afterTextChanged(self, s):
-            text = s.toString()
-            if text and len(text) > 0:
-                self.clear_btn.setVisibility(View.VISIBLE)
-                try:
-                    self.clear_btn.animate().alpha(1.0).setDuration(200).start()
-                except Exception:
-                    pass
-            else:
-                try:
-                    self.clear_btn.animate().alpha(0.0).setDuration(200).withEndAction(
-                        lambda: self.clear_btn.setVisibility(View.GONE)).start()
-                except Exception:
-                    self.clear_btn.setVisibility(View.GONE)
-            try:
-                from elyx import settings as _s
-                if _s.get("live_search", True) and not getattr(self.outer, "_ai_result_active", False):
-                    self._show_live_spinner()
-                    self._schedule_live_search(text)
-            except Exception:
-                pass
-
-        def beforeTextChanged(self, s, start, count, after):
-            pass
-        def onTextChanged(self, s, start, before, count):
-            pass
 
     search_row = LinearLayout(act)
     search_row.setOrientation(LinearLayout.HORIZONTAL)
@@ -251,24 +431,8 @@ def onTextChanged(self, s, start, before, count):
     clear_btn_icon.setScaleType(ImageView.ScaleType.CENTER)
     clear_btn.addView(clear_btn_icon, FrameLayout.LayoutParams(AndroidUtilities.dp(20), AndroidUtilities.dp(20), Gravity.CENTER))
 
-    def on_clear_click():
-        try:
-            from elyx import assets
-            playSound(assets.sounds.clear_search.path_str, "sfx_clear_search")
-        except Exception:
-            pass
-        try:
-            self._ai_result_active = False
-            self._ai_result_plugins = []
-            self.search.setText("")
-            self.last_search_query = ""
-            self.build_list("")
-            imm = act.getSystemService("input_method")
-            imm.hideSoftInputFromWindow(self.search.getWindowToken(), 0)
-        except Exception:
-            pass
 
-    clear_btn.setOnClickListener(OnClickListener(lambda v: on_clear_click()))
+    clear_btn.setOnClickListener(OnClickListener(lambda v: _on_clear_click(self, act)))
     self.install_ui._apply_press_scale(clear_btn)
     clear_btn.setVisibility(View.GONE)
     clear_btn.setAlpha(0.0)
@@ -297,15 +461,8 @@ def on_clear_click():
     search_btn_icon.setScaleType(ImageView.ScaleType.CENTER)
     search_btn.addView(search_btn_icon, FrameLayout.LayoutParams(AndroidUtilities.dp(20), AndroidUtilities.dp(20), Gravity.CENTER))
 
-    def onSearchBtnClick(v):
-        try:
-            from elyx import assets
-            playSound(assets.sounds.search_btn.path_str, "sfx_search")
-        except Exception:
-            pass
-        perform_search()
 
-    search_btn.setOnClickListener(OnClickListener(onSearchBtnClick))
+    search_btn.setOnClickListener(OnClickListener(lambda v: _on_search_btn_click(self, act)))
     self.install_ui._apply_press_scale(search_btn)
     try:
         from elyx import settings as _s
@@ -361,49 +518,8 @@ def onSearchBtnClick(v):
     ai_pill_lp = FrameLayout.LayoutParams(-2, -2, Gravity.LEFT | Gravity.CENTER_VERTICAL)
     header_row.addView(ai_pill, ai_pill_lp)
 
-    def on_ai_pill_click(v):
-        try:
-            def _on_ai_results(names, query):
-                # set search field text and filter list by AI-returned plugin names
-                try:
-                    ai_marker = "%ai response%"
-                    # filter visible plugins to only those returned by AI, preserving order
-                    name_set = set(n.lower() for n in names)
-                    ordered = []
-                    for name in names:
-                        for p in self.plugins:
-                            pname = str(p.get("name") or p.get("id") or "").strip()
-                            if pname.lower() == name.lower():
-                                ordered.append(p)
-                                break
-                    # fallback: include any plugin whose name is in name_set but not yet matched
-                    matched_names = set(str(p.get("name") or p.get("id") or "").strip().lower() for p in ordered)
-                    for p in self.plugins:
-                        pname = str(p.get("name") or p.get("id") or "").strip().lower()
-                        if pname in name_set and pname not in matched_names:
-                            ordered.append(p)
-                            matched_names.add(pname)
-                    self._ai_result_plugins = ordered
-                    # set flag before setText so watcher skips live search
-                    self._ai_result_active = True
-                    self.last_search_query = ai_marker
-                    self.search.setText(ai_marker)
-                    self.filtered_plugins = ordered
-                    self.visible_plugins = []
-                    self.lazy_load_queue = deque()
-                    self.results_container.removeAllViews()
-                    if hasattr(self, "subtitle"):
-                        total = len(self.plugins)
-                        self.subtitle.setText(f"{len(ordered)}/{_build_plugin_count_label(total)}")
-                    self._load_initial_batch()
-                except Exception as e:
-                    logx(f"listView: on_ai_results error: {e}", False)
-
-            show_ai_search_sheet(self.install_ui, act, on_ai_results=_on_ai_results)
-        except Exception as e:
-            logx(f"listView: ai search sheet error: {e}", False)
-
-    ai_pill.setOnClickListener(OnClickListener(on_ai_pill_click))
+
+    ai_pill.setOnClickListener(OnClickListener(lambda v: _on_ai_pill_click(self, act)))
     self.install_ui._apply_press_scale(ai_pill)
 
     subtitle = TextView(act)
@@ -447,26 +563,8 @@ def _on_ai_results(names, query):
         pass
     tag_filter_btn.addView(tag_filter_icon, FrameLayout.LayoutParams(AndroidUtilities.dp(20), AndroidUtilities.dp(20), Gravity.CENTER))
 
-    def show_tag_filter_handler():
-        try:
-            imm = act.getSystemService("input_method")
-            imm.hideSoftInputFromWindow(self.search.getWindowToken(), 0)
-        except Exception:
-            pass
-        def on_apply(tags, authors, app_versions, saved):
-            try:
-                self.selected_tags = tags
-                self.selected_authors = authors
-                self.selected_app_versions = app_versions
-                self.selected_saved = saved
-                current_q = self.search.getText().toString() if self.search else (self.last_search_query or "")
-                self.build_list_with_sort(self.current_sort_type, current_q)
-            except Exception:
-                pass
-        self._active_drawer = show_tag_drawer(act, self.content_view, self.plugins, self.selected_tags, on_apply,
-                                              self.selected_authors, self.selected_app_versions, self.selected_saved)
 
-    tag_filter_btn.setOnClickListener(OnClickListener(lambda v: show_tag_filter_handler()))
+    tag_filter_btn.setOnClickListener(OnClickListener(lambda v: _show_tag_filter(self, act)))
     self.install_ui._apply_press_scale(tag_filter_btn)
     tag_filter_btn_lp = FrameLayout.LayoutParams(-2, -2, Gravity.RIGHT | Gravity.CENTER_VERTICAL)
     tag_filter_btn_lp.rightMargin = AndroidUtilities.dp(40)
@@ -491,21 +589,8 @@ def on_apply(tags, authors, app_versions, saved):
         pass
     sort_btn.addView(sort_icon, FrameLayout.LayoutParams(AndroidUtilities.dp(20), AndroidUtilities.dp(20), Gravity.CENTER))
 
-    def show_sort_menu_handler():
-        try:
-            imm = act.getSystemService("input_method")
-            imm.hideSoftInputFromWindow(self.search.getWindowToken(), 0)
-        except Exception:
-            pass
-        def on_sort_selected(sort_type):
-            try:
-                current_q = self.search.getText().toString() if self.search else (self.last_search_query or "")
-            except Exception:
-                current_q = self.last_search_query or ""
-            self.build_list_with_sort(sort_type, current_q)
-        show_sort_menu(self.install_ui, act, self.current_sort_type, on_sort_selected)
 
-    sort_btn.setOnClickListener(OnClickListener(lambda v: show_sort_menu_handler()))
+    sort_btn.setOnClickListener(OnClickListener(lambda v: _open_sort_menu(self, act)))
     self.install_ui._apply_press_scale(sort_btn)
     sort_btn_lp = FrameLayout.LayoutParams(-2, -2, Gravity.RIGHT | Gravity.CENTER_VERTICAL)
     header_row.addView(sort_btn, sort_btn_lp)
@@ -522,6 +607,46 @@ def on_sort_selected(sort_type):
     except Exception:
         pass
 
+    return main_layout, scroll, clear_btn
+
+
+def build_list_view(self) -> View:
+    logx(f"InstallUI: build_list_view enter id={id(self)} show_loading_initial={self.show_loading_initial} loading_container={getattr(self, 'loading_container', None) is not None} data_ready_before_view={getattr(self, '_data_ready_before_view', False)}", True)
+    # save scroll position before rebuilding view
+    _saved_scroll_y = 0
+    try:
+        if hasattr(self, '_scroll_view') and self._scroll_view:
+            _saved_scroll_y = self._scroll_view.getScrollY()
+    except Exception:
+        pass
+
+    act = get_last_fragment().getContext()
+    colors = self.install_ui._get_theme_colors()
+    self.main_bg_color = colors["main_bg_color"]
+    self.card_bg_color = colors["card_bg_color"]
+    self.card_pressed_color = colors["card_pressed_color"]
+    self.text_color = colors["text_color"]
+    self.secondary_text_color = colors["secondary_text_color"]
+    self.hint_text_color = colors["hint_text_color"]
+    self.cursor_color = colors["cursor_color"]
+    self.search_border_color = colors["search_border_color"]
+    self.search_stroke_width = colors["search_stroke_width"]
+
+    self.content_view = FrameLayout(act)
+    self.content_view.setBackgroundColor(self.main_bg_color)
+    from ...ui.AchievementsActivity.service.AchivementsEngine import register_bulletin_container
+    register_bulletin_container(self.content_view)
+    chrome = None
+    try:
+        chrome = _build_chrome_kotlin(self, act)
+    except Exception as e:
+        logx(f"listView: kotlin chrome error: {e}", False)
+    if chrome is None:
+        main_layout, scroll, clear_btn = _build_chrome_python(self, act)
+    else:
+        main_layout, scroll, clear_btn = chrome
+    self.content_view.addView(main_layout, FrameLayout.LayoutParams(-1, -1))
+
     if self.show_loading_initial:
         content_wrapper = FrameLayout(act)
         content_wrapper.setLayoutParams(ScrollView.LayoutParams(-1, -2))
@@ -802,13 +927,14 @@ def onScrollChange(self, v, scrollX, scrollY, oldScrollX, oldScrollY):
         self.results_container = LinearLayout(act)
         self.results_container.setOrientation(LinearLayout.VERTICAL)
         self.results_container.setPadding(0, 0, 0, AndroidUtilities.dp(10))
-    main_layout.addView(scroll, LinearLayout.LayoutParams(-1, 0, 1.0))
+    if scroll.getParent() is None:
+        main_layout.addView(scroll, LinearLayout.LayoutParams(-1, 0, 1.0))
 
     if _saved_scroll_y > 0:
         _y = _saved_scroll_y
         run_on_ui_thread(lambda: scroll.scrollTo(0, _y))
 
-    self.search.addTextChangedListener(SearchTextWatcherWithClear(self, clear_btn))
+    self.search.addTextChangedListener(_SearchTextWatcherWithClear(self, clear_btn))
     try:
         from ..viewUtils import applyFontToTree
         applyFontToTree(self.content_view)
diff --git a/packit/src/ui/pluginsUpdates/fragment.py b/packit/src/ui/pluginsUpdates/fragment.py
index 827eb229..060bbc30 100644
--- a/packit/src/ui/pluginsUpdates/fragment.py
+++ b/packit/src/ui/pluginsUpdates/fragment.py
@@ -2,6 +2,7 @@
 # SPDX-License-Identifier: GPL-3.0-or-later
 
 from packutil import logx
+from ...utils.netQueue import run_io
 import json
 import os
 import threading
@@ -1015,7 +1016,7 @@ def on_done():
                 logx(f"pluginsUpdates: task error: {e}", False)
                 run_on_ui_thread(lambda: (setattr(self, '_is_loading', False), self._show_empty(str(strings["updates_failed_to_check"]), "error", title=str(strings["updates_error_title"]))) if alive[0] else None)
 
-        run_on_queue(task)
+        run_io(task)
 
     def _hide_spinner(self):
         try:
@@ -1511,7 +1512,7 @@ def on_ui():
                 except Exception as e:
                     logx(f"pluginsUpdates: _open_plugin_profile task error: {e}", False)
 
-            run_on_queue(task)
+            run_io(task)
         except Exception as e:
             logx(f"pluginsUpdates: _open_plugin_profile error: {e}", False)
 
@@ -1674,7 +1675,7 @@ def on_installed(installed_pid):
                 logx(f"pluginsUpdates: _install_update task error: {e}", False)
                 run_on_ui_thread(lambda: set_btn_state("idle"))
 
-        run_on_queue(task)
+        run_io(task)
 
     def _on_plugin_done(self):
         self._done_count[0] += 1
@@ -2191,7 +2192,7 @@ def on_error(error):
                 if on_done:
                     on_done()
 
-        run_on_queue(task)
+        run_io(task)
 
     def _on_update_all_click(self):
         if self._is_loading:
diff --git a/packit/src/utils/netQueue.py b/packit/src/utils/netQueue.py
new file mode 100644
index 00000000..ed26e489
--- /dev/null
+++ b/packit/src/utils/netQueue.py
@@ -0,0 +1,58 @@
+# pyright: reportMissingImports=false
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+# PackIt's own executors for network I/O. Historically these tasks went
+# through client_utils.run_on_queue — the HOST's plugins DispatchQueue, the
+# same one PluginsController uses to open any plugin settings screen. One
+# slow fetch (github raw timing out at 10-20s) stalled that queue and froze
+# every PackIt entry point until the timeout fired.
+#
+# run_io: small pool for independent fetches (catalogs, widgets, update
+# checks) — parallel, unordered.
+# run_serial_io: single lane for RepositoryManager tasks, preserving their
+# previous relative ordering (they read/write the repo cache files).
+
+import threading
+
+from packutil import logx
+
+_IO_WORKERS = 3
+
+_io_queue = None
+_serial_queue = None
+_lock = threading.Lock()
+
+
+def _start_workers(q, count, name):
+    def _worker():
+        while True:
+            fn = q.get()
+            try:
+                fn()
+            except Exception as e:
+                logx(f"netQueue: {name} task error: {e}", False)
+            finally:
+                q.task_done()
+
+    for _ in range(count):
+        threading.Thread(target=_worker, daemon=True).start()
+
+
+def run_io(task):
+    global _io_queue
+    with _lock:
+        if _io_queue is None:
+            import queue
+            _io_queue = queue.Queue()
+            _start_workers(_io_queue, _IO_WORKERS, "io")
+    _io_queue.put(task)
+
+
+def run_serial_io(task):
+    global _serial_queue
+    with _lock:
+        if _serial_queue is None:
+            import queue
+            _serial_queue = queue.Queue()
+            _start_workers(_serial_queue, 1, "serial")
+    _serial_queue.put(task)
diff --git a/scripts/linux/kotlin-build.sh b/scripts/linux/kotlin-build.sh
index 5e0f8a32..57cd99b2 100644
--- a/scripts/linux/kotlin-build.sh
+++ b/scripts/linux/kotlin-build.sh
@@ -25,6 +25,7 @@ MIN_API=26
 PACKAGES=(
   "badges=kawaii.packetik.badges.BadgesNative"
   "openfile=kawaii.packetik.openfile.OpenFileNative"
+  "catalog=kawaii.packetik.catalog.CatalogChromeNative"
 )
 
 die() { echo "error: $*" >&2; exit 1; }
