Merge pull request 'Let the "Open on" button choose a browser' (#8) from choose-browser-for-open-on into main
CI / Typecheck, test, build (push) Successful in 18s
Publish / Build and push (push) Successful in 3m24s
Publish / Move the working version on (push) Successful in 23s

Reviewed-on: #8
This commit was merged in pull request #8.
This commit is contained in:
2026-08-29 22:47:42 +00:00
11 changed files with 254 additions and 4 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
+29
View File
@@ -73,6 +73,35 @@ 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.
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 ## 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); }
+61
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 { 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,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 // 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 +186,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]);
}
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);
}
+1 -1
View File
@@ -28,7 +28,7 @@ export function renderChallenge(challenge: Challenge, box: Box | undefined): str
<footer class="post__foot"> <footer class="post__foot">
<div class="original"> <div class="original">
<button type="button" class="copy" id="give-up">Give up and show me the link</button> <button type="button" class="copy" id="give-up">Give up and show me the link</button>
<a class="original__open" href="${challenge.originalUrl}" rel="noopener noreferrer nofollow" target="_blank"> <a class="original__open" href="${challenge.originalUrl}" rel="noopener noreferrer nofollow" target="_blank" hidden>
Open on ${challenge.platformLabel} Open on ${challenge.platformLabel}
</a> </a>
<p class="original__url"><code>${challenge.originalUrl}</code></p> <p class="original__url"><code>${challenge.originalUrl}</code></p>
+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);
+7 -2
View File
@@ -43,14 +43,19 @@ export function badge(platform: string, label: string): Raw {
} }
/** The copy control, plus the URL itself so it is always selectable even if /** The copy control, plus the URL itself so it is always selectable even if
* the clipboard API is unavailable. */ * the clipboard API is unavailable.
*
* The open link ships hidden: followed in the browser the rewrite rules are
* installed in, it redirects straight back here, so it is only worth showing
* once a browser has been picked to hand it to. The script reveals it, and
* the href in the markup is the plain address it starts from. */
export function originalUrlBlock(originalUrl: string, platformLabel: string): Raw { export function originalUrlBlock(originalUrl: string, platformLabel: string): Raw {
return html`<div class="original"> return html`<div class="original">
<button type="button" class="copy" data-url="${originalUrl}"> <button type="button" class="copy" data-url="${originalUrl}">
<span class="copy__idle">Copy original link</span> <span class="copy__idle">Copy original link</span>
<span class="copy__done" hidden>Copied</span> <span class="copy__done" hidden>Copied</span>
</button> </button>
<a class="original__open" href="${originalUrl}" rel="noopener noreferrer nofollow" target="_blank">Open on ${platformLabel}</a> <a class="original__open" href="${originalUrl}" rel="noopener noreferrer nofollow" target="_blank" hidden>Open on ${platformLabel}</a>
<p class="original__url"><code>${originalUrl}</code></p> <p class="original__url"><code>${originalUrl}</code></p>
</div>`; </div>`;
} }
+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');
});
+5 -1
View File
@@ -105,8 +105,12 @@ test('a failure still hands the link back', () => {
detail: 'TikTok showed a verification puzzle instead of the post.', detail: 'TikTok showed a verification puzzle instead of the post.',
}); });
assert.ok(page.includes('data-url="https://www.tiktok.com/@a/video/1"')); assert.ok(page.includes('data-url="https://www.tiktok.com/@a/video/1"'));
assert.ok(page.includes('Open on TikTok')); assert.ok(page.includes('<code>https://www.tiktok.com/@a/video/1</code>'));
assert.ok(page.includes('verification puzzle')); assert.ok(page.includes('verification puzzle'));
// Following the original link in the browser the rewrite rules are
// installed in only comes back here, so it stays hidden until the script
// has a browser to hand it to.
assert.ok(/<a class="original__open"[^>]* hidden>Open on TikTok<\/a>/.test(page));
}); });
function redditPost(overrides: Partial<Post> = {}): Post { function redditPost(overrides: Partial<Post> = {}): Post {
+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,