diff --git a/README.en.md b/README.en.md
index 944c1ac..2e50b83 100644
--- a/README.en.md
+++ b/README.en.md
@@ -1,5 +1,5 @@
-[](README.md)
-[](README.en.md)
+[](README.md)
+[](README.en.md)
@@ -7,10 +7,14 @@
# Xray Reality VPN Server — deployment
-[](https://github.com/lifestreamy/xrayvpn/releases/latest)
-[](https://pypi.org/project/xrayvpn/)
-[](python-client/README.en.md)
-[](LICENSE)
+[](https://github.com/lifestreamy/xrayvpn/releases/latest)
+[](https://github.com/lifestreamy/homebrew-tap)
+[](https://github.com/microsoft/winget-pkgs/pull/440001)
+[](https://lifestreamy.github.io/xrayvpn/apt/)
+[](https://pypi.org/project/xrayvpn/)
+[](https://pypi.org/project/xrayvpn/)
+[](python-client/README.en.md)
+[](LICENSE)
> VLESS Xray Reality with an optional Cloudflare WARP outbound. A personal VPN on your own VPS
> running Ubuntu/Debian — no domain to buy, just an IP and the root password.
@@ -87,7 +91,7 @@ VPN stopped — start with [`docs/user/RUNBOOK.en.md`](docs/user/RUNBOOK.en.md).
| Path | What you need | How | Comment |
|---|---|---|---|
| Portable app | one downloaded file, nothing to install | download from [Releases](https://github.com/lifestreamy/xrayvpn/releases/latest) and run it (double-click on Windows), type `deploy` | console app |
-| Package managers | a package manager | `pip install xrayvpn` (Python 3.12+), apt (Ubuntu 24.04 / Debian 12) | one-command install |
+| Package managers | a package manager | `pip install xrayvpn` (Python 3.12+), brew (macOS Apple Silicon), apt (Ubuntu 24.04 / Debian 12) | one-command install |
| From the repository | `uv` (installs Python 3.12+) | `uv run --project python-client xrayvpn deploy` | running from source |
| Shell wrappers | Linux/WSL (bash) or Windows+WSL (PowerShell) | `provision-vpn.sh` / `Provision-VPN.ps1` | maintained, not developed |
| Ansible directly | ansible-core 2.14+ and `community.general` | `ansible-playbook -i inventory.yml deploy.yml` | for techies |
@@ -101,7 +105,13 @@ access to it — a password or a key. Install guide — [`docs/user/SETUP.en.md`
**Windows:** download `xrayvpn--windows-x64-portable.exe` from
[Releases](https://github.com/lifestreamy/xrayvpn/releases/latest) and double-click it.
-**macOS (Apple Silicon):** download `xrayvpn--macos-arm64-portable`:
+**macOS (Apple Silicon)** — Homebrew:
+
+```bash
+brew install lifestreamy/tap/xrayvpn
+```
+
+Or download `xrayvpn--macos-arm64-portable`:
```bash
chmod +x xrayvpn-*-macos-arm64-portable
diff --git a/README.md b/README.md
index b166797..33cb023 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
-[](README.en.md)
-[](README.md)
+[](README.en.md)
+[](README.md)
@@ -7,10 +7,14 @@
# Xray Reality VPN Server — развёртывание
-[](https://github.com/lifestreamy/xrayvpn/releases/latest)
-[](https://pypi.org/project/xrayvpn/)
-[](python-client/README.md)
-[](LICENSE)
+[](https://github.com/lifestreamy/xrayvpn/releases/latest)
+[](https://github.com/lifestreamy/homebrew-tap)
+[](https://github.com/microsoft/winget-pkgs/pull/440001)
+[](https://lifestreamy.github.io/xrayvpn/apt/)
+[](https://pypi.org/project/xrayvpn/)
+[](https://pypi.org/project/xrayvpn/)
+[](python-client/README.md)
+[](LICENSE)
> VLESS Xray Reality + опциональный исходящий Cloudflare WARP. Личный VPN-сервер на своём VPS
> Ubuntu/Debian без покупки домена: достаточно IP и пароля root.
@@ -85,7 +89,7 @@
| Путь | Что нужно | Как | Комментарий |
|---|---|---|---|
| Портативное приложение | один скачанный файл, ставить ничего не надо | скачать из [Releases](https://github.com/lifestreamy/xrayvpn/releases/latest) и запустить (Windows — двойной клик), набрать `deploy` | консольное приложение |
-| Пакетные менеджеры | менеджер пакетов | `pip install xrayvpn` (Python 3.12+), apt (Ubuntu 24.04 / Debian 12) | установка одной командой |
+| Пакетные менеджеры | менеджер пакетов | `pip install xrayvpn` (Python 3.12+), brew (macOS Apple Silicon), apt (Ubuntu 24.04 / Debian 12) | установка одной командой |
| Из репозитория | `uv` (поставит Python 3.12+) | `uv run --project python-client xrayvpn deploy` | запуск из исходников |
| Shell-обёртки | Linux/WSL (bash) или Windows+WSL (PowerShell) | `provision-vpn.sh` / `Provision-VPN.ps1` | поддержка без развития |
| Ansible напрямую | ansible-core 2.14+ и `community.general` | `ansible-playbook -i inventory.yml deploy.yml` | для технарей |
@@ -99,7 +103,13 @@ SSH-доступ к нему — пароль или ключ. Инструкц
**Windows:** скачайте `xrayvpn-<версия>-windows-x64-portable.exe` из
[Releases](https://github.com/lifestreamy/xrayvpn/releases/latest) и запустите двойным кликом.
-**macOS (Apple Silicon):** скачайте `xrayvpn-<версия>-macos-arm64-portable`:
+**macOS (Apple Silicon)** — Homebrew:
+
+```bash
+brew install lifestreamy/tap/xrayvpn
+```
+
+Или скачайте `xrayvpn-<версия>-macos-arm64-portable`:
```bash
chmod +x xrayvpn-*-macos-arm64-portable
diff --git a/assets/badges/apt.svg b/assets/badges/apt.svg
new file mode 100644
index 0000000..12df230
--- /dev/null
+++ b/assets/badges/apt.svg
@@ -0,0 +1,13 @@
+
\ No newline at end of file
diff --git a/assets/badges/brew.svg b/assets/badges/brew.svg
new file mode 100644
index 0000000..5fd34d0
--- /dev/null
+++ b/assets/badges/brew.svg
@@ -0,0 +1,13 @@
+
\ No newline at end of file
diff --git a/assets/badges/lang-en-muted.svg b/assets/badges/lang-en-muted.svg
new file mode 100644
index 0000000..033340f
--- /dev/null
+++ b/assets/badges/lang-en-muted.svg
@@ -0,0 +1,11 @@
+
\ No newline at end of file
diff --git a/assets/badges/lang-en.svg b/assets/badges/lang-en.svg
new file mode 100644
index 0000000..dac55d2
--- /dev/null
+++ b/assets/badges/lang-en.svg
@@ -0,0 +1,11 @@
+
\ No newline at end of file
diff --git a/assets/badges/lang-ru-muted.svg b/assets/badges/lang-ru-muted.svg
new file mode 100644
index 0000000..efb1a9c
--- /dev/null
+++ b/assets/badges/lang-ru-muted.svg
@@ -0,0 +1,11 @@
+
\ No newline at end of file
diff --git a/assets/badges/lang-ru.svg b/assets/badges/lang-ru.svg
new file mode 100644
index 0000000..b1763f4
--- /dev/null
+++ b/assets/badges/lang-ru.svg
@@ -0,0 +1,11 @@
+
\ No newline at end of file
diff --git a/assets/badges/license.svg b/assets/badges/license.svg
new file mode 100644
index 0000000..f99632d
--- /dev/null
+++ b/assets/badges/license.svg
@@ -0,0 +1,13 @@
+
\ No newline at end of file
diff --git a/assets/badges/pip.svg b/assets/badges/pip.svg
new file mode 100644
index 0000000..749b74c
--- /dev/null
+++ b/assets/badges/pip.svg
@@ -0,0 +1,13 @@
+
\ No newline at end of file
diff --git a/assets/badges/python.svg b/assets/badges/python.svg
new file mode 100644
index 0000000..f9a94a2
--- /dev/null
+++ b/assets/badges/python.svg
@@ -0,0 +1,13 @@
+
\ No newline at end of file
diff --git a/assets/badges/release.svg b/assets/badges/release.svg
new file mode 100644
index 0000000..be17a0d
--- /dev/null
+++ b/assets/badges/release.svg
@@ -0,0 +1,13 @@
+
\ No newline at end of file
diff --git a/assets/badges/uv.svg b/assets/badges/uv.svg
new file mode 100644
index 0000000..57bbd21
--- /dev/null
+++ b/assets/badges/uv.svg
@@ -0,0 +1,13 @@
+
\ No newline at end of file
diff --git a/assets/badges/winget-en.svg b/assets/badges/winget-en.svg
new file mode 100644
index 0000000..1010d7d
--- /dev/null
+++ b/assets/badges/winget-en.svg
@@ -0,0 +1,13 @@
+
\ No newline at end of file
diff --git a/assets/badges/winget-ru.svg b/assets/badges/winget-ru.svg
new file mode 100644
index 0000000..035f733
--- /dev/null
+++ b/assets/badges/winget-ru.svg
@@ -0,0 +1,13 @@
+
\ No newline at end of file
diff --git a/docs/dev/RELEASE.en.md b/docs/dev/RELEASE.en.md
index 6f610fd..c4a6a61 100644
--- a/docs/dev/RELEASE.en.md
+++ b/docs/dev/RELEASE.en.md
@@ -38,6 +38,12 @@ echo "deb [signed-by=/usr/share/keyrings/xrayvpn-archive-keyring.gpg] https://li
sudo apt update && sudo apt install xrayvpn
```
+Homebrew (macOS, Apple Silicon):
+
+```bash
+brew install lifestreamy/tap/xrayvpn
+```
+
## CHANGELOG
- Kept per shipped version; entries are added by the release commit; format — Added / Changed /
Fixed / Removed.
\ No newline at end of file
diff --git a/docs/dev/RELEASE.md b/docs/dev/RELEASE.md
index 1d261bb..9483d65 100644
--- a/docs/dev/RELEASE.md
+++ b/docs/dev/RELEASE.md
@@ -39,6 +39,12 @@ echo "deb [signed-by=/usr/share/keyrings/xrayvpn-archive-keyring.gpg] https://li
sudo apt update && sudo apt install xrayvpn
```
+Homebrew (macOS, Apple Silicon):
+
+```bash
+brew install lifestreamy/tap/xrayvpn
+```
+
## CHANGELOG
- Ведётся по выпущенным версиям; записи вносит релизный коммит; формат — Added / Changed / Fixed /
Removed.
\ No newline at end of file
diff --git a/docs/dev/publishing/brew.md b/docs/dev/publishing/brew.md
index e5344f3..3421d92 100644
--- a/docs/dev/publishing/brew.md
+++ b/docs/dev/publishing/brew.md
@@ -25,6 +25,21 @@ The `xrayvpn` formula in the tap `lifestreamy/homebrew-tap`, so users install wi
Secret: `BREW_DEPLOY_KEY` (write deploy key of the tap repository).
+## Tap trust
+
+Since Homebrew 6.0 a third-party tap is not loaded until it is trusted. The fully qualified
+command trusts exactly one formula, so it needs no extra step:
+
+```
+brew install lifestreamy/tap/xrayvpn
+```
+
+The short form works only after an explicit trust: `brew tap lifestreamy/tap`, then
+`brew trust --formula lifestreamy/tap/xrayvpn` (or `brew trust lifestreamy/tap` for the whole tap),
+and only then `brew install xrayvpn`.
+
+The formula covers macOS arm64 only (`depends_on arch: :arm64`); Homebrew on Linux is not covered.
+
## Repair
Re-run the pipeline with the released tag and approve it. The formula can also be pushed by hand:
diff --git a/docs/dev/publishing/winget.md b/docs/dev/publishing/winget.md
index 7188809..3a69d68 100644
--- a/docs/dev/publishing/winget.md
+++ b/docs/dev/publishing/winget.md
@@ -61,6 +61,21 @@ If `wingetcreate submit` fails in the pipeline, the same command can be run loca
wingetcreate.exe submit
```
+## URL validation
+
+The validation pipeline checks the URLs in the manifest set — metadata URLs (`PublisherUrl`,
+`PublisherSupportUrl`, `PackageUrl`, `LicenseUrl`, `ReleaseNotesUrl`) and the installer URL — and
+fails the URL step with a `URL-Validation-Error` when one of them answers with an HTTP error; the
+comment names the URL and the status code, and the domain step that follows is skipped. A publisher
+site that is down or answers through a broken origin fails this way. The check runs from Azure IP
+ranges, so a server that blocks them fails it too.
+
+Fix the URL and re-run the validation; no manifest change is needed when the fix is on the site
+side. `@wingetbot run` is a moderator command — it re-runs the validation and strips the transient
+labels — so the re-run is requested from a moderator. The validation service also retries failed
+steps on its own schedule: a failed run is picked up again within a few hours, the transient labels
+are dropped, and the retry re-checks every URL.
+
## Verify and remove
```
diff --git a/docs/user/PLANNED.en.md b/docs/user/PLANNED.en.md
index bad5e87..b4f87e7 100644
--- a/docs/user/PLANNED.en.md
+++ b/docs/user/PLANNED.en.md
@@ -2,7 +2,6 @@
What's coming next. The order can change.
-- **Visual client.** The deploy wizard and everyday commands in one menu: deploy, status, logs,
+- **Visual client.** The deploy wizard and regular commands in one menu: deploy, status, logs,
rotation. Russian and English.
-- **Status and recovery.** Service state, recent logs and a restart from the client.
- **WARP.** Multiple outbounds and endpoint rotation.
\ No newline at end of file
diff --git a/docs/user/PLANNED.md b/docs/user/PLANNED.md
index 05ca024..d91055f 100644
--- a/docs/user/PLANNED.md
+++ b/docs/user/PLANNED.md
@@ -2,7 +2,6 @@
Что появится дальше. Порядок может меняться.
-- **Визуальный клиент.** Мастер развёртывания и повседневные команды в одном меню: деплой,
+- **Визуальный клиент.** Мастер развёртывания и обычные команды в одном меню: деплой,
статус, логи, ротация. Русский и английский интерфейс.
-- **Статус и восстановление.** Состояние сервиса, последние логи и перезапуск из клиента.
- **WARP.** Несколько исходящих и смена точки подключения.
\ No newline at end of file
diff --git a/docs/user/SETUP.en.md b/docs/user/SETUP.en.md
index 40e805d..89c1ac0 100644
--- a/docs/user/SETUP.en.md
+++ b/docs/user/SETUP.en.md
@@ -1,6 +1,6 @@
# SETUP — install and configure
-A console application: everything for the first run and everyday use. Technical details live in the neighbouring files.
+A console application: everything for the first run and regular use. Technical details live in the neighbouring files.
Requirements: a VPS — fresh Ubuntu 20.04+ or Debian 11+, root or sudo, a public IP; on Windows for local mode — WSL2 with Ubuntu/Debian.
## Contents
diff --git a/packaging/badges/badge-lang.svg b/packaging/badges/badge-lang.svg
new file mode 100644
index 0000000..4abff93
--- /dev/null
+++ b/packaging/badges/badge-lang.svg
@@ -0,0 +1,11 @@
+
\ No newline at end of file
diff --git a/packaging/badges/badge.svg b/packaging/badges/badge.svg
new file mode 100644
index 0000000..cf6960b
--- /dev/null
+++ b/packaging/badges/badge.svg
@@ -0,0 +1,13 @@
+
\ No newline at end of file
diff --git a/python-client/tests/test_render_badges_script.py b/python-client/tests/test_render_badges_script.py
new file mode 100644
index 0000000..a580c8c
--- /dev/null
+++ b/python-client/tests/test_render_badges_script.py
@@ -0,0 +1,130 @@
+"""Tests for scripts/cd/render_badges.py (CD glue, README badges)."""
+
+from __future__ import annotations
+
+import importlib.util
+import re
+import sys
+from pathlib import Path
+
+import pytest
+
+_SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "cd" / "render_badges.py"
+sys.path.insert(0, str(_SCRIPT.parent))
+_spec = importlib.util.spec_from_file_location("render_badges", _SCRIPT)
+assert _spec and _spec.loader
+badges_mod = importlib.util.module_from_spec(_spec)
+_spec.loader.exec_module(badges_mod)
+
+_ROOT = _SCRIPT.parents[2]
+_NAMES = {
+ "release.svg",
+ "brew.svg",
+ "winget-ru.svg",
+ "winget-en.svg",
+ "apt.svg",
+ "pip.svg",
+ "uv.svg",
+ "python.svg",
+ "license.svg",
+ "lang-ru.svg",
+ "lang-ru-muted.svg",
+ "lang-en.svg",
+ "lang-en-muted.svg",
+}
+
+
+def test_channel_badges_winget_soon() -> None:
+ badges = {row[0]: row for row in badges_mod.channel_badges("0.4.1", False)}
+ assert badges["release.svg"][2] == "v0.4.1"
+ assert badges["winget-ru.svg"][2] == "скоро"
+ assert badges["winget-en.svg"][2] == "soon"
+ assert badges["winget-ru.svg"][3] == "warn"
+
+
+def test_channel_badges_winget_live() -> None:
+ badges = {row[0]: row for row in badges_mod.channel_badges("0.4.1", True)}
+ assert badges["winget-ru.svg"][2] == "v0.4.1"
+ assert badges["winget-en.svg"][2] == "v0.4.1"
+ assert badges["winget-ru.svg"][3] == "accent"
+
+
+def test_segment_width_fits_text() -> None:
+ for text in ("release", "v0.4.1", "Русский", "AGPL-3.0"):
+ assert badges_mod.segment_width(text, 400) > badges_mod.text_width(text, 400) + 16
+ assert badges_mod.segment_width(text, 700) > badges_mod.text_width(text, 700) + 16
+
+
+def test_shade_darkens_palette_colors() -> None:
+ assert badges_mod.shade("#00D587") == "#00B371"
+ assert badges_mod.shade("#FFFFFF", 0.5) == "#808080"
+
+
+def test_badge_files_complete() -> None:
+ files = badges_mod.badge_files(_ROOT, "0.4.1", False)
+ assert {path.name for path in files} == _NAMES
+
+
+def test_badge_files_use_palette() -> None:
+ palette = badges_mod.load_palette(_ROOT)
+ files = badges_mod.badge_files(_ROOT, "0.4.1", False)
+ release = files[Path("release.svg")].decode()
+ winget = files[Path("winget-ru.svg")].decode()
+ python = files[Path("python.svg")].decode()
+ assert palette["accent"] in release
+ assert palette["warn"] in winget
+ assert palette["muted"] in python
+ assert "url(#g)" in release
+
+
+def test_language_badges_pick_tone() -> None:
+ palette = badges_mod.load_palette(_ROOT)
+ files = badges_mod.badge_files(_ROOT, "0.4.1", False)
+ active = files[Path("lang-ru.svg")].decode()
+ inactive = files[Path("lang-ru-muted.svg")].decode()
+ assert palette["accent"] in active
+ assert palette["muted"] in inactive
+ assert "Русский" in active
+
+
+def test_two_segment_geometry() -> None:
+ template = badges_mod.read_template(_ROOT / "packaging" / "badges" / "badge.svg")
+ gradient = ("#00D587", "#00B371")
+ svg = badges_mod.render_two_segment(
+ template, "uv tool", "v0.4.1", gradient, badges_mod.INK, "uv tool v0.4.1"
+ )
+ total = int(re.search(r'width="(\d+)"', svg).group(1))
+ label_width = int(re.search(r']*font-weight="700"', svg).group(1))
+ assert value_x == label_width + 8
+ assert label_x + badges_mod.text_width("uv tool", 400) <= label_width - 8 + 0.5
+ assert value_x + badges_mod.text_width("v0.4.1", 700) <= total - 8 + 0.5
+
+
+def test_rendered_files_are_lf_and_resolved() -> None:
+ files = badges_mod.badge_files(_ROOT, "0.4.1", False)
+ for path, data in files.items():
+ assert b"\r\n" not in data, path
+ assert b"{{" not in data, path
+ assert b"" in data, path
+
+
+def test_main_writes_files(tmp_path: Path) -> None:
+ code = badges_mod.main(["--tag", "v0.4.1", "--out", str(tmp_path)])
+ assert code == 0
+ assert {path.name for path in tmp_path.iterdir()} == _NAMES
+ expected = badges_mod.badge_files(_ROOT, "0.4.1", False)[Path("release.svg")]
+ assert (tmp_path / "release.svg").read_bytes() == expected
+
+
+def test_main_winget_live(tmp_path: Path) -> None:
+ badges_mod.main(["--tag", "v0.4.1", "--winget-live", "--out", str(tmp_path)])
+ winget = (tmp_path / "winget-ru.svg").read_text(encoding="utf-8")
+ assert "v0.4.1" in winget
+ assert "скоро" not in winget
+
+
+def test_main_rejects_plain_tag(tmp_path: Path) -> None:
+ with pytest.raises(SystemExit):
+ badges_mod.main(["--tag", "0.4.1", "--out", str(tmp_path)])
\ No newline at end of file
diff --git a/scripts/cd/render_badges.py b/scripts/cd/render_badges.py
new file mode 100644
index 0000000..c621683
--- /dev/null
+++ b/scripts/cd/render_badges.py
@@ -0,0 +1,192 @@
+"""Render the README badge set into assets/badges from the release tag and channel states.
+
+Consumed by the docs finalize wave and by channel-state changes: reads the templates
+under packaging/badges and the palette from assets/color-tokens.json, then writes
+self-contained SVG badges with their own background and border, readable on both
+GitHub themes.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import math
+import sys
+from pathlib import Path
+
+from release_common import normalized_version
+from render_channels import read_template, substitute
+
+PAD = 8.0
+RADIUS = 4
+INK = "#0B1F16"
+INK_NEUTRAL = "#161B22"
+
+# Advance widths of Arial-compatible 12px text, measured in Chromium on 2026-09-26.
+WIDTHS_400 = {
+ "0": 6.67, "1": 6.67, "2": 6.67, "3": 6.67, "4": 6.67, "5": 6.67,
+ "6": 6.67, "7": 6.67, "8": 6.67, "9": 6.67,
+ "a": 6.67, "b": 6.67, "c": 6.0, "d": 6.67, "e": 6.67, "f": 3.33, "g": 6.67,
+ "h": 6.67, "i": 2.67, "j": 2.67, "k": 6.0, "l": 2.67, "m": 10.0, "n": 6.67,
+ "o": 6.67, "p": 6.67, "q": 6.67, "r": 4.0, "s": 6.0, "t": 3.33, "u": 6.67,
+ "v": 6.0, "w": 8.67, "x": 6.0, "y": 6.0, "z": 6.0,
+ "A": 8.0, "B": 8.0, "C": 8.67, "D": 8.67, "E": 8.0, "F": 7.33, "G": 9.33,
+ "H": 8.67, "I": 3.33, "J": 6.0, "K": 8.0, "L": 6.67, "M": 10.0, "N": 8.67,
+ "O": 9.33, "P": 8.0, "Q": 9.33, "R": 8.67, "S": 8.0, "T": 7.33, "U": 8.67,
+ "V": 8.0, "W": 11.33, "X": 8.0, "Y": 8.0, "Z": 7.33,
+ " ": 3.33, ".": 3.33, "-": 4.0, "+": 7.01,
+ "Р": 8.0, "у": 6.0, "с": 6.0, "к": 5.25, "и": 6.7, "й": 6.7, "о": 6.67, "р": 6.67,
+}
+WIDTHS_700 = {
+ "0": 6.67, "1": 6.67, "2": 6.67, "3": 6.67, "4": 6.67, "5": 6.67,
+ "6": 6.67, "7": 6.67, "8": 6.67, "9": 6.67,
+ "a": 6.67, "b": 7.33, "c": 6.67, "d": 7.33, "e": 6.67, "f": 4.0, "g": 7.33,
+ "h": 7.33, "i": 3.33, "j": 3.33, "k": 6.67, "l": 3.33, "m": 10.67, "n": 7.33,
+ "o": 7.33, "p": 7.33, "q": 7.33, "r": 4.67, "s": 6.67, "t": 4.0, "u": 7.33,
+ "v": 6.67, "w": 9.33, "x": 6.67, "y": 6.67, "z": 6.0,
+ "A": 8.67, "B": 8.67, "C": 8.67, "D": 8.67, "E": 8.0, "F": 7.33, "G": 9.33,
+ "H": 8.67, "I": 3.33, "J": 6.67, "K": 8.67, "L": 7.33, "M": 10.0, "N": 8.67,
+ "O": 9.33, "P": 8.0, "Q": 9.33, "R": 8.67, "S": 8.0, "T": 7.33, "U": 8.67,
+ "V": 8.0, "W": 11.33, "X": 8.0, "Y": 8.0, "Z": 7.33,
+ " ": 3.33, ".": 3.33, "-": 4.0, "+": 7.01,
+ "Р": 8.0, "у": 6.67, "с": 6.67, "к": 6.01, "и": 7.38, "й": 7.38, "о": 7.33, "р": 7.33,
+}
+DEFAULTS = {400: 7.0, 700: 7.5}
+
+
+def text_width(text: str, weight: int) -> float:
+ table = WIDTHS_400 if weight == 400 else WIDTHS_700
+ return sum(table.get(char, DEFAULTS[weight]) for char in text)
+
+
+def segment_width(text: str, weight: int) -> int:
+ return math.ceil(text_width(text, weight) * 1.02 + 2 * PAD)
+
+
+def shade(hex_color: str, factor: float = 0.84) -> str:
+ channels = (int(hex_color[index : index + 2], 16) for index in (1, 3, 5))
+ return "#" + "".join(f"{round(channel * factor):02X}" for channel in channels)
+
+
+def load_palette(root: Path) -> dict[str, str]:
+ raw = (root / "assets" / "color-tokens.json").read_text(encoding="utf-8")
+ return json.loads(raw)
+
+
+def render_two_segment(
+ template: str, label: str, value: str, gradient: tuple[str, str], ink: str, alt: str
+) -> str:
+ label_width = segment_width(label, 400)
+ value_width = segment_width(value, 700)
+ total = label_width + value_width
+ return substitute(
+ template,
+ {
+ "W": str(total),
+ "IW": str(total - 1),
+ "X2": str(label_width),
+ "ARCS": str(total - 1 - RADIUS),
+ "XRIGHT": str(total - 1),
+ "X2T": str(label_width + int(PAD)),
+ "LABEL": label,
+ "VALUE": value,
+ "G1": gradient[0],
+ "G2": gradient[1],
+ "INK": ink,
+ "ALT": alt,
+ },
+ )
+
+
+def render_single_segment(
+ template: str, text: str, gradient: tuple[str, str], ink: str, alt: str
+) -> str:
+ total = segment_width(text, 700)
+ return substitute(
+ template,
+ {
+ "W": str(total),
+ "IW": str(total - 1),
+ "TEXT": text,
+ "G1": gradient[0],
+ "G2": gradient[1],
+ "INK": ink,
+ "ALT": alt,
+ },
+ )
+
+
+def channel_badges(version: str, winget_live: bool) -> list[tuple[str, str, str, str, str]]:
+ tag = f"v{version}"
+ winget_value_ru = tag if winget_live else "скоро"
+ winget_value_en = tag if winget_live else "soon"
+ winget_tone = "accent" if winget_live else "warn"
+ return [
+ ("release.svg", "release", tag, "accent", f"release {tag}"),
+ ("brew.svg", "brew", tag, "accent", f"brew {tag}"),
+ ("winget-ru.svg", "winget", winget_value_ru, winget_tone, f"winget {winget_value_ru}"),
+ ("winget-en.svg", "winget", winget_value_en, winget_tone, f"winget {winget_value_en}"),
+ ("apt.svg", "apt", tag, "accent", f"apt {tag}"),
+ ("pip.svg", "pip", tag, "accent", f"pip {tag}"),
+ ("uv.svg", "uv tool", tag, "accent", f"uv tool {tag}"),
+ ("python.svg", "python", "3.12+", "muted", "python 3.12+"),
+ ("license.svg", "license", "AGPL-3.0", "muted", "license AGPL-3.0"),
+ ]
+
+
+def language_badges() -> list[tuple[str, str, str]]:
+ return [
+ ("lang-ru.svg", "Русский", "accent"),
+ ("lang-ru-muted.svg", "Русский", "muted"),
+ ("lang-en.svg", "English", "accent"),
+ ("lang-en-muted.svg", "English", "muted"),
+ ]
+
+
+def badge_files(root: Path, version: str, winget_live: bool) -> dict[Path, bytes]:
+ templates = root / "packaging" / "badges"
+ palette = load_palette(root)
+ gradients = {
+ tone: (palette[tone], shade(palette[tone])) for tone in ("accent", "warn", "muted")
+ }
+ inks = {"accent": INK, "warn": INK, "muted": INK_NEUTRAL}
+ two_segment = read_template(templates / "badge.svg")
+ single_segment = read_template(templates / "badge-lang.svg")
+ files: dict[Path, bytes] = {}
+ for name, label, value, tone, alt in channel_badges(version, winget_live):
+ rendered = render_two_segment(
+ two_segment, label, value, gradients[tone], inks[tone], alt
+ )
+ files[Path(name)] = rendered.encode("utf-8")
+ for name, text, tone in language_badges():
+ rendered = render_single_segment(
+ single_segment, text, gradients[tone], inks[tone], text
+ )
+ files[Path(name)] = rendered.encode("utf-8")
+ return files
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--tag", required=True, help="released tag, e.g. v0.4.1")
+ parser.add_argument(
+ "--winget-live",
+ action="store_true",
+ help="render the winget version instead of the placeholder",
+ )
+ parser.add_argument("--out", type=Path, help="output directory (default assets/badges)")
+ parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[2])
+ args = parser.parse_args(argv)
+ version = normalized_version(args.tag)
+ out = args.out or args.root / "assets" / "badges"
+ files = badge_files(args.root, version, args.winget_live)
+ for relative, data in sorted(files.items()):
+ target = out / relative
+ target.parent.mkdir(parents=True, exist_ok=True)
+ target.write_bytes(data)
+ print(f"wrote {target}")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
\ No newline at end of file