From abf8ec317cf4c5e32ecbfe73034f9dc0a58d5053 Mon Sep 17 00:00:00 2001 From: James Griffin Date: Sat, 29 Aug 2026 19:36:00 -0300 Subject: [PATCH 1/2] Let the "Open on" button choose a browser The StopTheMadness rules are indiscriminate, which is the point, but they catch the link on the way back out as well: in the browser they are installed in, "Open on " redirects straight back here. The one button meant to reach the app is the one that cannot. Handing the address to a different browser is the way past it, and the only way to do that from a page is that browser's own URL scheme. `/` gets a picker for which one; the choice lives in that browser's localStorage, because which browsers are installed is a fact about the device and the phone's answer is not the Mac's. The scheme table is its own module so a test can pin it -- every browser spells it differently, and Edge differs between macOS and iOS. Firefox has no scheme on macOS, so it is only offered on iOS, and an unknown or schemeless choice keeps the plain link rather than producing a dead one. The markup still carries the plain https address and the script swaps it afterwards, so nothing changes without JavaScript. Once a browser is chosen the button says which, since a scheme for a browser that is not installed opens nothing and the tap would otherwise be silent. Closes #7 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KF5YF3iZVKbezwALap8LYd --- CLAUDE.md | 4 +++ README.md | 28 ++++++++++++++++++ public/app.css | 24 +++++++++++++++ public/app.js | 64 ++++++++++++++++++++++++++++++++++++++++ public/browsers.js | 64 ++++++++++++++++++++++++++++++++++++++++ src/render/index-page.ts | 8 +++++ test/browsers.test.ts | 47 +++++++++++++++++++++++++++++ tsconfig.json | 4 +++ 8 files changed, 243 insertions(+) create mode 100644 public/browsers.js create mode 100644 test/browsers.test.ts 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, -- 2.54.0 From 899b6e38d854bb8f68702c03e0b576528eef6bbd Mon Sep 17 00:00:00 2001 From: James Griffin Date: Sat, 29 Aug 2026 19:40:39 -0300 Subject: [PATCH 2/2] No browser chosen, no "Open on" button The plain address is the one thing that button must not offer -- followed in the browser the rewrite rules are installed in, it comes straight back here -- so there is nothing to show until there is a browser to hand it to. The copy button and the selectable URL were always the part carrying the weight; the open link now ships hidden and the script reveals it along with the scheme. That also settles what the button should say. It went back to "Open on ": the browser's name was there to explain a tap that went nowhere, and a button that is not shown until it works needs no such explanation. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KF5YF3iZVKbezwALap8LYd --- README.md | 11 ++++++----- public/app.js | 19 ++++++++----------- public/browsers.js | 2 +- src/render/challenge.ts | 2 +- src/render/layout.ts | 9 +++++++-- test/render.test.ts | 6 +++++- 6 files changed, 28 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 7959933..9ab0e8a 100644 --- a/README.md +++ b/README.md @@ -95,11 +95,12 @@ 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. +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 diff --git a/public/app.js b/public/app.js index b233a98..3caef02 100644 --- a/public/app.js +++ b/public/app.js @@ -1,7 +1,7 @@ // 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'; +import { browsersFor, detectOs, openUrlFor } from './browsers.js'; const VIEW_KEY = 'antisocial:view'; const BROWSER_KEY = 'antisocial:browser'; @@ -48,24 +48,21 @@ function storedBrowser() { } } -// 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. +// 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(); - 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}`; + link.hidden = href === link.dataset.original; } } diff --git a/public/browsers.js b/public/browsers.js index 2db34fe..e9a6c82 100644 --- a/public/browsers.js +++ b/public/browsers.js @@ -47,7 +47,7 @@ export function browsersFor(os) { return BROWSERS.filter((browser) => browser.id === 'default' || browser[os]); } -export function browserById(id) { +function browserById(id) { return BROWSERS.find((browser) => browser.id === id); } 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