From 2ac2600dcf238f29eeb8d88e7658987a6f3e8eaa Mon Sep 17 00:00:00 2001 From: lyuksovannyy Date: Fri, 9 Oct 2026 14:16:37 +0200 Subject: [PATCH] [no bump] Move technical docs to docs/, add icon and PNG export tool README now carries 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). plugin.json points at the plugin's own icon. Co-Authored-By: Claude Opus 5.5 --- README.md | 211 +++++--------------------------- docs/development.md | 66 ++++++++++ docs/device-and-adb.md | 32 +++++ docs/how-plugins-work.md | 26 ++++ docs/versioning-and-releases.md | 84 +++++++++++++ images/icon.svg | 40 ++++++ plugin.json | 2 +- tools/icon_png.py | 96 +++++++++++++++ 8 files changed, 373 insertions(+), 184 deletions(-) create mode 100644 docs/development.md create mode 100644 docs/device-and-adb.md create mode 100644 docs/how-plugins-work.md create mode 100644 docs/versioning-and-releases.md create mode 100644 images/icon.svg create mode 100644 tools/icon_png.py diff --git a/README.md b/README.md index 3475603..7296dce 100644 --- a/README.md +++ b/README.md @@ -1,191 +1,36 @@ -# exteraGram Plugin Base +

+ Extended Media Forwards icon +

-A build toolchain and ADB automation for writing [exteraGram](https://exteragram.app) plugins -as ordinary multi-file Python projects instead of hand-maintaining one giant `.py` file. +

Extended Media Forwards

-## How exteraGram plugins actually work +**TL;DR:** an [exteraGram](https://exteragram.app) plugin that adds a Forward button to +photos and videos opened from telegra.ph articles and rich messages. -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`. +## Description -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. +Media inside an Instant View article can't be forwarded, and media inside a rich message +can only be forwarded together with the whole message. Extended Media Forwards adds a +Forward action to the media viewer for both: it downloads the photo or video and sends it +again from your account, with a link back to its source when there is one. -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`. +## Features -This repo does not use the official Elyx/ElyxBuilder archive format — see -`docs/sdk-notes.md` for why. +- **Forward from telegra.ph.** Works on media opened from telegra.ph and graph.org pages, + single items and carousels alike. +- **Forward from rich messages.** Single media and carousels inside rich (article) messages + (on by default, can be turned off). +- **Any Instant View article.** Optional switch to allow the button on every Instant View + page, not only telegra.ph (off by default). +- **The usual share sheet.** Pick chats in the app's own "Forward to" sheet, including a + topic in forums, and add a comment. +- **Source link.** The sent media carries a link back to the page or message it came from. -## Repo layout +## Documentation -``` -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) -.gitea/workflows/ CI that builds, verifies and publishes the .plugin to Releases -scripts/ ADB debug-loop automation and the git hooks (see below) -docs/sdk-notes.md condensed SDK API surface gathered from the official docs -docs/media-viewer-notes.md app internals behind the Forward button (page-block adapters, rich messages) -``` - -## 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](#git-hooks-and-versioning)). - -## Device prerequisites - -- exteraGram 12.5.1+. -- In-app: Preferences → Plugins → enable the plugin engine, then enable developer mode. -- USB debugging enabled, device authorized (`adb devices`). - -Release-build APKs disallow `adb run-as`, so you cannot `adb push` a file straight into the -app's private plugin directory. The on-device **dev server** (`tools/devclient.py`, -`scripts/deploy.sh`) is the actual install path — it talks to the running app over a TCP -socket forwarded with `adb forward`, and the app writes the file into its own storage. - -## ADB automation - -All scripts live in `scripts/`, are runnable from any working directory, and honor the -standard `ANDROID_SERIAL` env var for device selection. - -- `scripts/adb-connect.sh [--debug]` — starts adb, waits for the device, (re)creates the - `tcp:42690` port forward (and `tcp:5678` with `--debug`), pings the dev server. -- `scripts/deploy.sh [--release] [--enable] [--no-connect]` — bundles, connects, writes the - plugin to the device, optionally enables it, reloads it. -- `scripts/watch.sh [deploy args…]` — watches `src/` and `plugin.json`, redeploys on change - (uses `inotifywait` if available, otherwise polls). -- `scripts/logs.sh [-c] [-t] [-g PATTERN]` — streams logcat for the running app (`-t` for the - raw Chaquopy stdio tags, `-g` to grep, `-c` to clear first). -- `scripts/debug.sh start|stop [--platform vscode|pycharm]` — forwards the debugger port and - tells the dev server to start/stop the in-app debugger. Requires `debugpy` (vscode) or - `pydevd-pycharm` (pycharm) present in the app's Python runtime — add it to `requirements` in - `plugin.json` if it's missing. -- `scripts/fetch-sdk-stubs.sh [--beta]` — downloads the latest SDK type stubs into `stubs/` - for editor autocomplete. - -## Git hooks and versioning - -`scripts/pre-commit` bumps the patch number of `version` in `plugin.json` on every commit -(`1.0.0` → `1.0.1`) and stages it into that same commit. `scripts/post-commit` re-stages -`plugin.json` afterwards, because commits of a file selection (`git commit -- `, which -some IDEs use) are built from a temporary index and would otherwise leave it looking modified. - -**The hooks enable themselves.** Git never activates hooks from repository content, so a -fresh clone, or a repo created from this template, starts without them. The first run of -`tools/bundle.py` fixes that by running `git config core.hooksPath scripts`, and every -workflow runs it: directly, or through `scripts/deploy.sh` and `scripts/watch.sh`. That -includes `--check`, so after cloning run - -```bash -python3 tools/bundle.py --check -``` - -once, before your first commit. A commit made before any build runs without the hooks. -`bundle.py` leaves `core.hooksPath` alone when it is already set (so pointing it elsewhere on -purpose sticks), skips the step in CI (whenever `CI` is set) and outside a git work tree, and -never fails a build over it. To turn the hooks off for a clone, run -`git config core.hooksPath .git/hooks`; `git config --unset core.hooksPath` only lasts -until the next build. - -The bump is skipped when: - -- the commit message contains `[no bump]`, `[skip bump]`, `[no version bump]` or - `[skip version]` (case-insensitive; `nobump`, `no-bump` and `no_bump` also work), -- `NO_BUMP=1` (or `GIT_NO_BUMP=1`) is set, e.g. `NO_BUMP=1 git commit`, which is the only way - for commits written in an editor, since their message doesn't exist yet when the hook runs, -- `version` in `plugin.json` already differs from `HEAD` (you bumped it by hand, e.g. for a - minor or major release), -- a merge, rebase, cherry-pick or revert is in progress. - -Because every version change publishes a release (see below), a normal commit + push to -`main` ships a new patch release. Use a stop word for commits that should not. - -## Publishing a release - -`.gitea/workflows/release.yml` builds, verifies and publishes the `.plugin` to the repo's -Releases tab on every push to `main`. `dist/` is gitignored, so the artifact is always built -by CI rather than committed. Nothing in the workflow is plugin-specific — it reads the id and -version straight out of `plugin.json`, so it keeps working after you rename the plugin. - -The `version` field in `plugin.json` is the single source of truth: - -| Push to `main` with… | Result | -| --- | --- | -| a new `version` | creates tag `v` and a new release | -| the same `version` | rebuilds the existing release, replacing its attachment | - -So every push leaves a downloadable build attached to the current version, and bumping -`version` is what cuts a new release. With the git hooks enabled every commit bumps it, -so every push of a normal commit cuts a new patch release. Pushing a `v*` tag by hand still works and uses that -tag verbatim, as does a manual run (Actions → Publish plugin → Run workflow). - -Every path runs `--check`, builds with `--release`, and gates the upload on -`verify_bundle.py`, so a bundle that fails verification is never published. A rebuild -replaces the attachment rather than piling up duplicates, and refreshes the release notes to -name the commit it came from. The tag itself is never moved: it keeps pointing at the commit -that first introduced that version. - -Authentication uses `secrets.API_TOKEN`, an organisation-level Actions secret holding a user -token with the `write:repository` scope (which covers releases). Set it once under -**Org → Settings → Actions → Secrets** and every repo in the organisation inherits it; a -repo-level secret of the same name takes precedence if one repo ever needs different -credentials. - -The workflow passes it to `release.py` as `RELEASE_TOKEN`, **not** `GITEA_TOKEN`. The Actions -runner injects its own `GITEA_TOKEN` into every step, and that value overrides a step-level -`env:` entry of the same name, so the org secret would be silently ignored. - -If the org secret does not reach a repository the publish still works: `release.py` tries -`RELEASE_TOKEN` first and then falls back to the runner's `GITEA_TOKEN`, which can publish -releases in its own repository, and prints which one it used. An organisation secret is only -exposed to a repository that has Actions enabled, so check **Repo → Settings → Actions** if a -repo unexpectedly falls back. - -If you would rather not manage a token at all, that injected `GITEA_TOKEN` can create -releases in its own repo: pass `--token-env GITEA_TOKEN` and drop the org secret. It is -scoped to the single job and expires with it, so it is the safer default; `API_TOKEN` is what -you want when publishing needs to reach beyond this repo. - -## Writing your own plugin - -1. Run `python3 tools/bundle.py --check` once. It enables the git hooks that auto-bump the - version on commit (see [Git hooks and versioning](#git-hooks-and-versioning)). -2. Edit `plugin.json` (id, name, description, etc.). -3. Write your plugin under `src/`, with `src/main.py` as the entry module (absolute imports - only — it's executed at plugin-module top level, not inside a package). -4. `python3 tools/bundle.py --release && python3 tools/verify_bundle.py dist/.plugin` - (substitute your `id` from `plugin.json`). -5. `bash scripts/deploy.sh --enable` with a device connected. +- [Media viewer notes](docs/media-viewer-notes.md): the app internals behind the Forward button +- [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 diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..dd9df7b --- /dev/null +++ b/docs/development.md @@ -0,0 +1,66 @@ +# 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 +docs/media-viewer-notes.md app internals behind the Forward button (page-block adapters, rich messages) +``` + +## 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/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..68f2bc0 --- /dev/null +++ b/images/icon.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugin.json b/plugin.json index cd7c45a..a56df6e 100644 --- a/plugin.json +++ b/plugin.json @@ -4,7 +4,7 @@ "description": "Adds a Forward action to media opened from telegra.ph pages and rich messages (single media and carousels): downloads the media and re-sends it from your account with a link back to its source.", "author": "@lyuksovannyy", "version": "1.1.2", - "icon": "exteraPlugins/1", + "icon": "exteraGramPluginIcons/4", "app_version": ">=12.5.1", "sdk_version": ">=1.4.4.3", "requirements": [] 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()