Let the "Open on" button choose a browser
CI / Typecheck, test, build (pull_request) Successful in 28s

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 <platform>" 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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01KF5YF3iZVKbezwALap8LYd
This commit is contained in:
2026-08-29 19:36:00 -03:00
co-authored by Claude Opus 5
parent 3ff7375320
commit abf8ec317c
8 changed files with 243 additions and 0 deletions
+4
View File
@@ -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 - **No ESLint** — `typescript-eslint` does not support TS 7 yet. `npm run typecheck` is
the lint step. the lint step.
- Tests are `node:test` against captured fixtures. No network in the test suite. - 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. - 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. 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 - Post text comes from strangers: everything goes through the `html` tagged template or
+28
View File
@@ -73,6 +73,34 @@ button hands back.
`/` serves these rules with the live hostname already filled in, if you'd rather copy `/` serves these rules with the live hostname already filled in, if you'd rather copy
them from there. 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 ## How it works
Every request drives a real Chromium page load. One code path, and it survives markup Every request drives a real Chromium page load. One code path, and it survives markup
+24
View File
@@ -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 td { padding: 8px 8px 8px 0; border-top: 1px solid var(--line); vertical-align: middle; }
.index__table code { overflow-wrap: anywhere; } .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 ---------- */ /* ---------- verification puzzle ---------- */
.challenge__lede { margin: 14px; font-size: 14px; color: var(--ink-dim); } .challenge__lede { margin: 14px; font-size: 14px; color: var(--ink-dim); }
+64
View File
@@ -1,7 +1,10 @@
// Progressive enhancement only. Without this file the page still shows the // Progressive enhancement only. Without this file the page still shows the
// media in a swipeable rail and the original URL as selectable text. // 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 VIEW_KEY = 'antisocial:view';
const BROWSER_KEY = 'antisocial:browser';
function setupCopy() { function setupCopy() {
for (const button of document.querySelectorAll('.copy')) { 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 // 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. // the post it quotes -- and wiring only the first leaves the rest inert.
function setupMedia(media) { function setupMedia(media) {
@@ -127,6 +189,8 @@ function setupComments() {
} }
setupCopy(); setupCopy();
applyBrowser(storedBrowser());
setupBrowserPicker();
for (const media of document.querySelectorAll('.media')) setupMedia(media); for (const media of document.querySelectorAll('.media')) setupMedia(media);
setupComments(); setupComments();
+64
View File
@@ -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 <platform>" 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);
}
+8
View File
@@ -30,6 +30,14 @@ export function renderIndex(platforms: readonly PlatformSpec[]): string {
)} )}
</tbody> </tbody>
</table> </table>
<section class="picker" hidden>
<label class="picker__label" for="open-in">Open original links in</label>
<select class="picker__select" id="open-in"></select>
<p class="picker__note">
The rewrite rules catch the original link too, so in Safari Open on comes
straight back here. Another browser is a way out of that. Kept on this device.
</p>
</section>
</article>`; </article>`;
return layout('antisocial', body); return layout('antisocial', body);
+47
View File
@@ -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:[email protected]', 'chrome', 'ios'), 'mailto:[email protected]');
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');
});
+4
View File
@@ -17,6 +17,10 @@
// Source imports carry the real `.ts` specifier so `node --experimental- // Source imports carry the real `.ts` specifier so `node --experimental-
// strip-types` can run the tree directly for dev, tests and the resolve // strip-types` can run the tree directly for dev, tests and the resolve
// CLI. tsc rewrites them to `.js` on the way into dist. // 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, "allowImportingTsExtensions": true,
"rewriteRelativeImportExtensions": true, "rewriteRelativeImportExtensions": true,