chore: bump version to 1.2

This commit is contained in:
IMDelewer 2026-06-30 15:01:22 +03:00
parent f6bb33d193
commit 83f209e44a
101 changed files with 2419 additions and 608 deletions

View file

@ -1,26 +1,9 @@
#!/bin/bash
# WinterGram build wrapper — produces WinterGram-named IPAs for every install target.
# Lives in scripts/; all paths are resolved relative to the repo root (the script cd's there).
#
# Usage:
# ./scripts/build-wintergram.sh
# ./scripts/build-wintergram.sh all
# ./scripts/build-wintergram.sh sideload
# ./scripts/build-wintergram.sh livecontainer
# ./scripts/build-wintergram.sh sim
#
# Convenience:
# ./scripts/build-wintergram.sh --install # build the simulator IPA and install it into the active Simulator (sim mode only)
# ./scripts/build-wintergram.sh --install --run # also launch the app in the active Simulator
# ./scripts/build-wintergram.sh --clean # remove ./build before building
# ./scripts/build-wintergram.sh --open-build-dir # open ./build in Finder after build
# ./scripts/build-wintergram.sh --help
# WinterGram build wrapper.
set -euo pipefail
# Resolve to the repo root regardless of where the script is invoked from (it lives in scripts/).
cd "$(dirname "$0")/.."
REPO="$(pwd)"
source ~/.zshrc 2>/dev/null || true
OUT_DIR="build"
SIDELOAD_NAME="WinterGram.ipa"
@ -29,12 +12,19 @@ SIM_NAME="WinterGram-Simulator.ipa"
WNT_BUNDLE_ID="dev.reekeer.wintergram"
BAZEL="./build-input/bazel-8.4.2-darwin-arm64"
DEVICE_SRC="bazel-bin/Telegram/Telegram.ipa"
_XCODE_DEV_DIR="${DEVELOPER_DIR:-$(xcode-select -p 2>/dev/null)}"
BAZEL_XCODE_ACTION_ENV="--action_env=DEVELOPER_DIR=${_XCODE_DEV_DIR} --host_action_env=DEVELOPER_DIR=${_XCODE_DEV_DIR}"
# Xcode 27 compatibility
BAZEL_SDK_COMPAT_ARGS="--features=-treat_warnings_as_errors --@build_bazel_rules_swift//swift:copt=-no-warnings-as-errors --copt=-Wno-deprecated-declarations"
MODE="all"
INSTALL_REQUESTED=0
INSTALL_DEVICE=0
INSTALL_SIM=0
RUN_SIM=0
RUN_APP=0
CLEAN=0
OPEN_BUILD_DIR=0
DEVICE_SELECTOR=""
usage() {
cat <<EOF
@ -45,19 +35,25 @@ Usage:
Modes:
all Build all deliverables. Default.
sideload, device Build device sideload IPA -> build/$SIDELOAD_NAME
sideload, device,
ios Build device sideload IPA -> build/$SIDELOAD_NAME
livecontainer, lc Build unsigned LiveContainer IPA -> build/$LC_NAME
sim, simulator Build simulator IPA -> build/$SIM_NAME
Options:
--install Build the simulator IPA and install it into the active booted Simulator.
This forces "sim" mode (install only makes sense for the simulator).
--install Install after build. With ios/device/sideload mode, installs on a connected
iPhone/iPad via xcrun devicectl. With sim mode, installs into the active
booted Simulator. With no explicit mode, keeps the old simulator shortcut.
--device <id|name> Device selector for devicectl. Optional if exactly one iPhone/iPad is connected.
--run Launch the app after --install (implies --install).
--clean Remove ./build before building.
--open-build-dir Open ./build in Finder after build.
-h, --help Show this help.
Examples:
$0 ios --install
$0 ios --install --run
$0 ios --install --device "Del's iPhone"
$0 --install
$0 --install --run
$0 sideload --clean
@ -83,16 +79,21 @@ MODE_WAS_EXPLICIT=0
while [ "$#" -gt 0 ]; do
case "$1" in
all|sideload|device|livecontainer|lc|sim|simulator)
all|sideload|device|ios|livecontainer|lc|sim|simulator)
MODE="$1"
MODE_WAS_EXPLICIT=1
;;
--install)
INSTALL_SIM=1
INSTALL_REQUESTED=1
;;
--run)
RUN_SIM=1
INSTALL_SIM=1
RUN_APP=1
INSTALL_REQUESTED=1
;;
--device)
shift
[ "$#" -gt 0 ] || die "--device requires a value"
DEVICE_SELECTOR="$1"
;;
--clean)
CLEAN=1
@ -111,13 +112,26 @@ while [ "$#" -gt 0 ]; do
shift
done
# --install is simulator-only: installing a device/livecontainer IPA into a Simulator makes no
# sense, so --install always forces "sim" mode (warning if a conflicting mode was given).
if [ "$INSTALL_SIM" -eq 1 ]; then
if [ "$MODE_WAS_EXPLICIT" -eq 1 ] && [ "$MODE" != "sim" ] && [ "$MODE" != "simulator" ]; then
echo "==> --install is simulator-only; ignoring mode '$MODE' and building 'sim'." >&2
fi
MODE="sim"
if [ "$INSTALL_REQUESTED" -eq 1 ]; then
case "$MODE" in
sim|simulator)
INSTALL_SIM=1
;;
sideload|device|ios)
INSTALL_DEVICE=1
;;
all)
if [ "$MODE_WAS_EXPLICIT" -eq 1 ]; then
die "--install with 'all' is ambiguous. Use 'ios --install' for a device or 'sim --install' for Simulator."
fi
MODE="sim"
INSTALL_SIM=1
;;
livecontainer|lc)
die "--install does not support LiveContainer IPA. Use 'ios --install' for direct device install."
;;
esac
fi
if [ "$CLEAN" -eq 1 ]; then
@ -135,8 +149,10 @@ build_sim() {
--cacheDir "$HOME/telegram-bazel-cache" \
build \
--configurationPath build-system/wintergram-development-configuration.json \
--codesigningInformationPath build-system/fake-codesigning-wintergram \
--codesigningInformationPath build-system/fake-codesigning \
--disableProvisioningProfiles \
--disableExtensions \
--bazelArguments="$BAZEL_XCODE_ACTION_ENV $BAZEL_SDK_COMPAT_ARGS" \
--buildNumber=1 --configuration=debug_sim_arm64
[ -f "$DEVICE_SRC" ] || die "simulator artifact not found at $DEVICE_SRC"
@ -181,7 +197,7 @@ install_sim() {
echo "==> [Simulator] installed: $(basename "$APP_PATH")"
if [ "$RUN_SIM" -eq 1 ]; then
if [ "$RUN_APP" -eq 1 ]; then
[ -n "$INSTALLED_BUNDLE_ID" ] || {
rm -rf "$TMP_DIR"
die "could not read CFBundleIdentifier from app Info.plist"
@ -194,6 +210,135 @@ install_sim() {
rm -rf "$TMP_DIR"
}
select_ios_device() {
require_cmd xcrun
if [ -n "$DEVICE_SELECTOR" ]; then
echo "$DEVICE_SELECTOR"
return 0
fi
local JSON_PATH
JSON_PATH="$(mktemp)"
if ! xcrun devicectl list devices --timeout 20 --json-output "$JSON_PATH" >/dev/null; then
rm -f "$JSON_PATH"
die "could not list connected devices via xcrun devicectl. Unlock the phone, trust this Mac, and try again."
fi
local SELECTED
if ! SELECTED="$(python3 - "$JSON_PATH" 2>&1 <<'PY'
import json
import sys
path = sys.argv[1]
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
devices = data.get("result", {}).get("devices", [])
def get(obj, dotted, default=""):
current = obj
for part in dotted.split("."):
if not isinstance(current, dict):
return default
current = current.get(part)
return current if current is not None else default
candidates = []
for device in devices:
platform = str(get(device, "hardwareProperties.platform")).lower()
if platform not in ("ios", "ipados"):
continue
identifier = (
device.get("identifier")
or get(device, "hardwareProperties.udid")
or get(device, "hardwareProperties.serialNumber")
)
if not identifier:
continue
name = (
get(device, "deviceProperties.name")
or device.get("name")
or get(device, "hardwareProperties.marketingName")
or identifier
)
transport = get(device, "connectionProperties.transportType")
pair_state = get(device, "connectionProperties.pairingState")
candidates.append((str(identifier), str(name), str(transport), str(pair_state)))
if len(candidates) == 1:
print(candidates[0][0])
sys.exit(0)
if not candidates:
print("no connected iPhone/iPad found by devicectl", file=sys.stderr)
else:
print("multiple iPhone/iPad devices found; pass --device with one of these:", file=sys.stderr)
for identifier, name, transport, pair_state in candidates:
details = ", ".join(part for part in (transport, pair_state) if part)
suffix = f" ({details})" if details else ""
print(f" {identifier} {name}{suffix}", file=sys.stderr)
sys.exit(2)
PY
)"; then
rm -f "$JSON_PATH"
die "$SELECTED"
fi
rm -f "$JSON_PATH"
echo "$SELECTED"
}
install_device() {
require_cmd xcrun
local IPA="$OUT_DIR/$SIDELOAD_NAME"
[ -f "$IPA" ] || die "device IPA not found at $IPA"
echo "==> [Device] preparing app for install ..."
local TMP_DIR
TMP_DIR="$(mktemp -d)"
unzip -q "$IPA" -d "$TMP_DIR"
local APP_PATH
APP_PATH="$(find "$TMP_DIR/Payload" -maxdepth 1 -type d -name "*.app" | head -n 1)"
[ -n "${APP_PATH:-}" ] || {
rm -rf "$TMP_DIR"
die "no .app found inside $IPA"
}
local INSTALLED_BUNDLE_ID
INSTALLED_BUNDLE_ID="$(/usr/libexec/PlistBuddy -c 'Print CFBundleIdentifier' "$APP_PATH/Info.plist" 2>/dev/null || true)"
local DEVICE
DEVICE="$(select_ios_device)"
echo "==> [Device] installing $(basename "$APP_PATH") on $DEVICE ..."
xcrun devicectl device install app --device "$DEVICE" "$APP_PATH"
echo "==> [Device] installed: $(basename "$APP_PATH")"
if [ "$RUN_APP" -eq 1 ]; then
[ -n "$INSTALLED_BUNDLE_ID" ] || {
rm -rf "$TMP_DIR"
die "could not read CFBundleIdentifier from app Info.plist"
}
echo "==> [Device] launching $INSTALLED_BUNDLE_ID on $DEVICE ..."
xcrun devicectl device process launch --device "$DEVICE" --terminate-existing "$INSTALLED_BUNDLE_ID" || true
fi
rm -rf "$TMP_DIR"
}
# --- device build shared by sideload + livecontainer -----------------------
ensure_cert() {
@ -207,13 +352,17 @@ ensure_cert() {
build_device() {
ensure_cert
echo "==> [Device] generating fake provisioning profiles for ${WNT_BUNDLE_ID} ..."
python3 scripts/generate-fake-profiles.py build-system/fake-codesigning-generated
echo "==> [Device] build (debug_arm64, ${WNT_BUNDLE_ID}, extensions disabled) ..."
python3 build-system/Make/Make.py --overrideXcodeVersion \
--cacheDir "$HOME/telegram-bazel-cache" \
build \
--configurationPath build-system/wintergram-development-configuration.json \
--codesigningInformationPath build-system/fake-codesigning-wintergram \
--codesigningInformationPath build-system/fake-codesigning-generated \
--disableExtensions \
--bazelArguments="$BAZEL_XCODE_ACTION_ENV $BAZEL_SDK_COMPAT_ARGS" \
--buildNumber=1 --configuration=debug_arm64
[ -f "$DEVICE_SRC" ] || die "device artifact not found at $DEVICE_SRC"
@ -265,7 +414,7 @@ case "$MODE" in
sim|simulator)
build_sim
;;
sideload|device)
sideload|device|ios)
build_device
make_sideload
;;
@ -274,8 +423,7 @@ case "$MODE" in
make_livecontainer
;;
all)
# One device build feeds BOTH device deliverables; derive them before the sim build
# overwrites bazel-bin/Telegram/Telegram.ipa with the simulator artifact.
# Derive device deliverables before the simulator build overwrites the artifact.
build_device
make_sideload
make_livecontainer
@ -287,14 +435,17 @@ case "$MODE" in
esac
if [ "$INSTALL_SIM" -eq 1 ]; then
# --install forced MODE=sim above, so the simulator IPA was just built — install it.
install_sim
fi
if [ "$INSTALL_DEVICE" -eq 1 ]; then
install_device
fi
echo
echo "==> WinterGram deliverables in ./$OUT_DIR/:"
ls -1 "$OUT_DIR"/WinterGram*.ipa 2>/dev/null | sed 's#^# #' || true
if [ "$OPEN_BUILD_DIR" -eq 1 ]; then
open "$OUT_DIR"
fi
fi

View file

@ -0,0 +1,181 @@
#!/usr/bin/env python3
"""Generate fake provisioning profiles for the WinterGram dev bundle id."""
import datetime
import json
import os
import plistlib
import subprocess
import sys
import uuid
from pathlib import Path
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.serialization import pkcs12, pkcs7
REPO_ROOT = Path(__file__).resolve().parent.parent
CONFIG_PATH = REPO_ROOT / "build-system/wintergram-development-configuration.json"
SOURCE_PROFILES_DIR = REPO_ROOT / "build-system/fake-codesigning/profiles"
SOURCE_CERTS_DIR = REPO_ROOT / "build-system/fake-codesigning/certs"
SELF_SIGNED_P12 = SOURCE_CERTS_DIR / "SelfSigned.p12"
# mapping from application-identifier suffix -> output file name (without .mobileprovision)
PROFILE_NAME_MAPPING = {
".SiriIntents": "Intents",
".NotificationContent": "NotificationContent",
".NotificationService": "NotificationService",
".Share": "Share",
"": "Telegram",
".watchkitapp": "WatchApp",
".watchkitapp.watchkitextension": "WatchExtension",
".Widget": "Widget",
".BroadcastUpload": "BroadcastUpload",
}
def load_self_signed_cert_and_key():
with open(SELF_SIGNED_P12, "rb") as f:
key, cert, _ = pkcs12.load_key_and_certificates(f.read(), password=b"")
if key is None or cert is None:
raise RuntimeError("Could not load key/cert from SelfSigned.p12")
return key, cert
def extract_plist(profile_path: Path) -> dict:
result = subprocess.run(
["openssl", "smime", "-inform", "der", "-verify", "-noverify", "-in", str(profile_path)],
capture_output=True,
check=True,
)
return plistlib.loads(result.stdout)
def replace_in_string(s: str, old_team: str, new_team: str, old_bundle: str, new_bundle: str) -> str:
s = s.replace(old_team + "." + old_bundle, new_team + "." + new_bundle)
s = s.replace(old_bundle, new_bundle)
s = s.replace(old_team, new_team)
return s
def replace_in_value(value, old_team: str, new_team: str, old_bundle: str, new_bundle: str):
if isinstance(value, str):
return replace_in_string(value, old_team, new_team, old_bundle, new_bundle)
if isinstance(value, list):
return [replace_in_value(v, old_team, new_team, old_bundle, new_bundle) for v in value]
if isinstance(value, dict):
return {k: replace_in_value(v, old_team, new_team, old_bundle, new_bundle) for k, v in value.items()}
if isinstance(value, bytes):
try:
text = value.decode("utf-8")
replaced = replace_in_string(text, old_team, new_team, old_bundle, new_bundle)
return replaced.encode("utf-8")
except UnicodeDecodeError:
return value
return value
def profile_suffix_for(plist_data: dict) -> str:
app_id = plist_data["Entitlements"]["application-identifier"]
# We expect <old_team>.<old_bundle><suffix>
return app_id.split(".", 2)[2] if "." in app_id else ""
def generate_profile(source_path: Path, new_team: str, new_bundle: str, key, cert) -> tuple[str, bytes]:
plist_data = extract_plist(source_path)
old_team = plist_data["TeamIdentifier"][0]
old_bundle = plist_data["Entitlements"]["application-identifier"][len(old_team) + 1 :]
# strip known suffixes to find the base bundle id
old_bundle_base = old_bundle
for suffix in sorted(PROFILE_NAME_MAPPING.keys(), key=len, reverse=True):
if suffix and old_bundle.endswith(suffix):
old_bundle_base = old_bundle[: -len(suffix)]
break
new_bundle_base = new_bundle
plist_data = replace_in_value(plist_data, old_team, new_team, old_bundle_base, new_bundle_base)
# Some fields can't be string-replaced blindly; set them explicitly.
plist_data["ApplicationIdentifierPrefix"] = [new_team]
plist_data["TeamIdentifier"] = [new_team]
plist_data["UUID"] = str(uuid.uuid4()).upper()
plist_data["Name"] = f"WinterGram {source_path.stem}"
plist_data["TeamName"] = "WinterGram Self-Signed"
plist_data["IsXcodeManaged"] = False
now = datetime.datetime.now(datetime.timezone.utc)
plist_data["CreationDate"] = now
plist_data["ExpirationDate"] = now + datetime.timedelta(days=365)
# Replace embedded developer certificate with the self-signed one.
cert_der = cert.public_bytes(serialization.Encoding.DER)
plist_data["DeveloperCertificates"] = [cert_der]
# Strip capabilities we don't want/need for a sideload dev build.
entitlements = plist_data.get("Entitlements", {})
entitlements["get-task-allow"] = True
entitlements.pop("beta-reports-active", None)
entitlements["aps-environment"] = "development"
plist_data["Entitlements"] = entitlements
# Determine output name from the original suffix.
original_suffix = ""
for suffix in sorted(PROFILE_NAME_MAPPING.keys(), key=len, reverse=True):
if suffix and old_bundle.endswith(suffix):
original_suffix = suffix
break
output_name = PROFILE_NAME_MAPPING.get(original_suffix, source_path.stem) + ".mobileprovision"
# Re-sign as CMS SignedData (DER) using the self-signed certificate.
plist_bytes = plistlib.dumps(plist_data)
builder = pkcs7.PKCS7SignatureBuilder(
data=plist_bytes,
signers=[(cert, key, hashes.SHA256(), None)],
additional_certs=[cert],
)
signed = builder.sign(serialization.Encoding.DER, [pkcs7.PKCS7Options.Binary])
return output_name, signed
def main():
if len(sys.argv) > 1:
output_dir = Path(sys.argv[1])
else:
output_dir = REPO_ROOT / "build-system/fake-codesigning-generated"
output_profiles_dir = output_dir / "profiles"
output_certs_dir = output_dir / "certs"
output_profiles_dir.mkdir(parents=True, exist_ok=True)
output_certs_dir.mkdir(parents=True, exist_ok=True)
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
config = json.load(f)
new_team = config["team_id"]
new_bundle = config["bundle_id"]
print(f"Generating fake profiles for team={new_team} bundle={new_bundle} ...")
key, cert = load_self_signed_cert_and_key()
# Copy the self-signed cert material so the output dir is self-contained.
for src in SOURCE_CERTS_DIR.iterdir():
if src.is_file():
(output_certs_dir / src.name).write_bytes(src.read_bytes())
generated = []
for source_path in sorted(SOURCE_PROFILES_DIR.glob("*.mobileprovision")):
output_name, signed = generate_profile(source_path, new_team, new_bundle, key, cert)
out_path = output_profiles_dir / output_name
out_path.write_bytes(signed)
generated.append(output_name)
print(f" {source_path.name} -> {output_name}")
print(f"Wrote {len(generated)} profiles to {output_profiles_dir}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,98 @@
#!/bin/bash
# Generate a WinterGram Xcode project configured for your Apple Developer Team.
set -euo pipefail
cd "$(dirname "$0")/.."
CONFIG="build-system/wintergram-development-configuration.json"
BUNDLE_ID="${WINTERGRAM_BUNDLE_ID:-com.reekeer.wintergram}"
TEAM_ID="${WINTERGRAM_TEAM_ID:-}"
API_ID="${WINTERGRAM_API_ID:-2040}"
API_HASH="${WINTERGRAM_API_HASH:-b18441a1ff607e10a989891a5462e627}"
usage() {
cat <<EOF
Generate WinterGram.xcodeproj with your signing settings.
Usage:
$0 --team-id TEAMID [--bundle-id com.reekeer.wintergram] [--api-id ID --api-hash HASH]
Environment variables are also supported:
WINTERGRAM_TEAM_ID, WINTERGRAM_BUNDLE_ID, WINTERGRAM_API_ID, WINTERGRAM_API_HASH
Example:
$0 --team-id ABCDE12345 --bundle-id com.reekeer.wintergram
EOF
}
while [ "$#" -gt 0 ]; do
case "$1" in
--team-id)
TEAM_ID="${2:-}"
shift 2
;;
--bundle-id)
BUNDLE_ID="${2:-}"
shift 2
;;
--api-id)
API_ID="${2:-}"
shift 2
;;
--api-hash)
API_HASH="${2:-}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "ERROR: unknown argument: $1" >&2
usage >&2
exit 1
;;
esac
done
if [ -z "$TEAM_ID" ]; then
echo "ERROR: --team-id is required." >&2
echo "Find it in Xcode -> Settings -> Accounts -> your Apple ID -> Team ID." >&2
exit 1
fi
python3 - "$CONFIG" "$BUNDLE_ID" "$TEAM_ID" "$API_ID" "$API_HASH" <<'PY'
import json
import sys
path, bundle_id, team_id, api_id, api_hash = sys.argv[1:]
with open(path) as f:
data = json.load(f)
data["bundle_id"] = bundle_id
data["team_id"] = team_id
data["api_id"] = api_id
data["api_hash"] = api_hash
data["app_specific_url_scheme"] = "wnt"
data["enable_siri"] = False
data["enable_icloud"] = False
with open(path, "w") as f:
json.dump(data, f, indent="\t")
f.write("\n")
PY
echo "==> Wrote $CONFIG"
echo " bundle_id: $BUNDLE_ID"
echo " team_id: $TEAM_ID"
python3 build-system/Make/Make.py --overrideXcodeVersion \
--cacheDir "$HOME/telegram-bazel-cache" \
generateProject \
--configurationPath "$CONFIG" \
--xcodeManagedCodesigning
echo
echo "==> Generated WinterGram.xcodeproj"

394
scripts/wintergram-badge-tool.py Executable file
View file

@ -0,0 +1,394 @@
#!/usr/bin/env python3
"""Validate and optionally preview a WinterGram badge manifest."""
import argparse
import json
import math
import os
import re
import sys
ANIMATION_TYPES = {"none", "rotate", "blink", "pulse", "bounce", "shake", "lottie"}
DIRECTIONS = {"cw", "ccw"}
TINT_RE = re.compile(r"^(theme|none|#[0-9a-fA-F]{6})$")
MAX_BADGES = 64
MAX_LAYERS = 16
LOTTIE_EXTS = (".tgs", ".json")
RASTER_EXTS = (".png", ".jpg", ".jpeg")
class Report:
def __init__(self):
self.errors = []
self.warnings = []
def error(self, path, msg):
self.errors.append(f"{path}: {msg}")
def warn(self, path, msg):
self.warnings.append(f"{path}: {msg}")
def ok(self):
return not self.errors
def is_number(v):
return isinstance(v, (int, float)) and not isinstance(v, bool)
def is_int(v):
return isinstance(v, int) and not isinstance(v, bool)
def validate(manifest, manifest_dir, report):
if not isinstance(manifest, dict):
report.error("$", "manifest must be a JSON object")
return
if "version" not in manifest:
report.error("$", 'missing required "version"')
elif not is_int(manifest["version"]) or manifest["version"] < 0:
report.error("$.version", "must be an integer >= 0")
canvas = manifest.get("canvas", 1024)
if not is_number(canvas) or canvas <= 0:
report.error("$.canvas", "must be a positive number")
canvas = 1024
badges = manifest.get("badges")
if badges is None:
report.error("$", 'missing required "badges"')
return
if not isinstance(badges, list):
report.error("$.badges", "must be an array")
return
if len(badges) > MAX_BADGES:
report.warn("$.badges", f"{len(badges)} badges; client caps at {MAX_BADGES}")
seen_ids = set()
for i, badge in enumerate(badges):
bpath = f"$.badges[{i}]"
if not isinstance(badge, dict):
report.error(bpath, "must be an object")
continue
bid = badge.get("id")
if not isinstance(bid, str) or not bid:
report.error(f"{bpath}.id", "must be a non-empty string")
else:
if bid in seen_ids:
report.error(f"{bpath}.id", f'duplicate badge id "{bid}"')
seen_ids.add(bid)
if "priority" in badge and not is_int(badge["priority"]):
report.error(f"{bpath}.priority", "must be an integer")
if "description" in badge and not isinstance(badge.get("description"), str):
report.error(f"{bpath}.description", "must be a string")
peers = badge.get("peers")
if not isinstance(peers, dict):
report.error(f"{bpath}.peers", "must be an object")
else:
for key in ("users", "channels"):
vals = peers.get(key, [])
if not isinstance(vals, list) or not all(is_int(v) for v in vals):
report.error(f"{bpath}.peers.{key}", "must be an array of integers")
if not peers.get("users") and not peers.get("channels"):
report.warn(f"{bpath}.peers", "no users or channels; badge will never match")
layers = badge.get("layers")
if not isinstance(layers, list) or not layers:
report.error(f"{bpath}.layers", "must be a non-empty array")
continue
if len(layers) > MAX_LAYERS:
report.warn(f"{bpath}.layers", f"{len(layers)} layers; client caps at {MAX_LAYERS}")
for j, layer in enumerate(layers):
validate_layer(layer, f"{bpath}.layers[{j}]", canvas, manifest_dir, report)
def validate_layer(layer, path, canvas, manifest_dir, report):
if not isinstance(layer, dict):
report.error(path, "must be an object")
return
source = layer.get("source")
if not isinstance(source, str) or not source:
report.error(f"{path}.source", "must be a non-empty string")
source = ""
lowered = source.lower()
is_lottie = lowered.endswith(LOTTIE_EXTS)
if source and not is_lottie and not lowered.endswith(RASTER_EXTS):
report.warn(f"{path}.source", "unrecognised extension (expected .png/.jpg or .tgs/.json)")
for key in ("x", "y", "width", "height"):
if key in layer and not is_number(layer[key]):
report.error(f"{path}.{key}", "must be a number")
for key in ("width", "height"):
if is_number(layer.get(key)) and layer[key] <= 0:
report.error(f"{path}.{key}", "must be > 0")
x, y = layer.get("x", 0), layer.get("y", 0)
w, h = layer.get("width", 0), layer.get("height", 0)
if all(is_number(v) for v in (x, y, w, h)):
if x < 0 or y < 0 or x + w > canvas or y + h > canvas:
report.warn(path, f"layer rect ({x},{y},{w},{h}) extends outside the {canvas} canvas")
tint = layer.get("tint")
if tint is not None and (not isinstance(tint, str) or not TINT_RE.match(tint)):
report.error(f"{path}.tint", 'must be "theme", "none", or "#RRGGBB"')
anim = layer.get("animation")
if anim is not None:
validate_animation(anim, f"{path}.animation", is_lottie, report)
# Asset existence (best-effort, for path-style sources).
if source and ("/" in source or "." in source):
asset_path = os.path.join(manifest_dir, source)
if not os.path.isfile(asset_path):
report.warn(f"{path}.source", f'asset not found: "{source}"')
def validate_animation(anim, path, is_lottie, report):
if not isinstance(anim, dict):
report.error(path, "must be an object")
return
atype = anim.get("type")
if atype is not None and atype not in ANIMATION_TYPES:
report.warn(f"{path}.type", f'unknown type "{atype}"; client treats it as "none"')
if is_lottie and atype not in (None, "lottie", "none"):
report.warn(path, "ignored for Lottie sources (the .tgs/.json plays itself)")
if "duration" in anim and (not is_number(anim["duration"]) or anim["duration"] <= 0):
report.error(f"{path}.duration", "must be a positive number")
if "loop" in anim and not isinstance(anim["loop"], bool):
report.error(f"{path}.loop", "must be a boolean")
if "direction" in anim and anim["direction"] not in DIRECTIONS:
report.error(f"{path}.direction", 'must be "cw" or "ccw"')
if "amplitude" in anim and (not is_number(anim["amplitude"]) or anim["amplitude"] < 0):
report.error(f"{path}.amplitude", "must be a number >= 0")
def try_jsonschema(manifest, schema_path, report):
try:
import jsonschema
except ImportError:
return
try:
with open(schema_path, "r", encoding="utf-8") as f:
schema = json.load(f)
except OSError:
return
validator = jsonschema.Draft7Validator(schema)
for err in sorted(validator.iter_errors(manifest), key=lambda e: list(e.path)):
loc = "$" + "".join(f"[{p}]" if isinstance(p, int) else f".{p}" for p in err.path)
report.error(loc, f"[schema] {err.message}")
# ---------------------------------------------------------------------------
# Preview (GIF) generation
# ---------------------------------------------------------------------------
def parse_hex(color):
color = color.lstrip("#")
return (int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16))
def tint_layer(img, tint, accent_rgb):
from PIL import Image
img = img.convert("RGBA")
if tint in (None, "none"):
return img
rgb = accent_rgb if tint == "theme" else parse_hex(tint)
solid = Image.new("RGBA", img.size, rgb + (0,))
solid.putalpha(img.split()[3])
solid.paste(Image.new("RGBA", img.size, rgb + (255,)), (0, 0), img.split()[3])
return solid
def render_badge_gif(badge, canvas, manifest_dir, out_path, size, fps, bg, accent_rgb):
from PIL import Image
layers = []
for layer in badge["layers"]:
source = layer.get("source", "")
if source.lower().endswith(LOTTIE_EXTS):
print(f" note: Lottie layer '{source}' shown as a static frame in the preview")
asset = os.path.join(manifest_dir, source)
if not os.path.isfile(asset):
print(f" skip: missing asset '{source}'")
continue
try:
base = Image.open(asset).convert("RGBA")
except Exception as exc: # noqa: BLE001
print(f" skip: cannot open '{source}': {exc}")
continue
lw = max(1, int(round(layer.get("width", canvas) / canvas * size)))
lh = max(1, int(round(layer.get("height", canvas) / canvas * size)))
base = base.resize((lw, lh), Image.LANCZOS)
base = tint_layer(base, layer.get("tint"), accent_rgb)
anim = layer.get("animation", {}) or {}
layers.append({
"img": base,
"x": layer.get("x", 0) / canvas * size,
"y": layer.get("y", 0) / canvas * size,
"w": lw,
"h": lh,
"type": anim.get("type", "none"),
"duration": anim.get("duration", 1.0) or 1.0,
"cw": anim.get("direction", "cw") != "ccw",
"amplitude": anim.get("amplitude", 0.1),
"is_lottie": source.lower().endswith(LOTTIE_EXTS),
})
if not layers:
print(" skip: no renderable layers")
return False
durations = [l["duration"] for l in layers if l["type"] in ("rotate", "blink", "pulse", "bounce", "shake") and not l["is_lottie"]]
loop_seconds = max(durations) if durations else 2.0
frame_count = max(1, min(120, int(round(loop_seconds * fps))))
transparent = (bg == "none")
bg_rgba = (0, 0, 0, 0) if transparent else (parse_hex(bg) + (255,))
frames = []
for f in range(frame_count):
t = (f / frame_count) * loop_seconds
canvas_img = Image.new("RGBA", (size, size), bg_rgba)
for l in layers:
frame_img = l["img"]
ox, oy = l["x"], l["y"]
cycles = max(1, round(loop_seconds / l["duration"])) if l["duration"] else 1
phase = (t / loop_seconds) * cycles # whole cycles per loop -> seamless
angle = 2.0 * math.pi * phase
if l["is_lottie"] or l["type"] in ("none",):
pass
elif l["type"] == "rotate":
deg = (phase * 360.0) % 360.0
frame_img = frame_img.rotate(-deg if l["cw"] else deg, resample=Image.BICUBIC, expand=False)
elif l["type"] == "blink":
factor = 0.35 + 0.65 * (0.5 + 0.5 * math.cos(angle))
frame_img = apply_alpha(frame_img, factor)
elif l["type"] == "pulse":
scale = 1.0 + l["amplitude"] * math.sin(angle)
frame_img, ox, oy = scaled(frame_img, scale, ox, oy, l["w"], l["h"])
elif l["type"] == "bounce":
oy = oy - l["amplitude"] * l["h"] * abs(math.sin(angle))
elif l["type"] == "shake":
ox = ox + l["amplitude"] * l["w"] * math.sin(2.0 * angle)
canvas_img.alpha_composite(frame_img, (int(round(ox)), int(round(oy))))
frames.append(canvas_img)
save_gif(frames, out_path, fps, transparent)
print(f" wrote {out_path} ({frame_count} frames @ {fps}fps, loop {loop_seconds:.1f}s)")
return True
def apply_alpha(img, factor):
from PIL import Image
alpha = img.split()[3].point(lambda a: int(a * max(0.0, min(1.0, factor))))
out = img.copy()
out.putalpha(alpha)
return out
def scaled(img, scale, ox, oy, w, h):
from PIL import Image
nw = max(1, int(round(w * scale)))
nh = max(1, int(round(h * scale)))
resized = img.resize((nw, nh), Image.LANCZOS)
return resized, ox - (nw - w) / 2.0, oy - (nh - h) / 2.0
def save_gif(frames, out_path, fps, transparent):
from PIL import Image
duration_ms = int(round(1000.0 / fps))
if transparent:
conv = [f.convert("P", palette=Image.ADAPTIVE, colors=255) for f in frames]
conv[0].save(out_path, save_all=True, append_images=conv[1:], loop=0,
duration=duration_ms, disposal=2, transparency=255)
else:
conv = [f.convert("RGB") for f in frames]
conv[0].save(out_path, save_all=True, append_images=conv[1:], loop=0,
duration=duration_ms, disposal=2)
def run_preview(manifest, manifest_dir, args):
try:
import PIL # noqa: F401
except ImportError:
print("\n--preview needs Pillow. Install it with: pip install Pillow", file=sys.stderr)
return 1
out_dir = args.out or manifest_dir
os.makedirs(out_dir, exist_ok=True)
canvas = manifest.get("canvas", 1024)
accent_rgb = parse_hex(args.accent)
any_done = False
print("\nGenerating previews:")
for badge in manifest.get("badges", []):
bid = badge.get("id", "badge")
if args.badge and bid != args.badge:
continue
out_path = os.path.join(out_dir, f"preview_{bid}.gif")
print(f" badge '{bid}':")
if render_badge_gif(badge, canvas, manifest_dir, out_path, args.size, args.fps, args.bg, accent_rgb):
any_done = True
if args.badge and not any_done:
print(f" no badge with id '{args.badge}'", file=sys.stderr)
return 1
return 0
def main():
parser = argparse.ArgumentParser(description="Validate (and optionally preview) a WinterGram badge manifest.")
parser.add_argument("manifest", nargs="?", default=".wintergram/icons/manifest.json")
parser.add_argument("--schema", default=None)
parser.add_argument("--preview", action="store_true")
parser.add_argument("--badge", default=None)
parser.add_argument("--size", type=int, default=256)
parser.add_argument("--fps", type=int, default=30)
parser.add_argument("--bg", default="#1C1C1E")
parser.add_argument("--accent", default="#3478F6")
parser.add_argument("--out", default=None)
args = parser.parse_args()
try:
with open(args.manifest, "r", encoding="utf-8") as f:
manifest = json.load(f)
except OSError as exc:
print(f"error: cannot read manifest: {exc}", file=sys.stderr)
return 2
except json.JSONDecodeError as exc:
print(f"error: invalid JSON: {exc}", file=sys.stderr)
return 2
manifest_dir = os.path.dirname(os.path.abspath(args.manifest))
schema_path = args.schema or os.path.join(manifest_dir, "manifest.schema.json")
report = Report()
validate(manifest, manifest_dir, report)
try_jsonschema(manifest, schema_path, report)
for w in report.warnings:
print(f"warning {w}")
for e in report.errors:
print(f"error {e}")
if report.ok():
n = len(manifest.get("badges", []))
print(f"OK: manifest valid ({n} badge(s)).")
else:
print(f"\nFAILED: {len(report.errors)} error(s), {len(report.warnings)} warning(s).")
return 1
if args.preview:
return run_preview(manifest, manifest_dir, args)
return 0
if __name__ == "__main__":
sys.exit(main())