feat: tune deeplink fallback page

This commit is contained in:
3252a8
2026-05-23 23:26:10 +03:00
parent 5918a6cc71
commit 46391b10e2
12 changed files with 635 additions and 213 deletions
@@ -0,0 +1,218 @@
<!doctype html>
<html lang="__LANG__">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>__PAGE_TITLE__</title>
<style nonce="__NONCE__">
:root {
color-scheme: dark light;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
background: #0b1017;
color: #f7fafc;
}
body {
min-height: 100dvh;
margin: 0;
display: grid;
place-items: center;
padding: 24px;
box-sizing: border-box;
}
main {
width: min(100%, 420px);
display: grid;
gap: 14px;
text-align: center;
}
h1 {
margin: 0;
font-size: 24px;
line-height: 1.2;
}
p {
margin: 0;
color: #aeb8c5;
font-size: 15px;
line-height: 1.55;
}
.actions {
display: grid;
gap: 10px;
margin-top: 4px;
}
.button {
display: inline-flex;
min-height: 46px;
align-items: center;
justify-content: center;
border: 1px solid transparent;
border-radius: 8px;
background: #14b86f;
color: #03120b;
padding: 0 18px;
box-sizing: border-box;
font: inherit;
font-weight: 800;
text-decoration: none;
cursor: pointer;
}
.button.secondary {
border-color: #2d3847;
background: transparent;
color: #f7fafc;
}
.button[aria-disabled="true"] {
pointer-events: none;
background: #344052;
color: #aeb8c5;
}
[hidden] {
display: none !important;
}
</style>
</head>
<body>
<main>
<h1 id="title"></h1>
<p id="status"></p>
<div class="actions">
<a id="open-link" class="button" href="#" rel="noreferrer"></a>
<button id="close-button" class="button secondary" type="button" hidden></button>
</div>
</main>
<script nonce="__NONCE__">
(() => {
const messages = __MESSAGES_JSON__;
const titleEl = document.getElementById("title");
const statusEl = document.getElementById("status");
const openLink = document.getElementById("open-link");
const closeButton = document.getElementById("close-button");
const params = new URLSearchParams(window.location.hash.replace(/^#/, ""));
const target = String(params.get("url") || "").trim();
const isUnsafe =
!target ||
hasControlChars(target) ||
/^(?:javascript|data|vbscript|https?):/i.test(target);
let attempted = false;
let pageLeft = false;
let state = "opening";
function hasControlChars(value) {
return Array.from(String(value || "")).some((char) => {
const code = char.charCodeAt(0);
return code <= 31 || code === 127;
});
}
function text(key, fallback) {
const value = messages && messages[key];
return typeof value === "string" && value ? value : fallback;
}
function tryCloseWindow() {
try {
window.close();
} catch (_error) {
void _error;
}
}
function render(nextState) {
state = nextState;
if (nextState === "unavailable") {
titleEl.textContent = text("unavailableTitle", "App link unavailable");
statusEl.textContent = text("unavailableHint", "Return to Telegram and try again.");
openLink.textContent = text("button", "Open app");
openLink.setAttribute("aria-disabled", "true");
openLink.removeAttribute("href");
closeButton.hidden = true;
return;
}
if (nextState === "done") {
titleEl.textContent = text("doneTitle", "Settings added");
statusEl.textContent = text("doneHint", "You can close this window.");
openLink.textContent = text("retryButton", "Open again");
openLink.removeAttribute("aria-disabled");
openLink.href = target;
closeButton.textContent = text("closeButton", "Close window");
closeButton.hidden = false;
return;
}
titleEl.textContent = text("title", "Opening app");
statusEl.textContent =
nextState === "manual"
? text("manualHint", "If the app did not open automatically, tap the button below.")
: text("hint", "Opening the app on this device...");
openLink.textContent = text("button", "Open app");
openLink.removeAttribute("aria-disabled");
openLink.href = target;
closeButton.hidden = true;
}
function markDone() {
if (state === "done" || isUnsafe) return;
render("done");
window.setTimeout(tryCloseWindow, 120);
}
function notePageLeft() {
if (!attempted) return;
pageLeft = true;
window.setTimeout(markDone, 900);
}
function openTarget() {
if (isUnsafe) return;
attempted = true;
pageLeft = false;
render("opening");
window.location.href = target;
window.setTimeout(() => {
if (state === "opening" && !pageLeft) render("manual");
}, 1600);
}
if (isUnsafe) {
render("unavailable");
return;
}
openLink.addEventListener("click", (event) => {
event.preventDefault();
openTarget();
});
closeButton.addEventListener("click", () => {
tryCloseWindow();
render("done");
});
window.addEventListener("pagehide", notePageLeft);
window.addEventListener("blur", notePageLeft);
document.addEventListener("visibilitychange", () => {
if (!attempted) return;
if (document.hidden) {
pageLeft = true;
} else if (pageLeft) {
markDone();
}
});
render("opening");
window.setTimeout(openTarget, 80);
})();
</script>
</body>
</html>
+1
View File
@@ -59,6 +59,7 @@ logger = logging.getLogger(__name__)
TEMPLATE_PATH = Path(__file__).resolve().parents[1] / "templates" / "subscription_webapp.html" TEMPLATE_PATH = Path(__file__).resolve().parents[1] / "templates" / "subscription_webapp.html"
ASSET_DIR = TEMPLATE_PATH.parent ASSET_DIR = TEMPLATE_PATH.parent
APP_DEEPLINK_TEMPLATE_PATH = ASSET_DIR / "open_app_gateway.html"
APP_ROOT = Path(__file__).resolve().parents[5] APP_ROOT = Path(__file__).resolve().parents[5]
WEBAPP_LOGO_PROXY_PATH = "/webapp-logo" WEBAPP_LOGO_PROXY_PATH = "/webapp-logo"
WEBAPP_LOGO_CACHE_DIR = APP_ROOT / "data" / "webapp-logo" WEBAPP_LOGO_CACHE_DIR = APP_ROOT / "data" / "webapp-logo"
+58 -113
View File
@@ -1000,6 +1000,30 @@ async def _js_asset_route(request: web.Request, *, base_name: str) -> web.Respon
WEBAPP_BOOTSTRAP_I18N_PREFIXES = ("wa_",) WEBAPP_BOOTSTRAP_I18N_PREFIXES = ("wa_",)
WEBAPP_BOOTSTRAP_I18N_KEYS = {"menu_support_button"} WEBAPP_BOOTSTRAP_I18N_KEYS = {"menu_support_button"}
WEBAPP_I18N_SCOPES = {"webapp", "admin"} WEBAPP_I18N_SCOPES = {"webapp", "admin"}
APP_DEEPLINK_I18N_KEYS = {
"title": "wa_app_launch_title",
"hint": "wa_app_launch_opening_hint",
"manualHint": "wa_app_launch_hint",
"button": "wa_app_launch_button",
"retryButton": "wa_app_launch_retry_button",
"doneTitle": "wa_app_launch_done_title",
"doneHint": "wa_app_launch_done_hint",
"closeButton": "wa_app_launch_close_button",
"unavailableTitle": "wa_app_launch_unavailable_title",
"unavailableHint": "wa_app_launch_unavailable_hint",
}
APP_DEEPLINK_I18N_FALLBACKS = {
"wa_app_launch_title": "Opening app",
"wa_app_launch_opening_hint": "Opening the app on this device...",
"wa_app_launch_hint": "If the app did not open automatically, tap the button below.",
"wa_app_launch_button": "Open app",
"wa_app_launch_retry_button": "Open again",
"wa_app_launch_done_title": "Settings added",
"wa_app_launch_done_hint": "If the app opened, you can close this window.",
"wa_app_launch_close_button": "Close window",
"wa_app_launch_unavailable_title": "App link unavailable",
"wa_app_launch_unavailable_hint": "Return to Telegram and try again.",
}
def _is_webapp_bootstrap_i18n_key(key: str) -> bool: def _is_webapp_bootstrap_i18n_key(key: str) -> bool:
@@ -1199,124 +1223,45 @@ async def app_deeplink_route(request: web.Request) -> web.Response:
raise web.HTTPNotFound(text="webapp_disabled") raise web.HTTPNotFound(text="webapp_disabled")
nonce = html.escape(str(request.get("csp_nonce", "")), quote=True) nonce = html.escape(str(request.get("csp_nonce", "")), quote=True)
title = html.escape(str(getattr(settings, "WEBAPP_TITLE", "") or "Subscription"), quote=True) query = getattr(request, "query", {}) or {}
html_text = f"""<!doctype html> lang = _normalize_language(query.get("lang") or getattr(settings, "DEFAULT_LANGUAGE", "ru"))
<html lang="en"> messages = _app_deeplink_i18n_payload(request, lang)
<head> page_title = html.escape(
<meta charset="utf-8"> f"{getattr(settings, 'WEBAPP_TITLE', '') or 'Subscription'} - {messages['title']}",
<meta name="viewport" content="width=device-width, initial-scale=1"> quote=False,
<title>{title} - Opening app</title> )
<style nonce="{nonce}"> messages_json = json.dumps(
:root {{ messages,
color-scheme: dark light; ensure_ascii=False,
font-family: separators=(",", ":"),
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, ).replace("</", "<\\/")
"Segoe UI", sans-serif; html_text = (
background: #0b1017; _read_template_text_cached(APP_DEEPLINK_TEMPLATE_PATH)
color: #f7fafc; .replace("__LANG__", html.escape(lang, quote=True))
}} .replace("__PAGE_TITLE__", page_title)
.replace("__NONCE__", nonce)
body {{ .replace("__MESSAGES_JSON__", messages_json)
min-height: 100dvh; )
margin: 0;
display: grid;
place-items: center;
padding: 24px;
box-sizing: border-box;
}}
main {{
width: min(100%, 420px);
display: grid;
gap: 14px;
text-align: center;
}}
h1 {{
margin: 0;
font-size: 24px;
line-height: 1.2;
}}
p {{
margin: 0;
color: #aeb8c5;
font-size: 15px;
line-height: 1.55;
}}
a.button {{
display: inline-flex;
min-height: 46px;
align-items: center;
justify-content: center;
border-radius: 8px;
background: #14b86f;
color: #03120b;
padding: 0 18px;
font-weight: 800;
text-decoration: none;
}}
a.button[aria-disabled="true"] {{
pointer-events: none;
background: #344052;
color: #aeb8c5;
}}
</style>
</head>
<body>
<main>
<h1>Opening app</h1>
<p id="status">If nothing happened, tap the button below.</p>
<a id="open-link" class="button" href="#" rel="noreferrer">Open app</a>
</main>
<script nonce="{nonce}">
(() => {{
const statusEl = document.getElementById("status");
const openLink = document.getElementById("open-link");
const params = new URLSearchParams(window.location.hash.replace(/^#/, ""));
const target = String(params.get("url") || "").trim();
const isUnsafe =
!target ||
hasControlChars(target) ||
/^(?:javascript|data|vbscript|https?):/i.test(target);
function hasControlChars(value) {{
return Array.from(String(value || "")).some((char) => {{
const code = char.charCodeAt(0);
return code <= 31 || code === 127;
}});
}}
function openTarget() {{
if (isUnsafe) return;
window.location.href = target;
}}
if (isUnsafe) {{
statusEl.textContent = "The app link is unavailable.";
openLink.setAttribute("aria-disabled", "true");
openLink.removeAttribute("href");
return;
}}
openLink.href = target;
openLink.addEventListener("click", (event) => {{
event.preventDefault();
openTarget();
}});
window.setTimeout(openTarget, 80);
}})();
</script>
</body>
</html>"""
response = web.Response(text=html_text, content_type="text/html", charset="utf-8") response = web.Response(text=html_text, content_type="text/html", charset="utf-8")
response.headers["Cache-Control"] = "no-store" response.headers["Cache-Control"] = "no-store"
return response return response
def _app_deeplink_i18n_payload(request: web.Request, lang: str) -> Dict[str, str]:
i18n_instance: Optional[object] = request.app.get("i18n")
payload: Dict[str, str] = {}
for payload_key, i18n_key in APP_DEEPLINK_I18N_KEYS.items():
fallback = APP_DEEPLINK_I18N_FALLBACKS[i18n_key]
value = ""
if i18n_instance is not None:
try:
value = str(i18n_instance.gettext(lang, i18n_key) or "")
except Exception as exc:
logger.debug("Failed to resolve open-app i18n key %s: %s", i18n_key, exc)
payload[payload_key] = value if value and value != i18n_key else fallback
return payload
async def _serve_template_asset( async def _serve_template_asset(
request: web.Request, request: web.Request,
filename: str, filename: str,
+15 -41
View File
@@ -14,6 +14,7 @@
import AuthScreen from "./webapp/auth/AuthScreen.svelte"; import AuthScreen from "./webapp/auth/AuthScreen.svelte";
import PaymentDialogs from "./webapp/PaymentDialogs.svelte"; import PaymentDialogs from "./webapp/PaymentDialogs.svelte";
import TariffDialogs from "./webapp/TariffDialogs.svelte"; import TariffDialogs from "./webapp/TariffDialogs.svelte";
import AppLaunchScreen from "./webapp/screens/AppLaunchScreen.svelte";
import DevicesScreen from "./webapp/screens/DevicesScreen.svelte"; import DevicesScreen from "./webapp/screens/DevicesScreen.svelte";
import HomeScreen from "./webapp/screens/HomeScreen.svelte"; import HomeScreen from "./webapp/screens/HomeScreen.svelte";
import InstallGuideScreen from "./webapp/screens/InstallGuideScreen.svelte"; import InstallGuideScreen from "./webapp/screens/InstallGuideScreen.svelte";
@@ -508,18 +509,17 @@
return appLaunchTarget; return appLaunchTarget;
} }
function openAppLaunchTarget() { function openAppLaunchTarget(nextTarget = "") {
const target = refreshAppLaunchTarget(); const target = String(nextTarget || refreshAppLaunchTarget() || "").trim();
if (!target) return; if (!target) return false;
appLaunchTarget = target;
openUrlWithHiddenAnchor(target); openUrlWithHiddenAnchor(target);
return true;
} }
onMount(() => { onMount(() => {
if (isPreviewBoard) return; if (isPreviewBoard) return;
if (isAppLaunchRoute) { if (isAppLaunchRoute) return;
window.setTimeout(openAppLaunchTarget, 80);
return;
}
const onAnyPointerDown = () => { const onAnyPointerDown = () => {
if (mode === "login") loginEmailTooltipOpen = false; if (mode === "login") loginEmailTooltipOpen = false;
}; };
@@ -1094,7 +1094,7 @@
const isTelegramMiniApp = hasTelegramLaunchParams(); const isTelegramMiniApp = hasTelegramLaunchParams();
const currentTg = tg || telegramSdk.refresh(); const currentTg = tg || telegramSdk.refresh();
const gatewayUrl = isTelegramMiniApp ? buildExternalAppLaunchUrl(raw) : ""; const gatewayUrl = isTelegramMiniApp ? buildExternalAppLaunchUrl(raw, null, currentLang) : "";
if (gatewayUrl) { if (gatewayUrl) {
if (currentTg?.openLink) { if (currentTg?.openLink) {
try { try {
@@ -1427,39 +1427,13 @@
<div>{t("wa_loading")}</div> <div>{t("wa_loading")}</div>
</div> </div>
{:else if mode === "appLaunch"} {:else if mode === "appLaunch"}
<div class="app-launch-shell"> <AppLaunchScreen
<main class="app-launch-panel"> {brand}
<div class="app-launch-brand" aria-hidden="true"> {appLaunchTarget}
<BrandMark {brand} size="md" /> {refreshAppLaunchTarget}
</div> {openAppLaunchTarget}
{#if appLaunchTarget} {t}
<h1>{t("wa_app_launch_title", {}, "Opening app")}</h1> />
<p>
{t(
"wa_app_launch_hint",
{},
"If the app did not open automatically, tap the button below."
)}
</p>
<a
class="app-launch-button"
href={appLaunchTarget}
rel="noreferrer"
onclick={(event) => {
event.preventDefault();
openAppLaunchTarget();
}}
>
{t("wa_app_launch_button", {}, "Open app")}
</a>
{:else}
<h1>{t("wa_app_launch_unavailable_title", {}, "App link unavailable")}</h1>
<p>
{t("wa_app_launch_unavailable_hint", {}, "Return to Telegram and try again.")}
</p>
{/if}
</main>
</div>
{:else if mode === "publicInstall"} {:else if mode === "publicInstall"}
<div class="public-install-shell"> <div class="public-install-shell">
<a class="public-install-brand" href="/" aria-label={brandTitle}> <a class="public-install-brand" href="/" aria-label={brandTitle}>
+7 -1
View File
@@ -33,7 +33,7 @@ export function readExternalAppLaunchTarget(locationRef = null) {
return target; return target;
} }
export function buildExternalAppLaunchUrl(value, locationRef = null) { export function buildExternalAppLaunchUrl(value, locationRef = null, language = "") {
const target = String(value || "").trim(); const target = String(value || "").trim();
if (isUnsafeAppUrl(target) || isHttpUrl(target)) return ""; if (isUnsafeAppUrl(target) || isHttpUrl(target)) return "";
@@ -41,6 +41,12 @@ export function buildExternalAppLaunchUrl(value, locationRef = null) {
if (!ref?.href) return ""; if (!ref?.href) return "";
const url = new URL("/open-app", ref.href); const url = new URL("/open-app", ref.href);
const lang = String(language || "")
.trim()
.toLowerCase();
if (/^[a-z]{2}(?:-[a-z0-9]{2,8})?$/.test(lang)) {
url.searchParams.set("lang", lang);
}
url.hash = new URLSearchParams({ url: target }).toString(); url.hash = new URLSearchParams({ url: target }).toString();
return url.href; return url.href;
} }
+1 -3
View File
@@ -2,7 +2,6 @@ import { mount } from "svelte";
import App from "./App.svelte"; import App from "./App.svelte";
import "./styles.css"; import "./styles.css";
import { isExternalAppLaunchPath } from "./lib/webapp/appLinks.js";
async function loadBootstrap() { async function loadBootstrap() {
if (document.getElementById("webapp-config")) return; if (document.getElementById("webapp-config")) return;
@@ -29,10 +28,9 @@ async function loadBootstrap() {
} }
const target = document.getElementById("app"); const target = document.getElementById("app");
const skipBootstrap = isExternalAppLaunchPath(window.location.pathname);
if (target) { if (target) {
(skipBootstrap ? Promise.resolve() : loadBootstrap()).finally(() => { loadBootstrap().finally(() => {
target.replaceChildren(); target.replaceChildren();
mount(App, { target }); mount(App, { target });
}); });
-51
View File
@@ -53,57 +53,6 @@ a {
line-height: 1.2; line-height: 1.2;
} }
.app-launch-shell {
display: grid;
min-height: 100dvh;
place-items: center;
padding: max(24px, env(safe-area-inset-top)) max(18px, env(safe-area-inset-right))
max(24px, env(safe-area-inset-bottom)) max(18px, env(safe-area-inset-left));
box-sizing: border-box;
}
.app-launch-panel {
display: grid;
width: min(100%, 420px);
gap: 14px;
justify-items: center;
text-align: center;
}
.app-launch-brand {
margin-bottom: 4px;
}
.app-launch-panel h1 {
margin: 0;
color: var(--text);
font-size: 24px;
line-height: 1.18;
}
.app-launch-panel p {
margin: 0;
color: var(--muted);
font-size: 15px;
line-height: 1.55;
}
.app-launch-button {
display: inline-flex;
width: 100%;
min-height: 48px;
align-items: center;
justify-content: center;
border-radius: 8px;
background: var(--accent);
color: var(--accent-contrast);
padding: 0 18px;
box-sizing: border-box;
font-size: 15px;
font-weight: 850;
text-decoration: none;
}
.loader { .loader {
display: grid; display: grid;
min-height: 100dvh; min-height: 100dvh;
@@ -0,0 +1,241 @@
<script>
import { onMount } from "svelte";
import BrandMark from "$lib/webapp/BrandMark.svelte";
const AUTO_OPEN_DELAY_MS = 80;
const MANUAL_STATE_DELAY_MS = 1600;
const DONE_STATE_DELAY_MS = 900;
const CLOSE_ATTEMPT_DELAY_MS = 120;
export let brand = {};
export let appLaunchTarget = "";
export let refreshAppLaunchTarget = () => appLaunchTarget;
export let openAppLaunchTarget = () => false;
export let t = (_key, _params = {}, fallback = "") => fallback;
let activeTarget = appLaunchTarget;
let state = activeTarget ? "opening" : "unavailable";
let attempted = false;
let pageLeft = false;
let autoOpenTimer = null;
let manualStateTimer = null;
let doneStateTimer = null;
let closeAttemptTimer = null;
$: if (!attempted && appLaunchTarget !== activeTarget) {
activeTarget = appLaunchTarget;
state = activeTarget ? "opening" : "unavailable";
}
$: isDone = state === "done";
$: isUnavailable = state === "unavailable";
$: title = isUnavailable
? t("wa_app_launch_unavailable_title", {}, "App link unavailable")
: isDone
? t("wa_app_launch_done_title", {}, "Settings added")
: t("wa_app_launch_title", {}, "Opening app");
$: hint = isUnavailable
? t("wa_app_launch_unavailable_hint", {}, "Return to Telegram and try again.")
: isDone
? t("wa_app_launch_done_hint", {}, "If the app opened, you can close this window.")
: state === "manual"
? t(
"wa_app_launch_hint",
{},
"If the app did not open automatically, tap the button below."
)
: t("wa_app_launch_opening_hint", {}, "Opening the app on this device...");
$: openLabel = isDone
? t("wa_app_launch_retry_button", {}, "Open again")
: t("wa_app_launch_button", {}, "Open app");
onMount(() => {
autoOpenTimer = window.setTimeout(openTarget, AUTO_OPEN_DELAY_MS);
window.addEventListener("pagehide", notePageLeft);
window.addEventListener("blur", notePageLeft);
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
clearTimer(autoOpenTimer);
clearTimer(manualStateTimer);
clearTimer(doneStateTimer);
clearTimer(closeAttemptTimer);
window.removeEventListener("pagehide", notePageLeft);
window.removeEventListener("blur", notePageLeft);
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
});
function clearTimer(timer) {
if (timer) window.clearTimeout(timer);
}
function refreshTarget() {
const target = String(refreshAppLaunchTarget?.() || appLaunchTarget || "").trim();
activeTarget = target;
if (!activeTarget) state = "unavailable";
return activeTarget;
}
function tryCloseWindow() {
try {
window.close();
} catch (_error) {
void _error;
}
}
function markDone() {
if (!attempted || state === "done" || !activeTarget) return;
state = "done";
clearTimer(closeAttemptTimer);
closeAttemptTimer = window.setTimeout(tryCloseWindow, CLOSE_ATTEMPT_DELAY_MS);
}
function notePageLeft() {
if (!attempted) return;
pageLeft = true;
clearTimer(doneStateTimer);
doneStateTimer = window.setTimeout(markDone, DONE_STATE_DELAY_MS);
}
function handleVisibilityChange() {
if (!attempted) return;
if (document.hidden) {
pageLeft = true;
return;
}
if (pageLeft) markDone();
}
function openTarget() {
const target = refreshTarget();
if (!target) {
state = "unavailable";
return;
}
attempted = true;
pageLeft = false;
state = "opening";
openAppLaunchTarget(target);
clearTimer(manualStateTimer);
manualStateTimer = window.setTimeout(() => {
if (state === "opening" && !pageLeft) state = "manual";
}, MANUAL_STATE_DELAY_MS);
}
function closeWindow() {
tryCloseWindow();
state = "done";
}
</script>
<div class="app-launch-shell">
<main class="app-launch-panel">
<div class="app-launch-brand" aria-hidden="true">
<BrandMark {brand} size="md" />
</div>
<h1>{title}</h1>
<p>{hint}</p>
<div class="app-launch-actions">
<a
class="app-launch-button"
class:disabled={isUnavailable}
href={activeTarget || "#"}
aria-disabled={isUnavailable ? "true" : undefined}
rel="noreferrer"
onclick={(event) => {
event.preventDefault();
if (!isUnavailable) openTarget();
}}
>
{openLabel}
</a>
{#if isDone}
<button class="app-launch-button secondary" type="button" onclick={closeWindow}>
{t("wa_app_launch_close_button", {}, "Close window")}
</button>
{/if}
</div>
</main>
</div>
<style>
.app-launch-shell {
display: grid;
min-height: 100dvh;
place-items: center;
padding: max(24px, env(safe-area-inset-top)) max(18px, env(safe-area-inset-right))
max(24px, env(safe-area-inset-bottom)) max(18px, env(safe-area-inset-left));
box-sizing: border-box;
}
.app-launch-panel {
display: grid;
width: min(100%, 420px);
gap: 14px;
justify-items: center;
text-align: center;
}
.app-launch-brand {
margin-bottom: 4px;
}
.app-launch-panel h1 {
margin: 0;
color: var(--text);
font-size: 24px;
line-height: 1.18;
}
.app-launch-panel p {
margin: 0;
color: var(--muted);
font-size: 15px;
line-height: 1.55;
}
.app-launch-actions {
display: grid;
width: 100%;
gap: 10px;
margin-top: 4px;
}
.app-launch-button {
display: inline-flex;
width: 100%;
min-height: 48px;
align-items: center;
justify-content: center;
border: 1px solid transparent;
border-radius: 8px;
background: var(--accent);
color: var(--accent-contrast);
padding: 0 18px;
box-sizing: border-box;
font: inherit;
font-size: 15px;
font-weight: 850;
text-decoration: none;
cursor: pointer;
}
.app-launch-button.secondary {
border-color: var(--border-strong);
background: transparent;
color: var(--text);
}
.app-launch-button.disabled {
pointer-events: none;
border-color: transparent;
background: var(--panel-3);
color: var(--muted);
}
</style>
+10
View File
@@ -1618,6 +1618,16 @@
"wa_install_link_copied": "Link copied", "wa_install_link_copied": "Link copied",
"wa_install_share": "Share", "wa_install_share": "Share",
"wa_install_share_copied": "Install guide link copied", "wa_install_share_copied": "Install guide link copied",
"wa_app_launch_title": "Opening app",
"wa_app_launch_opening_hint": "Opening the app on this device...",
"wa_app_launch_hint": "If the app did not open automatically, tap the button below.",
"wa_app_launch_button": "Open app",
"wa_app_launch_retry_button": "Open again",
"wa_app_launch_done_title": "Settings added",
"wa_app_launch_done_hint": "If the app opened, you can close this window.",
"wa_app_launch_close_button": "Close window",
"wa_app_launch_unavailable_title": "App link unavailable",
"wa_app_launch_unavailable_hint": "Return to Telegram and try again.",
"admin_settings_section_subscription_guides": "Install guides", "admin_settings_section_subscription_guides": "Install guides",
"admin_settings_field_subscription_guides_enabled_label": "Embedded install guides", "admin_settings_field_subscription_guides_enabled_label": "Embedded install guides",
"admin_settings_field_subscription_guides_enabled_description": "Open install instructions inside the Web App instead of an external connect page.", "admin_settings_field_subscription_guides_enabled_description": "Open install instructions inside the Web App instead of an external connect page.",
+10
View File
@@ -1618,6 +1618,16 @@
"wa_install_link_copied": "Ссылка скопирована", "wa_install_link_copied": "Ссылка скопирована",
"wa_install_share": "Поделиться", "wa_install_share": "Поделиться",
"wa_install_share_copied": "Ссылка на инструкцию скопирована", "wa_install_share_copied": "Ссылка на инструкцию скопирована",
"wa_app_launch_title": "Открываем приложение",
"wa_app_launch_opening_hint": "Открываем приложение на этом устройстве...",
"wa_app_launch_hint": "Если приложение не открылось автоматически, нажмите кнопку ниже.",
"wa_app_launch_button": "Открыть приложение",
"wa_app_launch_retry_button": "Открыть еще раз",
"wa_app_launch_done_title": "Настройки добавлены",
"wa_app_launch_done_hint": "Если приложение открылось, это окно можно закрыть.",
"wa_app_launch_close_button": "Закрыть окно",
"wa_app_launch_unavailable_title": "Ссылка недоступна",
"wa_app_launch_unavailable_hint": "Вернитесь в Telegram и попробуйте еще раз.",
"admin_settings_section_subscription_guides": "Инструкции подключения", "admin_settings_section_subscription_guides": "Инструкции подключения",
"admin_settings_field_subscription_guides_enabled_label": "Встроенные инструкции подключения", "admin_settings_field_subscription_guides_enabled_label": "Встроенные инструкции подключения",
"admin_settings_field_subscription_guides_enabled_description": "Открывать инструкции прямо внутри Web App вместо внешней страницы подключения.", "admin_settings_field_subscription_guides_enabled_description": "Открывать инструкции прямо внутри Web App вместо внешней страницы подключения.",
+68 -1
View File
@@ -1,5 +1,7 @@
import asyncio import asyncio
import json
import unittest import unittest
from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
@@ -10,6 +12,8 @@ from bot.app.web import admin_api, subscription_webapp
from bot.app.web.admin_api_impl import auth as admin_auth_routes from bot.app.web.admin_api_impl import auth as admin_auth_routes
from bot.app.web.webapp_auth import create_webapp_session_token from bot.app.web.webapp_auth import create_webapp_session_token
REPO_ROOT = Path(__file__).resolve().parents[1]
class _Request(dict): class _Request(dict):
def __init__(self, *, path="/", app=None, headers=None, cookies=None): def __init__(self, *, path="/", app=None, headers=None, cookies=None):
@@ -31,6 +35,26 @@ class _AsyncSessionFactory:
return False return False
class _I18n:
locales_data = {
"en": {
"wa_app_launch_title": "Localized launch",
"wa_app_launch_opening_hint": "Localized opening hint",
"wa_app_launch_hint": "Localized manual hint",
"wa_app_launch_button": "Localized open",
"wa_app_launch_retry_button": "Localized retry",
"wa_app_launch_done_title": "Localized done",
"wa_app_launch_done_hint": "Localized done hint",
"wa_app_launch_close_button": "Localized close",
"wa_app_launch_unavailable_title": "Localized unavailable",
"wa_app_launch_unavailable_hint": "Localized unavailable hint",
}
}
def gettext(self, lang_code, key, **kwargs):
return self.locales_data.get(lang_code, {}).get(key, key)
def _route_map(app: web.Application) -> dict[tuple[str, str], str]: def _route_map(app: web.Application) -> dict[tuple[str, str], str]:
return { return {
(route.method, route.resource.canonical): route.handler.__name__ (route.method, route.resource.canonical): route.handler.__name__
@@ -202,6 +226,7 @@ class WebAppRouteContractTests(unittest.TestCase):
"settings": SimpleNamespace( "settings": SimpleNamespace(
WEBAPP_ENABLED=True, WEBAPP_ENABLED=True,
WEBAPP_TITLE="/minishop", WEBAPP_TITLE="/minishop",
DEFAULT_LANGUAGE="en",
) )
} }
) )
@@ -212,11 +237,53 @@ class WebAppRouteContractTests(unittest.TestCase):
self.assertEqual(response.status, 200) self.assertEqual(response.status, 200)
self.assertEqual(response.headers["Cache-Control"], "no-store") self.assertEqual(response.headers["Cache-Control"], "no-store")
self.assertIn('nonce="nonce-value"', response.text) self.assertIn('nonce="nonce-value"', response.text)
self.assertNotIn("__MESSAGES_JSON__", response.text)
self.assertIn("window.location.hash", response.text) self.assertIn("window.location.hash", response.text)
self.assertIn("URLSearchParams", response.text) self.assertIn("URLSearchParams", response.text)
self.assertIn("The app link is unavailable.", response.text) self.assertIn("Settings added", response.text)
self.assertIn("window.close()", response.text)
self.assertIn(r"/^(?:javascript|data|vbscript|https?):/i", response.text) self.assertIn(r"/^(?:javascript|data|vbscript|https?):/i", response.text)
def test_app_deeplink_gateway_uses_i18n_template(self):
request = _Request(
app={
"settings": SimpleNamespace(
WEBAPP_ENABLED=True,
WEBAPP_TITLE="/minishop",
DEFAULT_LANGUAGE="en",
),
"i18n": _I18n(),
}
)
request["csp_nonce"] = "nonce-value"
response = asyncio.run(subscription_webapp.app_deeplink_route(request))
self.assertEqual(response.status, 200)
self.assertIn("Localized launch", response.text)
self.assertIn("Localized done hint", response.text)
self.assertIn("<title>/minishop - Localized launch</title>", response.text)
self.assertTrue(
(REPO_ROOT / "backend/bot/app/web/templates/open_app_gateway.html").is_file()
)
def test_app_launch_i18n_keys_are_available_to_webapp_bootstrap(self):
required_keys = {
"wa_app_launch_title",
"wa_app_launch_opening_hint",
"wa_app_launch_hint",
"wa_app_launch_button",
"wa_app_launch_retry_button",
"wa_app_launch_done_title",
"wa_app_launch_done_hint",
"wa_app_launch_close_button",
"wa_app_launch_unavailable_title",
"wa_app_launch_unavailable_hint",
}
for locale in ("en", "ru"):
messages = json.loads((REPO_ROOT / f"locales/{locale}.json").read_text("utf-8"))
self.assertLessEqual(required_keys, set(messages))
class AdminApiAuthContractTests(unittest.IsolatedAsyncioTestCase): class AdminApiAuthContractTests(unittest.IsolatedAsyncioTestCase):
def _settings(self): def _settings(self):
+6 -3
View File
@@ -35,14 +35,17 @@ def test_logout_handler_is_noop_inside_telegram_mini_app():
assert guard_pos < mark_logout_pos assert guard_pos < mark_logout_pos
def test_open_app_route_skips_bootstrap_and_auth_flow(): def test_open_app_route_uses_fallback_screen_without_auth_flow():
main_source = _read("frontend/src/main.js") main_source = _read("frontend/src/main.js")
app_source = _read("frontend/src/App.svelte") app_source = _read("frontend/src/App.svelte")
screen_source = _read("frontend/src/webapp/screens/AppLaunchScreen.svelte")
assert "skipBootstrap = isExternalAppLaunchPath(window.location.pathname)" in main_source assert "loadBootstrap().finally" in main_source
assert "AppLaunchScreen" in app_source
assert 'mode = isAppLaunchRoute ? "appLaunch"' in app_source assert 'mode = isAppLaunchRoute ? "appLaunch"' in app_source
assert "window.close()" in screen_source
launch_guard_pos = app_source.index("if (isAppLaunchRoute) {") launch_guard_pos = app_source.index("if (isAppLaunchRoute) return;")
boot_pos = app_source.index("boot();", launch_guard_pos) boot_pos = app_source.index("boot();", launch_guard_pos)
assert launch_guard_pos < boot_pos assert launch_guard_pos < boot_pos