v0.165.1: native Megosztás… button in the share modal (Web Share API)
Feature-detected navigator.share opens the OS share sheet with title+text+URL only (no QR files: — narrow support drops the URL when given file+URL). Hidden unless supported; Link másolása stays the universal fallback and catches the non-cancel rejection; AbortError is silent. Template JS + tests only. 2 red-proofs verified red.
This commit is contained in:
@@ -1,5 +1,26 @@
|
|||||||
## Changelog
|
## Changelog
|
||||||
|
|
||||||
|
### v0.165.1 — Native "Megosztás…" button in the share modal (Web Share API) (2026-07-24)
|
||||||
|
|
||||||
|
No agent coupling; MinAgent unchanged. Template JS + tests only — no backend, no routes, no
|
||||||
|
settings, no dependency changes.
|
||||||
|
|
||||||
|
The "Indítópult megosztása" modal gains a **"Megosztás…"** button that opens the OS share sheet via
|
||||||
|
`navigator.share` (Messenger / WhatsApp / email / anything installed), sending the share **title +
|
||||||
|
text + URL only**. Feature-detected: the button is `display:none` in the markup and revealed only
|
||||||
|
when `navigator.share` exists; the universal **"Link másolása"** stays as the fallback and is never
|
||||||
|
demoted. A user cancel (`AbortError`) is silent; any other rejection falls back to `copyShareLink()`
|
||||||
|
so the user still keeps the link on the clipboard.
|
||||||
|
|
||||||
|
- **The QR is deliberately NOT attached** to the share payload (no Web Share Level-2 `files:`):
|
||||||
|
file-share support is narrow and several targets drop the URL when handed file+URL, leaving an
|
||||||
|
unscannable QR picture in a chat. The QR's job — physical cross-device scanning — is already served
|
||||||
|
by the modal image (mobile long-press covers "send the picture" with zero code).
|
||||||
|
- Share copy (user-to-user, deliberately conjugation-free): title `Indítópult — <domain>`, text
|
||||||
|
"Az otthoni alkalmazások egy helyen.".
|
||||||
|
- Tests: Group A (button hidden-by-default + feature-detect reveal + title/text/url-only payload,
|
||||||
|
no `files:`) + Group B (AbortError-silent + non-abort fallback to copy); 2 red-proofs verified red.
|
||||||
|
|
||||||
### v0.165.0 — Indítópult megosztása: guest launcher via capability URL (2026-07-24)
|
### v0.165.0 — Indítópult megosztása: guest launcher via capability URL (2026-07-24)
|
||||||
|
|
||||||
No agent coupling; MinAgent unchanged. New dependency: `github.com/skip2/go-qrcode`
|
No agent coupling; MinAgent unchanged. New dependency: `github.com/skip2/go-qrcode`
|
||||||
|
|||||||
+10
-1
@@ -7,7 +7,16 @@
|
|||||||
>
|
>
|
||||||
> Ask Claude Code: "Please update CONTEXT.md with what we did today"
|
> Ask Claude Code: "Please update CONTEXT.md with what we did today"
|
||||||
|
|
||||||
Last updated: 2026-07-24 (v0.165.0 — Indítópult megosztása: guest launcher via capability URL /s/<token>, optional share password, QR)
|
Last updated: 2026-07-24 (v0.165.1 — native "Megosztás…" button in the share modal via the Web Share API)
|
||||||
|
|
||||||
|
> **2026-07-24 — v0.165.1 (native "Megosztás…" in the share modal, Web Share API).** The share modal
|
||||||
|
> gains a feature-detected `navigator.share` button (OS share sheet → Messenger/WhatsApp/email),
|
||||||
|
> sending **title + text + URL only**. Hidden unless supported; "Link másolása" stays the universal
|
||||||
|
> fallback (and catches the non-cancel rejection); `AbortError` (user cancel) is silent. **Ruling: the
|
||||||
|
> QR is NOT attached** (no Web Share Level-2 `files:`) — file-share support is narrow and several
|
||||||
|
> targets drop the URL when handed file+URL, leaving an unscannable QR picture in a chat; the QR's job
|
||||||
|
> (physical cross-device scanning) is already served by the modal image (mobile long-press). Template
|
||||||
|
> JS + tests only; the OS sheet interaction is an operator manual check (not endpoint-testable).
|
||||||
|
|
||||||
> **2026-07-24 — v0.165.0 (Indítópult megosztása — guest launcher via capability URL).** The admin
|
> **2026-07-24 — v0.165.0 (Indítópult megosztása — guest launcher via capability URL).** The admin
|
||||||
> launcher gets an "Indítópult megosztása" button that mints a **capability URL**
|
> launcher gets an "Indítópult megosztása" button that mints a **capability URL**
|
||||||
|
|||||||
@@ -1,127 +1,79 @@
|
|||||||
# REPORT — felhom-controller v0.165.0 — Indítópult megosztása (guest launcher via capability URL)
|
# REPORT — felhom-controller v0.165.1 — Native "Megosztás…" button in the share modal (Web Share API)
|
||||||
|
|
||||||
## Baselines
|
## Baseline
|
||||||
|
|
||||||
| Repo | main @ start | version start → target |
|
| Repo | main @ start | version start → target |
|
||||||
|------|--------------|------------------------|
|
|------|--------------|------------------------|
|
||||||
| felhom-controller | `8e5edb2` | v0.164.0 → **v0.165.0** |
|
| felhom-controller | `570fb30` | v0.165.0 → **v0.165.1** |
|
||||||
|
|
||||||
Clean tree, `HEAD == origin/main`, verified before build.
|
Clean tree, `HEAD == origin/main`, verified before build.
|
||||||
|
|
||||||
## What shipped
|
## What shipped
|
||||||
|
|
||||||
The admin launcher gains an **"Indítópult megosztása"** button that mints a **capability URL**
|
The v0.165.0 "Indítópult megosztása" modal gains a feature-detected **"Megosztás…"** button that
|
||||||
(`https://<host>/s/<token>`, 160-bit token) serving a standalone, read-only guest launcher — same
|
opens the OS share sheet via `navigator.share`, sending the share **title + text + URL only**. Template
|
||||||
tiles, opens apps in new tabs — with **no accounts and no admin session**. Optional per-share
|
JS + tests only — no backend, no routes, no settings, no dependency changes.
|
||||||
password (separate credential); modal offers copy-link, QR, rotate, disable. The link grants
|
|
||||||
**information only, zero control**.
|
|
||||||
|
|
||||||
## Files created / modified
|
## Files modified
|
||||||
|
|
||||||
**Created**
|
- `controller/internal/web/templates/launcher.html` — the `#share-native-btn` button in the
|
||||||
- `controller/internal/web/share.go` — pure core: `newShareToken` (20 rand bytes → base64.RawURLEncoding, 27 chars), `shareTokenMatches` (constant-time; empty stored never matches), `shareCookieValue`/`shareCookieValid` (HMAC guest cookie), `shareCSRFToken`/`setShareCSRFCookie`/`validShareCSRF` (pre-auth HMAC CSRF), `shareRateLimited`/`shareRegisterFailure`/`shareClearFailures` (own attempt map).
|
share-link-row (`display:none` in markup); `nativeShare()` in the modal script; a `navigator.share`
|
||||||
- `controller/internal/web/share_handlers.go` — HTTP surface: guest GET/POST handlers, `share404`, `setGuestHeaders`, `GuestLauncherApp` + `buildGuestApps` (pure mapping), render helpers, admin `/launcher/share/*` handlers (enable/rotate/disable/password) + QR handler.
|
feature-detect reveal in the existing IIFE.
|
||||||
- `controller/internal/web/share_test.go` — Groups A–G (14 tests) + companion red-proofs.
|
- `controller/internal/web/launcher_share_modal_test.go` — **new**: Group A + Group B render tests.
|
||||||
- `controller/internal/web/templates/launcher_shared.html` — standalone guest launcher (own minimal `<html>`).
|
|
||||||
- `controller/internal/web/templates/launcher_share_password.html` — standalone one-field password gate.
|
|
||||||
|
|
||||||
**Modified**
|
|
||||||
- `controller/internal/settings/settings.go` — `LauncherShareToken` + `LauncherSharePasswordHash` fields + 4 accessors (copy of the `PasswordHash` pattern).
|
|
||||||
- `controller/internal/web/server.go` — Server struct `shareAttempts` map (+ NewServer init); ServeHTTP `/s/` mux cases (GET/POST) + `/launcher/share/*` cases; ServeHTTP debug-line `/s/<redacted>` redaction.
|
|
||||||
- `controller/internal/web/auth.go` — `/s/` added to the RequireAuth pre-auth allowlist (after the claim-gate block).
|
|
||||||
- `controller/internal/web/csrf.go` — `/s/` exempted from session CSRF (guest carries pre-auth HMAC CSRF).
|
|
||||||
- `controller/internal/web/handlers.go` — `launcherApps()` extracted; `launcherHandler` wires share modal state (ShareEnabled/ShareURL/SharePasswordSet/ShareFlash).
|
|
||||||
- `controller/internal/web/funcmap.go` — `isOperationalState` promoted to a package predicate; funcmap `isOperational` points at it.
|
|
||||||
- `controller/internal/web/templates/launcher.html` — `launch_tile` partial extracted; both tile branches use it; share button + modal + JS added.
|
|
||||||
- `controller/internal/web/templates/style.css` — share-modal + guest-launcher CSS.
|
|
||||||
- `controller/go.mod` / `go.sum` — `github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e`.
|
|
||||||
- Docs: `CHANGELOG.md`, `controller/README.md`, `CONTEXT.md`.
|
- Docs: `CHANGELOG.md`, `controller/README.md`, `CONTEXT.md`.
|
||||||
|
|
||||||
## Part-2 secret decision — and why
|
## Behavior
|
||||||
|
|
||||||
**REUSED `web.session_secret`** (via `s.cfg.Web.SessionSecret`) as the HMAC key for both the guest
|
- **Supported browser:** `#share-native-btn` is revealed (`navigator.share` truthy) and calls
|
||||||
gate cookie and the guest CSRF, with per-purpose domain-separation labels
|
`navigator.share({title:'Indítópult — <domain>', text:'Az otthoni alkalmazások egy helyen.',
|
||||||
(`felhom-share-cookie-v1|…`, `felhom-share-csrf-v1`). It qualifies for the **reuse branch** of the
|
url:<ShareURL>})` — **no `files:` key**. User cancel (`AbortError`) → silent; the modal stays open.
|
||||||
decision rule: it is **persisted** (a `controller.yaml` `web.session_secret` field) and
|
- **Unsupported browser** (Firefox desktop, Chrome/Linux): the button stays `display:none` (JS only
|
||||||
**box-scoped** (each box's own config), and it is **stable across restarts** — not per-boot and not
|
ever reveals it); "Link másolása" works exactly as in v0.165.0.
|
||||||
claim-generation-scoped. It is the SAME secret the claim pre-auth CSRF already trusts
|
- **Non-cancel rejection:** falls back to `copyShareLink()` so the user still keeps the link on the
|
||||||
(`claim.go:claimCSRFToken`), so reusing it introduces **no new security assumption** beyond what the
|
clipboard (with the existing "Másolva" feedback).
|
||||||
box already relies on. No `ShareCookieSecret` field was added. Binding the cookie to
|
|
||||||
`token|passwordHash` makes rotation and password-change invalidate cookies with zero bookkeeping.
|
## Design ruling (CONTEXT)
|
||||||
|
|
||||||
|
The **QR is NOT attached** to the share payload (no Web Share Level-2 `files:`). File-share support is
|
||||||
|
narrow and several targets drop the URL when handed file+URL, leaving an unscannable QR picture in a
|
||||||
|
chat. The QR's job — physical cross-device scanning — is already served by the modal image (mobile
|
||||||
|
long-press covers "send the picture" with zero code).
|
||||||
|
|
||||||
## Tests + red-proofs
|
## Tests + red-proofs
|
||||||
|
|
||||||
`go build ./... && go vet ./... && go test ./...` — all green. Gates: template_id, emoji,
|
`go build ./... && go vet ./... && go test ./...` — all green. Gates: template_id, emoji,
|
||||||
native_confirm, mojibake, app_row_dedup — all OK.
|
native_confirm — OK.
|
||||||
|
|
||||||
New tests (`share_test.go`), 14 total, all PASS:
|
New tests (`launcher_share_modal_test.go`), both PASS:
|
||||||
|
|
||||||
| Group | Scenario | Test |
|
| Group | Scenario | Test |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| B(core) | constant-time token match | `TestShareTokenMatches`, `TestNewShareToken_EntropyAndCharset` |
|
| A | button hidden-by-default + feature-detect reveal + title/text/url-only payload (no `files:`) + copy retained | `TestShareModal_NativeShareButton` |
|
||||||
| A | guest 200 + 3 headers + tiles + no admin chrome | `TestShareGuest_HeadersTilesNoAdminChrome` |
|
| B | AbortError-silent branch + non-abort fallback to `copyShareLink()` | `TestShareModal_NativeShareFallback` |
|
||||||
| B | wrong/disabled/empty = byte-identical mux 404 | `TestShareGuest_WrongTokenIs404LikeDefault` |
|
|
||||||
| C | password gate: 5 wrong → 6th rate-limited; correct → cookie; change pw invalidates | `TestShareGuest_PasswordGate` |
|
|
||||||
| D | rotate → old 404 + old cookie invalid; disable → all 404 | `TestShareGuest_RotateAndDisable` |
|
|
||||||
| E | guest labels + no internal state words + empty state | `TestBuildGuestApps_Labels`, `TestShareGuestTemplate_LabelsNoInternalWords` |
|
|
||||||
| F | claim gate intercepts guest page; admin surfaces need auth + CSRF | `TestShare_ClaimGateInterceptsGuestPage`, `TestShare_AdminSurfacesRequireAuthAndCSRF` |
|
|
||||||
| G | token never logged (valid + wrong), path redacted | `TestShareGuest_TokenNeverLogged` |
|
|
||||||
|
|
||||||
Companion **red-proofs** (mutate → FAIL → restore → green), all verified:
|
Companion **red-proofs** (mutate → FAIL → restore → green), both verified:
|
||||||
1. **Token match** (Group B): `subtle.ConstantTimeCompare` → prefix-accept (`presented[:len(stored)] == stored`) → `TestShareTokenMatches` FAILS on the superstring case ("a superstring must not match"). Restored → green.
|
1. **Group A:** removed `style="display:none"` from the button (revealed it in markup) →
|
||||||
2. **Cookie binding** (Group C): dropped `passwordHash` from `shareCookieValue`'s HMAC input → `TestShareGuest_PasswordGate` FAILS ("changing the password must invalidate the old gate cookie"). Restored → green.
|
`TestShareModal_NativeShareButton` FAILS the hidden-by-default assertion. Restored → green.
|
||||||
3. **Log redaction** (Group G): reverted the ServeHTTP `/s/<redacted>` redaction (log raw `path`) → `TestShareGuest_TokenNeverLogged` FAILS. Restored → green.
|
2. **Group B:** swapped the `.catch` to swallow all errors (dropped the `copyShareLink()` fallback) →
|
||||||
|
`TestShareModal_NativeShareFallback` FAILS the fallback-presence assertion. Restored → green.
|
||||||
|
|
||||||
Existing launcher tests (`TestBuildLauncherApps_*`, `TestLauncherTemplate_*`, `TestTileColor`,
|
## Deployed version + validation (§13)
|
||||||
`TestInitial`, `TestLauncherRoute_EndToEnd`) still green after the `launch_tile` partial extraction.
|
|
||||||
|
|
||||||
Test count (internal/web): +14 (share_test.go). Full `go test ./...` green before/after.
|
_Filled after build + deploy to guest 9201 — rendered-page assertion transcript below._
|
||||||
|
|
||||||
## Deployed version + live validation (§13)
|
<!-- LIVE-VALIDATION -->
|
||||||
|
|
||||||
Built `0.165.0` on DooPlex (digest `sha256:df3b5920…`), pushed, deployed to guest 9201 via the
|
## Pending human check (stated, not claimed)
|
||||||
bootstrap service. `docker ps`: `gitea.dooplex.hu/admin/felhom-controller:0.165.0 Up (healthy)`.
|
|
||||||
|
|
||||||
**Method (stated per the no-browser rule):** all checks invoke the exact endpoints the UI invokes,
|
The **OS share sheet interaction is an OS-level dialog** that endpoint checks and browser automation
|
||||||
via `docker exec felhom-controller curl http://localhost:8080/…` — `localhost` passes
|
cannot exercise. CC validates markup + JS presence at the rendered `/launcher` (authed curl, Group-A
|
||||||
CatchAllMiddleware, so the full server pipeline runs (only rendering is skipped). Admin steps use the
|
strings). The single pending manual check is the operator's: desktop Chrome/Edge or a phone → the
|
||||||
real `/login` (302 + session cookie); the guest surface is hit with NO session. **The live token is
|
"Megosztás…" button is visible → the sheet opens → a WhatsApp/email target receives the title + a
|
||||||
redacted throughout (first 4 chars + length only).**
|
tappable link; on Firefox desktop the button is absent and "Link másolása" still works.
|
||||||
|
|
||||||
1. **Enable + guest happy path (Scenario A):** `GET /s/<token>` (no session) → `200`, headers
|
|
||||||
`X-Robots-Tag: noindex, nofollow` + `Referrer-Policy: no-referrer` + `Cache-Control: no-store`;
|
|
||||||
the `Indítópult` heading renders; **no admin chrome** (`class="sidebar"`, `nav-links`, `/logout`,
|
|
||||||
version, `alert-banner` all absent).
|
|
||||||
2. **Wrong / disabled token (Scenario B):** `GET /s/WRONGTOKEN…` → `404 [404 page not found]`;
|
|
||||||
an authenticated `GET /definitely-no-such-route` (the mux default case) → `404 [404 page not found]`
|
|
||||||
— **byte-identical** (status + body).
|
|
||||||
3. **Optional password gate (Scenario C):** setting the password stored `launcher_share_password_hash`
|
|
||||||
(separate field). `GET /s/<token>` (no cookie) → the password gate. Correct password → `303` +
|
|
||||||
`felhom_share` gate cookie → a subsequent cookie-bearing GET renders the launcher directly (no
|
|
||||||
gate). Five wrong POSTs → `Hibás jelszó` each; the **6th → `Túl sok sikertelen…` (rate-limited)**.
|
|
||||||
4. **Rotation (Scenario D):** `POST /launcher/share/rotate` → old token `GET` = `404`, new token
|
|
||||||
`GET` = `200`.
|
|
||||||
5. **QR:** `GET /launcher/share/qr.png` **with** admin session → `200 image/png` (valid PNG magic
|
|
||||||
`89 50 4e 47`); **without** session → `302 → /login?next=/launcher/share/qr.png`. Decode method:
|
|
||||||
PNG validated by magic bytes + content-type; the encoded string is `https://<host>/s/<token>` **by
|
|
||||||
construction** (the handler builds exactly that) — a phone scan was not run (no browser/scanner on
|
|
||||||
DooPlex).
|
|
||||||
6. **Token never logged (Scenario G):** `docker logs` shows `[WARN] [web] 404 Not Found: GET
|
|
||||||
/s/<redacted>` (2 lines); a scan of the full log for any raw `/s/<20+ char token>` returned
|
|
||||||
**EMPTY**. (The DEBUG ServeHTTP-line redaction is unit-proven in Group G; the live box runs at
|
|
||||||
`info`, where the redacted WARN 404 is the observable proof.)
|
|
||||||
7. **Cleanup:** `POST /launcher/share/disable` → both `launcher_share_token` and
|
|
||||||
`launcher_share_password_hash` cleared in `settings.json` — the box is left in the shipped default
|
|
||||||
(sharing OFF). Post-run `docker ps`: still `Up (healthy)`.
|
|
||||||
|
|
||||||
## Accepted residuals (no code action)
|
|
||||||
|
|
||||||
- **Link-preview crawlers** (Messenger/WhatsApp/Slack) fetch the URL once and see app names — accepted; `X-Robots-Tag: noindex, nofollow` prevents search indexing.
|
|
||||||
- **Reverse-proxy / Cloudflare access logs** may record the `/s/<token>` path — an ops-tier residual outside the controller (the controller's own logs redact it).
|
|
||||||
- **LAN-IP link host** — the modal builds the link from the request `Host`, so an admin on a LAN IP gets a LAN-IP link. Kept the UI clean; noted here only.
|
|
||||||
|
|
||||||
## Observations
|
## Observations
|
||||||
|
|
||||||
- The guest gate cookie is scoped `Path=/s/` + `HttpOnly` + `SameSite=Lax` (Lax so a first click from an external app still sends it on top-level GET). The CSRF cookie is `SameSite=Strict`.
|
- No Web Share Level-2 `files:` — ruled out above (narrow support; URL-drop when given file+URL).
|
||||||
- Disable clears BOTH token and share password (clean slate — a later re-enable never inherits a stale gate).
|
- "Link másolása" is unchanged and remains the universal path; feature detection only, no UA sniffing.
|
||||||
- The token GET is deliberately NOT rate-limited or CAPTCHA'd — 160-bit entropy is the defence; the path stays fast and boring.
|
- The share `text` is deliberately conjugation-free ("Az otthoni alkalmazások egy helyen.") — a
|
||||||
|
user-to-user message, not magázó UI copy.
|
||||||
|
|||||||
@@ -311,7 +311,11 @@ template partial; the app slice comes from the extracted `Server.launcherApps()`
|
|||||||
(`GET /launcher/share/qr.png`, ~256px PNG via `github.com/skip2/go-qrcode`, admin-authed, `no-store`),
|
(`GET /launcher/share/qr.png`, ~256px PNG via `github.com/skip2/go-qrcode`, admin-authed, `no-store`),
|
||||||
set/clear share password, "Új link készítése" (rotate), "Megosztás kikapcsolása" (clears token AND
|
set/clear share password, "Új link készítése" (rotate), "Megosztás kikapcsolása" (clears token AND
|
||||||
password). The management POSTs live under `/launcher/share/*` and ride the normal admin session +
|
password). The management POSTs live under `/launcher/share/*` and ride the normal admin session +
|
||||||
session CSRF; rotate/disable use the inline `data-confirm` (felhomConfirm) affordance.
|
session CSRF; rotate/disable use the inline `data-confirm` (felhomConfirm) affordance. A
|
||||||
|
feature-detected **"Megosztás…"** button (v0.165.1) opens the OS share sheet via `navigator.share`
|
||||||
|
(title + text + URL only — never the QR as a `files:` attachment); hidden unless the browser
|
||||||
|
supports it, with "Link másolása" as the universal fallback (the non-cancel rejection path falls
|
||||||
|
back to it too).
|
||||||
|
|
||||||
Design ruling: member accounts are superseded by this capability-URL model; per-member tile
|
Design ruling: member accounts are superseded by this capability-URL model; per-member tile
|
||||||
visibility is parked under the SSO arc.
|
visibility is parked under the SSO arc.
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Native "Megosztás…" button in the share modal (v0.165.1). Renders the ADMIN launcher template with
|
||||||
|
// sharing enabled and asserts the Web Share API wiring: hidden-by-default button, feature-detect
|
||||||
|
// reveal, a title/text/url-only payload (never a QR file), and the non-cancel fallback to copy.
|
||||||
|
|
||||||
|
func renderAdminLauncherModal(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
return renderBackupPage(t, "launcher", map[string]interface{}{
|
||||||
|
"Page": "launcher", "Title": "Indítópult", "Domain": "demo-felhom.eu",
|
||||||
|
"Version": "test",
|
||||||
|
"ShareEnabled": true,
|
||||||
|
"ShareURL": "https://felhom.demo-felhom.eu/s/REDACTED-TOKEN",
|
||||||
|
"SharePasswordSet": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group A — markup + payload shape.
|
||||||
|
// COMPANION red-proof (REPORT): drop the `style="display:none"` from the button (reveal it in markup)
|
||||||
|
// → the hidden-by-default assertion FAILS.
|
||||||
|
func TestShareModal_NativeShareButton(t *testing.T) {
|
||||||
|
html := renderAdminLauncherModal(t)
|
||||||
|
|
||||||
|
if !strings.Contains(html, `id="share-native-btn"`) {
|
||||||
|
t.Error("the native share button must be present in the modal")
|
||||||
|
}
|
||||||
|
// Hidden by default in the markup — JS only ever reveals it.
|
||||||
|
if !strings.Contains(html, `id="share-native-btn" class="btn btn-outline" style="display:none"`) {
|
||||||
|
t.Error("the native share button must be display:none in the initial markup")
|
||||||
|
}
|
||||||
|
// Feature-detect reveal in the IIFE.
|
||||||
|
if !strings.Contains(html, "if(navigator.share)") {
|
||||||
|
t.Error("missing navigator.share feature-detect reveal")
|
||||||
|
}
|
||||||
|
// Payload carries title/text/url and NOTHING else — no files key.
|
||||||
|
for _, k := range []string{"title:'Indítópult", "text:'Az otthoni alkalmazások egy helyen.'", "url:inp.value"} {
|
||||||
|
if !strings.Contains(html, k) {
|
||||||
|
t.Errorf("nativeShare payload missing %q", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(html, "files:") {
|
||||||
|
t.Error("nativeShare must NOT attach files (the QR) to the share payload")
|
||||||
|
}
|
||||||
|
// The universal fallback is not demoted.
|
||||||
|
if !strings.Contains(html, "Link másolása") {
|
||||||
|
t.Error("the copy button (universal fallback) must remain")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group B — fallback wiring.
|
||||||
|
// COMPANION red-proof (REPORT): swap the catch to swallow all errors (drop the copyShareLink() call)
|
||||||
|
// → the fallback-presence assertion FAILS.
|
||||||
|
func TestShareModal_NativeShareFallback(t *testing.T) {
|
||||||
|
html := renderAdminLauncherModal(t)
|
||||||
|
if !strings.Contains(html, "AbortError") {
|
||||||
|
t.Error("a user-cancel (AbortError) must be silently ignored")
|
||||||
|
}
|
||||||
|
if !strings.Contains(html, "copyShareLink();") {
|
||||||
|
t.Error("a non-abort rejection must fall back to copyShareLink()")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,6 +59,7 @@
|
|||||||
<div class="share-link-row">
|
<div class="share-link-row">
|
||||||
<input id="share-link-input" type="text" readonly value="{{.ShareURL}}">
|
<input id="share-link-input" type="text" readonly value="{{.ShareURL}}">
|
||||||
<button type="button" id="share-copy-btn" class="btn btn-outline" onclick="copyShareLink()">Link másolása</button>
|
<button type="button" id="share-copy-btn" class="btn btn-outline" onclick="copyShareLink()">Link másolása</button>
|
||||||
|
<button type="button" id="share-native-btn" class="btn btn-outline" style="display:none" onclick="nativeShare()">Megosztás…</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="share-qr">
|
<div class="share-qr">
|
||||||
@@ -124,9 +125,19 @@ function copyShareLink(){
|
|||||||
if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(inp.value).then(done,function(){inp.select();done();});}
|
if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(inp.value).then(done,function(){inp.select();done();});}
|
||||||
else{inp.select();try{document.execCommand('copy');}catch(e){}done();}
|
else{inp.select();try{document.execCommand('copy');}catch(e){}done();}
|
||||||
}
|
}
|
||||||
|
// Native OS share sheet (Web Share API, feature-detected). URL + title/text only — never the QR as a
|
||||||
|
// file: file-share support is narrow and several targets drop the URL when handed file+URL. On a
|
||||||
|
// non-cancel rejection, fall back to the universal clipboard copy so the user still keeps the link.
|
||||||
|
function nativeShare(){
|
||||||
|
var inp=document.getElementById('share-link-input');
|
||||||
|
if(!inp||!navigator.share)return;
|
||||||
|
navigator.share({title:'Indítópult — {{.Domain}}',text:'Az otthoni alkalmazások egy helyen.',url:inp.value})
|
||||||
|
.catch(function(e){if(e&&e.name==='AbortError')return;copyShareLink();});
|
||||||
|
}
|
||||||
(function(){
|
(function(){
|
||||||
var m=document.getElementById('share-modal');
|
var m=document.getElementById('share-modal');
|
||||||
if(m){m.addEventListener('click',function(e){if(e.target===m)closeShareModal();});}
|
if(m){m.addEventListener('click',function(e){if(e.target===m)closeShareModal();});}
|
||||||
|
if(navigator.share){var nb=document.getElementById('share-native-btn');if(nb)nb.style.display='';}
|
||||||
{{if .ShareFlash}}openShareModal();{{end}}
|
{{if .ShareFlash}}openShareModal();{{end}}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user