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..7959933 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,34 @@ 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. + +The link in the page is always the plain `https://` address, and the scheme is swapped in +by the script afterwards; with JavaScript off, or with the default left alone, nothing +changes. Once a browser is chosen the button says which one, because a scheme for a +browser that isn't installed opens nothing at all, and a silent tap needs an +explanation. The URL underneath stays selectable either way. + ## 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..b233a98 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 { browserById, 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,65 @@ function setupCopy() { } } +function storedBrowser() { + try { + return localStorage.getItem(BROWSER_KEY) ?? 'default'; + } catch { + return 'default'; + } +} + +// The markup carries the plain address, so the link still goes somewhere +// with this file missing. Only once a browser has been chosen is the href +// swapped for that browser's scheme -- and the name goes onto the button, +// because a scheme for a browser that is not installed opens nothing and +// the tap would otherwise be a silent no-op. +function applyBrowser(id) { + const os = detectOs(); + const label = browserById(id)?.label; + + for (const link of document.querySelectorAll('.original__open')) { + link.dataset.original ??= link.getAttribute('href'); + link.dataset.label ??= link.textContent.trim(); + + const href = openUrlFor(link.dataset.original, id, os); + link.href = href; + link.textContent = href === link.dataset.original + ? link.dataset.label + : `${link.dataset.label} in ${label}`; + } +} + +// 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 +189,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..2db34fe --- /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]); +} + +export 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/index-page.ts b/src/render/index-page.ts index ed5f58d..8aeb952 100644 --- a/src/render/index-page.ts +++ b/src/render/index-page.ts @@ -30,6 +30,14 @@ export function renderIndex(platforms: readonly PlatformSpec[]): string { )} + `; return layout('antisocial', body); diff --git a/test/browsers.test.ts b/test/browsers.test.ts new file mode 100644 index 0000000..fda21f6 --- /dev/null +++ b/test/browsers.test.ts @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { browsersFor, detectOs, openUrlFor } from '../public/browsers.js'; + +const URL = 'https://www.instagram.com/p/ABC/?a=1&b=2'; + +test('each browser gets the scheme it actually answers to', () => { + assert.equal(openUrlFor(URL, 'chrome', 'macos'), 'googlechromes://www.instagram.com/p/ABC/?a=1&b=2'); + assert.equal(openUrlFor(URL, 'chrome', 'ios'), 'googlechromes://www.instagram.com/p/ABC/?a=1&b=2'); + assert.equal(openUrlFor('http://example.com/a', 'chrome', 'ios'), 'googlechrome://example.com/a'); + + // Edge folds the scheme into its own on iOS and prefixes it on macOS. + assert.equal(openUrlFor(URL, 'edge', 'macos'), `microsoft-edge:${URL}`); + assert.equal(openUrlFor(URL, 'edge', 'ios'), 'microsoft-edge-https://www.instagram.com/p/ABC/?a=1&b=2'); + + // The parameter form has to be encoded, or the original query string ends + // up read as the opener's own. + assert.equal(openUrlFor(URL, 'orion', 'macos'), + `orion://open-url?url=${encodeURIComponent(URL)}`); + assert.equal(openUrlFor(URL, 'firefox', 'ios'), + `firefox://open-url?url=${encodeURIComponent(URL)}`); +}); + +test('a browser with no scheme on this system keeps the plain link', () => { + // Firefox on macOS has none, and a dead scheme opens nothing at all -- + // worse than opening in the wrong browser. + assert.equal(openUrlFor(URL, 'firefox', 'macos'), URL); + assert.equal(openUrlFor(URL, 'default', 'ios'), URL); + assert.equal(openUrlFor(URL, 'nonesuch', 'ios'), URL); +}); + +test('only ordinary web links are rewritten', () => { + assert.equal(openUrlFor('mailto:a@b.c', 'chrome', 'ios'), 'mailto:a@b.c'); + assert.equal(openUrlFor('/reddit/r/a/comments/b', 'chrome', 'ios'), '/reddit/r/a/comments/b'); +}); + +test('only the browsers reachable on that system are offered', () => { + assert.deepEqual(browsersFor('macos').map((b) => b.id), ['default', 'chrome', 'edge', 'orion']); + assert.deepEqual(browsersFor('ios').map((b) => b.id), ['default', 'chrome', 'edge', 'firefox', 'orion']); +}); + +test('an iPad is told from a Mac by its touch points, not its platform string', () => { + assert.equal(detectOs({ platform: 'iPhone', maxTouchPoints: 5 }), 'ios'); + assert.equal(detectOs({ platform: 'MacIntel', maxTouchPoints: 5 }), 'ios'); + assert.equal(detectOs({ platform: 'MacIntel', maxTouchPoints: 0 }), 'macos'); + assert.equal(detectOs({}), 'macos'); +}); diff --git a/tsconfig.json b/tsconfig.json index 5d673f2..9f09426 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,6 +17,10 @@ // Source imports carry the real `.ts` specifier so `node --experimental- // strip-types` can run the tree directly for dev, tests and the resolve // CLI. tsc rewrites them to `.js` on the way into dist. + // The client-side scheme table is plain JS, because the browser loads it + // as-is out of `public`. Its types are inferred so the test can check it. + "allowJs": true, + "allowImportingTsExtensions": true, "rewriteRelativeImportExtensions": true,