diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index c85a229..cf8a82d 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -12,7 +12,6 @@ import ( "fmt" "log" "os/signal" - "strings" "syscall" "time" @@ -216,11 +215,9 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { // Operator alert emails on new feedback / word complaints, coalesced into one digest // per interval. Inert unless a distinct admin sender and recipient are configured. if cfg.SMTP.AdminFrom != "" && cfg.SMTP.AdminTo != "" { - consoleURL := "" - if cfg.PublicBaseURL != "" { - consoleURL = strings.TrimRight(cfg.PublicBaseURL, "/") + "/_gm" - } - alerts := adminalert.New(mailer, feedbackSvc, games, cfg.SMTP.AdminFrom, cfg.SMTP.AdminTo, consoleURL, logger) + // The alert digest carries no admin-console link on purpose — an admin URL must not + // travel in an email (a mail provider could cache or index it); see adminalert.New. + alerts := adminalert.New(mailer, feedbackSvc, games, cfg.SMTP.AdminFrom, cfg.SMTP.AdminTo, logger) go alerts.Run(ctx, adminAlertInterval) logger.Info("admin alert worker started", zap.Duration("interval", adminAlertInterval)) } diff --git a/backend/internal/account/email.go b/backend/internal/account/email.go index 102bd73..ac6e578 100644 --- a/backend/internal/account/email.go +++ b/backend/internal/account/email.go @@ -105,9 +105,10 @@ func (s *EmailService) allowSend(email string) bool { // issueCode generates a fresh confirm-code and one-tap deeplink token for (accountID, // email), replaces any prior pending confirmation, and mails the branded code in -// locale; purpose selects the email wording and what verifying does. Only the SHA-256 -// hashes of the code and token are stored. -func (s *EmailService) issueCode(ctx context.Context, accountID uuid.UUID, email, purpose, locale string) error { +// locale; purpose selects the email wording and what verifying does. omitLink drops the +// one-tap deeplink from the email (account deletion, or a login requested from an installed +// PWA). Only the SHA-256 hashes of the code and token are stored. +func (s *EmailService) issueCode(ctx context.Context, accountID uuid.UUID, email, purpose, locale string, omitLink bool) error { code, codeHash, err := generateCode() if err != nil { return err @@ -119,11 +120,12 @@ func (s *EmailService) issueCode(ctx context.Context, accountID uuid.UUID, email if err := s.store.replacePendingConfirmation(ctx, accountID, email, codeHash, tokenHash, purpose, s.now().Add(emailCodeTTL)); err != nil { return err } - // Account deletion is never a one-tap link (a prefetch or a stray click must not delete - // an account): the delete code is entered in the app only, so the email omits the - // deeplink and ConfirmByToken refuses a delete token. + // Omit the one-tap deeplink when asked: for a login from an installed PWA (the link would + // open in a separate browser whose minted session cannot reach the PWA), or for account + // deletion (a prefetch or stray click must not delete an account — the delete code is entered + // in the app only, and ConfirmByToken refuses a delete token). deeplink := s.confirmURL(token, locale) - if purpose == purposeDelete { + if purpose == purposeDelete || omitLink { deeplink = "" } msg, err := renderConfirmationEmail(purpose, code, deeplink, s.baseURL, locale) @@ -175,7 +177,7 @@ func (s *EmailService) RequestCode(ctx context.Context, accountID uuid.UUID, ema } return ErrEmailTaken } - return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID)) + return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID), false) } // ConfirmCode verifies code for accountID and email. On success it attaches a @@ -221,8 +223,11 @@ func (s *EmailService) ConfirmCode(ctx context.Context, accountID uuid.UUID, ema // the ordinary returning-user login. The code is mailed to the address, so only its // real owner can complete the login. On first contact browserTZ (the client's // detected "±HH:MM" UTC offset) seeds the new account's time zone and language its UI -// language. It returns the target account id for the subsequent LoginWithCode. -func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ, language string) (uuid.UUID, error) { +// language. When pwa is set (the request came from an installed PWA) the login email omits the +// one-tap confirm link — it would open in a separate browser, out of the PWA's reach — so the +// code is entered in the same window. It returns the target account id for the subsequent +// LoginWithCode. +func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ, language string, pwa bool) (uuid.UUID, error) { addr, err := normalizeEmail(email) if err != nil { return uuid.UUID{}, err @@ -234,7 +239,7 @@ func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ, l if err != nil { return uuid.UUID{}, err } - if err := s.issueCode(ctx, acc.ID, addr, purposeLogin, language); err != nil { + if err := s.issueCode(ctx, acc.ID, addr, purposeLogin, language, pwa); err != nil { return uuid.UUID{}, err } return acc.ID, nil diff --git a/backend/internal/account/link.go b/backend/internal/account/link.go index c897e15..229c4cb 100644 --- a/backend/internal/account/link.go +++ b/backend/internal/account/link.go @@ -92,7 +92,7 @@ func (s *EmailService) RequestLinkCode(ctx context.Context, accountID uuid.UUID, if !s.allowSend(addr) { return ErrTooManyRequests } - return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID)) + return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID), false) } // ConfirmLink verifies code for (accountID, email) and reports the address's @@ -140,7 +140,7 @@ func (s *EmailService) RequestChangeCode(ctx context.Context, accountID uuid.UUI if !s.allowSend(addr) { return ErrTooManyRequests } - return s.issueCode(ctx, accountID, addr, purposeChange, s.accountLocale(ctx, accountID)) + return s.issueCode(ctx, accountID, addr, purposeChange, s.accountLocale(ctx, accountID), false) } // ConfirmChange verifies code for (accountID, newEmail) and atomically replaces the @@ -199,7 +199,7 @@ func (s *EmailService) RequestDeleteCode(ctx context.Context, accountID uuid.UUI if !s.allowSend(addr) { return ErrTooManyRequests } - return s.issueCode(ctx, accountID, addr, purposeDelete, s.accountLocale(ctx, accountID)) + return s.issueCode(ctx, accountID, addr, purposeDelete, s.accountLocale(ctx, accountID), false) } // VerifyDeleteCode verifies the account-deletion code against the account's own email and diff --git a/backend/internal/adminalert/notify.go b/backend/internal/adminalert/notify.go index 5f2a242..88e9b85 100644 --- a/backend/internal/adminalert/notify.go +++ b/backend/internal/adminalert/notify.go @@ -33,21 +33,21 @@ type Notifier struct { complaints ComplaintCounter from string to string - consoleURL string clock func() time.Time log *zap.Logger last time.Time } -// New constructs a Notifier. from and to are the alert sender and recipient(s); consoleURL, -// when non-empty, is the admin-console link included in the email. log may be nil. The -// watermark starts at "now", so only items arriving after start-up are reported. -func New(mailer account.Mailer, fb FeedbackCounter, cp ComplaintCounter, from, to, consoleURL string, log *zap.Logger) *Notifier { +// New constructs a Notifier. from and to are the alert sender and recipient(s); log may be +// nil. The watermark starts at "now", so only items arriving after start-up are reported. The +// digest deliberately carries no admin-console link — an admin URL must never travel in an +// email, where a mail provider could cache or index it. +func New(mailer account.Mailer, fb FeedbackCounter, cp ComplaintCounter, from, to string, log *zap.Logger) *Notifier { if log == nil { log = zap.NewNop() } return &Notifier{ - mailer: mailer, feedback: fb, complaints: cp, from: from, to: to, consoleURL: consoleURL, + mailer: mailer, feedback: fb, complaints: cp, from: from, to: to, clock: func() time.Time { return time.Now().UTC() }, log: log, last: time.Now().UTC(), } } @@ -103,10 +103,9 @@ func (n *Notifier) digest(fb, cp int) account.Message { parts = append(parts, fmt.Sprintf("%d new word complaint(s)", cp)) } summary := strings.Join(parts, ", ") + // No admin-console link in the body: an admin URL must never travel in an email (a mail + // provider could cache or index it). The operator opens the console directly. text := summary + "." - if n.consoleURL != "" { - text += "\n\nOpen the admin console: " + n.consoleURL - } return account.Message{ From: n.from, To: n.to, diff --git a/backend/internal/adminalert/notify_test.go b/backend/internal/adminalert/notify_test.go index d001bb0..83d3a2a 100644 --- a/backend/internal/adminalert/notify_test.go +++ b/backend/internal/adminalert/notify_test.go @@ -28,7 +28,7 @@ func (m *recordingMailer) Send(_ context.Context, msg account.Message) error { func TestNotifierSkipsWhenNothingNew(t *testing.T) { mailer := &recordingMailer{} - n := New(mailer, fbCounter{0}, cpCounter{0}, "alerts@erudit-game.ru", "op@x.ru", "", nil) + n := New(mailer, fbCounter{0}, cpCounter{0}, "alerts@erudit-game.ru", "op@x.ru", nil) n.tick(context.Background()) if len(mailer.sent) != 0 { t.Fatalf("sent %d emails, want 0 when nothing is new", len(mailer.sent)) @@ -37,7 +37,7 @@ func TestNotifierSkipsWhenNothingNew(t *testing.T) { func TestNotifierDigestsNewItems(t *testing.T) { mailer := &recordingMailer{} - n := New(mailer, fbCounter{2}, cpCounter{1}, "alerts@erudit-game.ru", "op@x.ru, two@x.ru", "https://erudit-game.ru/_gm", nil) + n := New(mailer, fbCounter{2}, cpCounter{1}, "alerts@erudit-game.ru", "op@x.ru, two@x.ru", nil) n.tick(context.Background()) if len(mailer.sent) != 1 { t.Fatalf("sent %d emails, want 1 digest", len(mailer.sent)) @@ -49,7 +49,9 @@ func TestNotifierDigestsNewItems(t *testing.T) { if !strings.Contains(msg.Subject, "2 new feedback") || !strings.Contains(msg.Subject, "1 new word complaint") { t.Errorf("digest subject = %q, want the feedback + complaint counts", msg.Subject) } - if !strings.Contains(msg.Text, "/_gm") { - t.Errorf("digest body = %q, want the console link", msg.Text) + // The digest must never carry an admin-console link — an admin URL in an email is a leak + // (mail providers cache/index it). + if strings.Contains(msg.Text, "/_gm") || strings.Contains(strings.ToLower(msg.Text), "admin console") { + t.Errorf("digest body = %q, must not carry an admin-console link", msg.Text) } } diff --git a/backend/internal/inttest/email_test.go b/backend/internal/inttest/email_test.go index 667edd1..9d4235a 100644 --- a/backend/internal/inttest/email_test.go +++ b/backend/internal/inttest/email_test.go @@ -206,7 +206,7 @@ func TestEmailLoginFlow(t *testing.T) { svc := account.NewEmailService(account.NewStore(testDB), mailer, "https://erudit-game.ru") email := "login-" + uuid.NewString() + "@example.com" - accountID, err := svc.RequestLoginCode(ctx, email, "+02:00", "en") + accountID, err := svc.RequestLoginCode(ctx, email, "+02:00", "en", false) if err != nil { t.Fatalf("request login code: %v", err) } @@ -233,7 +233,7 @@ func TestEmailLoginFlow(t *testing.T) { } // A second login for the same email is the returning user: same account. - if _, err := svc.RequestLoginCode(ctx, email, "", "ru"); err != nil { + if _, err := svc.RequestLoginCode(ctx, email, "", "ru", false); err != nil { t.Fatalf("second request: %v", err) } acc2, err := svc.LoginWithCode(ctx, email, sixDigit.FindString(mailer.lastBody)) @@ -255,7 +255,7 @@ func TestEmailLoginProvisionsGuestUntilConfirmed(t *testing.T) { svc := account.NewEmailService(store, mailer, "https://erudit-game.ru") email := "squat-" + uuid.NewString() + "@example.com" - id, err := svc.RequestLoginCode(ctx, email, "", "en") + id, err := svc.RequestLoginCode(ctx, email, "", "en", false) if err != nil { t.Fatalf("request login code: %v", err) } @@ -279,6 +279,34 @@ func TestEmailLoginProvisionsGuestUntilConfirmed(t *testing.T) { } } +// TestEmailLoginPWAOmitsLink: a login requested from an installed PWA (pwa=true) mails only the +// code — no one-tap confirm link, which would otherwise open in a separate browser out of the +// PWA's reach. A non-PWA request keeps the link. +func TestEmailLoginPWAOmitsLink(t *testing.T) { + ctx := context.Background() + mailer := &capturingMailer{} + svc := account.NewEmailService(account.NewStore(testDB), mailer, "https://erudit-game.ru") + + pwaEmail := "pwa-login-" + uuid.NewString() + "@example.com" + if _, err := svc.RequestLoginCode(ctx, pwaEmail, "", "en", true); err != nil { + t.Fatalf("pwa request login: %v", err) + } + if sixDigit.FindString(mailer.lastBody) == "" { + t.Errorf("pwa login mail must still carry the code, body %q", mailer.lastBody) + } + if confirmToken.MatchString(mailer.lastBody) { + t.Errorf("pwa login mail must omit the one-tap confirm link, body %q", mailer.lastBody) + } + + webEmail := "web-login-" + uuid.NewString() + "@example.com" + if _, err := svc.RequestLoginCode(ctx, webEmail, "", "en", false); err != nil { + t.Fatalf("web request login: %v", err) + } + if !confirmToken.MatchString(mailer.lastBody) { + t.Errorf("non-pwa login mail must keep the one-tap confirm link, body %q", mailer.lastBody) + } +} + // confirmToken extracts the one-tap deeplink token from the /app/#/confirm/ link // the branded email carries. var confirmToken = regexp.MustCompile(`/confirm/([A-Za-z0-9_-]+)`) @@ -301,7 +329,7 @@ func TestConfirmByTokenLogin(t *testing.T) { svc := account.NewEmailService(store, mailer, "https://erudit-game.ru") email := "tok-login-" + uuid.NewString() + "@example.com" - id, err := svc.RequestLoginCode(ctx, email, "", "en") + id, err := svc.RequestLoginCode(ctx, email, "", "en", false) if err != nil { t.Fatalf("request login: %v", err) } @@ -382,7 +410,7 @@ func TestEmailAccountSeedsDisplayName(t *testing.T) { svc := account.NewEmailService(store, &capturingMailer{}, "https://erudit-game.ru") local := "kaya-" + uuid.NewString()[:8] - id, err := svc.RequestLoginCode(ctx, local+"@example.com", "", "en") + id, err := svc.RequestLoginCode(ctx, local+"@example.com", "", "en", false) if err != nil { t.Fatalf("request login: %v", err) } diff --git a/backend/internal/server/handlers_auth.go b/backend/internal/server/handlers_auth.go index edfc8a5..364f8c0 100644 --- a/backend/internal/server/handlers_auth.go +++ b/backend/internal/server/handlers_auth.go @@ -174,6 +174,10 @@ type emailRequest struct { Email string `json:"email"` BrowserTZ string `json:"browser_tz"` Language string `json:"language"` + // Pwa marks a request from an installed PWA (standalone display mode): the login email then + // omits the one-tap confirm link so the code is typed in the same window (the link would open + // in a separate browser whose session cannot reach the PWA). See EmailService.RequestLoginCode. + Pwa bool `json:"pwa"` } // handleEmailRequest issues a login confirm-code to the email. It always reports @@ -185,7 +189,7 @@ func (s *Server) handleEmailRequest(c *gin.Context) { abortBadRequest(c, "email is required") return } - if _, err := s.emails.RequestLoginCode(c.Request.Context(), req.Email, req.BrowserTZ, req.Language); err != nil { + if _, err := s.emails.RequestLoginCode(c.Request.Context(), req.Email, req.BrowserTZ, req.Language, req.Pwa); err != nil { s.abortErr(c, err) return } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 99e47b1..f1e113f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -244,7 +244,9 @@ arrive from a platform rather than completing a mandatory registration). 256-bit token stored only as its SHA-256, 12-hour TTL): a login mints a session in the browser that opens it (magic-link), a link confirms the identity and emits a `notify` profile-refresh to the in-app session, a change switches the account's email in place, - and a would-be merge is deferred to the interactive flow. The confirm runs on load; the token rides the URL fragment (never + and a would-be merge is deferred to the interactive flow. A login requested from an installed **PWA** omits the deeplink + entirely — its email carries the code only, typed in the same window, since the link would open + in a separate browser whose minted session cannot reach the PWA. The confirm runs on load; the token rides the URL fragment (never sent to the server), so a plain link prefetch cannot reach it, and the manual six-digit code is the fallback if an aggressive scanner runs the page. An **email-login** account is created flagged `is_guest` and stays reapable until the @@ -1041,7 +1043,9 @@ link — misses the event; while an add-email confirmation is pending the client (`internal/adminalert`, started from `main`) emails the operator (`ADMIN_EMAIL`, from a distinct `SMTP_RELAY_ADMIN_FROM`) when new player feedback or word complaints arrive, **coalescing** a burst into one digest per interval; both recipients may be several - comma-separated addresses. Both paths are inert unless configured. + comma-separated addresses. The digest carries **no admin-console link** — an admin URL must + never travel in an email, where a mail provider could cache or index it; the operator opens the + console directly. Both paths are inert unless configured. - Per-request server-side timing via gin middleware from day one (the access log carries method, route, status, latency and the active trace id). A client-measured RTT piggybacked on the next request is a later enhancement. @@ -1233,7 +1237,14 @@ origin, so the test contour canonicalises to production instead of being indexed separate site; the SPA shell is `noindex`, and the landing always boots in Russian (no browser-language detection — crawlers render with arbitrary languages). The favicon set, `og-image.png` and `robots.txt` ship unhashed from `ui/public/` (generated by -`assets/icons/`, same tile design as the VK loader). Hash-named `/assets/*` are served +`assets/icons/`, same tile design as the VK loader). On the web (`/app/`) the SPA is an installable **PWA**: +`manifest.webmanifest` and an install-only **service worker** (`sw.js`) ship unhashed from +`ui/public/`, and the client registers the worker **web-only** — never inside a Mini App or the +mock build. The worker intercepts only top-level navigations (network-first with a cached-shell +fallback), leaving `/assets/*` and the Connect stream untouched; it exists to satisfy Chromium's +installability requirement (a registered SW, needed for install on Android) and is the single +growth point for a future opt-in offline mode. The gateway registers the `.webmanifest` MIME type +in-process (the distroless image has no `/etc/mime.types`). Hash-named `/assets/*` are served `immutable` (a relaunch is a cache hit, not a re-download); the HTML shells are `no-cache` so a new deploy is picked up — both containers apply the same caching. An in-compose **caddy** is the contour's edge: it owns a single `/_gm` Basic-Auth and diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 806e3a8..39ffc2b 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -21,7 +21,8 @@ Settings also pick the board's bonus-label style (beginner / classic / none). A costs nothing when the rack has no legal move. The word-check accepts only the variant's alphabet, remembers answers within the session and rate-limits repeats. A public **landing page** at the site root introduces the game, switches language and -theme, and links to the Telegram game channel and the VK Mini App; the game itself runs at +theme, and links to the Telegram game channel, the VK Mini App and the **web version** +(`/app/`) — each under a caption (Telegram / VK / Веб-версия); the game itself runs at `/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App). The landing's theme is ephemeral (it follows the system scheme, not the saved preference); its language choice is saved. The landing always opens in **Russian** — the browser language is never auto-detected (crawlers @@ -31,6 +32,11 @@ nondeterministic); a saved 🌐 choice still wins. The page carries a static Rus pinned to the production origin, JSON-LD, the favicon set and `robots.txt`), while the SPA shell is `noindex` — the landing is the only indexable page. +On the plain web the client is an **installable PWA**: a logged-out player sees an install +call-to-action under the login form (and at the bottom of Settings) that installs the app to the +home screen / desktop in one tap where the browser supports it (Chromium), or shows manual +Add-to-Home-Screen steps on iOS Safari; it is hidden once installed and inside the Mini Apps. + ### First-run onboarding The first time a player opens the app it walks them through the interface with a light **coachmark overlay**: a dimmed layer draws one hint bubble at a time, each pointing — with a @@ -70,7 +76,9 @@ per address), so a mistyped address or an impatient tap cannot flood an inbox. T also carries a **one-tap link** that confirms the address — or signs the player straight in — in a single tap; opening it in another browser reflects in the app at once, and a mail scanner that merely fetches the link cannot reach it — the token rides the URL -fragment, which is never sent to the server. +fragment, which is never sent to the server. A sign-in requested from an **installed PWA** omits +this link: the email carries only the code, entered in the same window, since the link would open +in a separate browser the app cannot reach. Telegram runs a **single bot**: every player uses the same bot, and all of its chat and out-of-app notifications are written in the player's own **interface language** (en/ru). A separate optional **promo bot** can run alongside the diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 333ff76..1c6700e 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -22,7 +22,8 @@ top-1 подсказку, безлимитную проверку слова с Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии и ограничивает частоту повторов. Публичная **посадочная страница** в корне сайта представляет игру, переключает язык и -тему и ведёт в Telegram-канал игры и в VK Mini App; сама игра живёт по адресам +тему и ведёт в Telegram-канал игры, в VK Mini App и в **веб-версию** (`/app/`) — каждая под +подписью (Telegram / VK / Веб-версия); сама игра живёт по адресам `/app/` (веб), `/telegram/` (Telegram Mini App) и `/vk/` (VK Mini App). Тема на странице эфемерна (берётся из системной настройки, а не из сохранённой), выбор языка сохраняется. Страница всегда открывается на **русском** — язык браузера не определяется автоматически (роботы @@ -33,6 +34,11 @@ Open Graph, которую используют превью ссылок в Tel продакшен-домена, JSON-LD, набор favicon и `robots.txt`), а оболочка SPA помечена `noindex` — индексируется только посадочная страница. +В обычном вебе клиент — **устанавливаемое PWA**: незалогиненный игрок видит призыв к установке +под формой входа (и внизу «Настроек»), который в один тап ставит приложение на рабочий стол +(домашний экран) там, где браузер это умеет (Chromium), либо показывает ручные шаги +«На экран „Домой“» в Safari на iOS; он скрыт после установки и внутри Mini App. + ### Первый запуск: онбординг При первом открытии приложения игрока один раз проводят по интерфейсу лёгким **coachmark-оверлеем**: затемнённый слой показывает по одной подсказке-«облачку» за раз, каждое @@ -74,7 +80,9 @@ launch-параметрам VK (их проверяет gateway), и при пе не завалили почтовый ящик. В письме также есть **ссылка одного нажатия**, которая подтверждает адрес — или сразу выполняет вход — в один тап; открытие её в другом браузере тут же отражается в приложении, а почтовый сканер, который просто загружает ссылку, до токена не дотянется — -он едет в URL-фрагменте, который не уходит на сервер. +он едет в URL-фрагменте, который не уходит на сервер. Вход, запрошенный из **установленного +PWA**, эту ссылку не содержит: в письме только код, который вводится в том же окне (ссылка +открылась бы в отдельном браузере, недоступном приложению). Telegram держит **единого бота**: все игроки пользуются одним и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный diff --git a/gateway/README.md b/gateway/README.md index 426c2fc..a7adc18 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -8,6 +8,9 @@ backend over REST/JSON, and bridges the backend's gRPC push stream to each client's in-app live channel. It **embeds the static UI build** (`go:embed`, baked in by the gateway image's node stage) and serves a **landing page** at `/` and the game **SPA** at `/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App) — the single-origin model. +The web SPA is an installable **PWA**: it serves `manifest.webmanifest` (with the `.webmanifest` +MIME type registered in-process — the distroless image has no `/etc/mime.types`) and the +install-only `sw.js`, both shipped unhashed from `ui/public/`. Hash-named `/assets/*` are served `immutable`; the HTML shells are `no-cache`. It can also serve the backend's admin console at `/_gm` behind HTTP Basic-Auth for a local non-caddy run; in the deployed contour the front caddy owns `/_gm` (see diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index ef5ce5c..9aa357a 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -289,10 +289,11 @@ func (c *Client) GuestAuth(ctx context.Context, browserTz string) (SessionResp, // EmailRequest asks the backend to mail a login code, provisioning the account on // first contact; browserTz (the client's detected "±HH:MM" UTC offset) seeds the new -// account's time zone, since the email account is created here, not at login. -func (c *Client) EmailRequest(ctx context.Context, email, browserTz, language string) error { +// account's time zone, since the email account is created here, not at login. pwa marks a +// request from an installed PWA, so the backend omits the one-tap confirm link from the email. +func (c *Client) EmailRequest(ctx context.Context, email, browserTz, language string, pwa bool) error { return c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/email/request", "", "", - map[string]string{"email": email, "browser_tz": browserTz, "language": language}, nil) + map[string]any{"email": email, "browser_tz": browserTz, "language": language, "pwa": pwa}, nil) } // EmailLogin verifies a login code and mints a session. diff --git a/gateway/internal/connectsrv/metrics.go b/gateway/internal/connectsrv/metrics.go index c531e83..ff35ece 100644 --- a/gateway/internal/connectsrv/metrics.go +++ b/gateway/internal/connectsrv/metrics.go @@ -46,7 +46,13 @@ func newServerMetrics(meter metric.Meter) *serverMetrics { } h, err := meter.Float64Histogram("edge_request_duration", metric.WithUnit("s"), - metric.WithDescription("Seconds to serve one Connect Execute call, by message type and result.")) + metric.WithDescription("Seconds to serve one Connect Execute call, by message type and result."), + // Explicit second-scale buckets. The durations are recorded in seconds, so the SDK's + // default millisecond-calibrated boundaries (first boundary 5) would bin every sub-5s + // request into one bucket — making histogram_quantile(0.99) interpolate to ~4.95s + // regardless of the real latency, which flapped the >1s edge-latency alert. These + // boundaries straddle the 1s SLO so the p99 reflects real (mostly sub-second) latency. + metric.WithExplicitBucketBoundaries(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10)) if err != nil { h, _ = noop.NewMeterProvider().Meter(meterName).Float64Histogram("edge_request_duration") } diff --git a/gateway/internal/connectsrv/metrics_test.go b/gateway/internal/connectsrv/metrics_test.go index c2cf8c8..d776798 100644 --- a/gateway/internal/connectsrv/metrics_test.go +++ b/gateway/internal/connectsrv/metrics_test.go @@ -29,6 +29,7 @@ func TestEdgeMetric(t *testing.T) { type key struct{ messageType, result string } counts := map[key]uint64{} + var bounds []float64 for _, sm := range rm.ScopeMetrics { for _, md := range sm.Metrics { if md.Name != "edge_request_duration" { @@ -42,6 +43,7 @@ func TestEdgeMetric(t *testing.T) { mt, _ := dp.Attributes.Value(attribute.Key("message_type")) res, _ := dp.Attributes.Value(attribute.Key("result")) counts[key{mt.AsString(), res.AsString()}] += dp.Count + bounds = dp.Bounds } } } @@ -51,6 +53,19 @@ func TestEdgeMetric(t *testing.T) { if got := counts[key{"auth.guest", "domain"}]; got != 1 { t.Errorf("edge auth.guest/domain = %d, want 1", got) } + // The buckets must be second-scaled. The default (millisecond-calibrated) boundaries have no + // boundary between 0 and 5, so every sub-5s request bins into one bucket and p99 interpolates + // to ~4.95s, flapping the >1s alert. Require at least one sub-second boundary. + subSecond := false + for _, b := range bounds { + if b > 0 && b < 1 { + subSecond = true + break + } + } + if !subSecond { + t.Errorf("edge_request_duration bounds = %v, want sub-second boundaries (seconds-scaled)", bounds) + } } // TestRateLimitedMetric records limiter rejections through a manual reader and diff --git a/gateway/internal/transcode/transcode.go b/gateway/internal/transcode/transcode.go index c876964..15decd5 100644 --- a/gateway/internal/transcode/transcode.go +++ b/gateway/internal/transcode/transcode.go @@ -253,7 +253,7 @@ func authGuestHandler(backend *backendclient.Client) Handler { func authEmailRequestHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsEmailRequestRequest(req.Payload, 0) - if err := backend.EmailRequest(ctx, string(in.Email()), string(in.BrowserTz()), string(in.Language())); err != nil { + if err := backend.EmailRequest(ctx, string(in.Email()), string(in.BrowserTz()), string(in.Language()), in.Pwa()); err != nil { return nil, err } return encodeAck(true), nil diff --git a/gateway/internal/webui/webui.go b/gateway/internal/webui/webui.go index 0192096..d999377 100644 --- a/gateway/internal/webui/webui.go +++ b/gateway/internal/webui/webui.go @@ -17,6 +17,7 @@ package webui import ( "embed" "io/fs" + "mime" "net/http" "path" "strings" @@ -35,13 +36,25 @@ func distFS() fs.FS { return sub } -// Handler serves the embedded UI. An existing file is served directly (hash-named assets get -// an immutable cache); every other path falls back to indexName (the SPA shell) so a -// client-side deep link still loads. When stripPrefix is non-empty it is removed from the -// request path before lookup, so the same build serves under a sub-path (e.g. "/app/" or -// "/telegram/"). +// init registers the MIME type for .webmanifest, which Go's built-in table lacks and the +// distroless runtime image has no /etc/mime.types to supply. Without it the PWA Web App Manifest +// served under /app/ would be content-sniffed to text/plain, which some browsers reject. +func init() { + _ = mime.AddExtensionType(".webmanifest", "application/manifest+json") +} + +// Handler serves the compile-time embedded UI build over the public edge — the game SPA under +// /app/, /telegram/ and /vk/. It delegates to handlerFor over the embedded dist/. func Handler(stripPrefix, indexName string) http.Handler { - content := distFS() + return handlerFor(distFS(), stripPrefix, indexName) +} + +// handlerFor serves content as the UI: an existing file is served directly (hash-named assets get +// an immutable cache); every other path falls back to indexName (the SPA shell) so a client-side +// deep link still loads. When stripPrefix is non-empty it is removed from the request path before +// lookup, so the same build serves under a sub-path (e.g. "/app/" or "/telegram/"). Split from +// Handler so it can be exercised over an in-memory fs.FS in tests. +func handlerFor(content fs.FS, stripPrefix, indexName string) http.Handler { files := http.FileServer(http.FS(content)) h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/") diff --git a/gateway/internal/webui/webui_test.go b/gateway/internal/webui/webui_test.go index c40d30f..90c174e 100644 --- a/gateway/internal/webui/webui_test.go +++ b/gateway/internal/webui/webui_test.go @@ -6,6 +6,7 @@ import ( "net/http/httptest" "strings" "testing" + "testing/fstest" ) // get drives the handler with a GET for the given path and returns the response. @@ -55,3 +56,56 @@ func TestAppMountServesShellStripsPrefixAndCachesAssets(t *testing.T) { } } } + +// testFS mirrors what Vite emits into dist/: the SPA shell, the PWA manifest + service worker at +// the root (copied from ui/public), and a hash-named asset. The compile-time embedded dist/ holds +// only a placeholder shell, so the manifest/sw.js serving is exercised over this in-memory FS. +func testFS() fstest.MapFS { + return fstest.MapFS{ + "index.html": {Data: []byte("shell")}, + "manifest.webmanifest": {Data: []byte(`{"name":"Эрудит"}`)}, + "sw.js": {Data: []byte("self.addEventListener('fetch', () => {});")}, + "assets/app-abc123.js": {Data: []byte("export const x = 1;")}, + } +} + +// TestHandlerServesManifestWithManifestType: the PWA manifest is served with the manifest MIME +// type (registered in init, since the distroless image has no /etc/mime.types), not sniffed to +// text/plain, which some browsers reject. +func TestHandlerServesManifestWithManifestType(t *testing.T) { + h := handlerFor(testFS(), "/app/", "index.html") + resp := get(t, h, "/app/manifest.webmanifest") + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/manifest+json") { + t.Errorf("Content-Type = %q, want application/manifest+json", ct) + } +} + +// TestHandlerServesServiceWorkerAsJavaScript: sw.js is served as JavaScript from the root, so its +// registration (scope /app/) succeeds. +func TestHandlerServesServiceWorkerAsJavaScript(t *testing.T) { + h := handlerFor(testFS(), "/app/", "index.html") + resp := get(t, h, "/app/sw.js") + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/javascript") { + t.Errorf("Content-Type = %q, want text/javascript", ct) + } +} + +// TestHandlerFallsBackToShellForUnknownManifestPath guards the trap: a manifest/sw path NOT emitted +// into dist/ falls back to the SPA shell as text/html, on which SW registration would fail. The two +// tests above pin that the real files are served instead of the shell. +func TestHandlerFallsBackToShellForUnknownManifestPath(t *testing.T) { + h := handlerFor(testFS(), "/app/", "index.html") + resp := get(t, h, "/app/not-emitted.webmanifest") + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { + t.Errorf("Content-Type = %q, want text/html (SPA shell fallback)", ct) + } +} diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index 0afd743..636ab8f 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -138,6 +138,11 @@ table EmailRequestRequest { email:string; browser_tz:string; language:string; + // Set when the request originates from an installed PWA (standalone display mode): the + // backend then omits the one-tap confirm link from the login email so the code is typed in + // the same window, avoiding a link that opens in a separate browser (a different storage + // context) where the minted session could not reach the PWA. + pwa:bool; } // EmailLoginRequest logs in to the account owning email (provisioned at the diff --git a/pkg/fbs/scrabblefb/EmailRequestRequest.go b/pkg/fbs/scrabblefb/EmailRequestRequest.go index 9f95230..8c89159 100644 --- a/pkg/fbs/scrabblefb/EmailRequestRequest.go +++ b/pkg/fbs/scrabblefb/EmailRequestRequest.go @@ -65,8 +65,20 @@ func (rcv *EmailRequestRequest) Language() []byte { return nil } +func (rcv *EmailRequestRequest) Pwa() bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) + if o != 0 { + return rcv._tab.GetBool(o + rcv._tab.Pos) + } + return false +} + +func (rcv *EmailRequestRequest) MutatePwa(n bool) bool { + return rcv._tab.MutateBoolSlot(10, n) +} + func EmailRequestRequestStart(builder *flatbuffers.Builder) { - builder.StartObject(3) + builder.StartObject(4) } func EmailRequestRequestAddEmail(builder *flatbuffers.Builder, email flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(email), 0) @@ -77,6 +89,9 @@ func EmailRequestRequestAddBrowserTz(builder *flatbuffers.Builder, browserTz fla func EmailRequestRequestAddLanguage(builder *flatbuffers.Builder, language flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(language), 0) } +func EmailRequestRequestAddPwa(builder *flatbuffers.Builder, pwa bool) { + builder.PrependBoolSlot(3, pwa, false) +} func EmailRequestRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/ui/e2e/install.spec.ts b/ui/e2e/install.spec.ts new file mode 100644 index 0000000..4188bb7 --- /dev/null +++ b/ui/e2e/install.spec.ts @@ -0,0 +1,59 @@ +import { expect, test } from './fixtures'; + +// The install CTA (components/InstallApp.svelte) is platform-adaptive: a one-tap button where the +// browser offers a beforeinstallprompt (Chromium), a manual instructions modal on iOS Safari, and +// nothing elsewhere / once installed / inside a Mini App. The native OS install dialog is +// browser-level and not drivable from Playwright, so these assert the CTA's presence and branch. + +const IPHONE_SAFARI = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'; + +test('web: a captured install prompt shows the one-tap CTA and clicking it prompts', async ({ page }) => { + await page.goto('/'); + // The web login screen is shown (an ordinary browser tab, not a Mini App). + await expect(page.getByRole('button', { name: /guest/i })).toBeVisible(); + + // Hidden until the browser offers installation (Playwright never fires beforeinstallprompt). + await expect(page.getByRole('button', { name: /Install the app/i })).toHaveCount(0); + + // Simulate Chromium offering the install: dispatch a beforeinstallprompt the app can capture. + await page.evaluate(() => { + const e = new Event('beforeinstallprompt') as Event & { + prompt?: () => Promise; + userChoice?: Promise; + }; + (window as unknown as { __promptCalled: boolean }).__promptCalled = false; + e.prompt = () => { + (window as unknown as { __promptCalled: boolean }).__promptCalled = true; + return Promise.resolve(); + }; + e.userChoice = Promise.resolve({ outcome: 'accepted', platform: '' }); + window.dispatchEvent(e); + }); + + const cta = page.getByRole('button', { name: /Install the app/i }); + await expect(cta).toBeVisible(); + await cta.click(); + // Clicking calls the captured prompt (the native dialog itself is not observable here). + await expect + .poll(() => page.evaluate(() => (window as unknown as { __promptCalled: boolean }).__promptCalled)) + .toBe(true); +}); + +test.describe('iOS Safari', () => { + test.use({ userAgent: IPHONE_SAFARI }); + + test('shows manual Add-to-Home-Screen instructions (no programmatic install)', async ({ page }) => { + await page.goto('/'); + await expect(page.getByRole('button', { name: /guest/i })).toBeVisible(); + + // On iOS Safari the CTA shows at once (no beforeinstallprompt is ever fired there). + const cta = page.getByRole('button', { name: /Install the app/i }); + await expect(cta).toBeVisible(); + await cta.click(); + + // It opens the app's own instructions modal (not a native / Telegram popup). + await expect(page.getByRole('heading', { name: 'Add to Home Screen' })).toBeVisible(); + await expect(page.getByText(/Tap the Share button/)).toBeVisible(); + }); +}); diff --git a/ui/e2e/landing.spec.ts b/ui/e2e/landing.spec.ts index e6a5ab6..ebb3fe4 100644 --- a/ui/e2e/landing.spec.ts +++ b/ui/e2e/landing.spec.ts @@ -46,3 +46,15 @@ test('the landing is a normal scrolling document (the SPA document-pin does not expect(state.shell).toBe(false); expect(state.bodyPosition).not.toBe('fixed'); }); + +// The web-version entry (/app/) is always present, alongside the build-var-gated Telegram/VK ones +// (absent here, since e2e sets no VITE_* vars), each with a caption. +test('the landing shows a web-version entry linking /app/, with a caption', async ({ page }) => { + await page.goto('/landing.html'); + await expect(page.getByText(/Играй в «Эрудита»/)).toBeVisible(); // Russian by default + + const web = page.getByRole('link', { name: 'Играть в браузере' }); + await expect(web).toBeVisible(); + expect(await web.getAttribute('href')).toContain('/app/'); + await expect(page.getByText('Веб-версия')).toBeVisible(); +}); diff --git a/ui/index.html b/ui/index.html index 5095a37..bf38301 100644 --- a/ui/index.html +++ b/ui/index.html @@ -256,6 +256,18 @@ + + + + + + + + +
diff --git a/ui/public/icon-192.png b/ui/public/icon-192.png new file mode 100644 index 0000000..b87e170 Binary files /dev/null and b/ui/public/icon-192.png differ diff --git a/ui/public/icon-512.png b/ui/public/icon-512.png new file mode 100644 index 0000000..0e1aee5 Binary files /dev/null and b/ui/public/icon-512.png differ diff --git a/ui/public/icon-maskable-512.png b/ui/public/icon-maskable-512.png new file mode 100644 index 0000000..a94d40c Binary files /dev/null and b/ui/public/icon-maskable-512.png differ diff --git a/ui/public/manifest.webmanifest b/ui/public/manifest.webmanifest new file mode 100644 index 0000000..b1f7ecc --- /dev/null +++ b/ui/public/manifest.webmanifest @@ -0,0 +1,17 @@ +{ + "name": "Эрудит (Скрэббл)", + "short_name": "Эрудит", + "description": "Игра в слова «Эрудит» (Скрэббл): играйте с друзьями, случайным соперником или ИИ-роботом.", + "lang": "ru", + "dir": "ltr", + "start_url": ".", + "scope": ".", + "display": "standalone", + "background_color": "#f3f4f6", + "theme_color": "#f3f4f6", + "icons": [ + { "src": "icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, + { "src": "icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, + { "src": "icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ] +} diff --git a/ui/public/sw.js b/ui/public/sw.js new file mode 100644 index 0000000..af2d52f --- /dev/null +++ b/ui/public/sw.js @@ -0,0 +1,53 @@ +// Install-only service worker. +// +// Its sole job today is to satisfy Chromium's PWA installability requirement (a registered +// service worker with a fetch handler) so the web app can be installed to the home screen / +// desktop — notably on Android, where the manifest alone is not enough. It deliberately does +// NOT cache assets or the Connect-RPC stream: only top-level navigations are handled, +// network-first with a cached-shell fallback, so the live app, its immutable hashed assets and +// the live event stream are never served stale. +// +// This is the single designated growth point for the planned offline mode (a Settings toggle, +// default online — see docs/ARCHITECTURE.md). Extend the fetch router here; keep the same +// /app/ scope; gate any real caching behind the toggle and coordinate updates with the boot +// version guard. +const SHELL = 'scrabble-shell-v1'; // bump only when the SW strategy itself changes. + +self.addEventListener('install', () => { + // No cached content to migrate carefully, so activate the new worker immediately. + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + (async () => { + const keys = await caches.keys(); + await Promise.all(keys.filter((k) => k !== SHELL).map((k) => caches.delete(k))); + await self.clients.claim(); + })(), + ); +}); + +self.addEventListener('fetch', (event) => { + const req = event.request; + // Only top-level navigations are handled. Hashed assets and the Connect-RPC requests are + // left untouched (no respondWith -> the browser performs its default fetch), so the worker + // can never break the live stream or serve a stale immutable asset. + if (req.mode !== 'navigate') return; + event.respondWith( + (async () => { + try { + const res = await fetch(req); + const cache = await caches.open(SHELL); + // One shell entry regardless of the deep-link path: the hash router resolves the route + // client-side, so any offline navigation can be served the same cached shell. + cache.put('shell', res.clone()); + return res; + } catch { + const cache = await caches.open(SHELL); + const cached = await cache.match('shell'); + return cached ?? Response.error(); + } + })(), + ); +}); diff --git a/ui/scripts/bundle-size.mjs b/ui/scripts/bundle-size.mjs index 61c0519..0403740 100644 --- a/ui/scripts/bundle-size.mjs +++ b/ui/scripts/bundle-size.mjs @@ -19,10 +19,11 @@ import { join } from 'node:path'; const DIST = 'dist'; -// Per-chunk gzip budgets in KB. The app entry was raised to 110 when the local -// move-preview wiring landed (the heavy dict subsystem stays in lazy chunks; this -// covers the small in-entry game/debug wiring it needs). -const BUDGET = { app: 110, shared: 30, landing: 5 }; +// Per-chunk gzip budgets in KB. The app entry was raised to 110 for the local move-preview +// wiring, then to 112 for the PWA install feature (the install CTA + the pwa detection / +// service-worker registration live in the app entry; the heavy dict subsystem stays in lazy +// chunks). Its scoped CSS lands in the CSS chunk, not this JS budget. +const BUDGET = { app: 112, shared: 30, landing: 5 }; // gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a // local file (e.g. the Telegram SDK loaded from a CDN) or is missing. diff --git a/ui/src/Landing.svelte b/ui/src/Landing.svelte index 3a8d587..9f4bf3b 100644 --- a/ui/src/Landing.svelte +++ b/ui/src/Landing.svelte @@ -80,20 +80,26 @@

{about.title}

{t('landing.tagline')}

- {#if tgLink || vkLink} -
- {#if tgLink} - - - - {/if} - {#if vkLink} - - - - {/if} -
- {/if} + +
@@ -207,8 +213,8 @@ color: var(--text-muted); font-size: 1.05rem; } - /* The platform entries are just the bigger logos in a row (no button chrome, no captions); - each link keeps an aria-label for assistive tech. */ + /* The platform entries are the bigger logos in a row, each with a short caption below; the + link keeps an aria-label for assistive tech. */ .entries { align-self: center; display: flex; @@ -217,6 +223,11 @@ } .entry { display: inline-flex; + flex-direction: column; + align-items: center; + gap: 8px; + text-decoration: none; + color: var(--text); } .entry img { display: block; @@ -225,6 +236,10 @@ .entry:hover img { transform: scale(1.06); } + .caption { + font-size: 0.9rem; + color: var(--text-muted); + } .info { background: var(--surface-2); border-radius: var(--radius-sm); diff --git a/ui/src/components/InstallApp.svelte b/ui/src/components/InstallApp.svelte new file mode 100644 index 0000000..d0c4933 --- /dev/null +++ b/ui/src/components/InstallApp.svelte @@ -0,0 +1,85 @@ + + +{#if mode !== 'hidden'} + +{/if} + +{#if showIos} + (showIos = false)}> +
    +
  1. {t('install.iosStep1')}
  2. +
  3. {t('install.iosStep2')}
  4. +
  5. {t('install.iosStep3')}
  6. +
+ +
+{/if} + + diff --git a/ui/src/gen/fbs/scrabblefb/email-request-request.ts b/ui/src/gen/fbs/scrabblefb/email-request-request.ts index 47212e5..23835d6 100644 --- a/ui/src/gen/fbs/scrabblefb/email-request-request.ts +++ b/ui/src/gen/fbs/scrabblefb/email-request-request.ts @@ -41,8 +41,13 @@ language(optionalEncoding?:any):string|Uint8Array|null { return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; } +pwa():boolean { + const offset = this.bb!.__offset(this.bb_pos, 10); + return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false; +} + static startEmailRequestRequest(builder:flatbuffers.Builder) { - builder.startObject(3); + builder.startObject(4); } static addEmail(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset) { @@ -57,16 +62,21 @@ static addLanguage(builder:flatbuffers.Builder, languageOffset:flatbuffers.Offse builder.addFieldOffset(2, languageOffset, 0); } +static addPwa(builder:flatbuffers.Builder, pwa:boolean) { + builder.addFieldInt8(3, +pwa, +false); +} + static endEmailRequestRequest(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); return offset; } -static createEmailRequestRequest(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset, browserTzOffset:flatbuffers.Offset, languageOffset:flatbuffers.Offset):flatbuffers.Offset { +static createEmailRequestRequest(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset, browserTzOffset:flatbuffers.Offset, languageOffset:flatbuffers.Offset, pwa:boolean):flatbuffers.Offset { EmailRequestRequest.startEmailRequestRequest(builder); EmailRequestRequest.addEmail(builder, emailOffset); EmailRequestRequest.addBrowserTz(builder, browserTzOffset); EmailRequestRequest.addLanguage(builder, languageOffset); + EmailRequestRequest.addPwa(builder, pwa); return EmailRequestRequest.endEmailRequestRequest(builder); } } diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index c92fd44..1c0a784 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -35,6 +35,8 @@ import { } from './telegram'; import { onVKPath, insideVK, vkInit, vkClose, vkDisableSwipeBack, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets, vkSetViewSettings, appearanceForBg } from './vk'; import { pendingVKLink, type VKLinkCallback } from './vkid'; +import { isStandalone } from './pwa'; +import { registerServiceWorker } from './pwa.svelte'; import { haptic } from './haptics'; import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs'; import { parseStartParam } from './deeplink'; @@ -783,6 +785,11 @@ export async function bootstrap(): Promise { return; } + // On the plain web (both Mini-App branches returned above) register the install-only service + // worker so the app is installable — Chromium needs a registered SW, notably on Android. Web-only + // and skipped in the mock build; see lib/pwa.svelte. + registerServiceWorker(); + const saved = await loadSession(); // A VK ID web-link callback (?code&device_id&state) rides the URL after the redirect back // from VK; capture and clear it here (it needs the restored session to link against). @@ -974,7 +981,7 @@ export async function loginGuest(): Promise { export async function requestEmailCode(email: string): Promise { try { - await gateway.authEmailRequest(email, app.locale); + await gateway.authEmailRequest(email, app.locale, isStandalone()); return true; } catch (err) { handleError(err); diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts index 5c52348..a647da3 100644 --- a/ui/src/lib/client.ts +++ b/ui/src/lib/client.ts @@ -65,7 +65,7 @@ export interface GatewayClient { * cosmetic seed for a brand-new account). */ authVK(params: string, displayName: string): Promise; authGuest(locale?: string): Promise; - authEmailRequest(email: string, language: string): Promise; + authEmailRequest(email: string, language: string, pwa: boolean): Promise; authEmailLogin(email: string, code: string): Promise; /** Confirm a one-tap email deeplink token: a login returns a session to adopt; a * link returns a status ('confirmed' | 'merge_required'). */ diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index 45fac3f..bd8bb59 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -122,10 +122,11 @@ describe('codec', () => { expect(guest.browserTz()).toBe('-05:30'); const email = fb.EmailRequestRequest.getRootAsEmailRequestRequest( - new ByteBuffer(encodeEmailRequest('a@example.com', '+00:00', 'en')), + new ByteBuffer(encodeEmailRequest('a@example.com', '+00:00', 'en', true)), ); expect(email.email()).toBe('a@example.com'); expect(email.browserTz()).toBe('+00:00'); + expect(email.pwa()).toBe(true); expect(email.language()).toBe('en'); const confirm = fb.EmailConfirmLinkRequest.getRootAsEmailConfirmLinkRequest( diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index be6474a..8e62f08 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -214,7 +214,12 @@ export function encodeGuestLogin(locale: string, browserTz: string): Uint8Array return finish(b, fb.GuestLoginRequest.endGuestLoginRequest(b)); } -export function encodeEmailRequest(email: string, browserTz: string, language: string): Uint8Array { +export function encodeEmailRequest( + email: string, + browserTz: string, + language: string, + pwa: boolean, +): Uint8Array { const b = new Builder(128); const e = b.createString(email); const tz = b.createString(browserTz); @@ -223,6 +228,7 @@ export function encodeEmailRequest(email: string, browserTz: string, language: s fb.EmailRequestRequest.addEmail(b, e); fb.EmailRequestRequest.addBrowserTz(b, tz); fb.EmailRequestRequest.addLanguage(b, l); + fb.EmailRequestRequest.addPwa(b, pwa); return finish(b, fb.EmailRequestRequest.endEmailRequestRequest(b)); } diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index e19daca..a104b9a 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -234,6 +234,17 @@ export const en = { 'landing.tagline': 'Play Scrabble with friends, random opponent or an AI.', 'landing.playTelegram': 'Play in Telegram', 'landing.playVK': 'Play on VK', + 'landing.playWeb': 'Play in your browser', + 'landing.captionTelegram': 'Telegram', + 'landing.captionVK': 'VK', + 'landing.captionWeb': 'Web', + + 'install.title': 'Install the app', + 'install.subtitle': 'Put the app icon on your desktop (home screen) to open the game in one tap.', + 'install.iosTitle': 'Add to Home Screen', + 'install.iosStep1': 'Tap the Share button in the browser toolbar.', + 'install.iosStep2': 'Choose “Add to Home Screen”.', + 'install.iosStep3': 'Tap “Add”.', 'lang.en': 'English', 'lang.ru': 'Русский', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 81e6966..b95fb2e 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -234,6 +234,17 @@ export const ru: Record = { 'landing.tagline': 'Играй в «Эрудита» с друзьями, случайным соперником или ИИ-роботом.', 'landing.playTelegram': 'Играть в Telegram', 'landing.playVK': 'Играть во ВКонтакте', + 'landing.playWeb': 'Играть в браузере', + 'landing.captionTelegram': 'Telegram', + 'landing.captionVK': 'VK', + 'landing.captionWeb': 'Веб-версия', + + 'install.title': 'Установить приложение', + 'install.subtitle': 'Поместите иконку приложения на рабочий стол (домашний экран), чтобы открывать игру одним нажатием.', + 'install.iosTitle': 'На экран «Домой»', + 'install.iosStep1': 'Нажмите кнопку «Поделиться» на панели браузера.', + 'install.iosStep2': 'Выберите «На экран „Домой“».', + 'install.iosStep3': 'Нажмите «Добавить».', 'lang.en': 'English', 'lang.ru': 'Русский', diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index d0d589b..a1219e2 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -150,7 +150,7 @@ export class MockGateway implements GatewayClient { async authGuest(): Promise { return { ...SESSION }; } - async authEmailRequest(_email: string, _language: string): Promise {} + async authEmailRequest(_email: string, _language: string, _pwa: boolean): Promise {} async confirmEmailLink(_token: string): Promise { return { purpose: 'link', status: 'confirmed', session: null }; } diff --git a/ui/src/lib/pwa.svelte.ts b/ui/src/lib/pwa.svelte.ts new file mode 100644 index 0000000..8514093 --- /dev/null +++ b/ui/src/lib/pwa.svelte.ts @@ -0,0 +1,88 @@ +// Reactive PWA install runtime: captures the Chromium install prompt, tracks the installed +// state, and registers the install-only service worker. The pure platform branching lives in +// pwa.ts. Structured after the other reactive lib modules (app.svelte.ts, router.svelte.ts). + +import { isIosSafari, isStandalone, resolveInstallMode, type InstallMode } from './pwa'; +import { insideTelegram } from './telegram'; +import { insideVK } from './vk'; + +/** inMiniApp reports whether the app runs inside a Telegram or VK Mini App webview, where a web + * install makes no sense (the platform manages its own launcher). Kept here, out of the pure + * pwa.ts, so that module stays free of app imports for the node unit tests. */ +function inMiniApp(): boolean { + return insideTelegram() || insideVK(); +} + +// beforeinstallprompt is a Chromium-only event, absent from the DOM lib types. +interface BeforeInstallPromptEvent extends Event { + readonly userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string }>; + prompt(): Promise; +} +declare global { + interface WindowEventMap { + beforeinstallprompt: BeforeInstallPromptEvent; + appinstalled: Event; + } +} + +// Not named `state` (a Svelte-check hazard: `$state` would then read as a store subscription). +const install = $state<{ deferred: BeforeInstallPromptEvent | null; installed: boolean }>({ + deferred: null, + installed: isStandalone(), +}); + +/** + * installMode is the reactive install-CTA mode for the current platform and any captured prompt. + * Read inside a component's markup / $derived so the CTA updates when a Chromium prompt arrives + * (beforeinstallprompt fires shortly after load) or the app is installed. + */ +export function installMode(): InstallMode { + return resolveInstallMode({ + deferredAvailable: install.deferred !== null, + standalone: install.installed, + iosSafari: isIosSafari(), + inMiniApp: inMiniApp(), + }); +} + +/** + * initInstallListeners wires the Chromium install prompt and the installed event. Harmless on + * any platform — the events fire only in an installable browser — so it may run unconditionally + * at startup, early enough to catch a prompt that fires before the CTA mounts. + */ +export function initInstallListeners(): void { + if (typeof window === 'undefined') return; + window.addEventListener('beforeinstallprompt', (e) => { + // Suppress Chromium's mini-infobar; the app presents its own CTA instead. + e.preventDefault(); + install.deferred = e; + }); + window.addEventListener('appinstalled', () => { + install.deferred = null; + install.installed = true; + }); +} + +/** + * promptInstall shows the native Chromium install dialog. It MUST be called directly from a user + * gesture (the deferred prompt is gesture-gated — no await before it). A deferred prompt is + * single-use, so it is cleared here; a no-op when nothing was captured. + */ +export async function promptInstall(): Promise { + const deferred = install.deferred; + if (!deferred) return; + install.deferred = null; + await deferred.prompt(); +} + +/** + * registerServiceWorker registers the install-only worker (ui/public/sw.js). Web-only: skipped in + * the mock build (a real worker would perturb the Playwright run) and inside a Telegram/VK Mini + * App. Best-effort — a failed registration only means the app is not installable. + */ +export function registerServiceWorker(): void { + if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return; + if (import.meta.env.MODE === 'mock') return; + if (inMiniApp()) return; + void navigator.serviceWorker.register('sw.js').catch(() => {}); +} diff --git a/ui/src/lib/pwa.test.ts b/ui/src/lib/pwa.test.ts new file mode 100644 index 0000000..1324e5a --- /dev/null +++ b/ui/src/lib/pwa.test.ts @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { isIosSafari, isStandalone, resolveInstallMode } from './pwa'; + +describe('resolveInstallMode', () => { + const base = { deferredAvailable: false, standalone: false, iosSafari: false, inMiniApp: false }; + + it('hides inside a Mini App even with a captured prompt', () => { + expect(resolveInstallMode({ ...base, inMiniApp: true, deferredAvailable: true })).toBe('hidden'); + }); + + it('hides once the app is installed (standalone)', () => { + expect(resolveInstallMode({ ...base, standalone: true, deferredAvailable: true })).toBe('hidden'); + }); + + it('offers one-tap when a Chromium prompt was captured', () => { + expect(resolveInstallMode({ ...base, deferredAvailable: true })).toBe('oneTap'); + }); + + it('offers iOS instructions on iOS Safari without a prompt', () => { + expect(resolveInstallMode({ ...base, iosSafari: true })).toBe('iosInstructions'); + }); + + it('prefers one-tap over iOS instructions when both apply', () => { + expect(resolveInstallMode({ ...base, deferredAvailable: true, iosSafari: true })).toBe('oneTap'); + }); + + it('hides on an unsupported browser (no prompt, not iOS)', () => { + expect(resolveInstallMode(base)).toBe('hidden'); + }); +}); + +describe('isStandalone', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('is true when the display-mode media query matches', () => { + vi.stubGlobal('window', { + matchMedia: (q: string) => ({ matches: q.includes('standalone') }), + navigator: {}, + }); + expect(isStandalone()).toBe(true); + }); + + it('is true for the iOS navigator.standalone flag', () => { + vi.stubGlobal('window', { + matchMedia: () => ({ matches: false }), + navigator: { standalone: true }, + }); + expect(isStandalone()).toBe(true); + }); + + it('is false in an ordinary browser tab', () => { + vi.stubGlobal('window', { + matchMedia: () => ({ matches: false }), + navigator: { standalone: false }, + }); + expect(isStandalone()).toBe(false); + }); +}); + +describe('isIosSafari', () => { + afterEach(() => vi.unstubAllGlobals()); + + const IPHONE_SAFARI = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'; + const IPHONE_CHROME = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/120.0 Mobile/15E148 Safari/604.1'; + const ANDROID_CHROME = + 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Mobile Safari/537.36'; + const IPAD_SAFARI = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15'; + + it('is true for Safari on iPhone', () => { + vi.stubGlobal('navigator', { userAgent: IPHONE_SAFARI, maxTouchPoints: 5 }); + expect(isIosSafari()).toBe(true); + }); + + it('is true for Safari on iPadOS (Mac UA + touch points)', () => { + vi.stubGlobal('navigator', { userAgent: IPAD_SAFARI, maxTouchPoints: 5 }); + expect(isIosSafari()).toBe(true); + }); + + it('is false for a desktop Mac (no touch points)', () => { + vi.stubGlobal('navigator', { userAgent: IPAD_SAFARI, maxTouchPoints: 0 }); + expect(isIosSafari()).toBe(false); + }); + + it('is false for Chrome on iOS (CriOS)', () => { + vi.stubGlobal('navigator', { userAgent: IPHONE_CHROME, maxTouchPoints: 5 }); + expect(isIosSafari()).toBe(false); + }); + + it('is false for Android Chrome', () => { + vi.stubGlobal('navigator', { userAgent: ANDROID_CHROME, maxTouchPoints: 5 }); + expect(isIosSafari()).toBe(false); + }); +}); diff --git a/ui/src/lib/pwa.ts b/ui/src/lib/pwa.ts new file mode 100644 index 0000000..65a69fb --- /dev/null +++ b/ui/src/lib/pwa.ts @@ -0,0 +1,57 @@ +// Pure PWA install helpers, kept out of the Svelte layer (and free of app imports) so the +// platform branching is unit-testable in the node test env. The reactive runtime (the captured +// install prompt, the Mini-App gate, the service-worker registration) lives in pwa.svelte.ts. + +/** InstallMode is how the install call-to-action resolves for the current platform. */ +export type InstallMode = 'oneTap' | 'iosInstructions' | 'hidden'; + +/** + * isStandalone reports whether the app is already running as an installed PWA — launched from + * the home screen / desktop rather than a browser tab. Both the standard display-mode media + * query and the iOS-only navigator.standalone flag are checked. Returns false with no window + * (e.g. under the unit tests). + */ +export function isStandalone(): boolean { + if (typeof window === 'undefined') return false; + const displayMode = window.matchMedia?.('(display-mode: standalone)').matches ?? false; + const iosStandalone = + (window.navigator as Navigator & { standalone?: boolean }).standalone === true; + return displayMode || iosStandalone; +} + +/** + * isIosSafari reports whether the app runs in Safari on iOS / iPadOS — the only browser there + * that can add a site to the home screen, and one with no programmatic install (it never fires + * beforeinstallprompt). In-app webviews (Telegram, VK) and the iOS Chrome/Firefox/Edge wrappers + * are excluded, as is desktop Safari. Used to switch the CTA to manual Share -> Add-to-Home-Screen + * instructions. + */ +export function isIosSafari(): boolean { + if (typeof navigator === 'undefined') return false; + const ua = navigator.userAgent; + // iPadOS 13+ reports a Mac UA but exposes touch points — catch that as iOS too. + const isIos = + /iPhone|iPod|iPad/.test(ua) || (/Macintosh/.test(ua) && (navigator.maxTouchPoints ?? 0) > 1); + if (!isIos) return false; + // Safari only: the iOS Chrome (CriOS), Firefox (FxiOS), Edge (EdgiOS), Opera (OPiOS) and the + // Google-app (GSA) wrappers all carry "Safari" but cannot add to the home screen from our CTA. + return /Safari/.test(ua) && !/CriOS|FxiOS|EdgiOS|OPiOS|GSA/.test(ua); +} + +/** + * resolveInstallMode chooses how the install CTA should behave from the platform facts: + * `deferredAvailable` — a beforeinstallprompt event was captured (Chromium one-tap); `standalone` + * — already installed; `iosSafari` — manual instructions apply; `inMiniApp` — running inside a + * Telegram/VK Mini App. The CTA is offered only on the plain web and never once installed. + */ +export function resolveInstallMode(facts: { + deferredAvailable: boolean; + standalone: boolean; + iosSafari: boolean; + inMiniApp: boolean; +}): InstallMode { + if (facts.inMiniApp || facts.standalone) return 'hidden'; + if (facts.deferredAvailable) return 'oneTap'; + if (facts.iosSafari) return 'iosInstructions'; + return 'hidden'; +} diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index dd89956..51bf052 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -118,8 +118,8 @@ export function createTransport(baseUrl: string): GatewayClient { async authGuest(locale) { return codec.decodeSession(await exec('auth.guest', codec.encodeGuestLogin(locale ?? '', browserOffset()))); }, - async authEmailRequest(email, language) { - await exec('auth.email.request', codec.encodeEmailRequest(email, browserOffset(), language)); + async authEmailRequest(email, language, pwa) { + await exec('auth.email.request', codec.encodeEmailRequest(email, browserOffset(), language, pwa)); }, async confirmEmailLink(token) { return codec.decodeConfirmLinkResult(await exec('auth.email.confirm_link', codec.encodeEmailConfirmLink(token))); diff --git a/ui/src/main.ts b/ui/src/main.ts index af36848..a3cfdce 100644 --- a/ui/src/main.ts +++ b/ui/src/main.ts @@ -1,10 +1,15 @@ import { mount } from 'svelte'; import './app.css'; import App from './App.svelte'; +import { initInstallListeners } from './lib/pwa.svelte'; // Pin the document for the game SPA (see app.css `html.app-shell`) so iOS/WKWebView — notably // the Telegram Mini App — cannot rubber-band the whole page on a vertical drag. The standalone // landing page (landing.ts) is a normal scrolling document and deliberately omits this class. document.documentElement.classList.add('app-shell'); +// Capture the PWA install prompt as early as possible — before the install CTA mounts. Harmless +// where it never fires (Firefox, Safari, the Telegram/VK Mini App webviews). +initInstallListeners(); + export default mount(App, { target: document.getElementById('app')! }); diff --git a/ui/src/screens/Login.svelte b/ui/src/screens/Login.svelte index 3e71227..224ec9f 100644 --- a/ui/src/screens/Login.svelte +++ b/ui/src/screens/Login.svelte @@ -1,6 +1,7 @@