generated from exteraGramPlugins/pluginBase
Initial commit
This commit is contained in:
commit
80f1d5e55c
31 files changed
+2427
No files matched your search
@@ -0,0 +1,95 @@
|
|||||||
|
name: Publish plugin
|
||||||
|
|
||||||
|
# Builds dist/<id>.plugin and publishes it to the Releases tab.
|
||||||
|
#
|
||||||
|
# The version in plugin.json is the single source of truth:
|
||||||
|
# new version -> a new v<version> tag and release are created
|
||||||
|
# same version -> the existing release is rebuilt, replacing its attachment
|
||||||
|
#
|
||||||
|
# So a normal push to main always produces a downloadable build, and bumping
|
||||||
|
# the version in plugin.json is what cuts a new release. Pushing a v* tag by
|
||||||
|
# hand still works and uses that tag verbatim.
|
||||||
|
#
|
||||||
|
# dist/ is gitignored, so the artifact is always built here rather than
|
||||||
|
# committed. The build uses --release because --dev embeds absolute paths from
|
||||||
|
# whichever machine produced it.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out sources
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Validate plugin metadata and sources
|
||||||
|
run: python3 tools/bundle.py --check
|
||||||
|
|
||||||
|
- name: Build release bundle
|
||||||
|
id: build
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
python3 tools/bundle.py --release
|
||||||
|
plugin_id=$(python3 -c "import json; print(json.load(open('plugin.json'))['id'])")
|
||||||
|
version=$(python3 -c "import json; print(json.load(open('plugin.json'))['version'])")
|
||||||
|
artifact="dist/${plugin_id}.plugin"
|
||||||
|
test -s "$artifact"
|
||||||
|
echo "artifact=${artifact}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "asset_name=${plugin_id}.plugin" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version=${version}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "built ${artifact} ($(wc -c < "$artifact") bytes)"
|
||||||
|
|
||||||
|
- name: Verify the built bundle
|
||||||
|
# Executes the bundle against faked SDK modules and asserts the
|
||||||
|
# plugin's observable behaviour. A build that fails this is not
|
||||||
|
# publishable, so this gates the upload.
|
||||||
|
run: python3 tools/verify_bundle.py "${{ steps.build.outputs.artifact }}"
|
||||||
|
|
||||||
|
- name: Resolve release tag
|
||||||
|
id: tag
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
if [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
|
||||||
|
# Hand-pushed tag: honour it exactly as written.
|
||||||
|
tag="${GITHUB_REF_NAME}"
|
||||||
|
else
|
||||||
|
# Branch push or manual run: the version in plugin.json decides
|
||||||
|
# whether this is a new release or a rebuild of the current one.
|
||||||
|
tag="v${{ steps.build.outputs.version }}"
|
||||||
|
fi
|
||||||
|
echo "tag=${tag}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "publishing as ${tag}"
|
||||||
|
- name: Publish release and upload plugin
|
||||||
|
env:
|
||||||
|
# Passed under a name the runner does not reserve. A step-level
|
||||||
|
# GITEA_TOKEN is overridden by the runner's own job token, so the
|
||||||
|
# org secret would be silently ignored under that name.
|
||||||
|
#
|
||||||
|
# RELEASE_TOKEN is the org-wide API_TOKEN secret. If it is empty --
|
||||||
|
# which happens when Actions is disabled on the repository, so org
|
||||||
|
# secrets are never exposed to it -- release.py falls back to the
|
||||||
|
# runner's GITEA_TOKEN, which can still publish to its own repo.
|
||||||
|
RELEASE_TOKEN: ${{ secrets.API_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
if [ -n "${RELEASE_TOKEN:-}" ]; then
|
||||||
|
echo "org secret API_TOKEN is present (${#RELEASE_TOKEN} chars)"
|
||||||
|
else
|
||||||
|
echo "org secret API_TOKEN is empty; falling back to the runner token"
|
||||||
|
fi
|
||||||
|
python3 tools/release.py \
|
||||||
|
--api "${{ github.server_url }}/api/v1/repos/${{ github.repository }}" \
|
||||||
|
--tag "${{ steps.tag.outputs.tag }}" \
|
||||||
|
--artifact "${{ steps.build.outputs.artifact }}" \
|
||||||
|
--asset-name "${{ steps.build.outputs.asset_name }}" \
|
||||||
|
--commitish "${{ github.sha }}"
|
||||||
+179
@@ -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/
|
||||||
Generated
+10
@@ -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
|
||||||
Generated
+8
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="PYTHON_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$" />
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<profile version="1.0">
|
||||||
|
<option name="myName" value="Project Default" />
|
||||||
|
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||||
|
<inspection_tool class="PyStringConversionWithoutDunderMethodInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
||||||
|
<option name="ignoredTypes">
|
||||||
|
<list>
|
||||||
|
<option value="types.NoneType" />
|
||||||
|
<option value="_io.TextIOWrapper" />
|
||||||
|
<option value="str" />
|
||||||
|
<option value="int" />
|
||||||
|
<option value="float" />
|
||||||
|
<option value="complex" />
|
||||||
|
<option value="set" />
|
||||||
|
<option value="frozenset" />
|
||||||
|
<option value="bytes" />
|
||||||
|
<option value="bytearray" />
|
||||||
|
<option value="memoryview" />
|
||||||
|
<option value="slice" />
|
||||||
|
<option value="list" />
|
||||||
|
<option value="dict" />
|
||||||
|
<option value="bool" />
|
||||||
|
<option value="range" />
|
||||||
|
<option value="tuple" />
|
||||||
|
<option value="pathlib.PurePath" />
|
||||||
|
<option value="uuid.UUID" />
|
||||||
|
<option value="decimal.Decimal" />
|
||||||
|
<option value="fractions.Fraction" />
|
||||||
|
<option value="schemas.token.JWTToken" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</inspection_tool>
|
||||||
|
</profile>
|
||||||
|
</component>
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<settings>
|
||||||
|
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||||
|
<version value="1.0" />
|
||||||
|
</settings>
|
||||||
|
</component>
|
||||||
Generated
+14
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DiscordProjectSettings">
|
||||||
|
<option name="show" value="ASK" />
|
||||||
|
<option name="description" value="" />
|
||||||
|
<option name="applicationTheme" value="default" />
|
||||||
|
<option name="iconsTheme" value="default" />
|
||||||
|
<option name="button1Title" value="" />
|
||||||
|
<option name="button1Url" value="" />
|
||||||
|
<option name="button2Title" value="" />
|
||||||
|
<option name="button2Url" value="" />
|
||||||
|
<option name="customApplicationId" value="" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+8
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/exteraGramPluginBase.iml" filepath="$PROJECT_DIR$/.idea/exteraGramPluginBase.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Vendored
+15
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
# 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/<plugin_id>.py`. The distribution
|
||||||
|
convention names the same content `<id>.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/<id>.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
|
||||||
|
release.py publishes a built bundle to a Gitea release (used by CI)
|
||||||
|
.gitea/workflows/ CI that builds, verifies and publishes the .plugin to Releases
|
||||||
|
scripts/ ADB debug-loop automation and the git hooks (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/<id>.plugin
|
||||||
|
python3 tools/verify_bundle.py dist/<id>.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`.
|
||||||
|
Its first run in a clone also enables the repo's git hooks (see
|
||||||
|
[Git hooks and versioning](#git-hooks-and-versioning)).
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## Git hooks and versioning
|
||||||
|
|
||||||
|
`scripts/pre-commit` bumps the patch number of `version` in `plugin.json` on every commit
|
||||||
|
(`1.0.0` → `1.0.1`) and stages it into that same commit. `scripts/post-commit` re-stages
|
||||||
|
`plugin.json` afterwards, because commits of a file selection (`git commit -- <paths>`, which
|
||||||
|
some IDEs use) are built from a temporary index and would otherwise leave it looking modified.
|
||||||
|
|
||||||
|
**The hooks enable themselves.** Git never activates hooks from repository content, so a
|
||||||
|
fresh clone, or a repo created from this template, starts without them. The first run of
|
||||||
|
`tools/bundle.py` fixes that by running `git config core.hooksPath scripts`, and every
|
||||||
|
workflow runs it: directly, or through `scripts/deploy.sh` and `scripts/watch.sh`. That
|
||||||
|
includes `--check`, so after cloning run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tools/bundle.py --check
|
||||||
|
```
|
||||||
|
|
||||||
|
once, before your first commit. A commit made before any build runs without the hooks.
|
||||||
|
`bundle.py` leaves `core.hooksPath` alone when it is already set (so pointing it elsewhere on
|
||||||
|
purpose sticks), skips the step in CI (whenever `CI` is set) and outside a git work tree, and
|
||||||
|
never fails a build over it. To turn the hooks off for a clone, run
|
||||||
|
`git config core.hooksPath .git/hooks`; `git config --unset core.hooksPath` only lasts
|
||||||
|
until the next build.
|
||||||
|
|
||||||
|
The bump is skipped when:
|
||||||
|
|
||||||
|
- the commit message contains `[no bump]`, `[skip bump]`, `[no version bump]` or
|
||||||
|
`[skip version]` (case-insensitive; `nobump`, `no-bump` and `no_bump` also work),
|
||||||
|
- `NO_BUMP=1` (or `GIT_NO_BUMP=1`) is set, e.g. `NO_BUMP=1 git commit`, which is the only way
|
||||||
|
for commits written in an editor, since their message doesn't exist yet when the hook runs,
|
||||||
|
- `version` in `plugin.json` already differs from `HEAD` (you bumped it by hand, e.g. for a
|
||||||
|
minor or major release),
|
||||||
|
- a merge, rebase, cherry-pick or revert is in progress.
|
||||||
|
|
||||||
|
Because every version change publishes a release (see below), a normal commit + push to
|
||||||
|
`main` ships a new patch release. Use a stop word for commits that should not.
|
||||||
|
|
||||||
|
## Publishing a release
|
||||||
|
|
||||||
|
`.gitea/workflows/release.yml` builds, verifies and publishes the `.plugin` to the repo's
|
||||||
|
Releases tab on every push to `main`. `dist/` is gitignored, so the artifact is always built
|
||||||
|
by CI rather than committed. Nothing in the workflow is plugin-specific — it reads the id and
|
||||||
|
version straight out of `plugin.json`, so it keeps working after you rename the plugin.
|
||||||
|
|
||||||
|
The `version` field in `plugin.json` is the single source of truth:
|
||||||
|
|
||||||
|
| Push to `main` with… | Result |
|
||||||
|
| --- | --- |
|
||||||
|
| a new `version` | creates tag `v<version>` and a new release |
|
||||||
|
| the same `version` | rebuilds the existing release, replacing its attachment |
|
||||||
|
|
||||||
|
So every push leaves a downloadable build attached to the current version, and bumping
|
||||||
|
`version` is what cuts a new release. With the git hooks enabled every commit bumps it,
|
||||||
|
so every push of a normal commit cuts a new patch release. Pushing a `v*` tag by hand still works and uses that
|
||||||
|
tag verbatim, as does a manual run (Actions → Publish plugin → Run workflow).
|
||||||
|
|
||||||
|
Every path runs `--check`, builds with `--release`, and gates the upload on
|
||||||
|
`verify_bundle.py`, so a bundle that fails verification is never published. A rebuild
|
||||||
|
replaces the attachment rather than piling up duplicates, and refreshes the release notes to
|
||||||
|
name the commit it came from. The tag itself is never moved: it keeps pointing at the commit
|
||||||
|
that first introduced that version.
|
||||||
|
|
||||||
|
Authentication uses `secrets.API_TOKEN`, an organisation-level Actions secret holding a user
|
||||||
|
token with the `write:repository` scope (which covers releases). Set it once under
|
||||||
|
**Org → Settings → Actions → Secrets** and every repo in the organisation inherits it; a
|
||||||
|
repo-level secret of the same name takes precedence if one repo ever needs different
|
||||||
|
credentials.
|
||||||
|
|
||||||
|
The workflow passes it to `release.py` as `RELEASE_TOKEN`, **not** `GITEA_TOKEN`. The Actions
|
||||||
|
runner injects its own `GITEA_TOKEN` into every step, and that value overrides a step-level
|
||||||
|
`env:` entry of the same name, so the org secret would be silently ignored.
|
||||||
|
|
||||||
|
If the org secret does not reach a repository the publish still works: `release.py` tries
|
||||||
|
`RELEASE_TOKEN` first and then falls back to the runner's `GITEA_TOKEN`, which can publish
|
||||||
|
releases in its own repository, and prints which one it used. An organisation secret is only
|
||||||
|
exposed to a repository that has Actions enabled, so check **Repo → Settings → Actions** if a
|
||||||
|
repo unexpectedly falls back.
|
||||||
|
|
||||||
|
If you would rather not manage a token at all, that injected `GITEA_TOKEN` can create
|
||||||
|
releases in its own repo: pass `--token-env GITEA_TOKEN` and drop the org secret. It is
|
||||||
|
scoped to the single job and expires with it, so it is the safer default; `API_TOKEN` is what
|
||||||
|
you want when publishing needs to reach beyond this repo.
|
||||||
|
|
||||||
|
## Writing your own plugin
|
||||||
|
|
||||||
|
1. Run `python3 tools/bundle.py --check` once. It enables the git hooks that auto-bump the
|
||||||
|
version on commit (see [Git hooks and versioning](#git-hooks-and-versioning)).
|
||||||
|
2. Edit `plugin.json` (id, name, description, etc.).
|
||||||
|
3. 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).
|
||||||
|
4. `python3 tools/bundle.py --release && python3 tools/verify_bundle.py dist/<id>.plugin`
|
||||||
|
(substitute your `id` from `plugin.json`).
|
||||||
|
5. `bash scripts/deploy.sh --enable` with a device connected.
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# exteraGram plugin SDK — condensed notes
|
||||||
|
|
||||||
|
Gathered from <https://plugins.exteragram.app/docs>, 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:
|
||||||
|
<https://plugins.exteragram.app/docs/metadata>.
|
||||||
|
|
||||||
|
| 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
|
||||||
|
|
||||||
|
<https://plugins.exteragram.app/docs/base-plugin>
|
||||||
|
|
||||||
|
- `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
|
||||||
|
|
||||||
|
<https://plugins.exteragram.app/docs/ui-settings>
|
||||||
|
|
||||||
|
`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
|
||||||
|
|
||||||
|
<https://plugins.exteragram.app/docs/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
|
||||||
|
|
||||||
|
<https://plugins.exteragram.app/docs/client-utils>
|
||||||
|
|
||||||
|
`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
|
||||||
|
|
||||||
|
<https://plugins.exteragram.app/docs/hook-utils>
|
||||||
|
|
||||||
|
`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. <https://plugins.exteragram.app/docs/ui-alert>
|
||||||
|
- `ui.bulletin` — transient bottom bulletins/toasts. <https://plugins.exteragram.app/docs/ui-bulletin>
|
||||||
|
- `intents.IntentsManager` — Android intent integration, **exteraGram 12.6.4+ only**.
|
||||||
|
<https://plugins.exteragram.app/docs/intents>
|
||||||
|
|
||||||
|
## On-device dev server protocol
|
||||||
|
|
||||||
|
<https://plugins.exteragram.app/docs/dev-server>
|
||||||
|
|
||||||
|
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: `{"@": <command>, "#": <id>, ...}`; 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.
|
||||||
+11
@@ -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": []
|
||||||
|
}
|
||||||
Executable
+31
@@ -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:-<unset>})"
|
||||||
|
|
||||||
|
"$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
|
||||||
Executable
+49
@@ -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"
|
||||||
Executable
+36
@@ -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[@]}"
|
||||||
Executable
+62
@@ -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/"
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
Executable
+39
@@ -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
|
||||||
Executable
+13
@@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Companion to scripts/pre-commit. Both are enabled per clone by tools/bundle.py
|
||||||
|
# (it sets `core.hooksPath scripts` on its first run).
|
||||||
|
#
|
||||||
|
# `git commit -- <pathspec>` (also how some IDEs commit a file selection) builds
|
||||||
|
# the commit from a temporary index. The pre-commit bump therefore lands in the
|
||||||
|
# commit, but the real index keeps the pre-bump blob and plugin.json is left
|
||||||
|
# looking modified. The real index cannot be updated during the commit because
|
||||||
|
# git holds index.lock, so refresh it here instead.
|
||||||
|
#
|
||||||
|
# Must never fail a commit that already succeeded.
|
||||||
|
git add -- plugin.json 2>/dev/null || true
|
||||||
|
exit 0
|
||||||
Executable
+265
@@ -0,0 +1,265 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Increment the patch component of plugin.json's version as part of the commit.
|
||||||
|
|
||||||
|
Enabled per clone by tools/bundle.py, which sets `core.hooksPath scripts` on its
|
||||||
|
first run (git never enables hooks from repository content by itself).
|
||||||
|
|
||||||
|
Runs as a pre-commit hook, which is the only commit hook whose `git add` is
|
||||||
|
picked up by the commit currently being created:
|
||||||
|
|
||||||
|
* pre-commit -> staged changes land in THIS commit (correct)
|
||||||
|
* prepare-commit-msg -> staged changes land in the NEXT commit
|
||||||
|
* commit-msg -> staged changes are ignored entirely
|
||||||
|
|
||||||
|
The commit message is read from the invoking `git` process' argv (-m/-F/-C and
|
||||||
|
long forms). `.git/COMMIT_EDITMSG` is deliberately NOT consulted: at pre-commit
|
||||||
|
time it still holds the PREVIOUS commit's message, so a stop word there would
|
||||||
|
silently suppress every later bump.
|
||||||
|
|
||||||
|
Bump is skipped when:
|
||||||
|
* the commit message carries a stop word (see STOP_PATTERNS),
|
||||||
|
* NO_BUMP / GIT_NO_BUMP is set in the environment,
|
||||||
|
* plugin.json's version already differs from HEAD (manual bump),
|
||||||
|
* a merge or rebase is in progress,
|
||||||
|
* plugin.json is absent or its version is not x.y.z.
|
||||||
|
|
||||||
|
For editor-driven commits (`git commit` with no -m) the message does not exist
|
||||||
|
yet when this hook runs, so use `NO_BUMP=1 git commit` to skip the bump there.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PLUGIN_FILE = "plugin.json"
|
||||||
|
|
||||||
|
STOP_PATTERNS = (
|
||||||
|
r"\[\s*no[-_ ]?bump\s*\]",
|
||||||
|
r"\[\s*skip[-_ ]?bump\s*\]",
|
||||||
|
r"\[\s*no[-_ ]?version[-_ ]?bump\s*\]",
|
||||||
|
r"\[\s*skip[-_ ]?version\s*\]",
|
||||||
|
)
|
||||||
|
STOP_RE = re.compile("|".join(STOP_PATTERNS), re.IGNORECASE)
|
||||||
|
|
||||||
|
VERSION_RE = re.compile(r"\A(\d+)\.(\d+)\.(\d+)(.*)\Z", re.DOTALL)
|
||||||
|
|
||||||
|
# options whose value IS the message text
|
||||||
|
TEXT_OPTS = ("-m", "--message")
|
||||||
|
TEXT_OPTS_EQ = ("--message=",)
|
||||||
|
# options whose value NAMES a file containing the message
|
||||||
|
FILE_OPTS = ("-F", "--file")
|
||||||
|
FILE_OPTS_EQ = ("--file=",)
|
||||||
|
# options whose value names a commit to take the message from
|
||||||
|
COMMIT_OPTS = ("-C", "--reuse-message", "-c", "--reedit-message")
|
||||||
|
COMMIT_OPTS_EQ = ("--reuse-message=", "--reedit-message=")
|
||||||
|
|
||||||
|
|
||||||
|
def repo_root() -> Path:
|
||||||
|
out = subprocess.run(
|
||||||
|
["git", "rev-parse", "--show-toplevel"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if out.returncode != 0:
|
||||||
|
raise RuntimeError(out.stderr.strip() or "not inside a git work tree")
|
||||||
|
return Path(out.stdout.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def git_dir() -> Path:
|
||||||
|
out = subprocess.run(
|
||||||
|
["git", "rev-parse", "--absolute-git-dir"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if out.returncode != 0:
|
||||||
|
raise RuntimeError(out.stderr.strip() or "cannot resolve git dir")
|
||||||
|
return Path(out.stdout.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def bump_patch(version: str) -> str:
|
||||||
|
m = VERSION_RE.match(version.strip())
|
||||||
|
if not m:
|
||||||
|
raise ValueError(f"version {version!r} is not x.y.z")
|
||||||
|
major, minor, patch, suffix = m.groups()
|
||||||
|
return f"{major}.{minor}.{int(patch) + 1}{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def head_version(root: Path) -> str | None:
|
||||||
|
"""Version recorded for plugin.json in HEAD, or None when unavailable."""
|
||||||
|
if subprocess.run(
|
||||||
|
["git", "rev-parse", "--verify", "--quiet", "HEAD"],
|
||||||
|
cwd=root,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
).returncode != 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
show = subprocess.run(
|
||||||
|
["git", "show", f"HEAD:{PLUGIN_FILE}"],
|
||||||
|
cwd=root,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if show.returncode != 0:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return str(json.loads(show.stdout)["version"])
|
||||||
|
except (json.JSONDecodeError, KeyError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def commit_message_text(root: Path) -> str:
|
||||||
|
"""Commit message text of the git process that spawned this hook.
|
||||||
|
|
||||||
|
Covers `-m/--message` (inline), `-F/--file` (read the file) and
|
||||||
|
`-C/-c/--reuse-message/--reedit-message` (read that commit's message).
|
||||||
|
Returns "" for editor-driven commits, where no message exists yet.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
argv = Path(f"/proc/{os.getppid()}/cmdline").read_bytes().decode("utf-8", "replace").split("\0")
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
texts: list[str] = []
|
||||||
|
pending: str | None = None # "text" | "file" | "commit"
|
||||||
|
|
||||||
|
def take(kind: str, value: str) -> None:
|
||||||
|
if kind == "text":
|
||||||
|
texts.append(value)
|
||||||
|
elif kind == "file":
|
||||||
|
try:
|
||||||
|
texts.append(Path(value).read_text(encoding="utf-8", errors="replace"))
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
out = subprocess.run(
|
||||||
|
["git", "log", "-1", "--format=%B", value],
|
||||||
|
cwd=root,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if out.returncode == 0:
|
||||||
|
texts.append(out.stdout)
|
||||||
|
|
||||||
|
for arg in argv:
|
||||||
|
if not arg:
|
||||||
|
continue
|
||||||
|
if pending:
|
||||||
|
take(pending, arg)
|
||||||
|
pending = None
|
||||||
|
continue
|
||||||
|
if arg in TEXT_OPTS:
|
||||||
|
pending = "text"
|
||||||
|
elif arg in FILE_OPTS:
|
||||||
|
pending = "file"
|
||||||
|
elif arg in COMMIT_OPTS:
|
||||||
|
pending = "commit"
|
||||||
|
elif arg.startswith(TEXT_OPTS_EQ):
|
||||||
|
take("text", arg.split("=", 1)[1])
|
||||||
|
elif arg.startswith(FILE_OPTS_EQ):
|
||||||
|
take("file", arg.split("=", 1)[1])
|
||||||
|
elif arg.startswith(COMMIT_OPTS_EQ):
|
||||||
|
take("commit", arg.split("=", 1)[1])
|
||||||
|
elif len(arg) > 1 and arg[0] == "-" and arg[1] != "-":
|
||||||
|
# clustered short flags, e.g. -am "text" or -am"text"
|
||||||
|
for kind, letter in (("text", "m"), ("file", "F"), ("commit", "C")):
|
||||||
|
idx = arg.find(letter, 1)
|
||||||
|
if idx == -1:
|
||||||
|
continue
|
||||||
|
if idx + 1 < len(arg):
|
||||||
|
take(kind, arg[idx + 1:])
|
||||||
|
else:
|
||||||
|
pending = kind
|
||||||
|
break
|
||||||
|
|
||||||
|
return "\n".join(texts)
|
||||||
|
|
||||||
|
|
||||||
|
def strip_comments(text: str) -> str:
|
||||||
|
return " ".join(
|
||||||
|
line for line in text.splitlines() if not line.lstrip().startswith("#")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def stop_word_reason(root: Path) -> str | None:
|
||||||
|
if os.environ.get("NO_BUMP") or os.environ.get("GIT_NO_BUMP"):
|
||||||
|
return "NO_BUMP is set"
|
||||||
|
match = STOP_RE.search(strip_comments(commit_message_text(root)))
|
||||||
|
if match:
|
||||||
|
return f"commit message contains {match.group(0)!r}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def merge_or_rebase_in_progress(gitdir: Path) -> bool:
|
||||||
|
return any(
|
||||||
|
(gitdir / name).exists()
|
||||||
|
for name in ("MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "rebase-merge", "rebase-apply")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
try:
|
||||||
|
root = repo_root()
|
||||||
|
gitdir = git_dir()
|
||||||
|
except RuntimeError as exc:
|
||||||
|
print(f"pre-commit: {exc}", file=sys.stderr)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if merge_or_rebase_in_progress(gitdir):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
reason = stop_word_reason(root)
|
||||||
|
if reason:
|
||||||
|
print(f"pre-commit: {reason}, keeping version unchanged")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
path = root / PLUGIN_FILE
|
||||||
|
if not path.exists():
|
||||||
|
return 0
|
||||||
|
|
||||||
|
raw = path.read_text(encoding="utf-8")
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
print(f"pre-commit: {PLUGIN_FILE} is not valid JSON: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
current = str(data.get("version", ""))
|
||||||
|
previous = head_version(root)
|
||||||
|
if previous is not None and current != previous:
|
||||||
|
print(
|
||||||
|
f"pre-commit: version already changed ({previous} -> {current}), keeping it"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
new_version = bump_patch(current)
|
||||||
|
except ValueError as exc:
|
||||||
|
print(f"pre-commit: {exc}, keeping version unchanged", file=sys.stderr)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
data["version"] = new_version
|
||||||
|
trailing = "\n" if raw.endswith("\n") else ""
|
||||||
|
path.write_text(json.dumps(data, indent=2) + trailing, encoding="utf-8")
|
||||||
|
|
||||||
|
# Stage into the index git is actually building for this commit. For
|
||||||
|
# `git commit -- <pathspec>` that is a temporary index named by
|
||||||
|
# GIT_INDEX_FILE, so honouring the inherited environment is what makes the
|
||||||
|
# bump part of THIS commit.
|
||||||
|
add = subprocess.run(["git", "add", "--", PLUGIN_FILE], cwd=root)
|
||||||
|
if add.returncode != 0:
|
||||||
|
print(f"pre-commit: failed to stage {PLUGIN_FILE}", file=sys.stderr)
|
||||||
|
return add.returncode
|
||||||
|
|
||||||
|
# The real index may still hold the pre-bump blob after a pathspec commit;
|
||||||
|
# scripts/post-commit refreshes it once git releases index.lock.
|
||||||
|
|
||||||
|
print(f"pre-commit: version {current} -> {new_version}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Executable
+39
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
from .template import render, TemplateError
|
||||||
@@ -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}")
|
||||||
+50
@@ -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 <name>"
|
||||||
|
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)
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
def normalize(name: str) -> str:
|
||||||
|
return " ".join(w[:1].upper() + w[1:] for w in name.split())
|
||||||
+391
@@ -0,0 +1,391 @@
|
|||||||
|
#!/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]
|
||||||
|
|
||||||
|
Also enables the repo's git hooks (scripts/pre-commit, scripts/post-commit) on
|
||||||
|
first run; see ensure_git_hooks().
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import ast
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import zlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
SRC_DIR = REPO_ROOT / "src"
|
||||||
|
HOOKS_DIR = "scripts"
|
||||||
|
HOOK_NAMES = ("pre-commit", "post-commit")
|
||||||
|
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 ensure_git_hooks() -> None:
|
||||||
|
"""Point git at scripts/ for hooks, once per clone.
|
||||||
|
|
||||||
|
Git never enables hooks from repository content, so a fresh clone (or a
|
||||||
|
repo created from the template) starts without them. Every workflow runs
|
||||||
|
this bundler first, directly or via scripts/deploy.sh and scripts/watch.sh,
|
||||||
|
which makes it the one place that can switch them on without an extra
|
||||||
|
setup step. Skipped in CI, outside a git work tree, and when
|
||||||
|
core.hooksPath is already set (including to something else on purpose).
|
||||||
|
Never fails the build.
|
||||||
|
"""
|
||||||
|
if os.environ.get("CI"):
|
||||||
|
return
|
||||||
|
hooks = [REPO_ROOT / HOOKS_DIR / name for name in HOOK_NAMES]
|
||||||
|
if not any(h.is_file() for h in hooks):
|
||||||
|
return
|
||||||
|
|
||||||
|
def git(*args):
|
||||||
|
return subprocess.run(["git", *args], cwd=REPO_ROOT, capture_output=True, text=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
top = git("rev-parse", "--show-toplevel")
|
||||||
|
if top.returncode != 0 or Path(top.stdout.strip()).resolve() != REPO_ROOT:
|
||||||
|
return
|
||||||
|
if git("config", "--get", "core.hooksPath").stdout.strip():
|
||||||
|
return
|
||||||
|
# Git silently skips hooks that are not executable.
|
||||||
|
for hook in hooks:
|
||||||
|
if hook.is_file():
|
||||||
|
mode = hook.stat().st_mode
|
||||||
|
hook.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||||
|
if git("config", "core.hooksPath", HOOKS_DIR).returncode == 0:
|
||||||
|
print(f"enabled git hooks from {HOOKS_DIR}/ (git config core.hooksPath {HOOKS_DIR})", file=sys.stderr)
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
ensure_git_hooks()
|
||||||
|
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())
|
||||||
Executable
+219
@@ -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
|
||||||
|
{"@": <command>, "#": <id>, ...}; 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())
|
||||||
Executable
+220
@@ -0,0 +1,220 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Publishes a built .plugin file to a Gitea release.
|
||||||
|
|
||||||
|
Creates the release for the given tag if it does not exist, replaces any
|
||||||
|
same-named asset already attached, then uploads the artifact. Re-running is
|
||||||
|
safe: the release is reused and the asset is replaced rather than duplicated.
|
||||||
|
|
||||||
|
Stdlib only, so it runs on any runner image without a pip install step.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
tools/release.py --api <repo-api-url> --tag v1.0.0 --artifact dist/x.plugin
|
||||||
|
|
||||||
|
The token is read from RELEASE_TOKEN by default; override with --token-env.
|
||||||
|
Deliberately not GITEA_TOKEN: the Actions runner injects its own value for
|
||||||
|
that name into every step, which overrides a step-level `env:` entry.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message: str) -> "None":
|
||||||
|
print(f"error: {message}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def request(
|
||||||
|
method: str,
|
||||||
|
url: str,
|
||||||
|
token: str,
|
||||||
|
body: Optional[bytes] = None,
|
||||||
|
content_type: Optional[str] = None,
|
||||||
|
) -> Tuple[int, bytes]:
|
||||||
|
"""Performs one API call, returning (status, body) instead of raising."""
|
||||||
|
req = urllib.request.Request(url, data=body, method=method)
|
||||||
|
req.add_header("Authorization", f"token {token}")
|
||||||
|
req.add_header("Accept", "application/json")
|
||||||
|
if content_type:
|
||||||
|
req.add_header("Content-Type", content_type)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req) as response:
|
||||||
|
return response.status, response.read()
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
return exc.code, exc.read()
|
||||||
|
except urllib.error.URLError as exc:
|
||||||
|
fail(f"{method} {url} failed: {exc.reason}")
|
||||||
|
raise # unreachable, keeps type checkers quiet
|
||||||
|
|
||||||
|
|
||||||
|
def find_release(api: str, tag: str, token: str) -> Optional[dict]:
|
||||||
|
status, payload = request("GET", f"{api}/releases/tags/{tag}", token)
|
||||||
|
if status == 404:
|
||||||
|
return None
|
||||||
|
if status != 200:
|
||||||
|
fail(f"looking up release {tag} returned HTTP {status}: {payload[:400]!r}")
|
||||||
|
return json.loads(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def create_release(api: str, tag: str, token: str, commitish: str, notes: str) -> dict:
|
||||||
|
"""Creates the release, which also creates the tag if it does not exist."""
|
||||||
|
body = json.dumps(
|
||||||
|
{
|
||||||
|
"tag_name": tag,
|
||||||
|
"target_commitish": commitish,
|
||||||
|
"name": tag,
|
||||||
|
"body": notes,
|
||||||
|
"draft": False,
|
||||||
|
"prerelease": False,
|
||||||
|
}
|
||||||
|
).encode()
|
||||||
|
status, payload = request("POST", f"{api}/releases", token, body, "application/json")
|
||||||
|
if status != 201:
|
||||||
|
fail(f"creating release {tag} returned HTTP {status}: {payload[:400]!r}")
|
||||||
|
return json.loads(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def update_release(api: str, release_id: int, token: str, commitish: str, notes: str) -> dict:
|
||||||
|
"""Refreshes an existing release so a rebuild reports the commit it came from.
|
||||||
|
|
||||||
|
target_commitish is deliberately not changed: the tag already points at the
|
||||||
|
commit that first introduced this version, and moving it would silently
|
||||||
|
rewrite history for anyone who already fetched it.
|
||||||
|
"""
|
||||||
|
body = json.dumps({"body": notes}).encode()
|
||||||
|
status, payload = request(
|
||||||
|
"PATCH", f"{api}/releases/{release_id}", token, body, "application/json"
|
||||||
|
)
|
||||||
|
if status != 200:
|
||||||
|
fail(f"updating release {release_id} returned HTTP {status}: {payload[:400]!r}")
|
||||||
|
return json.loads(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_existing_assets(api: str, release_id: int, name: str, token: str) -> int:
|
||||||
|
"""Removes same-named assets, since Gitea keeps duplicates side by side."""
|
||||||
|
status, payload = request("GET", f"{api}/releases/{release_id}/assets", token)
|
||||||
|
if status != 200:
|
||||||
|
fail(f"listing assets returned HTTP {status}: {payload[:400]!r}")
|
||||||
|
removed = 0
|
||||||
|
for asset in json.loads(payload) or []:
|
||||||
|
if asset.get("name") != name:
|
||||||
|
continue
|
||||||
|
status, payload = request(
|
||||||
|
"DELETE", f"{api}/releases/{release_id}/assets/{asset['id']}", token
|
||||||
|
)
|
||||||
|
if status not in (204, 404):
|
||||||
|
fail(f"deleting asset {asset['id']} returned HTTP {status}: {payload[:400]!r}")
|
||||||
|
removed += 1
|
||||||
|
return removed
|
||||||
|
|
||||||
|
|
||||||
|
def upload_asset(api: str, release_id: int, path: Path, name: str, token: str) -> dict:
|
||||||
|
"""Uploads the artifact as multipart/form-data."""
|
||||||
|
boundary = uuid.uuid4().hex
|
||||||
|
mime = mimetypes.guess_type(name)[0] or "application/octet-stream"
|
||||||
|
payload = b"".join(
|
||||||
|
[
|
||||||
|
f"--{boundary}\r\n".encode(),
|
||||||
|
f'Content-Disposition: form-data; name="attachment"; filename="{name}"\r\n'.encode(),
|
||||||
|
f"Content-Type: {mime}\r\n\r\n".encode(),
|
||||||
|
path.read_bytes(),
|
||||||
|
f"\r\n--{boundary}--\r\n".encode(),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
url = f"{api}/releases/{release_id}/assets?name={urllib.parse.quote(name)}"
|
||||||
|
status, response = request(
|
||||||
|
"POST", url, token, payload, f"multipart/form-data; boundary={boundary}"
|
||||||
|
)
|
||||||
|
if status != 201:
|
||||||
|
fail(f"uploading {name} returned HTTP {status}: {response[:400]!r}")
|
||||||
|
return json.loads(response)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Optional[list] = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--api", required=True, help="repo API base, e.g. https://host/api/v1/repos/owner/repo")
|
||||||
|
parser.add_argument("--tag", required=True, help="release tag")
|
||||||
|
parser.add_argument("--artifact", required=True, help="file to attach")
|
||||||
|
parser.add_argument("--commitish", default="", help="target commit for a new tag")
|
||||||
|
parser.add_argument("--asset-name", default="", help="attachment name; defaults to the file name")
|
||||||
|
parser.add_argument(
|
||||||
|
"--token-env",
|
||||||
|
default="RELEASE_TOKEN",
|
||||||
|
help="preferred environment variable holding the API token",
|
||||||
|
)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
# Preferred name first, then the runner-provided job token. GITEA_TOKEN is
|
||||||
|
# not used as the primary name because the Actions runner injects its own
|
||||||
|
# value for it into every step, which overrides a step-level `env:` entry
|
||||||
|
# of the same name -- but it is a valid last resort, since it can publish
|
||||||
|
# releases in its own repository.
|
||||||
|
candidates = [args.token_env, "RELEASE_TOKEN", "GITEA_TOKEN", "GITHUB_TOKEN"]
|
||||||
|
token, token_source = "", ""
|
||||||
|
for name in candidates:
|
||||||
|
value = os.environ.get(name, "").strip()
|
||||||
|
if value:
|
||||||
|
token, token_source = value, name
|
||||||
|
break
|
||||||
|
if not token:
|
||||||
|
present = sorted(n for n in candidates if n in os.environ)
|
||||||
|
fail(
|
||||||
|
f"no API token found; looked at {', '.join(dict.fromkeys(candidates))}. "
|
||||||
|
+ (
|
||||||
|
f"These exist but are empty: {', '.join(present)}. "
|
||||||
|
"An organisation secret is only exposed to a repository when Actions "
|
||||||
|
"is enabled on that repository, so check Repo > Settings > Actions."
|
||||||
|
if present
|
||||||
|
else "None of them are set in the environment."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
print(f"using token from {token_source}")
|
||||||
|
|
||||||
|
artifact = Path(args.artifact)
|
||||||
|
if not artifact.is_file() or artifact.stat().st_size == 0:
|
||||||
|
fail(f"artifact {artifact} is missing or empty")
|
||||||
|
asset_name = args.asset_name or artifact.name
|
||||||
|
|
||||||
|
api = args.api.rstrip("/")
|
||||||
|
version = json.loads((REPO_ROOT / "plugin.json").read_text(encoding="utf-8"))["version"]
|
||||||
|
|
||||||
|
notes = [f"Automated build of version {version}."]
|
||||||
|
if args.commitish:
|
||||||
|
notes.append(f"Built from `{args.commitish[:12]}`.")
|
||||||
|
notes.append("Install by opening the attached `.plugin` file in exteraGram.")
|
||||||
|
notes_text = "\n\n".join(notes)
|
||||||
|
|
||||||
|
release = find_release(api, args.tag, token)
|
||||||
|
if release is None:
|
||||||
|
release = create_release(api, args.tag, token, args.commitish, notes_text)
|
||||||
|
print(f"created release {args.tag}")
|
||||||
|
else:
|
||||||
|
# Version unchanged since the last push: keep the same tag and refresh
|
||||||
|
# it in place so the attachment always matches the newest commit.
|
||||||
|
release = update_release(api, int(release["id"]), token, args.commitish, notes_text)
|
||||||
|
print(f"rebuilding existing release {args.tag}")
|
||||||
|
|
||||||
|
release_id = int(release["id"])
|
||||||
|
removed = delete_existing_assets(api, release_id, asset_name, token)
|
||||||
|
if removed:
|
||||||
|
print(f"removed {removed} previous copy/copies of {asset_name}")
|
||||||
|
|
||||||
|
asset = upload_asset(api, release_id, artifact, asset_name, token)
|
||||||
|
print(f"uploaded {asset_name} ({artifact.stat().st_size} bytes) -> {asset.get('browser_download_url', '')}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Executable
+263
@@ -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 <bundle.plugin>
|
||||||
|
"""
|
||||||
|
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 <bundle.plugin>", 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())
|
||||||
Reference in new issue
Block a user