commit a3b0b264c110f7245cfa37c81c8cb42afff223ef
Author: exteraGramPlugins <>
Date: Sat Sep 12 02:03:47 2026 +0000
Initial commit
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f5806f9
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,179 @@
+# ---> Python
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+cover/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+.pybuilder/
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+# For a library or package, you might want to ignore these files since the code is
+# intended to run in multiple environments; otherwise, check them in:
+# .python-version
+
+# pipenv
+# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+# However, in case of collaboration, if having platform-specific dependencies or dependencies
+# having no cross-platform support, pipenv may install dependencies that don't work, or not
+# install all needed dependencies.
+#Pipfile.lock
+
+# UV
+# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
+# This is especially recommended for binary packages to ensure reproducibility, and is more
+# commonly ignored for libraries.
+#uv.lock
+
+# poetry
+# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
+# This is especially recommended for binary packages to ensure reproducibility, and is more
+# commonly ignored for libraries.
+# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
+#poetry.lock
+
+# pdm
+# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
+#pdm.lock
+# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
+# in version control.
+# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
+.pdm.toml
+.pdm-python
+.pdm-build/
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
+
+# PyCharm
+# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
+# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
+# and can be added to the global gitignore or merged into this file. For a more nuclear
+# option (not recommended) you can uncomment the following to ignore the entire idea folder.
+#.idea/
+
+# Ruff stuff:
+.ruff_cache/
+
+# PyPI configuration file
+.pypirc
+
+# exteraGram plugin toolchain
+/stubs/
+/.venv/
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..30cf57e
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,10 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Ignored default folder with query files
+/queries/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/.idea/exteraGramPluginBase.iml b/.idea/exteraGramPluginBase.iml
new file mode 100644
index 0000000..d0876a7
--- /dev/null
+++ b/.idea/exteraGramPluginBase.iml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
new file mode 100644
index 0000000..348ee26
--- /dev/null
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml
new file mode 100644
index 0000000..105ce2d
--- /dev/null
+++ b/.idea/inspectionProfiles/profiles_settings.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 0000000..104c42f
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000..9224024
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..35eb1dd
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 0000000..5ae06a6
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,15 @@
+{
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "name": "exteraGram plugin: attach debugpy",
+ "type": "debugpy",
+ "request": "attach",
+ "connect": {
+ "host": "127.0.0.1",
+ "port": 5678
+ },
+ "justMyCode": false
+ }
+ ]
+}
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..4f61354
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,18 @@
+MIT License
+
+Copyright (c) 2026 exteraGramPlugins
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
+associated documentation files (the "Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
+following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial
+portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
+LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
+EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
+USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..1418811
--- /dev/null
+++ b/README.md
@@ -0,0 +1,101 @@
+# exteraGram Plugin Base
+
+A build toolchain and ADB automation for writing [exteraGram](https://exteragram.app) plugins
+as ordinary multi-file Python projects instead of hand-maintaining one giant `.py` file.
+
+## How exteraGram plugins actually work
+
+A plugin, as exteraGram loads it, is exactly **one Python file** on the device:
+`/data/user/0/com.exteragram.messenger/files/plugins/.py`. The distribution
+convention names the same content `.plugin`.
+
+The app reads plugin metadata **statically via AST** — it looks for top-level constant
+assignments (`__id__`, `__name__`, `__description__`, `__author__`, `__version__`,
+`__icon__`, `__app_version__`, `__sdk_version__`, `__requirements__`) without executing the
+file. Only `__id__` and `__name__` are required.
+
+The file must define exactly one class inheriting `BasePlugin` (`from base_plugin import
+BasePlugin`). Lifecycle methods (`on_plugin_load`, `on_plugin_unload`, `create_settings`) and
+hooks (`on_send_message_hook`, `pre_request_hook`, `post_request_hook`, `on_update_hook(s)`,
+`on_app_event`) are all called by the host; hooks must be registered inside
+`on_plugin_load` (e.g. `self.add_on_send_message_hook()`). A hook returns a
+`HookResult(strategy=HookStrategy.MODIFY, params=params)` to apply changes, or a bare
+`HookResult()` to pass through. Settings rows returned from `create_settings` come from
+`ui.settings` (`Header`, `Input`, `Switch`, `Text`, `Selector`, `Divider`) and persist through
+`self.get_setting`/`self.set_setting`. Logging goes through `android_utils.log`; Chaquopy also
+redirects `sys.stdout`/`sys.stderr` to the logcat tags `python.stdout`/`python.stderr`.
+
+This repo does not use the official Elyx/ElyxBuilder archive format — see
+`docs/sdk-notes.md` for why.
+
+## Repo layout
+
+```
+plugin.json single source of truth for plugin metadata
+src/ the plugin, written as an ordinary Python package
+ main.py entry module — exactly one BasePlugin subclass, absolute imports only
+ greeting/ sample package (proves relative + cross-module imports survive bundling)
+ textutil.py sample top-level sibling module
+tools/
+ bundle.py compiles plugin.json + src/ into dist/.plugin
+ devclient.py speaks the on-device dev-server protocol (127.0.0.1:42690)
+ verify_bundle.py offline harness that proves a built bundle behaves correctly, no phone needed
+scripts/ bash automation for the ADB debug loop (see below)
+docs/sdk-notes.md condensed SDK API surface gathered from the official docs
+```
+
+## The three commands that matter
+
+```bash
+python3 tools/bundle.py --release # compile src/ -> dist/.plugin
+python3 tools/verify_bundle.py dist/.plugin # offline proof the bundle works, no device needed
+bash scripts/deploy.sh --enable # build (--dev) + push to a connected device + enable + reload
+```
+
+`bundle.py` walks `src/` recursively, embeds every `.py` file's source and dotted module name
+into a compressed, base64-encoded payload, and appends a small runtime preamble that installs
+a private import hook (scoped to a per-module `__builtins__` dict, never touching
+`sys.modules`) so cross-module and cross-package imports resolve exactly as they would if the
+files were still on disk. `--dev` (the default) embeds absolute local paths so a debugger
+resolves breakpoints against your real source files; `--release` embeds repo-relative paths
+for distribution. Both modes produce byte-reproducible output for a given `src/` + `plugin.json`.
+
+## Device prerequisites
+
+- exteraGram 12.5.1+.
+- In-app: Preferences → Plugins → enable the plugin engine, then enable developer mode.
+- USB debugging enabled, device authorized (`adb devices`).
+
+Release-build APKs disallow `adb run-as`, so you cannot `adb push` a file straight into the
+app's private plugin directory. The on-device **dev server** (`tools/devclient.py`,
+`scripts/deploy.sh`) is the actual install path — it talks to the running app over a TCP
+socket forwarded with `adb forward`, and the app writes the file into its own storage.
+
+## ADB automation
+
+All scripts live in `scripts/`, are runnable from any working directory, and honor the
+standard `ANDROID_SERIAL` env var for device selection.
+
+- `scripts/adb-connect.sh [--debug]` — starts adb, waits for the device, (re)creates the
+ `tcp:42690` port forward (and `tcp:5678` with `--debug`), pings the dev server.
+- `scripts/deploy.sh [--release] [--enable] [--no-connect]` — bundles, connects, writes the
+ plugin to the device, optionally enables it, reloads it.
+- `scripts/watch.sh [deploy args…]` — watches `src/` and `plugin.json`, redeploys on change
+ (uses `inotifywait` if available, otherwise polls).
+- `scripts/logs.sh [-c] [-t] [-g PATTERN]` — streams logcat for the running app (`-t` for the
+ raw Chaquopy stdio tags, `-g` to grep, `-c` to clear first).
+- `scripts/debug.sh start|stop [--platform vscode|pycharm]` — forwards the debugger port and
+ tells the dev server to start/stop the in-app debugger. Requires `debugpy` (vscode) or
+ `pydevd-pycharm` (pycharm) present in the app's Python runtime — add it to `requirements` in
+ `plugin.json` if it's missing.
+- `scripts/fetch-sdk-stubs.sh [--beta]` — downloads the latest SDK type stubs into `stubs/`
+ for editor autocomplete.
+
+## Writing your own plugin
+
+1. Edit `plugin.json` (id, name, description, etc.).
+2. Write your plugin under `src/`, with `src/main.py` as the entry module (absolute imports
+ only — it's executed at plugin-module top level, not inside a package).
+3. `python3 tools/bundle.py --release && python3 tools/verify_bundle.py dist/.plugin`
+ (substitute your `id` from `plugin.json`).
+4. `bash scripts/deploy.sh --enable` with a device connected.
diff --git a/docs/sdk-notes.md b/docs/sdk-notes.md
new file mode 100644
index 0000000..a0b909d
--- /dev/null
+++ b/docs/sdk-notes.md
@@ -0,0 +1,114 @@
+# exteraGram plugin SDK — condensed notes
+
+Gathered from , the SDK type stubs (`stubs.zip` from
+`exteraSquad/plugins-pysdk-builds`), the official `exteragram-utils` PyPI package
+(`exteragram_utils/dev_client.py`), and the app's `gradle.properties`. Not exhaustive — see the
+full docs site for anything not covered here.
+
+## Plugin identity and metadata
+
+Read statically via AST from top-level constant assignments in the plugin's single `.py` file
+— the app never executes the file just to read metadata. Docs:
+.
+
+| Constant | Required | Notes |
+|---|---|---|
+| `__id__` | yes | 2–32 chars, starts with a letter, only `A-Za-z0-9_-` |
+| `__name__` | yes | display name |
+| `__description__` | no | |
+| `__author__` | no | |
+| `__version__` | no | |
+| `__icon__` | no | e.g. `"exteraPlugins/1"` |
+| `__app_version__` | no | e.g. `">=12.5.1"`; legacy alias `__min_version__` |
+| `__sdk_version__` | no | e.g. `">=1.4.4.3"` |
+| `__requirements__` | no | list of pip package specs the host installs before loading |
+
+## `BasePlugin` lifecycle and hooks
+
+
+
+- `on_plugin_load(self)` — called once when the plugin is enabled/loaded. Hooks must be
+ registered here (e.g. `self.add_on_send_message_hook()`); registering later has no effect.
+- `on_plugin_unload(self)` — called on disable/removal; release resources here.
+- `create_settings(self)` — returns a list of `ui.settings` rows rendered in the plugin's
+ settings screen.
+- Hooks: `on_send_message_hook(self, account, params)`, `pre_request_hook(self, ...)`,
+ `post_request_hook(self, ...)`, `on_update_hook(s)(self, ...)`, `on_app_event(self, ...)`.
+ Each returns a `HookResult`.
+- `HookResult(strategy=HookStrategy.MODIFY, params=params)` applies the mutated `params` back
+ to the host; `HookStrategy.DEFAULT` (a bare `HookResult()`) passes through unchanged;
+ `HookStrategy.CANCEL` drops the action; `HookStrategy.MODIFY_FINAL` applies changes and
+ short-circuits any later hooks.
+- Settings persistence: `self.get_setting(key, default)` / `self.set_setting(key, value)`.
+
+## `ui.settings` row types
+
+
+
+`Header`, `Input`, `Switch`, `Text`, `Selector`, `Divider` — plain kwargs-holding row
+descriptors consumed by the host's settings renderer; construct and return them from
+`create_settings`.
+
+## Logging
+
+
+
+`android_utils.log(...)` writes to logcat. Chaquopy additionally redirects
+`sys.stdout`/`sys.stderr` to the logcat tags `python.stdout` / `python.stderr` — useful when a
+dependency logs with plain `print()`.
+
+## Background work
+
+
+
+`client_utils.run_on_queue(...)` schedules work off the UI thread; use it for anything that
+shouldn't block message handling.
+
+## Reflection / private API access
+
+
+
+`hook_utils.find_class(...)` and related helpers locate app classes and private fields for
+plugins that need to reach beyond the public SDK surface.
+
+## UI helpers
+
+- `ui.alert.AlertDialogBuilder` — native alert dialogs.
+- `ui.bulletin` — transient bottom bulletins/toasts.
+- `intents.IntentsManager` — Android intent integration, **exteraGram 12.6.4+ only**.
+
+
+## On-device dev server protocol
+
+
+
+TCP `127.0.0.1:42690`, reachable from a PC via `adb forward tcp:42690 tcp:42690`. Wire format
+is consecutive UTF-8 JSON objects with **no length framing** — parse with
+`json.JSONDecoder().raw_decode` over an accumulating buffer, never assume one JSON object per
+`recv()`. Requests: `{"@": , "#": , ...}`; responses echo `"#"`.
+
+Commands used by this repo's `tools/devclient.py`: `ping`, `get_plugins`, `enable_plugin`,
+`disable_plugin`, `reload_plugin`, `remove_plugin` (field `plugin_id`), `write_plugin`
+(`plugin_id`, `content` — installs or updates), `start_debugger`/`stop_debugger` (`host`,
+`port`, `platform` ∈ `vscode`|`pycharm`). The official `exteragram-utils` client (PyPI) uses
+integer request ids from 1, a 30 s response timeout, a 1.0 s socket timeout, a
+`write_plugin` → sleep 0.3 s → `reload_plugin` deploy sequence, and `adb forward` (not
+`reverse`) for both the dev-server port and the debugger port. `elyx_*` commands exist for the
+Elyx engine and are out of scope here.
+
+## Runtime
+
+Chaquopy 16 / Python 3.11. Anything shipped in a bundle must be 3.11-compatible.
+
+## Why not Elyx/ElyxBuilder
+
+The official structured plugin format (Elyx/ElyxBuilder) is a real alternative to a single
+`.plugin` file, but it:
+
+- requires the separate Elyx engine to be installed and enabled on the device;
+- ships archives, not the `.plugin` files the base plugin engine understands;
+- needs a manual first install before live reload works.
+
+This repo's bundler instead compiles a normal multi-file `src/` project down to the one
+`.plugin` file the base engine already knows how to load, so the dev-server write/reload loop
+works from the first deploy with no extra on-device engine.
diff --git a/plugin.json b/plugin.json
new file mode 100644
index 0000000..b1fc013
--- /dev/null
+++ b/plugin.json
@@ -0,0 +1,11 @@
+{
+ "id": "hello_bundle",
+ "name": "Hello Bundle",
+ "description": "Multi-file exteraGram plugin built by tools/bundle.py",
+ "author": "@yourhandle",
+ "version": "1.0.0",
+ "icon": "exteraPlugins/1",
+ "app_version": ">=12.5.1",
+ "sdk_version": ">=1.4.4.3",
+ "requirements": []
+}
diff --git a/scripts/adb-connect.sh b/scripts/adb-connect.sh
new file mode 100755
index 0000000..f3baeeb
--- /dev/null
+++ b/scripts/adb-connect.sh
@@ -0,0 +1,31 @@
+#!/usr/bin/env bash
+set -euo pipefail
+cd "$(dirname "${BASH_SOURCE[0]}")/.."
+source scripts/lib.sh
+
+need_cmd "$ADB" "$PY"
+
+DEBUG=0
+for arg in "$@"; do
+ case "$arg" in
+ --debug) DEBUG=1 ;;
+ *) die "unknown argument: $arg" ;;
+ esac
+done
+
+"$ADB" start-server
+timeout 60 "$ADB" wait-for-device || die "timed out waiting for a device (ANDROID_SERIAL=${ANDROID_SERIAL:-})"
+
+"$ADB" forward --remove "tcp:$DEV_PORT" >/dev/null 2>&1 || true
+"$ADB" forward "tcp:$DEV_PORT" "tcp:$DEV_PORT"
+
+if [[ "$DEBUG" -eq 1 ]]; then
+ "$ADB" forward --remove "tcp:$DEBUG_PORT" >/dev/null 2>&1 || true
+ "$ADB" forward "tcp:$DEBUG_PORT" "tcp:$DEBUG_PORT"
+fi
+
+if "$PY" tools/devclient.py ping >/dev/null 2>&1; then
+ echo "connected"
+else
+ die "ping failed — is the plugin engine + developer mode enabled in exteraGram Preferences > Plugins?"
+fi
diff --git a/scripts/debug.sh b/scripts/debug.sh
new file mode 100755
index 0000000..365ae43
--- /dev/null
+++ b/scripts/debug.sh
@@ -0,0 +1,49 @@
+#!/usr/bin/env bash
+set -euo pipefail
+cd "$(dirname "${BASH_SOURCE[0]}")/.."
+source scripts/lib.sh
+
+need_cmd "$ADB" "$PY"
+
+usage() {
+ cat >&2 <<'EOF'
+usage: scripts/debug.sh start|stop [--platform vscode|pycharm]
+
+Requires the device-side debugger package to be present in the app's Python
+runtime: `debugpy` for vscode, `pydevd-pycharm` for pycharm. If it is not
+installed, add it to "requirements" in plugin.json so the app's pip
+controller installs it on next load.
+EOF
+}
+
+[[ $# -ge 1 ]] || { usage; exit 1; }
+ACTION="$1"
+shift
+PLATFORM=vscode
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --platform)
+ PLATFORM="${2:-}"
+ shift 2
+ ;;
+ --platform=*)
+ PLATFORM="${1#--platform=}"
+ shift
+ ;;
+ *)
+ die "unknown argument: $1"
+ ;;
+ esac
+done
+
+case "$ACTION" in
+ start|stop) ;;
+ *) usage; exit 1 ;;
+esac
+case "$PLATFORM" in
+ vscode|pycharm) ;;
+ *) die "--platform must be vscode or pycharm" ;;
+esac
+
+scripts/adb-connect.sh --debug
+"$PY" tools/devclient.py debugger "$ACTION" --platform "$PLATFORM"
diff --git a/scripts/deploy.sh b/scripts/deploy.sh
new file mode 100755
index 0000000..9885985
--- /dev/null
+++ b/scripts/deploy.sh
@@ -0,0 +1,36 @@
+#!/usr/bin/env bash
+set -euo pipefail
+cd "$(dirname "${BASH_SOURCE[0]}")/.."
+source scripts/lib.sh
+
+need_cmd "$PY"
+
+MODE=--dev
+ENABLE=0
+CONNECT=1
+for arg in "$@"; do
+ case "$arg" in
+ --release) MODE=--release ;;
+ --enable) ENABLE=1 ;;
+ --no-connect) CONNECT=0 ;;
+ *) die "unknown argument: $arg" ;;
+ esac
+done
+
+"$PY" tools/bundle.py "$MODE" --out dist
+
+BUNDLE="dist/${PLUGIN_ID}.plugin"
+[[ -f "$BUNDLE" ]] || die "expected bundle not found at $BUNDLE"
+echo "bundle: $BUNDLE ($(stat -c '%s' "$BUNDLE" 2>/dev/null || stat -f '%z' "$BUNDLE") bytes)"
+
+if [[ "$CONNECT" -eq 1 ]]; then
+ need_cmd "$ADB"
+ scripts/adb-connect.sh
+fi
+
+DEPLOY_ARGS=(deploy "$BUNDLE" --id "$PLUGIN_ID")
+if [[ "$ENABLE" -eq 1 ]]; then
+ DEPLOY_ARGS+=(--enable)
+fi
+
+"$PY" tools/devclient.py "${DEPLOY_ARGS[@]}"
diff --git a/scripts/fetch-sdk-stubs.sh b/scripts/fetch-sdk-stubs.sh
new file mode 100755
index 0000000..c8aba62
--- /dev/null
+++ b/scripts/fetch-sdk-stubs.sh
@@ -0,0 +1,62 @@
+#!/usr/bin/env bash
+set -euo pipefail
+cd "$(dirname "${BASH_SOURCE[0]}")/.."
+source scripts/lib.sh
+
+need_cmd "$PY" curl unzip sha256sum
+
+BETA=0
+for arg in "$@"; do
+ case "$arg" in
+ --beta) BETA=1 ;;
+ *) die "unknown argument: $arg" ;;
+ esac
+done
+
+API_URL="https://api.github.com/repos/exteraSquad/plugins-pysdk-builds/releases?per_page=20"
+
+RELEASE_JSON="$(mktemp)"
+trap 'rm -rf "$RELEASE_JSON" "$WORKDIR"' EXIT
+
+curl -fsSL "$API_URL" -o "$RELEASE_JSON" || die "failed to query GitHub releases API"
+
+read -r TAG ASSET_URL SHA_URL < <("$PY" - "$RELEASE_JSON" "$BETA" <<'PYEOF'
+import json, sys
+
+path, allow_beta = sys.argv[1], sys.argv[2] == "1"
+with open(path, encoding="utf-8") as f:
+ releases = json.load(f)
+
+for rel in releases:
+ if rel.get("prerelease") and not allow_beta:
+ continue
+ assets = {a["name"]: a["browser_download_url"] for a in rel.get("assets", [])}
+ if "stubs.zip" in assets:
+ sha_url = assets.get("stubs.zip.sha256", "")
+ print(rel["tag_name"], assets["stubs.zip"], sha_url)
+ break
+else:
+ sys.exit("no release with a stubs.zip asset found")
+PYEOF
+) || die "no release with a stubs.zip asset found"
+
+[[ -n "$TAG" ]] || die "could not determine a release to install"
+
+WORKDIR="$(mktemp -d)"
+curl -fsSL "$ASSET_URL" -o "$WORKDIR/stubs.zip" || die "failed to download stubs.zip"
+
+if [[ -n "$SHA_URL" ]]; then
+ curl -fsSL "$SHA_URL" -o "$WORKDIR/stubs.zip.sha256" || die "failed to download stubs.zip.sha256"
+ (cd "$WORKDIR" && sha256sum -c stubs.zip.sha256) || die "checksum verification failed for stubs.zip"
+else
+ echo "warning: no stubs.zip.sha256 asset published for $TAG; skipping checksum verification" >&2
+fi
+
+rm -rf stubs
+mkdir -p stubs
+unzip -q "$WORKDIR/stubs.zip" -d "$WORKDIR/outer"
+INNER="$WORKDIR/outer/pyi-files.zip"
+[[ -f "$INNER" ]] || die "expected pyi-files.zip inside stubs.zip, not found"
+unzip -q "$INNER" -d stubs
+
+echo "installed SDK stubs from release $TAG into stubs/"
diff --git a/scripts/lib.sh b/scripts/lib.sh
new file mode 100644
index 0000000..d706c86
--- /dev/null
+++ b/scripts/lib.sh
@@ -0,0 +1,26 @@
+# Shared helpers for scripts/*.sh. Source, do not execute:
+# source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+PKG=com.exteragram.messenger
+DEV_PORT=42690
+DEBUG_PORT=5678
+PY="${PYTHON:-python3}"
+ADB="${ADB:-adb}"
+
+PLUGIN_ID="$("$PY" -c "import json,sys; print(json.load(open(sys.argv[1]))['id'])" "$REPO_ROOT/plugin.json")"
+
+die() {
+ echo "error: $*" >&2
+ exit 1
+}
+
+need_cmd() {
+ for cmd in "$@"; do
+ command -v "$cmd" >/dev/null 2>&1 || die "required command not found on PATH: $cmd"
+ done
+}
+
+app_pid() {
+ "$ADB" shell pidof -s "$PKG" | tr -d '\r'
+}
diff --git a/scripts/logs.sh b/scripts/logs.sh
new file mode 100755
index 0000000..9b0f0ae
--- /dev/null
+++ b/scripts/logs.sh
@@ -0,0 +1,39 @@
+#!/usr/bin/env bash
+set -euo pipefail
+cd "$(dirname "${BASH_SOURCE[0]}")/.."
+source scripts/lib.sh
+
+need_cmd "$ADB"
+
+CLEAR=0
+STDIO=0
+PATTERN=""
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ -c) CLEAR=1; shift ;;
+ -t) STDIO=1; shift ;;
+ -g) PATTERN="${2:-}"; shift 2 ;;
+ *) die "unknown argument: $1" ;;
+ esac
+done
+
+if [[ "$CLEAR" -eq 1 ]]; then
+ "$ADB" logcat -c
+fi
+
+run_logcat() {
+ if [[ "$STDIO" -eq 1 ]]; then
+ "$ADB" logcat -s python.stdout:'*' python.stderr:'*'
+ else
+ local pid
+ pid="$(app_pid)"
+ [[ -n "$pid" ]] || die "exteraGram is not running — open the app first"
+ "$ADB" logcat --pid="$pid"
+ fi
+}
+
+if [[ -n "$PATTERN" ]]; then
+ run_logcat | grep --line-buffered -E "$PATTERN"
+else
+ run_logcat
+fi
diff --git a/scripts/watch.sh b/scripts/watch.sh
new file mode 100755
index 0000000..121333d
--- /dev/null
+++ b/scripts/watch.sh
@@ -0,0 +1,39 @@
+#!/usr/bin/env bash
+set -euo pipefail
+cd "$(dirname "${BASH_SOURCE[0]}")/.."
+source scripts/lib.sh
+
+need_cmd "$PY"
+
+checksum() {
+ find src plugin.json -type f -printf '%T@ %p\n' 2>/dev/null | sha256sum
+}
+
+run_deploy() {
+ if ! scripts/deploy.sh "$@"; then
+ echo "deploy failed, still watching..." >&2
+ fi
+}
+
+echo "watching src/ and plugin.json for changes..."
+run_deploy "$@"
+
+if command -v inotifywait >/dev/null 2>&1; then
+ while true; do
+ inotifywait -qq -r -e close_write,create,delete,move src plugin.json 2>/dev/null || true
+ sleep 0.3
+ run_deploy "$@"
+ done
+else
+ echo "inotifywait not found; falling back to polling" >&2
+ last="$(checksum)"
+ while true; do
+ sleep 1
+ current="$(checksum)"
+ if [[ "$current" != "$last" ]]; then
+ last="$current"
+ sleep 0.3
+ run_deploy "$@"
+ fi
+ done
+fi
diff --git a/src/greeting/__init__.py b/src/greeting/__init__.py
new file mode 100644
index 0000000..f0d6578
--- /dev/null
+++ b/src/greeting/__init__.py
@@ -0,0 +1 @@
+from .template import render, TemplateError
diff --git a/src/greeting/template.py b/src/greeting/template.py
new file mode 100644
index 0000000..5ccc0e9
--- /dev/null
+++ b/src/greeting/template.py
@@ -0,0 +1,13 @@
+from textutil import normalize
+
+
+class TemplateError(ValueError):
+ pass
+
+
+def render(template: str, name: str) -> str:
+ normalized = normalize(name)
+ try:
+ return template.format(name=normalized)
+ except (KeyError, IndexError, ValueError):
+ raise TemplateError(f"unsupported placeholder in {template!r}")
diff --git a/src/main.py b/src/main.py
new file mode 100644
index 0000000..f24e0fa
--- /dev/null
+++ b/src/main.py
@@ -0,0 +1,50 @@
+from base_plugin import BasePlugin, HookResult, HookStrategy
+from android_utils import log
+from ui.settings import Header, Input, Text
+from greeting import render, TemplateError
+
+DEFAULT_TEMPLATE = "Hello, {name}!"
+
+
+class HelloPlugin(BasePlugin):
+ def on_plugin_load(self):
+ log("hello_bundle loaded")
+ self.add_on_send_message_hook()
+
+ def on_plugin_unload(self):
+ log("hello_bundle unloaded")
+
+ def create_settings(self):
+ return [
+ Header(text="Hello Bundle"),
+ Input(
+ key="template",
+ text="Greeting template",
+ default=DEFAULT_TEMPLATE,
+ subtext="Use {name} as placeholder.",
+ icon="msg_edit",
+ ),
+ Text(
+ text="Example",
+ subtext=".hello alice smith -> Hello, Alice Smith!",
+ icon="msg_info",
+ ),
+ ]
+
+ def on_send_message_hook(self, account, params):
+ message = getattr(params, "message", None)
+ if not isinstance(message, str):
+ return HookResult()
+ stripped = message.strip()
+ if not stripped.startswith(".hello"):
+ return HookResult()
+ parts = stripped.split(" ", 1)
+ name = parts[1] if len(parts) > 1 else ""
+ if not name.strip():
+ params.message = "Usage: .hello "
+ else:
+ try:
+ params.message = render(self.get_setting("template", DEFAULT_TEMPLATE), name)
+ except TemplateError as exc:
+ params.message = "Bad template: " + str(exc)
+ return HookResult(strategy=HookStrategy.MODIFY, params=params)
diff --git a/src/textutil.py b/src/textutil.py
new file mode 100644
index 0000000..7c3107b
--- /dev/null
+++ b/src/textutil.py
@@ -0,0 +1,2 @@
+def normalize(name: str) -> str:
+ return " ".join(w[:1].upper() + w[1:] for w in name.split())
diff --git a/tools/bundle.py b/tools/bundle.py
new file mode 100755
index 0000000..72678be
--- /dev/null
+++ b/tools/bundle.py
@@ -0,0 +1,345 @@
+#!/usr/bin/env python3
+"""Bundle a multi-file exteraGram plugin (plugin.json + src/) into a single .plugin file.
+
+Usage: python3 tools/bundle.py [--dev|--release] [--out DIR] [--check]
+"""
+from __future__ import annotations
+
+import argparse
+import ast
+import base64
+import json
+import re
+import sys
+import zlib
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+SRC_DIR = REPO_ROOT / "src"
+PLUGIN_JSON = REPO_ROOT / "plugin.json"
+
+RECOGNIZED_KEYS = {
+ "id",
+ "name",
+ "description",
+ "author",
+ "version",
+ "icon",
+ "app_version",
+ "sdk_version",
+ "requirements",
+}
+REQUIRED_KEYS = {"id", "name"}
+ID_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_-]{1,31}$")
+
+KEY_TO_DUNDER = {
+ "id": "__id__",
+ "name": "__name__",
+ "description": "__description__",
+ "author": "__author__",
+ "version": "__version__",
+ "icon": "__icon__",
+ "app_version": "__app_version__",
+ "sdk_version": "__sdk_version__",
+ "requirements": "__requirements__",
+}
+METADATA_DUNDERS = set(KEY_TO_DUNDER.values()) | {"__min_version__"}
+
+RUNTIME_PREAMBLE = '''\
+import base64 as _b64, builtins as _bi, json as _j, linecache as _lc, types as _t, zlib as _z
+
+_BUNDLE = _j.loads(_z.decompress(_b64.b64decode(_BUNDLE_BLOB)).decode("utf-8"))
+_MODS = _BUNDLE["modules"]
+_LOADED = {}
+_ROOTS = {n.partition(".")[0] for n in _MODS}
+_REAL_IMPORT = _bi.__import__
+
+
+def _bundle_register_source(info):
+ lines = info["src"].splitlines(True)
+ _lc.cache[info["file"]] = (len(info["src"]), None, lines, info["file"])
+
+
+def _bundle_load(name):
+ mod = _LOADED.get(name)
+ if mod is not None:
+ return mod
+ info = _MODS.get(name)
+ if info is None:
+ raise ImportError("no bundled module named %r" % name)
+ parent = name.rpartition(".")[0]
+ if parent:
+ _bundle_load(parent)
+ mod = _t.ModuleType(name)
+ mod.__file__ = info["file"]
+ mod.__package__ = name if info["pkg"] else parent
+ if info["pkg"]:
+ mod.__path__ = []
+ builtins_ns = dict(vars(_bi))
+ builtins_ns["__import__"] = _bundle_import
+ mod.__dict__["__builtins__"] = builtins_ns
+ _LOADED[name] = mod
+ _bundle_register_source(info)
+ try:
+ exec(compile(info["src"], info["file"], "exec"), mod.__dict__)
+ except BaseException:
+ _LOADED.pop(name, None)
+ raise
+ if parent:
+ setattr(_LOADED[parent], name.rpartition(".")[2], mod)
+ return mod
+
+
+def _bundle_import(name, globals=None, locals=None, fromlist=(), level=0):
+ if level:
+ pkg = (globals or {}).get("__package__") or ""
+ parts = pkg.split(".") if pkg else []
+ if level > 1:
+ parts = parts[: 1 - level]
+ if not parts:
+ return _REAL_IMPORT(name, globals, locals, fromlist, level)
+ full = ".".join(parts) + ("." + name if name else "")
+ else:
+ full = name
+ if full.partition(".")[0] not in _ROOTS or full not in _MODS:
+ return _REAL_IMPORT(name, globals, locals, fromlist, level)
+ mod = _bundle_load(full)
+ if not fromlist:
+ return mod if level else _bundle_load(full.partition(".")[0])
+ for item in fromlist:
+ if item != "*" and not hasattr(mod, item) and full + "." + item in _MODS:
+ _bundle_load(full + "." + item)
+ return mod
+'''
+
+ENTRY_EXECUTION = '''\
+_ENTRY = _MODS[_BUNDLE["entry"]]
+_bundle_register_source(_ENTRY)
+_bundle_builtins = dict(vars(_bi))
+_bundle_builtins["__import__"] = _bundle_import
+globals()["__builtins__"] = _bundle_builtins
+exec(compile(_ENTRY["src"], _ENTRY["file"], "exec"), globals())
+'''
+
+
+class ValidationError(Exception):
+ pass
+
+
+def load_metadata() -> dict:
+ if not PLUGIN_JSON.is_file():
+ raise ValidationError(f"plugin.json not found at {PLUGIN_JSON}")
+ try:
+ text = PLUGIN_JSON.read_text(encoding="utf-8")
+ except OSError as exc:
+ raise ValidationError(f"cannot read plugin.json: {exc}")
+ try:
+ data = json.loads(text)
+ except json.JSONDecodeError as exc:
+ raise ValidationError(f"plugin.json is not valid JSON: {exc}")
+ if not isinstance(data, dict):
+ raise ValidationError("plugin.json must be a JSON object")
+ unknown = set(data) - RECOGNIZED_KEYS
+ if unknown:
+ raise ValidationError(
+ "plugin.json has unrecognized keys: " + ", ".join(sorted(unknown))
+ )
+ missing = REQUIRED_KEYS - set(data)
+ if missing:
+ raise ValidationError(
+ "plugin.json is missing required keys: " + ", ".join(sorted(missing))
+ )
+ plugin_id = data["id"]
+ if not isinstance(plugin_id, str) or not ID_RE.match(plugin_id):
+ raise ValidationError(
+ f"plugin.json id {plugin_id!r} is invalid: must be 2-32 chars, start with a "
+ "letter, and contain only A-Za-z0-9_-"
+ )
+ if not isinstance(data["name"], str) or not data["name"]:
+ raise ValidationError("plugin.json name must be a non-empty string")
+ return data
+
+
+def collect_modules() -> dict:
+ if not SRC_DIR.is_dir():
+ raise ValidationError(f"src/ directory not found at {SRC_DIR}")
+ modules: dict = {}
+ for path in sorted(SRC_DIR.rglob("*")):
+ if path.is_dir():
+ continue
+ rel = path.relative_to(SRC_DIR)
+ if any(part.startswith(".") for part in rel.parts):
+ continue
+ if "__pycache__" in rel.parts:
+ continue
+ if path.suffix == ".pyc":
+ continue
+ if path.suffix != ".py":
+ print(
+ f"warning: skipping non-Python file {path.relative_to(REPO_ROOT)} "
+ "(single-file plugins cannot carry assets)",
+ file=sys.stderr,
+ )
+ continue
+ is_pkg = rel.name == "__init__.py"
+ dotted_parts = rel.parts[:-1] if is_pkg else rel.parts[:-1] + (rel.stem,)
+ name = ".".join(dotted_parts)
+ if not name:
+ raise ValidationError(f"cannot derive a module name for {path}")
+ try:
+ src = path.read_text(encoding="utf-8")
+ except OSError as exc:
+ raise ValidationError(f"cannot read {path}: {exc}")
+ modules[name] = {"src": src, "path": path, "pkg": is_pkg}
+ return modules
+
+
+def validate_entry(modules: dict) -> None:
+ main_path = SRC_DIR / "main.py"
+ if not main_path.is_file():
+ raise ValidationError("src/main.py does not exist")
+ entry = modules.get("main")
+ if entry is None:
+ raise ValidationError("src/main.py could not be collected as the 'main' module")
+ try:
+ tree = ast.parse(entry["src"], filename=str(main_path))
+ except SyntaxError as exc:
+ raise ValidationError(f"src/main.py has a syntax error: {exc}")
+
+ for node in ast.walk(tree):
+ if isinstance(node, ast.ImportFrom) and node.level > 0:
+ raise ValidationError(
+ "src/main.py must use absolute imports only (found a relative import); "
+ "it is executed at plugin-module top level"
+ )
+ if isinstance(node, (ast.Assign, ast.AnnAssign)):
+ targets = node.targets if isinstance(node, ast.Assign) else [node.target]
+ for target in targets:
+ if isinstance(target, ast.Name) and target.id in METADATA_DUNDERS:
+ raise ValidationError(
+ f"src/main.py must not assign metadata dunder {target.id!r} "
+ "(metadata belongs to plugin.json only)"
+ )
+
+ def _base_names(class_node: ast.ClassDef) -> set:
+ names = set()
+ for base in class_node.bases:
+ if isinstance(base, ast.Name):
+ names.add(base.id)
+ elif isinstance(base, ast.Attribute):
+ names.add(base.attr)
+ return names
+
+ plugin_classes = [
+ node.name
+ for node in tree.body
+ if isinstance(node, ast.ClassDef) and "BasePlugin" in _base_names(node)
+ ]
+ if len(plugin_classes) != 1:
+ raise ValidationError(
+ "src/main.py must contain exactly one class inheriting BasePlugin, found "
+ f"{len(plugin_classes)}"
+ )
+
+
+def validate_modules_syntax(modules: dict) -> None:
+ for name, info in modules.items():
+ try:
+ ast.parse(info["src"], filename=str(info["path"]))
+ except SyntaxError as exc:
+ raise ValidationError(f"{info['path']} has a syntax error: {exc}")
+
+
+def format_metadata_constants(data: dict) -> str:
+ lines = []
+ for key, dunder in KEY_TO_DUNDER.items():
+ if key not in data:
+ continue
+ value = data[key]
+ if key in ("description", "icon") and value == "":
+ continue
+ if key == "requirements" and value == []:
+ continue
+ lines.append(f"{dunder} = {value!r}")
+ return "\n".join(lines)
+
+
+def build_payload(modules: dict, release: bool) -> dict:
+ mod_dict = {}
+ for name, info in modules.items():
+ if release:
+ file_value = str(info["path"].relative_to(REPO_ROOT))
+ else:
+ file_value = str(info["path"].resolve())
+ mod_dict[name] = {"src": info["src"], "file": file_value, "pkg": info["pkg"]}
+ return {"modules": mod_dict, "entry": "main"}
+
+
+def encode_payload(payload: dict) -> str:
+ data = json.dumps(payload, sort_keys=True).encode("utf-8")
+ compressed = zlib.compress(data, 9)
+ return base64.b64encode(compressed).decode("ascii")
+
+
+def format_blob_literal(blob: str, width: int = 96) -> str:
+ if not blob:
+ return '_BUNDLE_BLOB = ""'
+ lines = [blob[i : i + width] for i in range(0, len(blob), width)]
+ body = "\n".join(f' "{line}"' for line in lines)
+ return "_BUNDLE_BLOB = (\n" + body + "\n)"
+
+
+def assemble_bundle(data: dict, modules: dict, release: bool) -> str:
+ header = "# Generated by tools/bundle.py from plugin.json + src/ — do not edit; edit the sources.\n"
+ metadata = format_metadata_constants(data)
+ payload = build_payload(modules, release)
+ blob_literal = format_blob_literal(encode_payload(payload))
+ text = "\n".join([header, metadata, "", blob_literal, "", RUNTIME_PREAMBLE, ENTRY_EXECUTION])
+ if not text.endswith("\n"):
+ text += "\n"
+ return text
+
+
+def parse_args(argv):
+ parser = argparse.ArgumentParser(
+ description="Bundle a multi-file exteraGram plugin into a single .plugin file."
+ )
+ mode = parser.add_mutually_exclusive_group()
+ mode.add_argument(
+ "--dev", action="store_true", help="embed absolute local paths for debugging (default)"
+ )
+ mode.add_argument(
+ "--release", action="store_true", help="embed repo-relative paths for distribution"
+ )
+ parser.add_argument("--out", default="dist", help="output directory (default: dist)")
+ parser.add_argument("--check", action="store_true", help="validate only; do not write output")
+ return parser.parse_args(argv)
+
+
+def main(argv=None) -> int:
+ args = parse_args(sys.argv[1:] if argv is None else argv)
+ release = bool(args.release)
+ try:
+ data = load_metadata()
+ modules = collect_modules()
+ validate_entry(modules)
+ validate_modules_syntax(modules)
+ except ValidationError as exc:
+ print(f"error: {exc}", file=sys.stderr)
+ return 2
+
+ if args.check:
+ print("ok")
+ return 0
+
+ text = assemble_bundle(data, modules, release)
+ out_dir = Path(args.out)
+ out_dir.mkdir(parents=True, exist_ok=True)
+ out_path = out_dir / f"{data['id']}.plugin"
+ out_path.write_text(text, encoding="utf-8")
+ print(f"wrote {out_path} ({out_path.stat().st_size} bytes)")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tools/devclient.py b/tools/devclient.py
new file mode 100755
index 0000000..93e2efb
--- /dev/null
+++ b/tools/devclient.py
@@ -0,0 +1,219 @@
+#!/usr/bin/env python3
+"""Client for the exteraGram on-device plugin dev server.
+
+Speaks the wire protocol at 127.0.0.1:42690 (after `adb forward tcp:42690 tcp:42690`):
+consecutive UTF-8 JSON objects, no length framing. Requests are
+{"@": , "#": , ...}; responses echo "#".
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import socket
+import sys
+import time
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+PLUGIN_JSON = REPO_ROOT / "plugin.json"
+
+CONNECT_ATTEMPTS = 5
+CONNECT_RETRY_DELAY = 1.0
+SOCKET_TIMEOUT = 1.0
+RESPONSE_TIMEOUT = 30.0
+
+UNREACHABLE_MESSAGE = (
+ "dev server unreachable — run scripts/adb-connect.sh, and enable exteraGram "
+ "Preferences > Plugins > plugin engine + developer mode"
+)
+
+
+def default_plugin_id() -> str | None:
+ try:
+ data = json.loads(PLUGIN_JSON.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ return None
+ return data.get("id")
+
+
+class DevClient:
+ def __init__(self, host: str, port: int):
+ self.host = host
+ self.port = port
+ self._sock: socket.socket | None = None
+ self._buf = b""
+ self._decoder = json.JSONDecoder()
+ self._next_id = 1
+
+ def connect(self) -> None:
+ last_exc = None
+ for attempt in range(CONNECT_ATTEMPTS):
+ try:
+ sock = socket.create_connection((self.host, self.port), timeout=SOCKET_TIMEOUT)
+ sock.settimeout(SOCKET_TIMEOUT)
+ self._sock = sock
+ return
+ except OSError as exc:
+ last_exc = exc
+ if attempt < CONNECT_ATTEMPTS - 1:
+ time.sleep(CONNECT_RETRY_DELAY)
+ print(UNREACHABLE_MESSAGE, file=sys.stderr)
+ raise SystemExit(1) from last_exc
+
+ def close(self) -> None:
+ if self._sock is not None:
+ self._sock.close()
+ self._sock = None
+
+ def _read_one(self, deadline: float) -> dict:
+ while True:
+ self._buf = self._buf.lstrip()
+ if self._buf:
+ try:
+ text = self._buf.decode("utf-8")
+ except UnicodeDecodeError:
+ text = None
+ if text:
+ try:
+ obj, idx = self._decoder.raw_decode(text)
+ consumed = len(text[:idx].encode("utf-8"))
+ self._buf = self._buf[consumed:]
+ return obj
+ except json.JSONDecodeError:
+ pass
+ if time.monotonic() >= deadline:
+ print("error: timed out waiting for dev server response", file=sys.stderr)
+ raise SystemExit(1)
+ try:
+ chunk = self._sock.recv(4096)
+ except socket.timeout:
+ continue
+ except OSError as exc:
+ print(f"error: connection lost: {exc}", file=sys.stderr)
+ raise SystemExit(1)
+ if not chunk:
+ print("error: dev server closed the connection", file=sys.stderr)
+ raise SystemExit(1)
+ self._buf += chunk
+
+ def request(self, command: str, **fields) -> dict:
+ req_id = self._next_id
+ self._next_id += 1
+ payload = {"@": command, "#": req_id, **fields}
+ self._sock.sendall(json.dumps(payload).encode("utf-8"))
+ deadline = time.monotonic() + RESPONSE_TIMEOUT
+ while True:
+ resp = self._read_one(deadline)
+ if resp.get("#") == req_id:
+ if resp.get("error") or resp.get("errors"):
+ print(json.dumps(resp, indent=2), file=sys.stderr)
+ raise SystemExit(1)
+ return resp
+ # response to a different (stale) request id: keep draining
+
+
+def cmd_ping(client: DevClient, args) -> int:
+ resp = client.request("ping")
+ print(json.dumps(resp, indent=2))
+ return 0
+
+
+def cmd_plugins(client: DevClient, args) -> int:
+ resp = client.request("get_plugins")
+ print(json.dumps(resp, indent=2))
+ return 0
+
+
+def cmd_deploy(client: DevClient, args) -> int:
+ plugin_id = args.id or default_plugin_id()
+ if not plugin_id:
+ print("error: could not determine plugin id (pass --id or fix plugin.json)", file=sys.stderr)
+ return 1
+ try:
+ content = Path(args.file).read_text(encoding="utf-8")
+ except OSError as exc:
+ print(f"error: cannot read {args.file}: {exc}", file=sys.stderr)
+ return 1
+ client.request("write_plugin", plugin_id=plugin_id, content=content)
+ time.sleep(0.3)
+ if args.enable:
+ client.request("enable_plugin", plugin_id=plugin_id)
+ if not args.no_reload:
+ client.request("reload_plugin", plugin_id=plugin_id)
+ print(f"deployed {args.file} as {plugin_id}")
+ return 0
+
+
+def cmd_simple_id(command: str):
+ def handler(client: DevClient, args) -> int:
+ client.request(command, plugin_id=args.id)
+ print(f"{command} {args.id}: ok")
+ return 0
+
+ return handler
+
+
+def cmd_debugger(client: DevClient, args) -> int:
+ if args.action == "start":
+ client.request(
+ "start_debugger",
+ host=args.debug_host,
+ port=args.debug_port,
+ platform=args.platform,
+ )
+ print(f"debugger start requested ({args.platform} -> {args.debug_host}:{args.debug_port})")
+ else:
+ client.request("stop_debugger", platform=args.platform)
+ print(f"debugger stop requested ({args.platform})")
+ return 0
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(description="exteraGram plugin dev server client")
+ parser.add_argument("--host", default="127.0.0.1")
+ parser.add_argument("--port", type=int, default=42690)
+ sub = parser.add_subparsers(dest="command", required=True)
+
+ sub.add_parser("ping").set_defaults(func=cmd_ping)
+ sub.add_parser("plugins").set_defaults(func=cmd_plugins)
+
+ p_deploy = sub.add_parser("deploy")
+ p_deploy.add_argument("file")
+ p_deploy.add_argument("--id")
+ p_deploy.add_argument("--enable", action="store_true")
+ p_deploy.add_argument("--no-reload", action="store_true")
+ p_deploy.set_defaults(func=cmd_deploy)
+
+ for name, command in (
+ ("enable", "enable_plugin"),
+ ("disable", "disable_plugin"),
+ ("reload", "reload_plugin"),
+ ("remove", "remove_plugin"),
+ ):
+ p = sub.add_parser(name)
+ p.add_argument("id")
+ p.set_defaults(func=cmd_simple_id(command))
+
+ p_debugger = sub.add_parser("debugger")
+ p_debugger.add_argument("action", choices=["start", "stop"])
+ p_debugger.add_argument("--platform", choices=["vscode", "pycharm"], default="vscode")
+ p_debugger.add_argument("--debug-host", default="127.0.0.1")
+ p_debugger.add_argument("--debug-port", type=int, default=5678)
+ p_debugger.set_defaults(func=cmd_debugger)
+
+ return parser
+
+
+def main(argv=None) -> int:
+ parser = build_parser()
+ args = parser.parse_args(sys.argv[1:] if argv is None else argv)
+ client = DevClient(args.host, args.port)
+ client.connect()
+ try:
+ return args.func(client, args)
+ finally:
+ client.close()
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tools/verify_bundle.py b/tools/verify_bundle.py
new file mode 100755
index 0000000..2dbc171
--- /dev/null
+++ b/tools/verify_bundle.py
@@ -0,0 +1,263 @@
+#!/usr/bin/env python3
+"""Offline verification harness for a built .plugin bundle. Runs without a phone.
+
+Usage: python3 tools/verify_bundle.py
+"""
+from __future__ import annotations
+
+import ast
+import json
+import sys
+import traceback
+import types
+from dataclasses import dataclass
+from enum import Enum, auto
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+PLUGIN_JSON = REPO_ROOT / "plugin.json"
+
+_FAILURES = []
+
+
+def check(name: str, condition: bool, detail: str = "") -> None:
+ if condition:
+ print(f"PASS {name}")
+ else:
+ message = f"FAIL {name}"
+ if detail:
+ message += f": {detail}"
+ print(message, file=sys.stderr)
+ _FAILURES.append(name)
+
+
+class HookStrategy(Enum):
+ DEFAULT = auto()
+ MODIFY = auto()
+ CANCEL = auto()
+ MODIFY_FINAL = auto()
+
+
+@dataclass
+class HookResult:
+ strategy: HookStrategy = HookStrategy.DEFAULT
+ params: object = None
+
+
+LOG_LINES: list[str] = []
+
+
+class BasePlugin:
+ def __init__(self):
+ self._settings = {}
+
+ def get_setting(self, key, default=None):
+ return self._settings.get(key, default)
+
+ def set_setting(self, key, value):
+ self._settings[key] = value
+
+ def add_on_send_message_hook(self):
+ pass
+
+ def on_plugin_load(self):
+ pass
+
+ def on_plugin_unload(self):
+ pass
+
+ def create_settings(self):
+ return []
+
+
+def fake_log(*args, **kwargs):
+ LOG_LINES.append(" ".join(str(a) for a in args))
+
+
+class _SettingsRow:
+ def __init__(self, **kwargs):
+ self.__dict__.update(kwargs)
+
+
+def install_fakes() -> None:
+ base_plugin_mod = types.ModuleType("base_plugin")
+ base_plugin_mod.BasePlugin = BasePlugin
+ base_plugin_mod.HookResult = HookResult
+ base_plugin_mod.HookStrategy = HookStrategy
+ sys.modules["base_plugin"] = base_plugin_mod
+
+ android_utils_mod = types.ModuleType("android_utils")
+ android_utils_mod.log = fake_log
+ sys.modules["android_utils"] = android_utils_mod
+
+ ui_mod = types.ModuleType("ui")
+ ui_mod.__path__ = []
+ sys.modules["ui"] = ui_mod
+
+ ui_settings_mod = types.ModuleType("ui.settings")
+ for cls_name in ("Header", "Input", "Text", "Switch"):
+ setattr(ui_settings_mod, cls_name, type(cls_name, (_SettingsRow,), {}))
+ sys.modules["ui.settings"] = ui_settings_mod
+ ui_mod.settings = ui_settings_mod
+
+
+class SimpleParams:
+ def __init__(self, message):
+ self.message = message
+
+
+KEY_TO_DUNDER = {
+ "id": "__id__",
+ "name": "__name__",
+ "description": "__description__",
+ "author": "__author__",
+ "version": "__version__",
+ "icon": "__icon__",
+ "app_version": "__app_version__",
+ "sdk_version": "__sdk_version__",
+ "requirements": "__requirements__",
+}
+
+
+def expected_dunders(meta: dict) -> dict:
+ result = {}
+ for key, dunder in KEY_TO_DUNDER.items():
+ if key not in meta:
+ continue
+ value = meta[key]
+ if key in ("description", "icon") and value == "":
+ continue
+ if key == "requirements" and value == []:
+ continue
+ result[dunder] = value
+ return result
+
+
+def main(argv=None) -> int:
+ argv = sys.argv[1:] if argv is None else argv
+ if len(argv) != 1:
+ print("usage: tools/verify_bundle.py ", file=sys.stderr)
+ return 1
+ bundle_path = Path(argv[0])
+ if not bundle_path.is_file():
+ print(f"error: bundle not found at {bundle_path}", file=sys.stderr)
+ return 1
+
+ install_fakes()
+ source = bundle_path.read_text(encoding="utf-8")
+
+ modules_before = set(sys.modules)
+ ns = {"__name__": "hello_bundle"}
+ exec(compile(source, str(bundle_path), "exec"), ns)
+ new_modules = set(sys.modules) - modules_before
+
+ meta = json.loads(PLUGIN_JSON.read_text(encoding="utf-8"))
+ expected = expected_dunders(meta)
+ mismatches = [
+ f"{dunder}: expected {value!r}, got {ns.get(dunder)!r}"
+ for dunder, value in expected.items()
+ if ns.get(dunder) != value
+ ]
+ check("metadata constants match plugin.json", not mismatches, "; ".join(mismatches))
+
+ tree = ast.parse(source, filename=str(bundle_path))
+ top_level_assigned = {
+ target.id
+ for node in tree.body
+ if isinstance(node, ast.Assign)
+ for target in node.targets
+ if isinstance(target, ast.Name)
+ }
+ check(
+ "metadata exposed as top-level literal assignments (AST)",
+ set(expected) <= top_level_assigned,
+ f"missing: {set(expected) - top_level_assigned}",
+ )
+
+ plugin_classes = [
+ value
+ for value in ns.values()
+ if isinstance(value, type) and issubclass(value, BasePlugin) and value is not BasePlugin
+ ]
+ check(
+ "exactly one BasePlugin subclass defined",
+ len(plugin_classes) == 1,
+ f"found {len(plugin_classes)}",
+ )
+
+ plugin_cls = plugin_classes[0] if len(plugin_classes) == 1 else None
+ if plugin_cls is not None:
+ check(
+ "plugin class __module__ matches executed namespace",
+ plugin_cls.__module__ == ns["__name__"],
+ f"__module__={plugin_cls.__module__!r} != {ns['__name__']!r}",
+ )
+
+ plugin = plugin_cls()
+ LOG_LINES.clear()
+ plugin.on_plugin_load()
+ check(
+ "on_plugin_load emits load log line",
+ any("hello_bundle loaded" in line for line in LOG_LINES),
+ f"log lines: {LOG_LINES}",
+ )
+
+ params = SimpleParams(".hello alice smith")
+ result = plugin.on_send_message_hook(account=0, params=params)
+ check(
+ "'.hello alice smith' renders greeting",
+ params.message == "Hello, Alice Smith!",
+ f"got {params.message!r}",
+ )
+ check(
+ "on_send_message_hook returns MODIFY strategy",
+ result.strategy == HookStrategy.MODIFY,
+ f"got {result.strategy}",
+ )
+
+ plugin.set_setting("template", "{nope}")
+ params2 = SimpleParams(".hello someone")
+ plugin.on_send_message_hook(account=0, params=params2)
+ check(
+ "bad template produces 'Bad template:' message",
+ params2.message.startswith("Bad template:"),
+ f"got {params2.message!r}",
+ )
+
+ # main.py imports `render`/`TemplateError` by name, so they live directly
+ # in the executed namespace — call render() directly to inspect the raised
+ # traceback (proves linecache registration for bundled, non-file modules).
+ render = ns.get("render")
+ template_error = ns.get("TemplateError")
+ traceback_text = ""
+ if render is not None and template_error is not None:
+ try:
+ render("{nope}", "x")
+ except template_error:
+ traceback_text = traceback.format_exc()
+ check(
+ "render() traceback names src/greeting/template.py",
+ "src/greeting/template.py" in traceback_text,
+ f"traceback:\n{traceback_text}",
+ )
+ check(
+ "traceback includes the raising source line text",
+ "unsupported placeholder" in traceback_text,
+ f"traceback:\n{traceback_text}",
+ )
+
+ check(
+ "sys.modules gained no entry for bundled module names",
+ not ({"greeting", "textutil", "main"} & new_modules),
+ f"leaked: {({'greeting', 'textutil', 'main'} & new_modules)}",
+ )
+
+ if _FAILURES:
+ print(f"\n{len(_FAILURES)} check(s) failed: {', '.join(_FAILURES)}", file=sys.stderr)
+ return 1
+ print("\nall checks passed")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())