diff --git a/backend/bot/app/web/templates/open_app_gateway.html b/backend/bot/app/web/templates/open_app_gateway.html new file mode 100644 index 0000000..cb1bc46 --- /dev/null +++ b/backend/bot/app/web/templates/open_app_gateway.html @@ -0,0 +1,218 @@ + + + + + + __PAGE_TITLE__ + + + +
+

+

+
+ + +
+
+ + + diff --git a/backend/bot/app/web/webapp/_runtime.py b/backend/bot/app/web/webapp/_runtime.py index 93c123c..463ddb3 100644 --- a/backend/bot/app/web/webapp/_runtime.py +++ b/backend/bot/app/web/webapp/_runtime.py @@ -59,6 +59,7 @@ logger = logging.getLogger(__name__) TEMPLATE_PATH = Path(__file__).resolve().parents[1] / "templates" / "subscription_webapp.html" ASSET_DIR = TEMPLATE_PATH.parent +APP_DEEPLINK_TEMPLATE_PATH = ASSET_DIR / "open_app_gateway.html" APP_ROOT = Path(__file__).resolve().parents[5] WEBAPP_LOGO_PROXY_PATH = "/webapp-logo" WEBAPP_LOGO_CACHE_DIR = APP_ROOT / "data" / "webapp-logo" diff --git a/backend/bot/app/web/webapp/assets.py b/backend/bot/app/web/webapp/assets.py index 0e2ccea..d5ff26f 100644 --- a/backend/bot/app/web/webapp/assets.py +++ b/backend/bot/app/web/webapp/assets.py @@ -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_KEYS = {"menu_support_button"} 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: @@ -1199,124 +1223,45 @@ async def app_deeplink_route(request: web.Request) -> web.Response: raise web.HTTPNotFound(text="webapp_disabled") nonce = html.escape(str(request.get("csp_nonce", "")), quote=True) - title = html.escape(str(getattr(settings, "WEBAPP_TITLE", "") or "Subscription"), quote=True) - html_text = f""" - - - - - {title} - Opening app - - - -
-

Opening app

-

If nothing happened, tap the button below.

- Open app -
- - -""" + query = getattr(request, "query", {}) or {} + lang = _normalize_language(query.get("lang") or getattr(settings, "DEFAULT_LANGUAGE", "ru")) + messages = _app_deeplink_i18n_payload(request, lang) + page_title = html.escape( + f"{getattr(settings, 'WEBAPP_TITLE', '') or 'Subscription'} - {messages['title']}", + quote=False, + ) + messages_json = json.dumps( + messages, + ensure_ascii=False, + separators=(",", ":"), + ).replace(" 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( request: web.Request, filename: str, diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index af5f68b..8eeb809 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -14,6 +14,7 @@ import AuthScreen from "./webapp/auth/AuthScreen.svelte"; import PaymentDialogs from "./webapp/PaymentDialogs.svelte"; import TariffDialogs from "./webapp/TariffDialogs.svelte"; + import AppLaunchScreen from "./webapp/screens/AppLaunchScreen.svelte"; import DevicesScreen from "./webapp/screens/DevicesScreen.svelte"; import HomeScreen from "./webapp/screens/HomeScreen.svelte"; import InstallGuideScreen from "./webapp/screens/InstallGuideScreen.svelte"; @@ -508,18 +509,17 @@ return appLaunchTarget; } - function openAppLaunchTarget() { - const target = refreshAppLaunchTarget(); - if (!target) return; + function openAppLaunchTarget(nextTarget = "") { + const target = String(nextTarget || refreshAppLaunchTarget() || "").trim(); + if (!target) return false; + appLaunchTarget = target; openUrlWithHiddenAnchor(target); + return true; } onMount(() => { if (isPreviewBoard) return; - if (isAppLaunchRoute) { - window.setTimeout(openAppLaunchTarget, 80); - return; - } + if (isAppLaunchRoute) return; const onAnyPointerDown = () => { if (mode === "login") loginEmailTooltipOpen = false; }; @@ -1094,7 +1094,7 @@ const isTelegramMiniApp = hasTelegramLaunchParams(); const currentTg = tg || telegramSdk.refresh(); - const gatewayUrl = isTelegramMiniApp ? buildExternalAppLaunchUrl(raw) : ""; + const gatewayUrl = isTelegramMiniApp ? buildExternalAppLaunchUrl(raw, null, currentLang) : ""; if (gatewayUrl) { if (currentTg?.openLink) { try { @@ -1427,39 +1427,13 @@
{t("wa_loading")}
{:else if mode === "appLaunch"} -
-
- - {#if appLaunchTarget} -

{t("wa_app_launch_title", {}, "Opening app")}

-

- {t( - "wa_app_launch_hint", - {}, - "If the app did not open automatically, tap the button below." - )} -

- { - event.preventDefault(); - openAppLaunchTarget(); - }} - > - {t("wa_app_launch_button", {}, "Open app")} - - {:else} -

{t("wa_app_launch_unavailable_title", {}, "App link unavailable")}

-

- {t("wa_app_launch_unavailable_hint", {}, "Return to Telegram and try again.")} -

- {/if} -
-
+ {:else if mode === "publicInstall"}
diff --git a/frontend/src/lib/webapp/appLinks.js b/frontend/src/lib/webapp/appLinks.js index f61cfcc..a26d1e2 100644 --- a/frontend/src/lib/webapp/appLinks.js +++ b/frontend/src/lib/webapp/appLinks.js @@ -33,7 +33,7 @@ export function readExternalAppLaunchTarget(locationRef = null) { return target; } -export function buildExternalAppLaunchUrl(value, locationRef = null) { +export function buildExternalAppLaunchUrl(value, locationRef = null, language = "") { const target = String(value || "").trim(); if (isUnsafeAppUrl(target) || isHttpUrl(target)) return ""; @@ -41,6 +41,12 @@ export function buildExternalAppLaunchUrl(value, locationRef = null) { if (!ref?.href) return ""; 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(); return url.href; } diff --git a/frontend/src/main.js b/frontend/src/main.js index 7f1927a..7472361 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -2,7 +2,6 @@ import { mount } from "svelte"; import App from "./App.svelte"; import "./styles.css"; -import { isExternalAppLaunchPath } from "./lib/webapp/appLinks.js"; async function loadBootstrap() { if (document.getElementById("webapp-config")) return; @@ -29,10 +28,9 @@ async function loadBootstrap() { } const target = document.getElementById("app"); -const skipBootstrap = isExternalAppLaunchPath(window.location.pathname); if (target) { - (skipBootstrap ? Promise.resolve() : loadBootstrap()).finally(() => { + loadBootstrap().finally(() => { target.replaceChildren(); mount(App, { target }); }); diff --git a/frontend/src/styles/webapp.css b/frontend/src/styles/webapp.css index aedeb6e..f917cdc 100644 --- a/frontend/src/styles/webapp.css +++ b/frontend/src/styles/webapp.css @@ -53,57 +53,6 @@ a { 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 { display: grid; min-height: 100dvh; diff --git a/frontend/src/webapp/screens/AppLaunchScreen.svelte b/frontend/src/webapp/screens/AppLaunchScreen.svelte new file mode 100644 index 0000000..d996fb7 --- /dev/null +++ b/frontend/src/webapp/screens/AppLaunchScreen.svelte @@ -0,0 +1,241 @@ + + +
+
+ +

{title}

+

{hint}

+
+ { + event.preventDefault(); + if (!isUnavailable) openTarget(); + }} + > + {openLabel} + + {#if isDone} + + {/if} +
+
+
+ + diff --git a/locales/en.json b/locales/en.json index 32afdc5..357b1b2 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1618,6 +1618,16 @@ "wa_install_link_copied": "Link copied", "wa_install_share": "Share", "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_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.", diff --git a/locales/ru.json b/locales/ru.json index ecf9e47..78b799c 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1618,6 +1618,16 @@ "wa_install_link_copied": "Ссылка скопирована", "wa_install_share": "Поделиться", "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_field_subscription_guides_enabled_label": "Встроенные инструкции подключения", "admin_settings_field_subscription_guides_enabled_description": "Открывать инструкции прямо внутри Web App вместо внешней страницы подключения.", diff --git a/tests/test_webapp_route_contract.py b/tests/test_webapp_route_contract.py index a7060da..ef298a5 100644 --- a/tests/test_webapp_route_contract.py +++ b/tests/test_webapp_route_contract.py @@ -1,5 +1,7 @@ import asyncio +import json import unittest +from pathlib import Path from types import SimpleNamespace 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.webapp_auth import create_webapp_session_token +REPO_ROOT = Path(__file__).resolve().parents[1] + class _Request(dict): def __init__(self, *, path="/", app=None, headers=None, cookies=None): @@ -31,6 +35,26 @@ class _AsyncSessionFactory: 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]: return { (route.method, route.resource.canonical): route.handler.__name__ @@ -202,6 +226,7 @@ class WebAppRouteContractTests(unittest.TestCase): "settings": SimpleNamespace( WEBAPP_ENABLED=True, WEBAPP_TITLE="/minishop", + DEFAULT_LANGUAGE="en", ) } ) @@ -212,11 +237,53 @@ class WebAppRouteContractTests(unittest.TestCase): self.assertEqual(response.status, 200) self.assertEqual(response.headers["Cache-Control"], "no-store") self.assertIn('nonce="nonce-value"', response.text) + self.assertNotIn("__MESSAGES_JSON__", response.text) self.assertIn("window.location.hash", 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) + 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("/minishop - Localized launch", 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): def _settings(self): diff --git a/tests/test_webapp_telegram_logout.py b/tests/test_webapp_telegram_logout.py index 3cafcfe..aa19b63 100644 --- a/tests/test_webapp_telegram_logout.py +++ b/tests/test_webapp_telegram_logout.py @@ -35,14 +35,17 @@ def test_logout_handler_is_noop_inside_telegram_mini_app(): 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") 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 "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) assert launch_guard_pos < boot_pos