Implement download table, backend API for downloads

This commit is contained in:
2026-03-15 23:36:32 +03:00
Unverified
parent 78d11b8f27
commit a266de1793
14 changed files with 997 additions and 528 deletions
+3 -2
View File
@@ -6,7 +6,7 @@ from contextlib import asynccontextmanager
import subprocess import subprocess
import sys import sys
import os import os
from routes import account, messaging, profile, push, webrtc, devices, moderation from routes import account, messaging, profile, push, webrtc, devices, moderation, download
import logging import logging
from models import User from models import User
from constants import OWNER_USERNAME from constants import OWNER_USERNAME
@@ -175,4 +175,5 @@ app.include_router(profile.router)
app.include_router(push.router, prefix="/push") app.include_router(push.router, prefix="/push")
app.include_router(webrtc.router, prefix="/webrtc") app.include_router(webrtc.router, prefix="/webrtc")
app.include_router(devices.router, prefix="/devices") app.include_router(devices.router, prefix="/devices")
app.include_router(moderation.router) app.include_router(moderation.router)
app.include_router(download.router)
+381
View File
@@ -0,0 +1,381 @@
"""
Download routes for FromChat desktop and mobile builds.
Fetches from GitHub Actions (PC) and GitHub Releases (mobile), with disk caching.
"""
import asyncio
import logging
import os
from pathlib import Path
import httpx
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import FileResponse, Response, StreamingResponse
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/download", tags=["download"])
GITHUB_API = "https://api.github.com"
WEB_OWNER, WEB_REPO = "fromchat-messenger", "web"
APP_OWNER, APP_REPO = "fromchat-messenger", "app"
WORKFLOW_FILE = "build.yml"
TIMEOUT = 10.0
ARTIFACT_NAMES = {
"windows": "FromChat-windows",
"linux": "FromChat-linux",
"macos": "FromChat-macOS",
}
CACHE_DIR = Path(__file__).resolve().parent.parent / "data" / "downloads"
CACHE_DIR.mkdir(parents=True, exist_ok=True)
def _headers() -> dict[str, str]:
token = os.environ.get("RELEASES_TOKEN")
if not token:
raise HTTPException(status_code=503, detail="RELEASES_TOKEN not configured")
return {
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
def _etag_path(os_name: str) -> Path:
return CACHE_DIR / f"{os_name}.etag"
def _cached_file_path(os_name: str) -> Path:
ext = ".zip" if os_name in ARTIFACT_NAMES else (".apk" if os_name == "android" else ".ipa")
return CACHE_DIR / f"{os_name}{ext}"
async def _fetch_pc_artifact_url(os_name: str) -> tuple[str, int]:
"""Fetch workflow runs, get latest run, find artifact. Returns (download_url, artifact_id)."""
artifact_name = ARTIFACT_NAMES[os_name]
logger.info("[download] Fetching PC artifact for %s: workflow=%s/%s/%s", os_name, WEB_OWNER, WEB_REPO, WORKFLOW_FILE)
async with httpx.AsyncClient(timeout=TIMEOUT, follow_redirects=False) as client:
runs_url = f"{GITHUB_API}/repos/{WEB_OWNER}/{WEB_REPO}/actions/workflows/{WORKFLOW_FILE}/runs"
logger.info("[download] GitHub API: GET %s (per_page=1, status=success)", runs_url)
runs_resp = await client.get(
runs_url,
headers=_headers(),
params={"per_page": 1, "status": "success"},
)
logger.info("[download] GitHub workflow runs response: status=%s", runs_resp.status_code)
runs_resp.raise_for_status()
runs = runs_resp.json()
workflow_runs = runs.get("workflow_runs", [])
if not workflow_runs:
logger.warning("[download] No successful workflow runs for %s", artifact_name)
raise HTTPException(status_code=404, detail=f"No successful workflow run for {artifact_name}")
run_id = workflow_runs[0]["id"]
logger.info("[download] Latest run_id=%s, fetching artifacts", run_id)
artifacts_url = f"{GITHUB_API}/repos/{WEB_OWNER}/{WEB_REPO}/actions/runs/{run_id}/artifacts"
artifacts_resp = await client.get(artifacts_url, headers=_headers())
logger.info("[download] GitHub artifacts response: status=%s", artifacts_resp.status_code)
artifacts_resp.raise_for_status()
data = artifacts_resp.json()
for artifact in data.get("artifacts", []):
if artifact["name"] == artifact_name:
url = artifact["archive_download_url"]
aid = artifact["id"]
logger.info("[download] Found artifact %s id=%s, download_url=%s", artifact_name, aid, url[:80] + "..." if len(url) > 80 else url)
return url, aid
logger.warning("[download] Artifact %s not found in run %s", artifact_name, run_id)
raise HTTPException(status_code=404, detail=f"Artifact {artifact_name} not found")
async def _fetch_mobile_asset_url(os_name: str) -> str:
"""Fetch latest release, find asset by name. Returns browser_download_url."""
keyword = "android" if os_name == "android" else "ios"
logger.info("[download] Fetching mobile asset for %s: releases %s/%s", os_name, APP_OWNER, APP_REPO)
async with httpx.AsyncClient(timeout=TIMEOUT, follow_redirects=False) as client:
releases_url = f"{GITHUB_API}/repos/{APP_OWNER}/{APP_REPO}/releases"
logger.info("[download] GitHub API: GET %s (per_page=10)", releases_url)
resp = await client.get(
releases_url,
headers=_headers(),
params={"per_page": 10},
)
logger.info("[download] GitHub releases response: status=%s", resp.status_code)
resp.raise_for_status()
releases = resp.json()
for release in releases:
if release.get("draft"):
continue
for asset in release.get("assets", []):
if keyword.lower() in asset.get("name", "").lower():
url = asset["browser_download_url"]
logger.info("[download] Found %s asset: %s (release: %s)", os_name, asset.get("name"), release.get("tag_name"))
return url
logger.warning("[download] No %s asset in releases", os_name)
raise HTTPException(status_code=404, detail=f"No {os_name} asset found in releases")
async def _download_and_stream(
url: str,
os_name: str,
stored_etag: str | None,
) -> StreamingResponse | FileResponse:
"""Stream from GitHub to client and save to disk. If 304, serve from disk."""
etag_path = _etag_path(os_name)
cache_path = _cached_file_path(os_name)
cache_path.parent.mkdir(parents=True, exist_ok=True)
headers = {**_headers(), "Accept": "*/*"}
if stored_etag:
headers["If-None-Match"] = stored_etag
logger.info("[download] Mobile %s: GET %s (etag=%s)", os_name, url[:100] + "..." if len(url) > 100 else url, stored_etag or "none")
async def stream_and_save():
total = 0
tmp_path = cache_path.with_name(cache_path.name + ".tmp")
new_etag: str | None = None
try:
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
async with client.stream("GET", url, headers=headers) as resp:
if resp.status_code == 304 and cache_path.exists():
yield None
return
if resp.status_code != 200:
if resp.status_code in (404, 410):
raise HTTPException(
status_code=404,
detail="Release asset not found on GitHub",
)
raise HTTPException(
status_code=503,
detail="GitHub returned an error while downloading asset",
)
new_etag = resp.headers.get("etag")
logger.info("[download] Mobile %s: streaming (content-length=%s)", os_name, resp.headers.get("content-length") or "unknown")
with open(tmp_path, "wb") as f:
async for chunk in resp.aiter_bytes(chunk_size=65536):
f.write(chunk)
total += len(chunk)
yield chunk
tmp_path.rename(cache_path)
if new_etag:
etag_path.write_text(new_etag)
logger.info("[download] Mobile %s: completed, saved %d bytes", os_name, total)
except httpx.StreamClosed:
logger.info("[download] Mobile %s: client disconnected after %d bytes", os_name, total)
tmp_path.unlink(missing_ok=True)
except httpx.TimeoutException:
tmp_path.unlink(missing_ok=True)
if cache_path.exists():
raise _CacheFallback()
raise HTTPException(status_code=503, detail="GitHub unavailable and no cached file")
except HTTPException:
tmp_path.unlink(missing_ok=True)
raise
class _CacheFallback(Exception):
pass
gen = stream_and_save()
try:
first = await gen.__anext__()
except StopAsyncIteration:
first = None
except _CacheFallback:
await gen.aclose()
return FileResponse(str(cache_path), media_type="application/octet-stream", filename=cache_path.name)
if first is None:
await gen.aclose()
logger.info("[download] Mobile %s: serving from cache (304)", os_name)
return FileResponse(str(cache_path), media_type="application/octet-stream", filename=cache_path.name)
async def body():
yield first
async for chunk in gen:
yield chunk
return StreamingResponse(
body(),
media_type="application/octet-stream",
headers={"Content-Disposition": f'attachment; filename="{cache_path.name}"'},
)
async def _resolve_artifact_download_url(url: str) -> str:
"""Resolve artifact URL: GitHub 302 redirects to Azure; Azure rejects Authorization. Get Location without following."""
headers = {**_headers(), "Accept": "application/vnd.github+json"}
async with httpx.AsyncClient(timeout=TIMEOUT, follow_redirects=False) as client:
resp = await client.get(url, headers=headers)
if resp.status_code in (404, 410):
raise HTTPException(status_code=404, detail="Artifact not found on GitHub")
if resp.status_code != 302:
raise HTTPException(status_code=503, detail="GitHub returned an error while resolving artifact URL")
location = resp.headers.get("location")
if not location:
raise HTTPException(status_code=502, detail="No redirect location from GitHub")
return location
async def _download_artifact_and_stream(
url: str,
os_name: str,
artifact_id: int,
) -> StreamingResponse | FileResponse:
"""Download artifact (zip). GitHub redirects to Azure; Azure must be called WITHOUT Authorization."""
etag_path = _etag_path(os_name)
cache_path = _cached_file_path(os_name)
stored_id = etag_path.read_text().strip() if etag_path.exists() else None
if stored_id == str(artifact_id) and cache_path.exists():
logger.info("[download] PC %s: serving from cache (artifact_id=%s)", os_name, artifact_id)
return FileResponse(
str(cache_path),
media_type="application/zip",
filename=cache_path.name,
)
try:
download_url = await _resolve_artifact_download_url(url)
except HTTPException:
if cache_path.exists():
logger.info("[download] PC %s: GitHub error, serving from cache", os_name)
return FileResponse(str(cache_path), media_type="application/zip", filename=cache_path.name)
raise
logger.info("[download] PC %s: streaming from Azure URL (no auth)", os_name)
async def stream_and_save():
total = 0
tmp_path = cache_path.with_name(cache_path.name + ".tmp")
try:
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
async with client.stream("GET", download_url) as resp:
if resp.status_code != 200:
if resp.status_code in (404, 410):
raise HTTPException(status_code=404, detail="Artifact file not found on GitHub")
raise HTTPException(
status_code=503,
detail="GitHub returned an error while downloading artifact file",
)
logger.info("[download] PC %s: streaming (content-length=%s)", os_name, resp.headers.get("content-length") or "unknown")
with open(tmp_path, "wb") as f:
async for chunk in resp.aiter_bytes(chunk_size=65536):
f.write(chunk)
total += len(chunk)
yield chunk
tmp_path.rename(cache_path)
etag_path.write_text(str(artifact_id))
logger.info("[download] PC %s: completed, saved %d bytes", os_name, total)
except httpx.StreamClosed:
logger.info("[download] PC %s: client disconnected after %d bytes", os_name, total)
tmp_path.unlink(missing_ok=True)
except HTTPException:
tmp_path.unlink(missing_ok=True)
raise
gen = stream_and_save()
try:
first = await gen.__anext__()
except StopAsyncIteration:
first = None
except HTTPException:
if cache_path.exists():
return FileResponse(str(cache_path), media_type="application/zip", filename=cache_path.name)
raise
if first is None:
await gen.aclose()
raise HTTPException(status_code=502, detail="Empty response from download")
async def body():
yield first
async for chunk in gen:
yield chunk
return StreamingResponse(
body(),
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{cache_path.name}"'},
)
def _head_response(filename: str, content_length: int | None = None) -> Response:
headers = {"Content-Disposition": f'attachment; filename="{filename}"'}
if content_length is not None:
headers["Content-Length"] = str(content_length)
return Response(status_code=200, headers=headers)
@router.api_route("/{os_name}", methods=["GET", "HEAD"])
async def download(request: Request, os_name: str):
"""Download app for the given OS: windows, linux, macos, android, ios."""
is_head = request.method == "HEAD"
os_name = os_name.lower()
logger.info("[download] %s /download/%s", request.method, os_name)
if os_name not in ("windows", "linux", "macos", "android", "ios"):
raise HTTPException(status_code=400, detail="Invalid os. Use: windows, linux, macos, android, ios")
try:
if os_name in ARTIFACT_NAMES:
try:
url, artifact_id = await asyncio.wait_for(
_fetch_pc_artifact_url(os_name),
timeout=TIMEOUT,
)
except asyncio.TimeoutError:
logger.warning("[download] PC %s: GitHub API timeout", os_name)
cache_path = _cached_file_path(os_name)
if cache_path.exists():
if is_head:
return _head_response(cache_path.name, cache_path.stat().st_size)
return FileResponse(
str(cache_path),
media_type="application/zip",
filename=cache_path.name,
)
raise HTTPException(status_code=503, detail="GitHub unavailable and no cached file")
cache_path = _cached_file_path(os_name)
result = await _download_artifact_and_stream(url, os_name, artifact_id)
if is_head:
fn = getattr(result, "filename", None) or cache_path.name
size = cache_path.stat().st_size if cache_path.exists() else None
return _head_response(fn, size)
return result
else:
stored_etag = None
etag_path = _etag_path(os_name)
cache_path = _cached_file_path(os_name)
if etag_path.exists():
stored_etag = etag_path.read_text().strip() or None
try:
url = await asyncio.wait_for(
_fetch_mobile_asset_url(os_name),
timeout=TIMEOUT,
)
except asyncio.TimeoutError:
logger.warning("[download] Mobile %s: GitHub API timeout", os_name)
if cache_path.exists():
if is_head:
return _head_response(cache_path.name, cache_path.stat().st_size)
return FileResponse(
str(cache_path),
media_type="application/octet-stream",
filename=cache_path.name,
)
raise HTTPException(status_code=503, detail="GitHub unavailable and no cached file")
result = await _download_and_stream(url, os_name, stored_etag)
if is_head:
fn = getattr(result, "filename", None) or cache_path.name
size = cache_path.stat().st_size if cache_path.exists() else None
return _head_response(fn, size)
return result
except HTTPException as exc:
if exc.status_code in (404, 410):
raise HTTPException(status_code=404, detail=exc.detail)
if exc.status_code in (502, 503, 504):
raise HTTPException(status_code=503, detail=exc.detail)
raise
@@ -1,28 +1,29 @@
import { MaterialButton } from "@/utils/material"; import { MaterialIcon } from "@/utils/material";
import styles from "./download-app.module.scss"; import styles from "./download-app.module.scss";
export default function DownloadAppPage() { export default function DownloadAppPage() {
return ( return (
<div className={styles.downloadAppScreen}> <div className={styles.downloadAppScreen}>
<div> <div className={styles.downloadAppCard}>
<h1>Чтобы пользоваться мессенджером, скачайте приложение</h1> <h1>Скачайте приложение</h1>
<p> <p>
Этот сайт <b>не предназначен</b> для работы на маленьких экранах, поэтому Этот сайт не предназначен для работы на маленьких экранах.
вам нужно скачать приложение мессенджера. Выберите вашу платформу:
</p> </p>
<div className={styles.downloadAppButtons}>
<a href="https://github.com/fromchat-messenger/app/releases/latest" target="_blank" rel="noopener noreferrer"> <a href="/download?os=android" className={styles.downloadAppBtn}>
<MaterialButton>Скачать на GitHub</MaterialButton> <MaterialIcon name="android" />
</a> Android
</a>
<a href="/download?os=ios" className={styles.downloadAppBtn}>
<MaterialIcon name="phone_iphone" />
iOS
</a>
</div>
<p> <p>
Если возникнут сложности или есть вопросы, нажмите кнопку! <a href="https://t.me/denis0001-dev">Написать в поддержку</a>
</p> </p>
<a href="https://t.me/denis0001-dev">
<MaterialButton>Написать в поддержку</MaterialButton>
</a>
</div> </div>
</div> </div>
) );
} }
@@ -1,9 +1,70 @@
@use "../../css/material" as *;
.downloadAppScreen { .downloadAppScreen {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
min-width: 100vw; min-width: 100vw;
min-height: 100vh; min-height: 100vh;
padding: 32px; padding: 24px;
background-color: $color-dark-surface;
}
.downloadAppCard {
max-width: 400px;
padding: 40px 32px;
background: rgba($color-dark-surface-container, 0.6);
border: 1px solid rgba($color-dark-outline, 0.3);
border-radius: 24px;
text-align: center;
h1 {
font-size: 24px;
font-weight: 600;
margin: 0 0 16px;
color: $color-dark-on-surface;
}
p {
color: $color-dark-on-surface-variant;
margin: 0 0 24px;
font-size: 16px;
a {
color: $color-dark-primary;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
}
.downloadAppButtons {
display: flex;
gap: 16px;
justify-content: center;
flex-wrap: wrap;
margin-bottom: 24px;
}
.downloadAppBtn {
display: flex;
align-items: center;
gap: 8px;
padding: 16px 24px;
background: rgba($color-dark-primary, 0.2);
border: 1px solid rgba($color-dark-primary, 0.5);
border-radius: 12px;
color: $color-dark-on-surface;
text-decoration: none;
font-weight: 600;
transition: all 0.2s ease;
&:hover {
background: rgba($color-dark-primary, 0.3);
border-color: $color-dark-primary;
}
} }
+87
View File
@@ -0,0 +1,87 @@
import { Link } from "react-router-dom";
import { MaterialIcon } from "@/utils/material";
import { GitHubLink, GITHUB_WEB, GITHUB_APP, GITHUB_LICENSE } from "./homeLinks";
import styles from "./home-footer.module.scss";
export function HomeFooter() {
return (
<footer className={styles.homepageFooter}>
<div className={styles.footerBrand}>
<div className={styles.footerLogoRow}>
<div className={styles.footerLogo} />
<span className={styles.footerBrandName}>FromChat</span>
</div>
<p className={styles.footerCopyright}>FromChat © 2026</p>
</div>
<div className={styles.footerLinks}>
<div className={styles.footerSection}>
<Link to="/download" className={styles.footerLink}>
<MaterialIcon name="download" className={styles.footerLinkIcon} />
Скачать приложение
</Link>
<Link to="/login" className={styles.footerLink}>
<MaterialIcon name="language" className={styles.footerLinkIcon} />
Веб-версия
</Link>
<a
href={`${GITHUB_WEB}/actions/workflows/build.yml`}
target="_blank"
rel="noopener noreferrer"
className={styles.footerLink}
>
<MaterialIcon name="computer" className={styles.footerLinkIcon} />
ПК-клиент
</a>
</div>
<div className={styles.footerSection}>
<a
href={`${GITHUB_APP}/tree/main`}
target="_blank"
rel="noopener noreferrer"
className={styles.footerLink}
>
<MaterialIcon name="android" className={styles.footerLinkIcon} />
Исходный код приложения
</a>
<GitHubLink className={styles.footerLink}>
<MaterialIcon name="code" className={styles.footerLinkIcon} />
Исходный код веб-версии
</GitHubLink>
<a
href={GITHUB_LICENSE}
target="_blank"
rel="noopener noreferrer"
className={styles.footerLink}
>
<MaterialIcon name="description" className={styles.footerLinkIcon} />
Лицензия
</a>
</div>
<div className={styles.footerSection}>
<a
href="https://t.me/fromchat_ch"
target="_blank"
rel="noopener noreferrer"
className={styles.footerLink}
>
<span
className={`${styles.footerLinkIcon} ${styles.footerLinkIconSvg} ${styles.footerLinkIconSvgTelegram}`}
/>
Telegram
</a>
<a
href="https://max.ru/join/c5t6LfnCCPetQSAOshmouEvq9vsjHZT_Lt63kw8YCg0"
target="_blank"
rel="noopener noreferrer"
className={styles.footerLink}
>
<span
className={`${styles.footerLinkIcon} ${styles.footerLinkIconSvg} ${styles.footerLinkIconSvgMax}`}
/>
MAX
</a>
</div>
</div>
</footer>
);
}
+68
View File
@@ -0,0 +1,68 @@
import { useNavigate } from "react-router-dom";
import { useUserStore } from "@/state/user";
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
import { MaterialButton, MaterialIconButton } from "@/utils/material";
import { GitHubLink, SupportLink } from "./homeLinks";
import styles from "./home-header.module.scss";
export function HomeHeader() {
const navigate = useNavigate();
const { user } = useUserStore();
const { isMobile } = useDownloadAppScreen();
const isLoggedIn = user.authToken && user.currentUser;
function handleGetStarted() {
if (isMobile) {
navigate("/download-app");
} else if (isLoggedIn) {
navigate("/chat");
} else {
navigate("/login");
}
}
const openBtn = (
<MaterialButton
variant="filled"
onClick={handleGetStarted}
icon={
isMobile ? "download" : isLoggedIn ? "open_in_new" : "login"
}
className={styles.headerDownloadButton}
>
{isMobile ? "Скачать" : isLoggedIn ? "Открыть" : "Войти"}
</MaterialButton>
);
return (
<header className={styles.homepageHeader}>
<div className={styles.headerInner}>
<div className={styles.headerContent}>
<div className={styles.logo}>
<div className={styles.logoIcon} />
<h1>FromChat</h1>
</div>
<div className={styles.headerCenterLinks}>
<GitHubLink>
<MaterialButton variant="text" icon="code">GitHub</MaterialButton>
</GitHubLink>
<SupportLink>
<MaterialButton variant="text" icon="support">Поддержка</MaterialButton>
</SupportLink>
</div>
<div className={styles.headerButton}>
{openBtn}
{isMobile ? (
<MaterialIconButton
variant="filled"
onClick={() => navigate("/download-app")}
icon="download"
className={styles.headerSmallButton}
/>
) : null}
</div>
</div>
</div>
</header>
);
}
+43 -248
View File
@@ -1,53 +1,13 @@
import { Link, useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useUserStore } from "@/state/user"; import type { ReactNode } from "react";
import styles from "./home.module.scss"; import styles from "./home.module.scss";
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen"; import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
import { MaterialButton, MaterialIcon, MaterialIconButton } from "@/utils/material"; import { MaterialButton, MaterialIcon } from "@/utils/material";
import generalChatScreenshot from "../../images/screenshots/general-chat.png"; import generalChatScreenshot from "../../images/screenshots/general-chat.png";
import dmScreenshot from "../../images/screenshots/dm.png"; import dmScreenshot from "../../images/screenshots/dm.png";
import type { ReactNode } from "react"; import { HomeHeader } from "./HomeHeader";
import { HomeFooter } from "./HomeFooter";
const GITHUB_WEB = "https://github.com/fromchat-messenger/web"; import { OS_CONFIG, ALL_OS } from "@/core/downloads/os";
const GITHUB_APP = "https://github.com/fromchat-messenger/app";
const GITHUB_LICENSE = `${GITHUB_WEB}/blob/main/LICENSE`;
function GitHubLink({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<a
href={`${GITHUB_WEB}/tree/main`}
target="_blank"
rel="noopener noreferrer"
className={className}
>
{children}
</a>
);
}
function SupportLink({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<a
href="https://t.me/denis0001-dev"
target="_blank"
rel="noopener noreferrer"
className={className}
>
{children}
</a>
);
}
interface FeatureSectionProps { interface FeatureSectionProps {
title: ReactNode; title: ReactNode;
@@ -85,64 +45,11 @@ function FeatureSection({
export default function HomePage() { export default function HomePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useUserStore();
const { isMobile } = useDownloadAppScreen(); const { isMobile } = useDownloadAppScreen();
const isLoggedIn = user.authToken && user.currentUser;
function handleGetStarted() {
if (isMobile) {
navigate("/download-app");
} else if (isLoggedIn) {
navigate("/chat");
} else {
navigate("/login");
}
}
const openBtn = (
<MaterialButton
variant="filled"
onClick={handleGetStarted}
icon={
isMobile ? "download" : isLoggedIn ? "open_in_new" : "login"
}
className={styles.headerDownloadButton}
>
{isMobile ? "Скачать" : isLoggedIn ? "Открыть" : "Войти"}
</MaterialButton>
);
return ( return (
<div className={styles.homepage}> <div className={styles.homepage}>
<header className={styles.homepageHeader}> <HomeHeader />
<div className={styles.headerInner}>
<div className={styles.headerContent}>
<div className={styles.logo}>
<div className={styles.logoIcon} />
<h1>FromChat</h1>
</div>
<div className={styles.headerCenterLinks}>
<GitHubLink>
<MaterialButton variant="text" icon="code">GitHub</MaterialButton>
</GitHubLink>
<SupportLink>
<MaterialButton variant="text" icon="support">Поддержка</MaterialButton>
</SupportLink>
</div>
<div className={styles.headerButton}>
{openBtn}
{isMobile ? (
<MaterialIconButton
variant="filled"
onClick={() => navigate("/download-app")}
icon="download"
className={styles.headerSmallButton}
/>
) : null}
</div>
</div>
</div>
</header>
<main> <main>
<section className={styles.title}> <section className={styles.title}>
@@ -155,9 +62,9 @@ export default function HomePage() {
</div> </div>
<div className={styles.titleButtons}> <div className={styles.titleButtons}>
{isMobile ? null : ( {isMobile ? null : (
<MaterialButton <MaterialButton
variant="filled" variant="filled"
onClick={() => navigate("/auth?mode=login")} onClick={() => navigate("/auth?mode=login")}
icon="devices" icon="devices"
> >
Открыть веб-версию Открыть веб-версию
@@ -193,77 +100,42 @@ export default function HomePage() {
<div className={styles.downloadContent}> <div className={styles.downloadContent}>
<h3>Скачайте приложение</h3> <h3>Скачайте приложение</h3>
<p> <p>
Для лучшего опыта используйте настольное приложение с уведомлениями Настольное приложение с уведомлениями и автономной работой
и автономной работой или мобильное приложение для Android. или мобильное приложение для Android и iOS.
</p> </p>
<div className={styles.downloadPlatforms}> <div className={styles.downloadTable}>
{!isMobile ? ( <div className={styles.downloadTableHeader}>
<> <span>Платформа</span>
<a <span>Описание</span>
href={`${GITHUB_WEB}/actions/workflows/build.yml`} <span />
target="_blank" </div>
rel="noopener noreferrer" {ALL_OS.map((os) => (
className={styles.downloadCard} <div key={os} className={styles.downloadTableRow}>
> <div className={styles.downloadTableOs}>
<MaterialIcon name="computer" className={styles.downloadCardIcon} />
<span className={styles.downloadCardTitle}>Для ПК</span>
<span className={styles.downloadCardDesc}>Windows, macOS, Linux</span>
<MaterialButton variant="filled" className={styles.downloadCardBtn}>
<MaterialIcon name="download" slot="icon" />
Скачать
</MaterialButton>
</a>
<a
href={`${GITHUB_APP}/releases/latest`}
target="_blank"
rel="noopener noreferrer"
className={styles.downloadCard}
>
<MaterialIcon name="android" className={styles.downloadCardIcon} />
<span className={styles.downloadCardTitle}>Android</span>
<span className={styles.downloadCardDesc}>APK на GitHub</span>
<MaterialButton variant="outlined" className={styles.downloadCardBtn}>
<MaterialIcon name="download" slot="icon" />
Скачать
</MaterialButton>
</a>
<div className={styles.downloadCard}>
<MaterialIcon <MaterialIcon
name="language" name={OS_CONFIG[os].icon}
className={styles.downloadCardIcon} className={styles.downloadTableIcon}
/> />
<span className={styles.downloadCardTitle}> <span>{OS_CONFIG[os].label}</span>
Веб-версия
</span>
<span className={styles.downloadCardDesc}>
Без установки
</span>
<MaterialButton
variant="outlined"
className={styles.downloadCardBtn}
onClick={() => navigate("/login")}
>
<MaterialIcon name="open_in_new" slot="icon" />
Открыть
</MaterialButton>
</div> </div>
</> <span className={styles.downloadTableDesc}>
) : ( {OS_CONFIG[os].description}
<a </span>
href={`${GITHUB_APP}/releases/latest`} <div className={styles.downloadTableAction}>
target="_blank" <a
rel="noopener noreferrer" href={`/api/download/${os}`}
className={styles.downloadCard} className={styles.downloadTableActionLink}
> >
<MaterialIcon name="android" className={styles.downloadCardIcon} /> <MaterialButton
<span className={styles.downloadCardTitle}>Android</span> variant="outlined"
<span className={styles.downloadCardDesc}>Скачайте APK на GitHub</span> icon="download"
<MaterialButton variant="filled" className={styles.downloadCardBtn}> >
<MaterialIcon name="download" slot="icon" /> Скачать
Скачать приложение </MaterialButton>
</MaterialButton> </a>
</a> </div>
)} </div>
))}
</div> </div>
</div> </div>
</div> </div>
@@ -302,84 +174,7 @@ export default function HomePage() {
</section> </section>
</main> </main>
<footer className={styles.homepageFooter}> <HomeFooter />
<div className={styles.footerBrand}>
<div className={styles.footerLogoRow}>
<div className={styles.footerLogo} />
<span className={styles.footerBrandName}>FromChat</span>
</div>
<p className={styles.footerCopyright}>FromChat © 2026</p>
</div>
<div className={styles.footerLinks}>
<div className={styles.footerSection}>
<Link to="/download-app" className={styles.footerLink}>
<MaterialIcon name="download" className={styles.footerLinkIcon} />
Скачать приложение
</Link>
<Link to="/login" className={styles.footerLink}>
<MaterialIcon name="language" className={styles.footerLinkIcon} />
Веб-версия
</Link>
<a
href={`${GITHUB_WEB}/actions/workflows/build.yml`}
target="_blank"
rel="noopener noreferrer"
className={styles.footerLink}
>
<MaterialIcon name="computer" className={styles.footerLinkIcon} />
ПК-клиент
</a>
</div>
<div className={styles.footerSection}>
<a
href={`${GITHUB_APP}/tree/main`}
target="_blank"
rel="noopener noreferrer"
className={styles.footerLink}
>
<MaterialIcon name="android" className={styles.footerLinkIcon} />
Исходный код приложения
</a>
<GitHubLink className={styles.footerLink}>
<MaterialIcon name="code" className={styles.footerLinkIcon} />
Исходный код веб-версии
</GitHubLink>
<a
href={GITHUB_LICENSE}
target="_blank"
rel="noopener noreferrer"
className={styles.footerLink}
>
<MaterialIcon name="description" className={styles.footerLinkIcon} />
Лицензия
</a>
</div>
<div className={styles.footerSection}>
<a
href="https://t.me/fromchat_ch"
target="_blank"
rel="noopener noreferrer"
className={styles.footerLink}
>
<span
className={`${styles.footerLinkIcon} ${styles.footerLinkIconSvg} ${styles.footerLinkIconSvgTelegram}`}
/>
Telegram
</a>
<a
href="https://max.ru/join/c5t6LfnCCPetQSAOshmouEvq9vsjHZT_Lt63kw8YCg0"
target="_blank"
rel="noopener noreferrer"
className={styles.footerLink}
>
<span
className={`${styles.footerLinkIcon} ${styles.footerLinkIconSvg} ${styles.footerLinkIconSvgMax}`}
/>
MAX
</a>
</div>
</div>
</footer>
</div> </div>
); );
} }
+11
View File
@@ -0,0 +1,11 @@
@use "../../css/material" as *;
$gradient-rainbow: linear-gradient(45deg, #9333EA, #6366F1, #3B82F6, #A855F7, #D946EF, #EC4899, #7E22CE);
$glow-purple: rgba(147, 51, 234, 0.5);
@mixin gradient-text {
background: $gradient-rainbow;
background-clip: text;
-webkit-text-fill-color: transparent;
text-shadow: 0 0 20px $glow-purple;
}
@@ -0,0 +1,120 @@
@use "../../css/material" as *;
@use "home-shared" as shared;
.homepageFooter {
padding: 0 16px;
background: $color-dark-surface;
display: flex;
flex-direction: row;
gap: 32px;
max-width: 1000px;
margin-left: auto;
margin-right: auto;
align-items: center;
justify-content: center;
padding-bottom: 48px;
@media (max-width: 635px) {
flex-direction: column-reverse;
align-items: flex-start;
padding: 32px;
}
}
.footerBrand {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.footerLogoRow {
display: flex;
align-items: center;
gap: 10px;
}
.footerLogo {
width: 40px;
height: 40px;
background-image: url('../../images/logo_square.svg');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
border-radius: 12px;
}
.footerBrandName {
font-size: 32px;
font-weight: 700;
@include shared.gradient-text;
}
.footerCopyright {
font-size: 14px;
color: $color-dark-on-surface-variant;
opacity: 0.8;
margin: 0;
}
.footerLinks {
display: flex;
gap: 32px;
flex-wrap: wrap;
}
.footerSection {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.footerLink {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
color: $color-dark-on-surface-variant;
text-decoration: none;
font-size: 15px;
transition: color 0.2s ease, transform 0.15s ease;
-webkit-user-drag: none;
user-select: none;
&:hover {
color: $color-dark-on-surface;
}
&:active {
transform: scale(0.92);
}
}
.footerLinkIcon {
width: 24px;
height: 24px;
min-width: 24px;
opacity: 0.9;
}
.footerLinkIconSvg {
display: inline-block;
background-color: currentColor;
mask-size: contain;
mask-repeat: no-repeat;
mask-position: center;
-webkit-mask-size: contain;
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
}
.footerLinkIconSvgTelegram {
mask-image: url('../../images/telegram.svg');
-webkit-mask-image: url('../../images/telegram.svg');
}
.footerLinkIconSvgMax {
mask-image: url('../../images/max.svg');
-webkit-mask-image: url('../../images/max.svg');
}
@@ -0,0 +1,89 @@
@use "../../css/material" as *;
@use "home-shared" as shared;
.homepageHeader {
display: flex;
justify-content: center;
padding: 16px;
position: sticky;
top: 0;
z-index: 1000;
}
.headerInner {
max-width: 960px;
width: 100%;
padding: 16px 24px;
background: rgba($color-dark-surface, 0.8);
backdrop-filter: blur(10px);
border-radius: 30px;
}
.headerContent {
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
display: flex;
align-items: center;
gap: 10px;
}
.logoIcon {
width: 40px;
height: 40px;
background-image: url('../../images/logo_square.svg');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
border-radius: 12px;
}
.logo h1 {
font-size: 32px;
font-weight: 700;
margin: 0;
@include shared.gradient-text;
}
.headerCenterLinks {
display: flex;
align-items: center;
gap: 10px;
@media (max-width: 730px) {
display: none;
}
a {
display: flex;
align-items: center;
gap: 10px;
}
}
.headerButton {
display: flex;
align-items: center;
justify-content: center;
}
.headerDownloadButton {
display: block;
}
.headerSmallButton {
display: none;
}
@media (max-width: 450px) {
.headerSmallButton {
display: block;
}
.headerDownloadButton {
display: none;
}
}
+63 -261
View File
@@ -1,7 +1,7 @@
@use "../../css/material" as *; @use "../../css/material" as *;
@use "home-shared" as shared;
// Shared variables // Shared variables (home-specific)
$gradient-rainbow: linear-gradient(45deg, #9333EA, #6366F1, #3B82F6, #A855F7, #D946EF, #EC4899, #7E22CE);
$gradient-conic: conic-gradient( $gradient-conic: conic-gradient(
from 0deg, from 0deg,
rgba(147, 51, 234, 0.5) 0%, rgba(147, 51, 234, 0.5) 0%,
@@ -14,23 +14,15 @@ $gradient-conic: conic-gradient(
rgba(126, 34, 206, 0.6) 87.5%, rgba(126, 34, 206, 0.6) 87.5%,
rgba(147, 51, 234, 0.5) 100% rgba(147, 51, 234, 0.5) 100%
); );
$glow-purple: rgba(147, 51, 234, 0.5);
$radius-card: 20px; $radius-card: 20px;
$radius-pill: 9999px; $radius-pill: 9999px;
// Mixins // Mixins
@mixin gradient-text {
background: $gradient-rainbow;
background-clip: text;
-webkit-text-fill-color: transparent;
text-shadow: 0 0 20px $glow-purple;
}
@mixin section-heading { @mixin section-heading {
font-size: 40px; font-size: 40px;
font-weight: 700; font-weight: 700;
margin-bottom: 24px; margin-bottom: 24px;
@include gradient-text; @include shared.gradient-text;
} }
@mixin section-content { @mixin section-content {
@@ -54,6 +46,8 @@ $radius-pill: 9999px;
.homepage { .homepage {
min-height: 100vh; min-height: 100vh;
display: flex;
flex-direction: column;
color: $color-dark-on-background; color: $color-dark-on-background;
font-family: 'Montserrat', sans-serif; font-family: 'Montserrat', sans-serif;
position: relative; position: relative;
@@ -66,94 +60,9 @@ $radius-pill: 9999px;
user-select: none; user-select: none;
} }
header.homepageHeader {
display: flex;
justify-content: center;
padding: 16px;
position: sticky;
top: 0;
z-index: 1000;
.headerInner {
max-width: 960px;
width: 100%;
padding: 16px 24px;
background: rgba($color-dark-surface, 0.8);
backdrop-filter: blur(10px);
border-radius: 30px;
.headerContent {
display: flex;
justify-content: space-between;
align-items: center;
.logo {
display: flex;
align-items: center;
gap: 10px;
.logoIcon {
width: 40px;
height: 40px;
background-image: url('../../images/logo_square.svg');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
border-radius: 12px;
}
h1 {
font-size: 32px;
font-weight: 700;
margin: 0;
@include gradient-text;
}
}
.headerCenterLinks {
display: flex;
align-items: center;
gap: 10px;
@media (max-width: 730px) {
display: none;
}
a {
display: flex;
align-items: center;
gap: 10px;
}
}
.headerButton {
display: flex;
align-items: center;
justify-content: center;
}
.headerDownloadButton {
display: block;
}
.headerSmallButton {
display: none;
}
@media (max-width: 450px) {
.headerSmallButton {
display: block;
}
.headerDownloadButton {
display: none;
}
}
}
}
}
main { main {
flex: 1;
.title { .title {
padding: 60px 0; padding: 60px 0;
display: flex; display: flex;
@@ -201,7 +110,7 @@ $radius-pill: 9999px;
font-weight: 700; font-weight: 700;
margin-top: 10px; margin-top: 10px;
padding: 0 80px; padding: 0 80px;
@include gradient-text; @include shared.gradient-text;
} }
.titleDesc { .titleDesc {
@@ -247,7 +156,7 @@ $radius-pill: 9999px;
font-size: 30pt; font-size: 30pt;
font-weight: 700; font-weight: 700;
line-height: normal; line-height: normal;
@include gradient-text; @include shared.gradient-text;
} }
.featureDesc { .featureDesc {
@@ -302,55 +211,66 @@ $radius-pill: 9999px;
@include section-heading; @include section-heading;
} }
.downloadPlatforms { .downloadTable {
display: flex; width: 100%;
gap: 24px; max-width: 720px;
justify-content: center; margin: 0 auto;
flex-wrap: wrap; border-radius: 20px;
} background: rgba($color-dark-surface-container, 0.8);
border: 1px solid rgba($color-dark-outline, 0.3);
.downloadCard { padding: 8px 16px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px;
}
.downloadTableHeader {
display: grid;
grid-template-columns: 2fr 2fr auto;
padding: 8px 4px;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: $color-dark-on-surface-variant;
}
.downloadTableRow {
display: grid;
grid-template-columns: 2fr 2fr auto;
align-items: center; align-items: center;
gap: 12px; padding: 8px 4px;
padding: 32px 24px; border-radius: 12px;
min-width: 200px;
max-width: 260px;
background: rgba($color-dark-surface-container, 0.6);
border: 1px solid rgba($color-dark-outline, 0.3);
border-radius: $radius-card;
text-decoration: none;
color: inherit;
transition: all 0.3s ease;
&:hover { &:hover {
background: rgba($color-dark-surface-container-high, 0.8); background-color: rgba($color-dark-surface-container-high, 0.8);
border-color: rgba($color-dark-primary, 0.5);
box-shadow: 0 0 24px rgba($color-dark-primary, 0.2);
}
.downloadCardIcon {
width: 48px;
height: 48px;
color: $color-dark-primary;
}
.downloadCardTitle {
font-size: 20px;
font-weight: 600;
@include gradient-text;
}
.downloadCardDesc {
font-size: 14px;
opacity: 0.8;
}
.downloadCardBtn {
margin-top: 8px;
} }
} }
.downloadTableOs {
display: flex;
align-items: center;
gap: 8px;
}
.downloadTableIcon {
font-size: 20px;
color: $color-dark-primary;
opacity: 0.9;
}
.downloadTableDesc {
font-size: 14px;
color: $color-dark-on-surface-variant;
}
.downloadTableAction {
display: flex;
justify-content: flex-end;
}
.downloadTableActionLink {
text-decoration: none;
}
} }
} }
@@ -378,124 +298,6 @@ $radius-pill: 9999px;
} }
} }
footer.homepageFooter {
padding: 0 16px;
background: $color-dark-surface;
display: flex;
flex-direction: row;
gap: 32px;
max-width: 1000px;
margin-left: auto;
margin-right: auto;
align-items: center;
justify-content: center;
padding-bottom: 48px;
.footerBrand {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
.footerLogoRow {
display: flex;
align-items: center;
gap: 10px;
}
.footerLogo {
width: 40px;
height: 40px;
background-image: url('../../images/logo_square.svg');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
border-radius: 12px;
}
.footerBrandName {
font-size: 32px;
font-weight: 700;
@include gradient-text;
}
.footerCopyright {
font-size: 14px;
color: $color-dark-on-surface-variant;
opacity: 0.8;
margin: 0;
}
}
.footerLinks {
display: flex;
gap: 32px;
flex-wrap: wrap;
.footerSection {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
.footerLink {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
color: $color-dark-on-surface-variant;
text-decoration: none;
font-size: 15px;
transition: color 0.2s ease, transform 0.15s ease;
-webkit-user-drag: none;
user-select: none;
&:hover {
color: $color-dark-on-surface;
}
&:active {
transform: scale(0.92);
}
.footerLinkIcon {
width: 24px;
height: 24px;
min-width: 24px;
opacity: 0.9;
}
.footerLinkIconSvg {
display: inline-block;
background-color: currentColor;
mask-size: contain;
mask-repeat: no-repeat;
mask-position: center;
-webkit-mask-size: contain;
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
}
.footerLinkIconSvgTelegram {
mask-image: url('../../images/telegram.svg');
-webkit-mask-image: url('../../images/telegram.svg');
}
.footerLinkIconSvgMax {
mask-image: url('../../images/max.svg');
-webkit-mask-image: url('../../images/max.svg');
}
}
}
}
@media (max-width: 635px) {
flex-direction: column-reverse;
align-items: flex-start;
padding: 32px;
}
}
@media (max-width: 635px) { @media (max-width: 635px) {
main { main {
.title { .title {
+43
View File
@@ -0,0 +1,43 @@
const GITHUB_WEB = "https://github.com/fromchat-messenger/web";
const GITHUB_APP = "https://github.com/fromchat-messenger/app";
export const GITHUB_LICENSE = `${GITHUB_WEB}/blob/main/LICENSE`;
export function GitHubLink({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<a
href={`${GITHUB_WEB}/tree/main`}
target="_blank"
rel="noopener noreferrer"
className={className}
>
{children}
</a>
);
}
export function SupportLink({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<a
href="https://t.me/denis0001-dev"
target="_blank"
rel="noopener noreferrer"
className={className}
>
{children}
</a>
);
}
export { GITHUB_WEB, GITHUB_APP };
+9
View File
@@ -17,6 +17,7 @@ import 'mdui/components/button';
import 'mdui/components/text-field'; import 'mdui/components/text-field';
import 'mdui/components/button-icon'; import 'mdui/components/button-icon';
import 'mdui/components/switch'; import 'mdui/components/switch';
import 'mdui/components/ripple';
import 'mdui/components/chip'; import 'mdui/components/chip';
import 'mdui/components/badge'; import 'mdui/components/badge';
import "mdui/mdui.css"; import "mdui/mdui.css";
@@ -29,6 +30,7 @@ import type { Switch } from 'mdui/components/switch';
import type { Override } from '@/core/types'; import type { Override } from '@/core/types';
import type { Button } from 'mdui/components/button'; import type { Button } from 'mdui/components/button';
import type { ButtonIcon } from 'mdui/components/button-icon'; import type { ButtonIcon } from 'mdui/components/button-icon';
import type { Ripple } from 'mdui/components/ripple';
import type { Icon } from 'mdui/components/icon'; import type { Icon } from 'mdui/components/icon';
import type { Fab } from 'mdui/components/fab'; import type { Fab } from 'mdui/components/fab';
import type { Tabs } from 'mdui/components/tabs'; import type { Tabs } from 'mdui/components/tabs';
@@ -58,6 +60,7 @@ export type MDUISwitch = Override<HTMLElement, Switch>;
export type MDUIButton = Override<HTMLElement, Button>; export type MDUIButton = Override<HTMLElement, Button>;
export type MDUIButtonIcon = Override<HTMLElement, ButtonIcon>; export type MDUIButtonIcon = Override<HTMLElement, ButtonIcon>;
export type MDUIIcon = Override<HTMLElement, Icon>; export type MDUIIcon = Override<HTMLElement, Icon>;
export type MDUIRipple = Override<HTMLElement, Ripple>;
export type MDUIFab = Override<HTMLElement, Fab>; export type MDUIFab = Override<HTMLElement, Fab>;
export type MDUITabs = Override<HTMLElement, Tabs>; export type MDUITabs = Override<HTMLElement, Tabs>;
export type MDUITab = Override<HTMLElement, Tab>; export type MDUITab = Override<HTMLElement, Tab>;
@@ -140,4 +143,10 @@ export function MaterialCircularProgress(props: MaterialCircularProgressProps) {
export type MaterialBottomAppBarProps = BasePropCustomization<"mdui-bottom-app-bar", MDUIBottomAppBar>; export type MaterialBottomAppBarProps = BasePropCustomization<"mdui-bottom-app-bar", MDUIBottomAppBar>;
export function MaterialBottomAppBar(props: MaterialBottomAppBarProps) { export function MaterialBottomAppBar(props: MaterialBottomAppBarProps) {
return <mdui-bottom-app-bar {...props as ComponentProps<"mdui-bottom-app-bar">} /> return <mdui-bottom-app-bar {...props as ComponentProps<"mdui-bottom-app-bar">} />
}
export type MaterialRippleProps = BasePropCustomization<"div", MDUIRipple>;
export function MaterialRipple(props: MaterialRippleProps) {
// Wrapper component for future custom ripple usage; MDUI buttons already include real ripple.
return <div {...props as ComponentProps<"div">} />;
} }
+1
View File
@@ -10,4 +10,5 @@ TURN_USERNAME=<set>
TURN_SECRET=<set> TURN_SECRET=<set>
DEPLOYMENT_SERVER=<set> DEPLOYMENT_SERVER=<set>
FIREBASE_CERT=<set> FIREBASE_CERT=<set>
RELEASES_TOKEN=<set>
EOF EOF