commit 7a3d3e505d9de8d68929927f823cdeab80fc7904 Author: exteraGramPlugins <> Date: Fri Oct 9 12:23:57 2026 +0000 Initial commit diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..46e90b6 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,95 @@ +name: Publish plugin + +# Builds dist/.plugin and publishes it to the Releases tab. +# +# The version in plugin.json is the single source of truth: +# new version -> a new v 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 }}" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f5806f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,179 @@ +# ---> Python +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# exteraGram plugin toolchain +/stubs/ +/.venv/ diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..30cf57e --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/exteraGramPluginBase.iml b/.idea/exteraGramPluginBase.iml new file mode 100644 index 0000000..d0876a7 --- /dev/null +++ b/.idea/exteraGramPluginBase.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..348ee26 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,34 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..104c42f --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,14 @@ + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..9224024 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..5ae06a6 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "exteraGram plugin: attach debugpy", + "type": "debugpy", + "request": "attach", + "connect": { + "host": "127.0.0.1", + "port": 5678 + }, + "justMyCode": false + } + ] +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4f61354 --- /dev/null +++ b/LICENSE @@ -0,0 +1,18 @@ +MIT License + +Copyright (c) 2026 exteraGramPlugins + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d32f405 --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +

+ exteraGram Plugin Base icon +

+ +

exteraGram Plugin Base

+ +**TL;DR:** a template repo for writing [exteraGram](https://exteragram.app) plugins as normal +multi-file Python projects. Write code in `src/`, run one command, get a single `.plugin` +file deployed to your phone or published to Releases. + +## Description + +exteraGram loads every plugin from exactly one Python file. This template lets you split a +plugin into ordinary modules and packages anyway: a bundler compiles `src/` and `plugin.json` +into that single file, an offline harness verifies the result without a device, ADB scripts +push it to a connected phone and reload it, and CI publishes every version to the repo's +Releases tab. + +## Features + +None for the end user. The bundled sample plugin (`Hello Bundle`) only proves that the +toolchain works. This repo is meant to be used as a **template** for creating other plugins: +click "Use this template" (or clone it), then replace `plugin.json` and `src/` with your own. + +## Documentation + +- [Development](docs/development.md): getting started, repo layout, building and verifying, icon export +- [How exteraGram plugins work](docs/how-plugins-work.md): the plugin format and lifecycle +- [Device setup and ADB automation](docs/device-and-adb.md): deploy, watch, logs, debugger +- [Versioning and releases](docs/versioning-and-releases.md): git hooks, auto-bump, CI publishing +- [SDK notes](docs/sdk-notes.md): condensed exteraGram SDK API surface + +## Quick start + +```bash +python3 tools/bundle.py --check # once after cloning: enables the git hooks +bash scripts/deploy.sh --enable # build + push to a connected device + enable +``` diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..8f49826 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,65 @@ +# Development + +## 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](versioning-and-releases.md#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/.plugin` + (substitute your `id` from `plugin.json`). +5. `bash scripts/deploy.sh --enable` with a device connected. + +## Repo layout + +``` +plugin.json single source of truth for plugin metadata +src/ the plugin, written as an ordinary Python package + main.py entry module — exactly one BasePlugin subclass, absolute imports only + greeting/ sample package (proves relative + cross-module imports survive bundling) + textutil.py sample top-level sibling module +tools/ + bundle.py compiles plugin.json + src/ into dist/.plugin + devclient.py speaks the on-device dev-server protocol (127.0.0.1:42690) + verify_bundle.py offline harness that proves a built bundle behaves correctly, no phone needed + release.py publishes a built bundle to a Gitea release (used by CI) + icon_png.py renders images/icon.svg to a square PNG +images/icon.svg the plugin icon, source of truth for any PNG export +.gitea/workflows/ CI that builds, verifies and publishes the .plugin to Releases +scripts/ ADB debug-loop automation and the git hooks +docs/ these notes + sdk-notes.md, the condensed SDK API surface +``` + +## The three commands that matter + +```bash +python3 tools/bundle.py --release # compile src/ -> dist/.plugin +python3 tools/verify_bundle.py dist/.plugin # offline proof the bundle works, no device needed +bash scripts/deploy.sh --enable # build (--dev) + push to a connected device + enable + reload +``` + +`bundle.py` walks `src/` recursively, embeds every `.py` file's source and dotted module name +into a compressed, base64-encoded payload, and appends a small runtime preamble that installs +a private import hook (scoped to a per-module `__builtins__` dict, never touching +`sys.modules`) so cross-module and cross-package imports resolve exactly as they would if the +files were still on disk. `--dev` (the default) embeds absolute local paths so a debugger +resolves breakpoints against your real source files; `--release` embeds repo-relative paths +for distribution. Both modes produce byte-reproducible output for a given `src/` + `plugin.json`. +Its first run in a clone also enables the repo's git hooks (see +[Git hooks and versioning](versioning-and-releases.md#git-hooks-and-versioning)). + +## Exporting the icon as PNG + +`tools/icon_png.py` renders `images/icon.svg` to `images/icon.png`, 512x512 by default. +`--resolution` sets the size of both sides, so `--resolution 1024` gives 1024x1024: + +```bash +python3 tools/icon_png.py # images/icon.png, 512x512 +python3 tools/icon_png.py --resolution 1024 # images/icon.png, 1024x1024 +python3 tools/icon_png.py some.svg -o out.png +``` + +The output is always PNG; any other `-o` extension is rejected. The script itself is stdlib +only and hands the rendering to the first renderer it finds: the `cairosvg` Python package, +`rsvg-convert` (librsvg), ImageMagick or Inkscape. Install any one of them. diff --git a/docs/device-and-adb.md b/docs/device-and-adb.md new file mode 100644 index 0000000..79193dd --- /dev/null +++ b/docs/device-and-adb.md @@ -0,0 +1,32 @@ +# Device setup and ADB automation + +## 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. diff --git a/docs/how-plugins-work.md b/docs/how-plugins-work.md new file mode 100644 index 0000000..8f89645 --- /dev/null +++ b/docs/how-plugins-work.md @@ -0,0 +1,26 @@ +# How exteraGram plugins work + +## How exteraGram plugins actually work + +A plugin, as exteraGram loads it, is exactly **one Python file** on the device: +`/data/user/0/com.exteragram.messenger/files/plugins/.py`. The distribution +convention names the same content `.plugin`. + +The app reads plugin metadata **statically via AST** — it looks for top-level constant +assignments (`__id__`, `__name__`, `__description__`, `__author__`, `__version__`, +`__icon__`, `__app_version__`, `__sdk_version__`, `__requirements__`) without executing the +file. Only `__id__` and `__name__` are required. + +The file must define exactly one class inheriting `BasePlugin` (`from base_plugin import +BasePlugin`). Lifecycle methods (`on_plugin_load`, `on_plugin_unload`, `create_settings`) and +hooks (`on_send_message_hook`, `pre_request_hook`, `post_request_hook`, `on_update_hook(s)`, +`on_app_event`) are all called by the host; hooks must be registered inside +`on_plugin_load` (e.g. `self.add_on_send_message_hook()`). A hook returns a +`HookResult(strategy=HookStrategy.MODIFY, params=params)` to apply changes, or a bare +`HookResult()` to pass through. Settings rows returned from `create_settings` come from +`ui.settings` (`Header`, `Input`, `Switch`, `Text`, `Selector`, `Divider`) and persist through +`self.get_setting`/`self.set_setting`. Logging goes through `android_utils.log`; Chaquopy also +redirects `sys.stdout`/`sys.stderr` to the logcat tags `python.stdout`/`python.stderr`. + +This repo does not use the official Elyx/ElyxBuilder archive format — see +[`sdk-notes.md`](sdk-notes.md) for why. diff --git a/docs/sdk-notes.md b/docs/sdk-notes.md new file mode 100644 index 0000000..a0b909d --- /dev/null +++ b/docs/sdk-notes.md @@ -0,0 +1,114 @@ +# exteraGram plugin SDK — condensed notes + +Gathered from , the SDK type stubs (`stubs.zip` from +`exteraSquad/plugins-pysdk-builds`), the official `exteragram-utils` PyPI package +(`exteragram_utils/dev_client.py`), and the app's `gradle.properties`. Not exhaustive — see the +full docs site for anything not covered here. + +## Plugin identity and metadata + +Read statically via AST from top-level constant assignments in the plugin's single `.py` file +— the app never executes the file just to read metadata. Docs: +. + +| Constant | Required | Notes | +|---|---|---| +| `__id__` | yes | 2–32 chars, starts with a letter, only `A-Za-z0-9_-` | +| `__name__` | yes | display name | +| `__description__` | no | | +| `__author__` | no | | +| `__version__` | no | | +| `__icon__` | no | e.g. `"exteraPlugins/1"` | +| `__app_version__` | no | e.g. `">=12.5.1"`; legacy alias `__min_version__` | +| `__sdk_version__` | no | e.g. `">=1.4.4.3"` | +| `__requirements__` | no | list of pip package specs the host installs before loading | + +## `BasePlugin` lifecycle and hooks + + + +- `on_plugin_load(self)` — called once when the plugin is enabled/loaded. Hooks must be + registered here (e.g. `self.add_on_send_message_hook()`); registering later has no effect. +- `on_plugin_unload(self)` — called on disable/removal; release resources here. +- `create_settings(self)` — returns a list of `ui.settings` rows rendered in the plugin's + settings screen. +- Hooks: `on_send_message_hook(self, account, params)`, `pre_request_hook(self, ...)`, + `post_request_hook(self, ...)`, `on_update_hook(s)(self, ...)`, `on_app_event(self, ...)`. + Each returns a `HookResult`. +- `HookResult(strategy=HookStrategy.MODIFY, params=params)` applies the mutated `params` back + to the host; `HookStrategy.DEFAULT` (a bare `HookResult()`) passes through unchanged; + `HookStrategy.CANCEL` drops the action; `HookStrategy.MODIFY_FINAL` applies changes and + short-circuits any later hooks. +- Settings persistence: `self.get_setting(key, default)` / `self.set_setting(key, value)`. + +## `ui.settings` row types + + + +`Header`, `Input`, `Switch`, `Text`, `Selector`, `Divider` — plain kwargs-holding row +descriptors consumed by the host's settings renderer; construct and return them from +`create_settings`. + +## Logging + + + +`android_utils.log(...)` writes to logcat. Chaquopy additionally redirects +`sys.stdout`/`sys.stderr` to the logcat tags `python.stdout` / `python.stderr` — useful when a +dependency logs with plain `print()`. + +## Background work + + + +`client_utils.run_on_queue(...)` schedules work off the UI thread; use it for anything that +shouldn't block message handling. + +## Reflection / private API access + + + +`hook_utils.find_class(...)` and related helpers locate app classes and private fields for +plugins that need to reach beyond the public SDK surface. + +## UI helpers + +- `ui.alert.AlertDialogBuilder` — native alert dialogs. +- `ui.bulletin` — transient bottom bulletins/toasts. +- `intents.IntentsManager` — Android intent integration, **exteraGram 12.6.4+ only**. + + +## On-device dev server protocol + + + +TCP `127.0.0.1:42690`, reachable from a PC via `adb forward tcp:42690 tcp:42690`. Wire format +is consecutive UTF-8 JSON objects with **no length framing** — parse with +`json.JSONDecoder().raw_decode` over an accumulating buffer, never assume one JSON object per +`recv()`. Requests: `{"@": , "#": , ...}`; responses echo `"#"`. + +Commands used by this repo's `tools/devclient.py`: `ping`, `get_plugins`, `enable_plugin`, +`disable_plugin`, `reload_plugin`, `remove_plugin` (field `plugin_id`), `write_plugin` +(`plugin_id`, `content` — installs or updates), `start_debugger`/`stop_debugger` (`host`, +`port`, `platform` ∈ `vscode`|`pycharm`). The official `exteragram-utils` client (PyPI) uses +integer request ids from 1, a 30 s response timeout, a 1.0 s socket timeout, a +`write_plugin` → sleep 0.3 s → `reload_plugin` deploy sequence, and `adb forward` (not +`reverse`) for both the dev-server port and the debugger port. `elyx_*` commands exist for the +Elyx engine and are out of scope here. + +## Runtime + +Chaquopy 16 / Python 3.11. Anything shipped in a bundle must be 3.11-compatible. + +## Why not Elyx/ElyxBuilder + +The official structured plugin format (Elyx/ElyxBuilder) is a real alternative to a single +`.plugin` file, but it: + +- requires the separate Elyx engine to be installed and enabled on the device; +- ships archives, not the `.plugin` files the base plugin engine understands; +- needs a manual first install before live reload works. + +This repo's bundler instead compiles a normal multi-file `src/` project down to the one +`.plugin` file the base engine already knows how to load, so the dev-server write/reload loop +works from the first deploy with no extra on-device engine. diff --git a/docs/versioning-and-releases.md b/docs/versioning-and-releases.md new file mode 100644 index 0000000..3163bf7 --- /dev/null +++ b/docs/versioning-and-releases.md @@ -0,0 +1,84 @@ +# Versioning and releases + +## 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 -- `, 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 [Publishing a release](#publishing-a-release)), 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` 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. diff --git a/images/icon.svg b/images/icon.svg new file mode 100644 index 0000000..ccb8fe5 --- /dev/null +++ b/images/icon.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugin.json b/plugin.json new file mode 100644 index 0000000..47fe8ff --- /dev/null +++ b/plugin.json @@ -0,0 +1,11 @@ +{ + "id": "hello_bundle", + "name": "Hello Bundle", + "description": "Multi-file exteraGram plugin built by tools/bundle.py", + "author": "@lyuksovannyy", + "version": "1.0.0", + "icon": "exteraGramPluginIcons/0", + "app_version": ">=12.5.1", + "sdk_version": ">=1.4.4.3", + "requirements": [] +} diff --git a/scripts/adb-connect.sh b/scripts/adb-connect.sh new file mode 100755 index 0000000..f3baeeb --- /dev/null +++ b/scripts/adb-connect.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." +source scripts/lib.sh + +need_cmd "$ADB" "$PY" + +DEBUG=0 +for arg in "$@"; do + case "$arg" in + --debug) DEBUG=1 ;; + *) die "unknown argument: $arg" ;; + esac +done + +"$ADB" start-server +timeout 60 "$ADB" wait-for-device || die "timed out waiting for a device (ANDROID_SERIAL=${ANDROID_SERIAL:-})" + +"$ADB" forward --remove "tcp:$DEV_PORT" >/dev/null 2>&1 || true +"$ADB" forward "tcp:$DEV_PORT" "tcp:$DEV_PORT" + +if [[ "$DEBUG" -eq 1 ]]; then + "$ADB" forward --remove "tcp:$DEBUG_PORT" >/dev/null 2>&1 || true + "$ADB" forward "tcp:$DEBUG_PORT" "tcp:$DEBUG_PORT" +fi + +if "$PY" tools/devclient.py ping >/dev/null 2>&1; then + echo "connected" +else + die "ping failed — is the plugin engine + developer mode enabled in exteraGram Preferences > Plugins?" +fi diff --git a/scripts/debug.sh b/scripts/debug.sh new file mode 100755 index 0000000..365ae43 --- /dev/null +++ b/scripts/debug.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." +source scripts/lib.sh + +need_cmd "$ADB" "$PY" + +usage() { + cat >&2 <<'EOF' +usage: scripts/debug.sh start|stop [--platform vscode|pycharm] + +Requires the device-side debugger package to be present in the app's Python +runtime: `debugpy` for vscode, `pydevd-pycharm` for pycharm. If it is not +installed, add it to "requirements" in plugin.json so the app's pip +controller installs it on next load. +EOF +} + +[[ $# -ge 1 ]] || { usage; exit 1; } +ACTION="$1" +shift +PLATFORM=vscode +while [[ $# -gt 0 ]]; do + case "$1" in + --platform) + PLATFORM="${2:-}" + shift 2 + ;; + --platform=*) + PLATFORM="${1#--platform=}" + shift + ;; + *) + die "unknown argument: $1" + ;; + esac +done + +case "$ACTION" in + start|stop) ;; + *) usage; exit 1 ;; +esac +case "$PLATFORM" in + vscode|pycharm) ;; + *) die "--platform must be vscode or pycharm" ;; +esac + +scripts/adb-connect.sh --debug +"$PY" tools/devclient.py debugger "$ACTION" --platform "$PLATFORM" diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..9885985 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." +source scripts/lib.sh + +need_cmd "$PY" + +MODE=--dev +ENABLE=0 +CONNECT=1 +for arg in "$@"; do + case "$arg" in + --release) MODE=--release ;; + --enable) ENABLE=1 ;; + --no-connect) CONNECT=0 ;; + *) die "unknown argument: $arg" ;; + esac +done + +"$PY" tools/bundle.py "$MODE" --out dist + +BUNDLE="dist/${PLUGIN_ID}.plugin" +[[ -f "$BUNDLE" ]] || die "expected bundle not found at $BUNDLE" +echo "bundle: $BUNDLE ($(stat -c '%s' "$BUNDLE" 2>/dev/null || stat -f '%z' "$BUNDLE") bytes)" + +if [[ "$CONNECT" -eq 1 ]]; then + need_cmd "$ADB" + scripts/adb-connect.sh +fi + +DEPLOY_ARGS=(deploy "$BUNDLE" --id "$PLUGIN_ID") +if [[ "$ENABLE" -eq 1 ]]; then + DEPLOY_ARGS+=(--enable) +fi + +"$PY" tools/devclient.py "${DEPLOY_ARGS[@]}" diff --git a/scripts/fetch-sdk-stubs.sh b/scripts/fetch-sdk-stubs.sh new file mode 100755 index 0000000..c8aba62 --- /dev/null +++ b/scripts/fetch-sdk-stubs.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." +source scripts/lib.sh + +need_cmd "$PY" curl unzip sha256sum + +BETA=0 +for arg in "$@"; do + case "$arg" in + --beta) BETA=1 ;; + *) die "unknown argument: $arg" ;; + esac +done + +API_URL="https://api.github.com/repos/exteraSquad/plugins-pysdk-builds/releases?per_page=20" + +RELEASE_JSON="$(mktemp)" +trap 'rm -rf "$RELEASE_JSON" "$WORKDIR"' EXIT + +curl -fsSL "$API_URL" -o "$RELEASE_JSON" || die "failed to query GitHub releases API" + +read -r TAG ASSET_URL SHA_URL < <("$PY" - "$RELEASE_JSON" "$BETA" <<'PYEOF' +import json, sys + +path, allow_beta = sys.argv[1], sys.argv[2] == "1" +with open(path, encoding="utf-8") as f: + releases = json.load(f) + +for rel in releases: + if rel.get("prerelease") and not allow_beta: + continue + assets = {a["name"]: a["browser_download_url"] for a in rel.get("assets", [])} + if "stubs.zip" in assets: + sha_url = assets.get("stubs.zip.sha256", "") + print(rel["tag_name"], assets["stubs.zip"], sha_url) + break +else: + sys.exit("no release with a stubs.zip asset found") +PYEOF +) || die "no release with a stubs.zip asset found" + +[[ -n "$TAG" ]] || die "could not determine a release to install" + +WORKDIR="$(mktemp -d)" +curl -fsSL "$ASSET_URL" -o "$WORKDIR/stubs.zip" || die "failed to download stubs.zip" + +if [[ -n "$SHA_URL" ]]; then + curl -fsSL "$SHA_URL" -o "$WORKDIR/stubs.zip.sha256" || die "failed to download stubs.zip.sha256" + (cd "$WORKDIR" && sha256sum -c stubs.zip.sha256) || die "checksum verification failed for stubs.zip" +else + echo "warning: no stubs.zip.sha256 asset published for $TAG; skipping checksum verification" >&2 +fi + +rm -rf stubs +mkdir -p stubs +unzip -q "$WORKDIR/stubs.zip" -d "$WORKDIR/outer" +INNER="$WORKDIR/outer/pyi-files.zip" +[[ -f "$INNER" ]] || die "expected pyi-files.zip inside stubs.zip, not found" +unzip -q "$INNER" -d stubs + +echo "installed SDK stubs from release $TAG into stubs/" diff --git a/scripts/lib.sh b/scripts/lib.sh new file mode 100644 index 0000000..d706c86 --- /dev/null +++ b/scripts/lib.sh @@ -0,0 +1,26 @@ +# Shared helpers for scripts/*.sh. Source, do not execute: +# source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PKG=com.exteragram.messenger +DEV_PORT=42690 +DEBUG_PORT=5678 +PY="${PYTHON:-python3}" +ADB="${ADB:-adb}" + +PLUGIN_ID="$("$PY" -c "import json,sys; print(json.load(open(sys.argv[1]))['id'])" "$REPO_ROOT/plugin.json")" + +die() { + echo "error: $*" >&2 + exit 1 +} + +need_cmd() { + for cmd in "$@"; do + command -v "$cmd" >/dev/null 2>&1 || die "required command not found on PATH: $cmd" + done +} + +app_pid() { + "$ADB" shell pidof -s "$PKG" | tr -d '\r' +} diff --git a/scripts/logs.sh b/scripts/logs.sh new file mode 100755 index 0000000..9b0f0ae --- /dev/null +++ b/scripts/logs.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." +source scripts/lib.sh + +need_cmd "$ADB" + +CLEAR=0 +STDIO=0 +PATTERN="" +while [[ $# -gt 0 ]]; do + case "$1" in + -c) CLEAR=1; shift ;; + -t) STDIO=1; shift ;; + -g) PATTERN="${2:-}"; shift 2 ;; + *) die "unknown argument: $1" ;; + esac +done + +if [[ "$CLEAR" -eq 1 ]]; then + "$ADB" logcat -c +fi + +run_logcat() { + if [[ "$STDIO" -eq 1 ]]; then + "$ADB" logcat -s python.stdout:'*' python.stderr:'*' + else + local pid + pid="$(app_pid)" + [[ -n "$pid" ]] || die "exteraGram is not running — open the app first" + "$ADB" logcat --pid="$pid" + fi +} + +if [[ -n "$PATTERN" ]]; then + run_logcat | grep --line-buffered -E "$PATTERN" +else + run_logcat +fi diff --git a/scripts/post-commit b/scripts/post-commit new file mode 100755 index 0000000..b44c59a --- /dev/null +++ b/scripts/post-commit @@ -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 -- ` (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 diff --git a/scripts/pre-commit b/scripts/pre-commit new file mode 100755 index 0000000..9d7743a --- /dev/null +++ b/scripts/pre-commit @@ -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 -- ` 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()) diff --git a/scripts/watch.sh b/scripts/watch.sh new file mode 100755 index 0000000..121333d --- /dev/null +++ b/scripts/watch.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." +source scripts/lib.sh + +need_cmd "$PY" + +checksum() { + find src plugin.json -type f -printf '%T@ %p\n' 2>/dev/null | sha256sum +} + +run_deploy() { + if ! scripts/deploy.sh "$@"; then + echo "deploy failed, still watching..." >&2 + fi +} + +echo "watching src/ and plugin.json for changes..." +run_deploy "$@" + +if command -v inotifywait >/dev/null 2>&1; then + while true; do + inotifywait -qq -r -e close_write,create,delete,move src plugin.json 2>/dev/null || true + sleep 0.3 + run_deploy "$@" + done +else + echo "inotifywait not found; falling back to polling" >&2 + last="$(checksum)" + while true; do + sleep 1 + current="$(checksum)" + if [[ "$current" != "$last" ]]; then + last="$current" + sleep 0.3 + run_deploy "$@" + fi + done +fi diff --git a/src/greeting/__init__.py b/src/greeting/__init__.py new file mode 100644 index 0000000..f0d6578 --- /dev/null +++ b/src/greeting/__init__.py @@ -0,0 +1 @@ +from .template import render, TemplateError diff --git a/src/greeting/template.py b/src/greeting/template.py new file mode 100644 index 0000000..5ccc0e9 --- /dev/null +++ b/src/greeting/template.py @@ -0,0 +1,13 @@ +from textutil import normalize + + +class TemplateError(ValueError): + pass + + +def render(template: str, name: str) -> str: + normalized = normalize(name) + try: + return template.format(name=normalized) + except (KeyError, IndexError, ValueError): + raise TemplateError(f"unsupported placeholder in {template!r}") diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..f24e0fa --- /dev/null +++ b/src/main.py @@ -0,0 +1,50 @@ +from base_plugin import BasePlugin, HookResult, HookStrategy +from android_utils import log +from ui.settings import Header, Input, Text +from greeting import render, TemplateError + +DEFAULT_TEMPLATE = "Hello, {name}!" + + +class HelloPlugin(BasePlugin): + def on_plugin_load(self): + log("hello_bundle loaded") + self.add_on_send_message_hook() + + def on_plugin_unload(self): + log("hello_bundle unloaded") + + def create_settings(self): + return [ + Header(text="Hello Bundle"), + Input( + key="template", + text="Greeting template", + default=DEFAULT_TEMPLATE, + subtext="Use {name} as placeholder.", + icon="msg_edit", + ), + Text( + text="Example", + subtext=".hello alice smith -> Hello, Alice Smith!", + icon="msg_info", + ), + ] + + def on_send_message_hook(self, account, params): + message = getattr(params, "message", None) + if not isinstance(message, str): + return HookResult() + stripped = message.strip() + if not stripped.startswith(".hello"): + return HookResult() + parts = stripped.split(" ", 1) + name = parts[1] if len(parts) > 1 else "" + if not name.strip(): + params.message = "Usage: .hello " + else: + try: + params.message = render(self.get_setting("template", DEFAULT_TEMPLATE), name) + except TemplateError as exc: + params.message = "Bad template: " + str(exc) + return HookResult(strategy=HookStrategy.MODIFY, params=params) diff --git a/src/textutil.py b/src/textutil.py new file mode 100644 index 0000000..7c3107b --- /dev/null +++ b/src/textutil.py @@ -0,0 +1,2 @@ +def normalize(name: str) -> str: + return " ".join(w[:1].upper() + w[1:] for w in name.split()) diff --git a/tools/bundle.py b/tools/bundle.py new file mode 100644 index 0000000..0060891 --- /dev/null +++ b/tools/bundle.py @@ -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()) diff --git a/tools/devclient.py b/tools/devclient.py new file mode 100755 index 0000000..93e2efb --- /dev/null +++ b/tools/devclient.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +"""Client for the exteraGram on-device plugin dev server. + +Speaks the wire protocol at 127.0.0.1:42690 (after `adb forward tcp:42690 tcp:42690`): +consecutive UTF-8 JSON objects, no length framing. Requests are +{"@": , "#": , ...}; responses echo "#". +""" +from __future__ import annotations + +import argparse +import json +import socket +import sys +import time +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +PLUGIN_JSON = REPO_ROOT / "plugin.json" + +CONNECT_ATTEMPTS = 5 +CONNECT_RETRY_DELAY = 1.0 +SOCKET_TIMEOUT = 1.0 +RESPONSE_TIMEOUT = 30.0 + +UNREACHABLE_MESSAGE = ( + "dev server unreachable — run scripts/adb-connect.sh, and enable exteraGram " + "Preferences > Plugins > plugin engine + developer mode" +) + + +def default_plugin_id() -> str | None: + try: + data = json.loads(PLUGIN_JSON.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + return data.get("id") + + +class DevClient: + def __init__(self, host: str, port: int): + self.host = host + self.port = port + self._sock: socket.socket | None = None + self._buf = b"" + self._decoder = json.JSONDecoder() + self._next_id = 1 + + def connect(self) -> None: + last_exc = None + for attempt in range(CONNECT_ATTEMPTS): + try: + sock = socket.create_connection((self.host, self.port), timeout=SOCKET_TIMEOUT) + sock.settimeout(SOCKET_TIMEOUT) + self._sock = sock + return + except OSError as exc: + last_exc = exc + if attempt < CONNECT_ATTEMPTS - 1: + time.sleep(CONNECT_RETRY_DELAY) + print(UNREACHABLE_MESSAGE, file=sys.stderr) + raise SystemExit(1) from last_exc + + def close(self) -> None: + if self._sock is not None: + self._sock.close() + self._sock = None + + def _read_one(self, deadline: float) -> dict: + while True: + self._buf = self._buf.lstrip() + if self._buf: + try: + text = self._buf.decode("utf-8") + except UnicodeDecodeError: + text = None + if text: + try: + obj, idx = self._decoder.raw_decode(text) + consumed = len(text[:idx].encode("utf-8")) + self._buf = self._buf[consumed:] + return obj + except json.JSONDecodeError: + pass + if time.monotonic() >= deadline: + print("error: timed out waiting for dev server response", file=sys.stderr) + raise SystemExit(1) + try: + chunk = self._sock.recv(4096) + except socket.timeout: + continue + except OSError as exc: + print(f"error: connection lost: {exc}", file=sys.stderr) + raise SystemExit(1) + if not chunk: + print("error: dev server closed the connection", file=sys.stderr) + raise SystemExit(1) + self._buf += chunk + + def request(self, command: str, **fields) -> dict: + req_id = self._next_id + self._next_id += 1 + payload = {"@": command, "#": req_id, **fields} + self._sock.sendall(json.dumps(payload).encode("utf-8")) + deadline = time.monotonic() + RESPONSE_TIMEOUT + while True: + resp = self._read_one(deadline) + if resp.get("#") == req_id: + if resp.get("error") or resp.get("errors"): + print(json.dumps(resp, indent=2), file=sys.stderr) + raise SystemExit(1) + return resp + # response to a different (stale) request id: keep draining + + +def cmd_ping(client: DevClient, args) -> int: + resp = client.request("ping") + print(json.dumps(resp, indent=2)) + return 0 + + +def cmd_plugins(client: DevClient, args) -> int: + resp = client.request("get_plugins") + print(json.dumps(resp, indent=2)) + return 0 + + +def cmd_deploy(client: DevClient, args) -> int: + plugin_id = args.id or default_plugin_id() + if not plugin_id: + print("error: could not determine plugin id (pass --id or fix plugin.json)", file=sys.stderr) + return 1 + try: + content = Path(args.file).read_text(encoding="utf-8") + except OSError as exc: + print(f"error: cannot read {args.file}: {exc}", file=sys.stderr) + return 1 + client.request("write_plugin", plugin_id=plugin_id, content=content) + time.sleep(0.3) + if args.enable: + client.request("enable_plugin", plugin_id=plugin_id) + if not args.no_reload: + client.request("reload_plugin", plugin_id=plugin_id) + print(f"deployed {args.file} as {plugin_id}") + return 0 + + +def cmd_simple_id(command: str): + def handler(client: DevClient, args) -> int: + client.request(command, plugin_id=args.id) + print(f"{command} {args.id}: ok") + return 0 + + return handler + + +def cmd_debugger(client: DevClient, args) -> int: + if args.action == "start": + client.request( + "start_debugger", + host=args.debug_host, + port=args.debug_port, + platform=args.platform, + ) + print(f"debugger start requested ({args.platform} -> {args.debug_host}:{args.debug_port})") + else: + client.request("stop_debugger", platform=args.platform) + print(f"debugger stop requested ({args.platform})") + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="exteraGram plugin dev server client") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=42690) + sub = parser.add_subparsers(dest="command", required=True) + + sub.add_parser("ping").set_defaults(func=cmd_ping) + sub.add_parser("plugins").set_defaults(func=cmd_plugins) + + p_deploy = sub.add_parser("deploy") + p_deploy.add_argument("file") + p_deploy.add_argument("--id") + p_deploy.add_argument("--enable", action="store_true") + p_deploy.add_argument("--no-reload", action="store_true") + p_deploy.set_defaults(func=cmd_deploy) + + for name, command in ( + ("enable", "enable_plugin"), + ("disable", "disable_plugin"), + ("reload", "reload_plugin"), + ("remove", "remove_plugin"), + ): + p = sub.add_parser(name) + p.add_argument("id") + p.set_defaults(func=cmd_simple_id(command)) + + p_debugger = sub.add_parser("debugger") + p_debugger.add_argument("action", choices=["start", "stop"]) + p_debugger.add_argument("--platform", choices=["vscode", "pycharm"], default="vscode") + p_debugger.add_argument("--debug-host", default="127.0.0.1") + p_debugger.add_argument("--debug-port", type=int, default=5678) + p_debugger.set_defaults(func=cmd_debugger) + + return parser + + +def main(argv=None) -> int: + parser = build_parser() + args = parser.parse_args(sys.argv[1:] if argv is None else argv) + client = DevClient(args.host, args.port) + client.connect() + try: + return args.func(client, args) + finally: + client.close() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/icon_png.py b/tools/icon_png.py new file mode 100644 index 0000000..fd5fc2c --- /dev/null +++ b/tools/icon_png.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Renders images/icon.svg to a square PNG (512x512 by default). + +Stdlib only: the actual rasterising is delegated to the first renderer found, +in this order: the cairosvg Python package, rsvg-convert (librsvg), +ImageMagick (magick/convert), Inkscape. Install any one of them. + +Usage: + tools/icon_png.py # images/icon.svg -> images/icon.png, 512x512 + tools/icon_png.py --resolution 1024 # 1024x1024 + tools/icon_png.py other.svg -o out.png # explicit input/output + +The output is always PNG, always x. +""" +from __future__ import annotations + +import argparse +import shutil +import subprocess +import sys +from pathlib import Path +from typing import List, Optional + +REPO_ROOT = Path(__file__).resolve().parent.parent +DEFAULT_SVG = REPO_ROOT / "images" / "icon.svg" +DEFAULT_RESOLUTION = 512 + + +def fail(message: str) -> "None": + print(f"error: {message}", file=sys.stderr) + raise SystemExit(1) + + +def cli_command(svg: Path, png: Path, size: int) -> Optional[List[str]]: + """Returns the command line for the first installed CLI renderer, if any.""" + if shutil.which("rsvg-convert"): + return ["rsvg-convert", "-w", str(size), "-h", str(size), "-f", "png", + "-o", str(png), str(svg)] + magick = shutil.which("magick") or shutil.which("convert") + if magick: + # A high density rasterises the vector sharply before scaling down; + # "!" forces the exact size. + return [magick, "-background", "none", "-density", "1200", str(svg), + "-resize", f"{size}x{size}!", f"png:{png}"] + if shutil.which("inkscape"): + return ["inkscape", str(svg), "--export-type=png", f"--export-filename={png}", + "-w", str(size), "-h", str(size)] + return None + + +def render(svg: Path, png: Path, size: int) -> str: + """Renders svg into png at size x size, returning the renderer's name.""" + try: + import cairosvg # type: ignore[import-not-found] + except ImportError: + pass + else: + cairosvg.svg2png(url=str(svg), write_to=str(png), + output_width=size, output_height=size) + return "cairosvg" + + command = cli_command(svg, png, size) + if command is None: + fail("no SVG renderer found; install one of: cairosvg (pip), " + "rsvg-convert (librsvg), ImageMagick, Inkscape") + result = subprocess.run(command, capture_output=True, text=True) + if result.returncode != 0: + fail(f"{Path(command[0]).name} failed:\n{result.stderr.strip()}") + return Path(command[0]).name + + +def main() -> None: + parser = argparse.ArgumentParser(description="Render an SVG icon to a square PNG.") + parser.add_argument("svg", nargs="?", type=Path, default=DEFAULT_SVG, + help="input SVG (default: images/icon.svg)") + parser.add_argument("-r", "--resolution", type=int, default=DEFAULT_RESOLUTION, + help=f"width and height in pixels (default: {DEFAULT_RESOLUTION})") + parser.add_argument("-o", "--output", type=Path, + help="output PNG (default: the input path with a .png suffix)") + args = parser.parse_args() + + if args.resolution <= 0: + fail("--resolution must be a positive integer") + if not args.svg.is_file(): + fail(f"{args.svg} not found") + png = args.output or args.svg.with_suffix(".png") + if png.suffix.lower() != ".png": + fail(f"output must be a .png file, got {png}") + png.parent.mkdir(parents=True, exist_ok=True) + + renderer = render(args.svg, png, args.resolution) + print(f"{png} ({args.resolution}x{args.resolution}, via {renderer})") + + +if __name__ == "__main__": + main() diff --git a/tools/release.py b/tools/release.py new file mode 100755 index 0000000..2d11a08 --- /dev/null +++ b/tools/release.py @@ -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 --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()) diff --git a/tools/verify_bundle.py b/tools/verify_bundle.py new file mode 100755 index 0000000..2dbc171 --- /dev/null +++ b/tools/verify_bundle.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +"""Offline verification harness for a built .plugin bundle. Runs without a phone. + +Usage: python3 tools/verify_bundle.py +""" +from __future__ import annotations + +import ast +import json +import sys +import traceback +import types +from dataclasses import dataclass +from enum import Enum, auto +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +PLUGIN_JSON = REPO_ROOT / "plugin.json" + +_FAILURES = [] + + +def check(name: str, condition: bool, detail: str = "") -> None: + if condition: + print(f"PASS {name}") + else: + message = f"FAIL {name}" + if detail: + message += f": {detail}" + print(message, file=sys.stderr) + _FAILURES.append(name) + + +class HookStrategy(Enum): + DEFAULT = auto() + MODIFY = auto() + CANCEL = auto() + MODIFY_FINAL = auto() + + +@dataclass +class HookResult: + strategy: HookStrategy = HookStrategy.DEFAULT + params: object = None + + +LOG_LINES: list[str] = [] + + +class BasePlugin: + def __init__(self): + self._settings = {} + + def get_setting(self, key, default=None): + return self._settings.get(key, default) + + def set_setting(self, key, value): + self._settings[key] = value + + def add_on_send_message_hook(self): + pass + + def on_plugin_load(self): + pass + + def on_plugin_unload(self): + pass + + def create_settings(self): + return [] + + +def fake_log(*args, **kwargs): + LOG_LINES.append(" ".join(str(a) for a in args)) + + +class _SettingsRow: + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + + +def install_fakes() -> None: + base_plugin_mod = types.ModuleType("base_plugin") + base_plugin_mod.BasePlugin = BasePlugin + base_plugin_mod.HookResult = HookResult + base_plugin_mod.HookStrategy = HookStrategy + sys.modules["base_plugin"] = base_plugin_mod + + android_utils_mod = types.ModuleType("android_utils") + android_utils_mod.log = fake_log + sys.modules["android_utils"] = android_utils_mod + + ui_mod = types.ModuleType("ui") + ui_mod.__path__ = [] + sys.modules["ui"] = ui_mod + + ui_settings_mod = types.ModuleType("ui.settings") + for cls_name in ("Header", "Input", "Text", "Switch"): + setattr(ui_settings_mod, cls_name, type(cls_name, (_SettingsRow,), {})) + sys.modules["ui.settings"] = ui_settings_mod + ui_mod.settings = ui_settings_mod + + +class SimpleParams: + def __init__(self, message): + self.message = message + + +KEY_TO_DUNDER = { + "id": "__id__", + "name": "__name__", + "description": "__description__", + "author": "__author__", + "version": "__version__", + "icon": "__icon__", + "app_version": "__app_version__", + "sdk_version": "__sdk_version__", + "requirements": "__requirements__", +} + + +def expected_dunders(meta: dict) -> dict: + result = {} + for key, dunder in KEY_TO_DUNDER.items(): + if key not in meta: + continue + value = meta[key] + if key in ("description", "icon") and value == "": + continue + if key == "requirements" and value == []: + continue + result[dunder] = value + return result + + +def main(argv=None) -> int: + argv = sys.argv[1:] if argv is None else argv + if len(argv) != 1: + print("usage: tools/verify_bundle.py ", file=sys.stderr) + return 1 + bundle_path = Path(argv[0]) + if not bundle_path.is_file(): + print(f"error: bundle not found at {bundle_path}", file=sys.stderr) + return 1 + + install_fakes() + source = bundle_path.read_text(encoding="utf-8") + + modules_before = set(sys.modules) + ns = {"__name__": "hello_bundle"} + exec(compile(source, str(bundle_path), "exec"), ns) + new_modules = set(sys.modules) - modules_before + + meta = json.loads(PLUGIN_JSON.read_text(encoding="utf-8")) + expected = expected_dunders(meta) + mismatches = [ + f"{dunder}: expected {value!r}, got {ns.get(dunder)!r}" + for dunder, value in expected.items() + if ns.get(dunder) != value + ] + check("metadata constants match plugin.json", not mismatches, "; ".join(mismatches)) + + tree = ast.parse(source, filename=str(bundle_path)) + top_level_assigned = { + target.id + for node in tree.body + if isinstance(node, ast.Assign) + for target in node.targets + if isinstance(target, ast.Name) + } + check( + "metadata exposed as top-level literal assignments (AST)", + set(expected) <= top_level_assigned, + f"missing: {set(expected) - top_level_assigned}", + ) + + plugin_classes = [ + value + for value in ns.values() + if isinstance(value, type) and issubclass(value, BasePlugin) and value is not BasePlugin + ] + check( + "exactly one BasePlugin subclass defined", + len(plugin_classes) == 1, + f"found {len(plugin_classes)}", + ) + + plugin_cls = plugin_classes[0] if len(plugin_classes) == 1 else None + if plugin_cls is not None: + check( + "plugin class __module__ matches executed namespace", + plugin_cls.__module__ == ns["__name__"], + f"__module__={plugin_cls.__module__!r} != {ns['__name__']!r}", + ) + + plugin = plugin_cls() + LOG_LINES.clear() + plugin.on_plugin_load() + check( + "on_plugin_load emits load log line", + any("hello_bundle loaded" in line for line in LOG_LINES), + f"log lines: {LOG_LINES}", + ) + + params = SimpleParams(".hello alice smith") + result = plugin.on_send_message_hook(account=0, params=params) + check( + "'.hello alice smith' renders greeting", + params.message == "Hello, Alice Smith!", + f"got {params.message!r}", + ) + check( + "on_send_message_hook returns MODIFY strategy", + result.strategy == HookStrategy.MODIFY, + f"got {result.strategy}", + ) + + plugin.set_setting("template", "{nope}") + params2 = SimpleParams(".hello someone") + plugin.on_send_message_hook(account=0, params=params2) + check( + "bad template produces 'Bad template:' message", + params2.message.startswith("Bad template:"), + f"got {params2.message!r}", + ) + + # main.py imports `render`/`TemplateError` by name, so they live directly + # in the executed namespace — call render() directly to inspect the raised + # traceback (proves linecache registration for bundled, non-file modules). + render = ns.get("render") + template_error = ns.get("TemplateError") + traceback_text = "" + if render is not None and template_error is not None: + try: + render("{nope}", "x") + except template_error: + traceback_text = traceback.format_exc() + check( + "render() traceback names src/greeting/template.py", + "src/greeting/template.py" in traceback_text, + f"traceback:\n{traceback_text}", + ) + check( + "traceback includes the raising source line text", + "unsupported placeholder" in traceback_text, + f"traceback:\n{traceback_text}", + ) + + check( + "sys.modules gained no entry for bundled module names", + not ({"greeting", "textutil", "main"} & new_modules), + f"leaked: {({'greeting', 'textutil', 'main'} & new_modules)}", + ) + + if _FAILURES: + print(f"\n{len(_FAILURES)} check(s) failed: {', '.join(_FAILURES)}", file=sys.stderr) + return 1 + print("\nall checks passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main())