diff --git a/CLAUDE.md b/CLAUDE.md index 3f5bb7c..0c51bf5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -183,6 +183,10 @@ Login walls are not passed through — those need an account and cannot be solve - **No ESLint** — `typescript-eslint` does not support TS 7 yet. `npm run typecheck` is the lint step. - Tests are `node:test` against captured fixtures. No network in the test suite. +- `public/` is served as-is to the browser, so what is in there is plain JS, not TS. + `public/browsers.js` — the per-browser URL schemes behind the "open in" picker — is + a module rather than more of `app.js` so a test can import it; that is what `allowJs` + in `tsconfig.json` is for. - Comments explain *why*, especially where the code looks odd because a platform is odd. Match that; do not add narration of what the next line does. - Post text comes from strangers: everything goes through the `html` tagged template or diff --git a/README.md b/README.md index 22e0f3c..9ab0e8a 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,35 @@ button hands back. `/` serves these rules with the live hostname already filled in, if you'd rather copy them from there. +## Opening the original + +The rules are indiscriminate, which is the point — but that catches the way back out +too. Tapping "Open on Instagram" in the browser the rules are installed in redirects +straight back here, so the one button meant to get you to the app is the one button that +cannot. + +The way past it is to hand the address to a *different* browser, by its own URL scheme. +`/` has a picker for which one: + +| Browser | macOS | iOS | +| --- | --- | --- | +| Chrome | `googlechromes://…` | `googlechromes://…` | +| Edge | `microsoft-edge:https://…` | `microsoft-edge-https://…` | +| Orion | `orion://open-url?url=…` | `orion://open-url?url=…` | +| Firefox | — | `firefox://open-url?url=…` | + +Firefox on macOS registers no scheme, so it is not offered there; nor is Safari itself, +which is the browser being escaped. The choice is kept in that browser's own +`localStorage` — not a cookie and not synced, because "which browser is installed" is a +fact about the device, not about you, and the phone's answer is not the Mac's. + +Until a browser is chosen there is no "Open on …" button at all — the plain address is +the one thing it must not offer, since following it only comes back here. The markup +carries that address anyway, hidden, and the script swaps in the scheme and reveals the +button once there is somewhere to send it. So with JavaScript off, or with the default +left alone, the copy button and the selectable URL are the whole of it, which is all +that was ever load-bearing. + ## How it works Every request drives a real Chromium page load. One code path, and it survives markup diff --git a/public/app.css b/public/app.css index a0c150f..d4d64ad 100644 --- a/public/app.css +++ b/public/app.css @@ -242,6 +242,30 @@ main { max-width: 680px; margin: 0 auto; } .index__table td { padding: 8px 8px 8px 0; border-top: 1px solid var(--line); vertical-align: middle; } .index__table code { overflow-wrap: anywhere; } +.picker { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 10px; + margin-top: 18px; + padding-top: 14px; + border-top: 1px solid var(--line); +} +/* The control ships hidden and is revealed by the script, and `display: + flex` above would otherwise beat the browser's own rule for [hidden]. */ +.picker[hidden] { display: none; } +.picker__label { font-size: 13px; color: var(--ink-dim); } +.picker__select { + font: inherit; + font-size: 14px; + padding: 7px 10px; + border-radius: 999px; + border: 1px solid var(--line); + background: var(--card); + color: var(--ink); +} +.picker__note { flex-basis: 100%; margin: 0; font-size: 12px; color: var(--ink-dim); } + /* ---------- verification puzzle ---------- */ .challenge__lede { margin: 14px; font-size: 14px; color: var(--ink-dim); } diff --git a/public/app.js b/public/app.js index 9511095..3caef02 100644 --- a/public/app.js +++ b/public/app.js @@ -1,7 +1,10 @@ // Progressive enhancement only. Without this file the page still shows the // media in a swipeable rail and the original URL as selectable text. +import { browsersFor, detectOs, openUrlFor } from './browsers.js'; + const VIEW_KEY = 'antisocial:view'; +const BROWSER_KEY = 'antisocial:browser'; function setupCopy() { for (const button of document.querySelectorAll('.copy')) { @@ -37,6 +40,62 @@ function setupCopy() { } } +function storedBrowser() { + try { + return localStorage.getItem(BROWSER_KEY) ?? 'default'; + } catch { + return 'default'; + } +} + +// The markup carries the plain address, which is the one thing the button +// must not offer: followed in the browser the rewrite rules are installed +// in, it redirects straight back to this page. So the link ships hidden and +// is only revealed once a browser has been picked to hand it to. With no +// choice made -- or none this system has a scheme for -- the copy button +// and the URL below it are the whole of it. +function applyBrowser(id) { + const os = detectOs(); + + for (const link of document.querySelectorAll('.original__open')) { + link.dataset.original ??= link.getAttribute('href'); + + const href = openUrlFor(link.dataset.original, id, os); + link.href = href; + link.hidden = href === link.dataset.original; + } +} + +// Only on the index page, and only with this file running: the choice is +// useless without the rewriting above, so the control ships hidden. +function setupBrowserPicker() { + const select = document.querySelector('.picker__select'); + if (!select) return; + + for (const browser of browsersFor(detectOs())) { + const option = document.createElement('option'); + option.value = browser.id; + option.textContent = browser.label; + select.append(option); + } + + // A stored id with no scheme on this system would leave the control + // showing nothing at all, so fall back rather than render a blank. + const stored = storedBrowser(); + select.value = [...select.options].some((option) => option.value === stored) ? stored : 'default'; + + select.addEventListener('change', () => { + try { + localStorage.setItem(BROWSER_KEY, select.value); + } catch { + // Private browsing. The choice just won't survive the page. + } + applyBrowser(select.value); + }); + + select.closest('.picker').hidden = false; +} + // One per rail: a post can carry several -- a thread of them, or a post and // the post it quotes -- and wiring only the first leaves the rest inert. function setupMedia(media) { @@ -127,6 +186,8 @@ function setupComments() { } setupCopy(); +applyBrowser(storedBrowser()); +setupBrowserPicker(); for (const media of document.querySelectorAll('.media')) setupMedia(media); setupComments(); diff --git a/public/browsers.js b/public/browsers.js new file mode 100644 index 0000000..e9a6c82 --- /dev/null +++ b/public/browsers.js @@ -0,0 +1,64 @@ +// The rewrite rules that send a shared link here catch the original link on +// the way back out too, so in Safari "Open on " lands back on this +// page. Handing the address to a different browser is the way out, and the +// only way to do that from a web page is that browser's own URL scheme -- +// which every one of them spells differently. Some swap the scheme, some +// prefix it, some take the whole address as a query parameter. + +/** http -> googlechrome://, https -> googlechromes:// */ +const chrome = (url) => url.replace(/^http(s?):/, 'googlechrome$1:'); + +// Edge keeps the original scheme either way, but on macOS it stays in the +// address and the scheme is prefixed, while on iOS it is folded into the +// scheme itself. +const edgeMacos = (url) => `microsoft-edge:${url}`; +const edgeIos = (url) => url.replace(/^http(s?):/, 'microsoft-edge-http$1:'); + +/** Firefox and Orion take the address as a parameter instead. */ +const openUrl = (scheme) => (url) => `${scheme}://open-url?url=${encodeURIComponent(url)}`; + +// `null` means the browser is there but ships no scheme to reach it on that +// system, so there is nothing to offer beyond the plain link. +export const BROWSERS = [ + { id: 'default', label: 'Default browser', macos: null, ios: null }, + { id: 'chrome', label: 'Chrome', macos: chrome, ios: chrome }, + { id: 'edge', label: 'Edge', macos: edgeMacos, ios: edgeIos }, + { id: 'firefox', label: 'Firefox', macos: null, ios: openUrl('firefox') }, + { id: 'orion', label: 'Orion', macos: openUrl('orion'), ios: openUrl('orion') }, +]; + +/** + * iPadOS calls itself MacIntel, so the platform string alone cannot tell the + * two apart; the touch points can. Everything else gets the desktop table -- + * the only desktop this is ever opened on is a Mac. + * + * @param {{ platform?: string, maxTouchPoints?: number }} [nav] + */ +export function detectOs(nav = globalThis.navigator) { + const platform = nav?.platform ?? ''; + if (/^iP(hone|ad|od)/.test(platform)) return 'ios'; + if (platform === 'MacIntel' && (nav?.maxTouchPoints ?? 0) > 1) return 'ios'; + return 'macos'; +} + +/** The browsers worth offering here: the default, plus the ones this system + * actually has a scheme for. */ +export function browsersFor(os) { + return BROWSERS.filter((browser) => browser.id === 'default' || browser[os]); +} + +function browserById(id) { + return BROWSERS.find((browser) => browser.id === id); +} + +/** + * The address to open. A browser with no scheme on this system, an unknown + * id, or anything that is not an ordinary web link is handed back untouched: + * a dead custom scheme opens nothing at all, which is worse than the plain + * link opening in the wrong browser. + */ +export function openUrlFor(url, browserId, os) { + const rewrite = browserById(browserId)?.[os]; + if (!rewrite || !/^https?:\/\//i.test(url)) return url; + return rewrite(url); +} diff --git a/src/render/challenge.ts b/src/render/challenge.ts index b8606b7..e613826 100644 --- a/src/render/challenge.ts +++ b/src/render/challenge.ts @@ -28,7 +28,7 @@ export function renderChallenge(challenge: Challenge, box: Box | undefined): str