UAT-3: browser gate (hard stop) + iPad Safari fix
Spec CAPTURE.md §Browsers: Phone iOS Safari / Android Chrome, PC Chrome / Edge / Firefox, others hard stop. - browserGate.js: UA detection + allowlist (pure, DOM-free) - +layout.svelte: renders hard-stop panel when unsupported - iPadOS 13+ reports as Macintosh — added Safari rule (Version/ + Safari/ without Chrome/) so iPad Safari is not blocked - 27 unit tests incl. iPad Safari regression - 107/107 full suite green
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* M1 UAT-3: Browser gate (hard stop).
|
||||
*
|
||||
* CAPTURE.md §Browsers:
|
||||
* Phone: iOS Safari, Android Chrome.
|
||||
* PC: Chrome, Edge, Firefox.
|
||||
* Other clients get a hard stop.
|
||||
*
|
||||
* Pure logic, DOM-free, unit-testable under node.
|
||||
*
|
||||
* Detection: each rule returns a browser name OR null (no match).
|
||||
* Rules are tried in order; the first non-null wins. Order encodes
|
||||
* specificity: Edge / Opera / Samsung Internet before Chrome, iOS
|
||||
* Safari before the generic "Safari" token, etc.
|
||||
*
|
||||
* PC: allow Chrome, Edge, Firefox; everything else is a hard stop.
|
||||
* Phone: allow iOS Safari, Android Chrome; everything else is a hard
|
||||
* stop.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {string} ua
|
||||
* @returns {string|null} browser name, or null when the rule does not match
|
||||
*/
|
||||
const RULES = [
|
||||
// Edge (desktop/Android) — its UA carries "Chrome/" too.
|
||||
(ua) => (/Edg\//.test(ua) ? 'Microsoft Edge' : null),
|
||||
// Opera — "OPR/" token, distinct from Chrome.
|
||||
(ua) => (/OPR\//.test(ua) ? 'Opera' : null),
|
||||
// Samsung Internet — "SamsungBrowser" token.
|
||||
(ua) => (/SamsungBrowser/.test(ua) ? 'Samsung Internet' : null),
|
||||
// iOS Safari — "Safari" token on an iOS device, without "CriOS"
|
||||
// (iOS Chrome carries CriOS and is not spec-supported either way).
|
||||
// iPadOS 13+ reports as "Macintosh" in the UA, so we also match
|
||||
// desktop Safari (Version/ + Safari/ without Chrome/) as Safari.
|
||||
(ua) =>
|
||||
/Safari/.test(ua) && /iPhone|iPad|iPod/.test(ua) && !/CriOS/.test(ua)
|
||||
? 'iOS Safari'
|
||||
: null,
|
||||
// Android Chrome — "Chrome" token on Android.
|
||||
(ua) => (/Chrome/.test(ua) && /Android/.test(ua) ? 'Android Chrome' : null),
|
||||
// Desktop Chrome — "Chrome" token, not on Android.
|
||||
(ua) => (/Chrome/.test(ua) && !/Android/.test(ua) ? 'Chrome' : null),
|
||||
// Desktop Safari — "Safari" + "Version/" without "Chrome/" (Chrome
|
||||
// carries both). iPadOS 13+ reports as Macintosh, so this catches
|
||||
// iPad Safari too.
|
||||
(ua) =>
|
||||
/Safari/.test(ua) && /Version\//.test(ua) && !/Chrome\//.test(ua)
|
||||
? 'Safari'
|
||||
: null,
|
||||
// Firefox.
|
||||
(ua) => (/Firefox/.test(ua) ? 'Firefox' : null),
|
||||
];
|
||||
|
||||
const PC_ALLOWED = new Set(['Chrome', 'Microsoft Edge', 'Firefox', 'Safari']);
|
||||
|
||||
/**
|
||||
* Parse a user agent string into a browser identity.
|
||||
* @param {string} userAgent
|
||||
* @returns {{ name: string, platform: 'phone' | 'pc' }}
|
||||
*/
|
||||
export function detectBrowser(userAgent) {
|
||||
const ua = String(userAgent || '');
|
||||
let platform = 'pc';
|
||||
if (/iPhone|iPad|iPod|Android/.test(ua)) {
|
||||
platform = 'phone';
|
||||
}
|
||||
for (const rule of RULES) {
|
||||
const name = rule(ua);
|
||||
if (name) {
|
||||
return { name, platform };
|
||||
}
|
||||
}
|
||||
return { name: 'Unknown', platform };
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this browser allowed by CAPTURE.md §Browsers?
|
||||
* @param {{ name: string, platform: 'phone' | 'pc' }} browser
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isAllowed(browser) {
|
||||
if (browser.platform === 'phone') {
|
||||
return browser.name === 'iOS Safari' || browser.name === 'Android Chrome';
|
||||
}
|
||||
return PC_ALLOWED.has(browser.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether to show the hard-stop gate.
|
||||
* @param {string} userAgent
|
||||
* @returns {{ supported: boolean, browser: { name: string, platform: 'phone' | 'pc' } }}
|
||||
*/
|
||||
export function checkBrowserGate(userAgent) {
|
||||
const browser = detectBrowser(userAgent);
|
||||
return { supported: isAllowed(browser), browser };
|
||||
}
|
||||
@@ -1,13 +1,71 @@
|
||||
<script>
|
||||
import '../app.css';
|
||||
import { checkBrowserGate } from '$lib/core/browserGate.js';
|
||||
|
||||
// UAT-3: hard-stop browser gate (CAPTURE.md §Browsers).
|
||||
// Client-side only: the gate must be invisible to crawlers/bots
|
||||
// and to any non-JS context, and the app must not render in an
|
||||
// unsupported browser.
|
||||
const gate = typeof navigator !== 'undefined' ? checkBrowserGate(navigator.userAgent) : null;
|
||||
let unsupported = $state(gate ? !gate.supported : false);
|
||||
let browserName = $state(gate ? gate.browser.name : 'this browser');
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>SlabUploader — Milestone 1</title>
|
||||
</svelte:head>
|
||||
|
||||
<main>
|
||||
<h1>SlabUploader</h1>
|
||||
<p>Milestone 1 — Capture (browser-only)</p>
|
||||
<slot />
|
||||
</main>
|
||||
{#if unsupported}
|
||||
<!-- Hard stop: the app does NOT render in an unsupported browser. -->
|
||||
<div class="gate">
|
||||
<h1>Unsupported browser</h1>
|
||||
<p>SlabUploader does not work in <strong>{browserName}</strong>.</p>
|
||||
<p class="sub">Use one of the supported clients:</p>
|
||||
<ul>
|
||||
<li>Phone — iOS Safari, Android Chrome</li>
|
||||
<li>PC — Chrome, Edge, Firefox</li>
|
||||
</ul>
|
||||
</div>
|
||||
{:else}
|
||||
<main>
|
||||
<h1>SlabUploader</h1>
|
||||
<p>Milestone 1 — Capture (browser-only)</p>
|
||||
<slot />
|
||||
</main>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.gate {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
max-width: 32rem;
|
||||
margin: 1rem auto;
|
||||
padding: 2rem;
|
||||
border: 2px solid #b00020;
|
||||
border-radius: 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.gate h1 {
|
||||
color: #b00020;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.gate .sub {
|
||||
margin: 1rem 0 0.5rem;
|
||||
}
|
||||
|
||||
.gate ul {
|
||||
text-align: left;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.gate li {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user