feat: tune deeplink fallback page
This commit is contained in:
@@ -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>
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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}>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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 вместо внешней страницы подключения.",
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user