Merge pull request 'Let the "Open on" button choose a browser' (#8) from choose-browser-for-open-on into main
Reviewed-on: #8
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user