Compare commits

...
6 Commits
Author SHA1 Message Date
lyuksovannyyandClaude Opus 5.5 740ad9f1a3 [no bump] Shift plugin icon index down by one
Publish plugin / publish (push) Successful in 3s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:20:24 +02:00
lyuksovannyy e6f92ca563 [no bump] plugin.json update
Publish plugin / publish (push) Successful in 4s
2026-10-09 14:03:38 +02:00
lyuksovannyyandClaude Opus 5.5 450b7155b1 [no bump] Move technical docs to docs/, add icon PNG export tool
README now carries only the icon, TL;DR, description and features; the
technical sections live in docs/. tools/icon_png.py renders
images/icon.svg to a square PNG (512x512 by default, --resolution N).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:03:06 +02:00
lyuksovannyyandClaude Opus 5.5 1bd5c72ce5 [no bump] Enable git hooks automatically on first bundle run
Publish plugin / publish (push) Successful in 3s
tools/bundle.py now sets `core.hooksPath scripts` the first time it runs in a
clone (skipped in CI, outside a git work tree, or when core.hooksPath is
already set), so the version auto-bump hooks work in fresh clones and repos
created from the template without a manual setup step. README documents the
hooks, the self-enabling behaviour and the bump stop words.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 12:56:23 +02:00
lyuksovannyy e01dc843b2 [nobump] Version bump script update
Publish plugin / publish (push) Successful in 29s
2026-09-19 17:28:51 +02:00
lyuksovannyy 5e7f722bd9 [nobump] Added plugin minor version auto-increment 2026-09-19 17:16:51 +02:00
11 changed files with 690 additions and 138 deletions

No files matched your search

+26 -136
View File
@@ -1,148 +1,38 @@
# exteraGram Plugin Base <p align="center">
<img src="images/icon.svg" width="128" height="128" alt="exteraGram Plugin Base icon">
</p>
A build toolchain and ADB automation for writing [exteraGram](https://exteragram.app) plugins <h1 align="center">exteraGram Plugin Base</h1>
as ordinary multi-file Python projects instead of hand-maintaining one giant `.py` file.
## How exteraGram plugins actually work **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.
A plugin, as exteraGram loads it, is exactly **one Python file** on the device: ## Description
`/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 exteraGram loads every plugin from exactly one Python file. This template lets you split a
assignments (`__id__`, `__name__`, `__description__`, `__author__`, `__version__`, plugin into ordinary modules and packages anyway: a bundler compiles `src/` and `plugin.json`
`__icon__`, `__app_version__`, `__sdk_version__`, `__requirements__`) without executing the into that single file, an offline harness verifies the result without a device, ADB scripts
file. Only `__id__` and `__name__` are required. push it to a connected phone and reload it, and CI publishes every version to the repo's
Releases tab.
The file must define exactly one class inheriting `BasePlugin` (`from base_plugin import ## Features
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 None for the end user. The bundled sample plugin (`Hello Bundle`) only proves that the
`docs/sdk-notes.md` for why. 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.
## Repo layout ## Documentation
``` - [Development](docs/development.md): getting started, repo layout, building and verifying, icon export
plugin.json single source of truth for plugin metadata - [How exteraGram plugins work](docs/how-plugins-work.md): the plugin format and lifecycle
src/ the plugin, written as an ordinary Python package - [Device setup and ADB automation](docs/device-and-adb.md): deploy, watch, logs, debugger
main.py entry module — exactly one BasePlugin subclass, absolute imports only - [Versioning and releases](docs/versioning-and-releases.md): git hooks, auto-bump, CI publishing
greeting/ sample package (proves relative + cross-module imports survive bundling) - [SDK notes](docs/sdk-notes.md): condensed exteraGram SDK API surface
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/ bash automation for the ADB debug loop (see below)
docs/sdk-notes.md condensed SDK API surface gathered from the official docs
```
## The three commands that matter ## Quick start
```bash ```bash
python3 tools/bundle.py --release # compile src/ -> dist/<id>.plugin python3 tools/bundle.py --check # once after cloning: enables the git hooks
python3 tools/verify_bundle.py dist/<id>.plugin # offline proof the bundle works, no device needed bash scripts/deploy.sh --enable # build + push to a connected device + enable
bash scripts/deploy.sh --enable # build (--dev) + push to a connected device + enable + reload
``` ```
`bundle.py` walks `src/` recursively, embeds every `.py` file's source and dotted module name
into a compressed, base64-encoded payload, and appends a small runtime preamble that installs
a private import hook (scoped to a per-module `__builtins__` dict, never touching
`sys.modules`) so cross-module and cross-package imports resolve exactly as they would if the
files were still on disk. `--dev` (the default) embeds absolute local paths so a debugger
resolves breakpoints against your real source files; `--release` embeds repo-relative paths
for distribution. Both modes produce byte-reproducible output for a given `src/` + `plugin.json`.
## Device prerequisites
- exteraGram 12.5.1+.
- In-app: Preferences → Plugins → enable the plugin engine, then enable developer mode.
- USB debugging enabled, device authorized (`adb devices`).
Release-build APKs disallow `adb run-as`, so you cannot `adb push` a file straight into the
app's private plugin directory. The on-device **dev server** (`tools/devclient.py`,
`scripts/deploy.sh`) is the actual install path — it talks to the running app over a TCP
socket forwarded with `adb forward`, and the app writes the file into its own storage.
## ADB automation
All scripts live in `scripts/`, are runnable from any working directory, and honor the
standard `ANDROID_SERIAL` env var for device selection.
- `scripts/adb-connect.sh [--debug]` — starts adb, waits for the device, (re)creates the
`tcp:42690` port forward (and `tcp:5678` with `--debug`), pings the dev server.
- `scripts/deploy.sh [--release] [--enable] [--no-connect]` — bundles, connects, writes the
plugin to the device, optionally enables it, reloads it.
- `scripts/watch.sh [deploy args…]` — watches `src/` and `plugin.json`, redeploys on change
(uses `inotifywait` if available, otherwise polls).
- `scripts/logs.sh [-c] [-t] [-g PATTERN]` — streams logcat for the running app (`-t` for the
raw Chaquopy stdio tags, `-g` to grep, `-c` to clear first).
- `scripts/debug.sh start|stop [--platform vscode|pycharm]` — forwards the debugger port and
tells the dev server to start/stop the in-app debugger. Requires `debugpy` (vscode) or
`pydevd-pycharm` (pycharm) present in the app's Python runtime — add it to `requirements` in
`plugin.json` if it's missing.
- `scripts/fetch-sdk-stubs.sh [--beta]` — downloads the latest SDK type stubs into `stubs/`
for editor autocomplete.
## 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. 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. Edit `plugin.json` (id, name, description, etc.).
2. Write your plugin under `src/`, with `src/main.py` as the entry module (absolute imports
only — it's executed at plugin-module top level, not inside a package).
3. `python3 tools/bundle.py --release && python3 tools/verify_bundle.py dist/<id>.plugin`
(substitute your `id` from `plugin.json`).
4. `bash scripts/deploy.sh --enable` with a device connected.
+65
View File
@@ -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/<id>.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/<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)
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/<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](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.
+32
View File
@@ -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.
+26
View File
@@ -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/<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
[`sdk-notes.md`](sdk-notes.md) for why.
+84
View File
@@ -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 -- <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 [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<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.
+35
View File
@@ -0,0 +1,35 @@
<svg xmlns="http://www.w3.org/2000/svg" width="640" height="640" viewBox="0 0 640 640">
<defs>
<linearGradient id="bg" x1="0.3" y1="0" x2="0.7" y2="1">
<stop offset="0" stop-color="#F89B2E"/><stop offset="0.5" stop-color="#F7724A"/><stop offset="1" stop-color="#F5517A"/>
</linearGradient>
<radialGradient id="glow" cx="0.5" cy="0.45" r="0.5">
<stop offset="0" stop-color="#FF5A3C" stop-opacity="0.18"/><stop offset="1" stop-color="#FF5A3C" stop-opacity="0"/>
</radialGradient>
<linearGradient id="paper" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#F7F7F7"/><stop offset="1" stop-color="#E2E2E2"/>
</linearGradient>
<linearGradient id="star" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#FFE27A"/><stop offset="1" stop-color="#FFC53D"/>
</linearGradient>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#7CC8FF"/><stop offset="1" stop-color="#CFEBFF"/>
</linearGradient>
<linearGradient id="metal" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#9C9C9C"/><stop offset="0.45" stop-color="#E4E4E4"/><stop offset="1" stop-color="#A8A8A8"/>
</linearGradient>
<clipPath id="photo"><rect x="166" y="196" width="228" height="208" rx="18"/></clipPath>
<filter id="sh" x="-20%" y="-20%" width="140%" height="150%">
<feDropShadow dx="0" dy="10" stdDeviation="10" flood-color="#7A1E2E" flood-opacity="0.28"/>
</filter>
</defs>
<rect width="640" height="640" fill="url(#bg)"/>
<rect width="640" height="640" fill="url(#glow)"/>
<g transform="translate(0,-14)">
<rect filter="url(#sh)" x="140" y="470" width="360" height="54" rx="20" fill="url(#paper)" stroke="#B9B9B9" stroke-width="6" stroke-linejoin="round"/>
<line x1="176" y1="497" x2="464" y2="497" stroke="#CFCFCF" stroke-width="6" stroke-linecap="round"/>
<path filter="url(#sh)" transform="translate(-32,0)" fill="url(#star)" stroke="#F59A2E" stroke-width="8" stroke-linejoin="round" d="M200,220 H285 C275,180 290,152 320,152 C350,152 365,180 355,220 H440 V300 C480,290 508,305 508,340 C508,375 480,390 440,380 V460 H355 C365,420 350,398 320,398 C290,398 275,420 285,460 H200 Z"/>
<path transform="translate(-32,0)" d="M222,250 V300" stroke="#FFF3C4" stroke-width="12" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+2 -2
View File
@@ -2,9 +2,9 @@
"id": "hello_bundle", "id": "hello_bundle",
"name": "Hello Bundle", "name": "Hello Bundle",
"description": "Multi-file exteraGram plugin built by tools/bundle.py", "description": "Multi-file exteraGram plugin built by tools/bundle.py",
"author": "@yourhandle", "author": "@lyuksovannyy",
"version": "1.0.0", "version": "1.0.0",
"icon": "exteraPlugins/1", "icon": "exteraGramPluginIcons/0",
"app_version": ">=12.5.1", "app_version": ">=12.5.1",
"sdk_version": ">=1.4.4.3", "sdk_version": ">=1.4.4.3",
"requirements": [] "requirements": []
+13
View File
@@ -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
+265
View File
@@ -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 → Regular
+46
View File
@@ -2,6 +2,9 @@
"""Bundle a multi-file exteraGram plugin (plugin.json + src/) into a single .plugin file. """Bundle a multi-file exteraGram plugin (plugin.json + src/) into a single .plugin file.
Usage: python3 tools/bundle.py [--dev|--release] [--out DIR] [--check] 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 from __future__ import annotations
@@ -9,13 +12,18 @@ import argparse
import ast import ast
import base64 import base64
import json import json
import os
import re import re
import stat
import subprocess
import sys import sys
import zlib import zlib
from pathlib import Path from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent REPO_ROOT = Path(__file__).resolve().parent.parent
SRC_DIR = REPO_ROOT / "src" SRC_DIR = REPO_ROOT / "src"
HOOKS_DIR = "scripts"
HOOK_NAMES = ("pre-commit", "post-commit")
PLUGIN_JSON = REPO_ROOT / "plugin.json" PLUGIN_JSON = REPO_ROOT / "plugin.json"
RECOGNIZED_KEYS = { RECOGNIZED_KEYS = {
@@ -300,6 +308,43 @@ def assemble_bundle(data: dict, modules: dict, release: bool) -> str:
return text 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): def parse_args(argv):
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Bundle a multi-file exteraGram plugin into a single .plugin file." description="Bundle a multi-file exteraGram plugin into a single .plugin file."
@@ -318,6 +363,7 @@ def parse_args(argv):
def main(argv=None) -> int: def main(argv=None) -> int:
args = parse_args(sys.argv[1:] if argv is None else argv) args = parse_args(sys.argv[1:] if argv is None else argv)
ensure_git_hooks()
release = bool(args.release) release = bool(args.release)
try: try:
data = load_metadata() data = load_metadata()
+96
View File
@@ -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 <resolution>x<resolution>.
"""
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()