chore: bump version to 1.2
This commit is contained in:
parent
f6bb33d193
commit
83f209e44a
101 changed files with 2419 additions and 608 deletions
|
|
@ -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
|
||||
|
|
|
|||
181
scripts/generate-fake-profiles.py
Normal file
181
scripts/generate-fake-profiles.py
Normal 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()
|
||||
98
scripts/generate-wintergram-xcodeproj.sh
Executable file
98
scripts/generate-wintergram-xcodeproj.sh
Executable 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
394
scripts/wintergram-badge-tool.py
Executable 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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue