Merge pull request 'Release v1.11.0: PWA install + code-only PWA login + email/metrics fixes' (#187) from development into master
This commit was merged in pull request #187.
This commit is contained in:
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/<token> 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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+14
-3
@@ -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
|
||||
|
||||
+10
-2
@@ -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
|
||||
|
||||
+10
-2
@@ -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). Рядом с основным может работать отдельный опциональный
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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), "/")
|
||||
|
||||
@@ -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("<!doctype html><title>shell</title>")},
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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<void>;
|
||||
userChoice?: Promise<unknown>;
|
||||
};
|
||||
(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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -256,6 +256,18 @@
|
||||
<link rel="icon" href="favicon.ico" sizes="32x32" />
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
|
||||
<!-- PWA installability (web /app/ only): the manifest + a service worker (registered from
|
||||
lib/pwa, web-only) let the app be added to the home screen / desktop. The relative href
|
||||
resolves per serving path; these tags are inert inside the Telegram/VK Mini App webviews,
|
||||
which manage their own chrome. theme-color mirrors the light/dark --bg tokens (app.css). -->
|
||||
<link rel="manifest" href="manifest.webmanifest" />
|
||||
<meta name="theme-color" content="#f3f4f6" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#0f1115" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="Эрудит" />
|
||||
<meta name="application-name" content="Эрудит" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.6 KiB |
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
})(),
|
||||
);
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
+31
-16
@@ -80,20 +80,26 @@
|
||||
<section class="hero">
|
||||
<h1>{about.title}</h1>
|
||||
<p class="tagline">{t('landing.tagline')}</p>
|
||||
{#if tgLink || vkLink}
|
||||
<div class="entries">
|
||||
{#if tgLink}
|
||||
<a class="entry" href={tgLink} target="_blank" rel="noopener noreferrer" aria-label={t('landing.playTelegram')}>
|
||||
<img src="telegram-logo.svg" alt="" width="64" height="64" />
|
||||
</a>
|
||||
{/if}
|
||||
{#if vkLink}
|
||||
<a class="entry" href={vkLink} target="_blank" rel="noopener noreferrer" aria-label={t('landing.playVK')}>
|
||||
<img src="vk-logo.svg" alt="" width="64" height="64" />
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Platform entries: Telegram/VK are gated by their build vars; the web version (/app/) is
|
||||
always available. A caption under each clarifies what it opens. -->
|
||||
<div class="entries">
|
||||
{#if tgLink}
|
||||
<a class="entry" href={tgLink} target="_blank" rel="noopener noreferrer" aria-label={t('landing.playTelegram')}>
|
||||
<img src="telegram-logo.svg" alt="" width="64" height="64" />
|
||||
<span class="caption">{t('landing.captionTelegram')}</span>
|
||||
</a>
|
||||
{/if}
|
||||
{#if vkLink}
|
||||
<a class="entry" href={vkLink} target="_blank" rel="noopener noreferrer" aria-label={t('landing.playVK')}>
|
||||
<img src="vk-logo.svg" alt="" width="64" height="64" />
|
||||
<span class="caption">{t('landing.captionVK')}</span>
|
||||
</a>
|
||||
{/if}
|
||||
<a class="entry" href="/app/" aria-label={t('landing.playWeb')}>
|
||||
<img src="favicon.svg" alt="" width="64" height="64" />
|
||||
<span class="caption">{t('landing.captionWeb')}</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="info">
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
import { installMode, promptInstall } from '../lib/pwa.svelte';
|
||||
import Modal from './Modal.svelte';
|
||||
|
||||
// Platform-adaptive: 'oneTap' fires the native Chromium install dialog; 'iosInstructions' opens
|
||||
// the manual Share -> Add-to-Home-Screen guide (iOS Safari has no programmatic install); 'hidden'
|
||||
// renders nothing (already installed, inside a Telegram/VK Mini App, or an unsupported browser).
|
||||
const mode = $derived(installMode());
|
||||
let showIos = $state(false);
|
||||
|
||||
function onClick(): void {
|
||||
// promptInstall must run synchronously from this gesture — the deferred prompt is gated on it.
|
||||
if (mode === 'oneTap') void promptInstall();
|
||||
else if (mode === 'iosInstructions') showIos = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if mode !== 'hidden'}
|
||||
<button type="button" class="install" onclick={onClick}>
|
||||
<img src="favicon.svg" alt="" width="40" height="40" />
|
||||
<span class="text">
|
||||
<span class="title">{t('install.title')}</span>
|
||||
<span class="subtitle">{t('install.subtitle')}</span>
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if showIos}
|
||||
<Modal title={t('install.iosTitle')} onclose={() => (showIos = false)}>
|
||||
<ol class="steps">
|
||||
<li>{t('install.iosStep1')}</li>
|
||||
<li>{t('install.iosStep2')}</li>
|
||||
<li>{t('install.iosStep3')}</li>
|
||||
</ol>
|
||||
<button type="button" class="ok" onclick={() => (showIos = false)}>{t('common.ok')}</button>
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.install {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.install img {
|
||||
flex: none;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.title {
|
||||
font-weight: 600;
|
||||
}
|
||||
.subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.35;
|
||||
}
|
||||
.steps {
|
||||
margin: 0 0 16px;
|
||||
padding-left: 1.25rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.ok {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--accent);
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
</style>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
|
||||
export async function requestEmailCode(email: string): Promise<boolean> {
|
||||
try {
|
||||
await gateway.authEmailRequest(email, app.locale);
|
||||
await gateway.authEmailRequest(email, app.locale, isStandalone());
|
||||
return true;
|
||||
} catch (err) {
|
||||
handleError(err);
|
||||
|
||||
@@ -65,7 +65,7 @@ export interface GatewayClient {
|
||||
* cosmetic seed for a brand-new account). */
|
||||
authVK(params: string, displayName: string): Promise<Session>;
|
||||
authGuest(locale?: string): Promise<Session>;
|
||||
authEmailRequest(email: string, language: string): Promise<void>;
|
||||
authEmailRequest(email: string, language: string, pwa: boolean): Promise<void>;
|
||||
authEmailLogin(email: string, code: string): Promise<Session>;
|
||||
/** Confirm a one-tap email deeplink token: a login returns a session to adopt; a
|
||||
* link returns a status ('confirmed' | 'merge_required'). */
|
||||
|
||||
@@ -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(
|
||||
|
||||
+7
-1
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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': 'Русский',
|
||||
|
||||
@@ -234,6 +234,17 @@ export const ru: Record<MessageKey, string> = {
|
||||
'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': 'Русский',
|
||||
|
||||
@@ -150,7 +150,7 @@ export class MockGateway implements GatewayClient {
|
||||
async authGuest(): Promise<Session> {
|
||||
return { ...SESSION };
|
||||
}
|
||||
async authEmailRequest(_email: string, _language: string): Promise<void> {}
|
||||
async authEmailRequest(_email: string, _language: string, _pwa: boolean): Promise<void> {}
|
||||
async confirmEmailLink(_token: string): Promise<ConfirmLinkResult> {
|
||||
return { purpose: 'link', status: 'confirmed', session: null };
|
||||
}
|
||||
|
||||
@@ -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<void>;
|
||||
}
|
||||
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<void> {
|
||||
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(() => {});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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)));
|
||||
|
||||
@@ -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')! });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { loginEmail, loginGuest, requestEmailCode } from '../lib/app.svelte';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
import InstallApp from '../components/InstallApp.svelte';
|
||||
|
||||
let email = $state('');
|
||||
let code = $state('');
|
||||
@@ -55,6 +56,9 @@
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Web-only install call-to-action, under the login card. Renders nothing unless the app is
|
||||
installable here (see components/InstallApp.svelte / lib/pwa). -->
|
||||
<div class="promo"><InstallApp /></div>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@@ -62,8 +66,15 @@
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
/* Centre the card + install promo as a group, with a gap between them. */
|
||||
align-content: center;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
/* Match the card width so the install CTA lines up under it. */
|
||||
.promo {
|
||||
width: min(94vw, 360px);
|
||||
}
|
||||
.card {
|
||||
width: min(94vw, 360px);
|
||||
background: var(--surface);
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import type { ThemePref } from '../lib/theme';
|
||||
import type { BoardLabelMode } from '../lib/boardlabels';
|
||||
import { insideTelegram } from '../lib/telegram';
|
||||
import InstallApp from '../components/InstallApp.svelte';
|
||||
|
||||
const themes: ThemePref[] = ['auto', 'light', 'dark'];
|
||||
const themeLabel: Record<ThemePref, MessageKey> = {
|
||||
@@ -73,6 +74,10 @@
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<!-- Web-only install call-to-action at the bottom of Settings (renders nothing unless the app
|
||||
is installable here — see components/InstallApp.svelte / lib/pwa). -->
|
||||
<InstallApp />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
Reference in New Issue
Block a user