0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

パソコン用アプリのダウンロードページで、スマホの人にだけ「自分に送る」案内を出す(判定と共有のフォールバック)

0
Posted at

パソコンで動かすアプリ(zip を落として開くもの)のダウンロードページを作りました。ところが、SNS から来る人の多くはスマホです。スマホでは zip を落としても使えないので、スマホで来た人にだけ「このアプリはパソコン用です」という案内と、「このページを自分に送る」ボタンを出すことにしました。

この記事では、その仕組み(mobile.js、80行ほど)を紹介します。

  • スマホかどうかの判定(判定できないときはパソコン扱い)
  • 「自分に送る」ボタン:共有メニュー → コピー → URLを表示、の順に切り替える
  • CSP でインラインのスクリプトを禁止しているページへの組み込み方
  • Node でブラウザの部品を模擬してテストする方法

確認した環境:Node.js v24.20.0、macOS の Google Chrome(headless)。

1. 方針:出さないほうに倒す

このページの主役は、パソコン向けのダウンロードボタンです。案内の判定をまちがえてパソコンの人に出すと、ボタンの上に関係のない枠が出てしまいます。そこで、決まりを次のようにしました。

  • 案内の枠は、HTML では最初から hidden にしておく
  • スクリプトが「スマホだ」とはっきり言えたときだけ hidden を外す
  • 判定できない・途中でエラーになったときは、何もしない(=パソコン扱い)
<aside class="mobile-note" id="mobileNote" hidden aria-label="スマホでご覧の方へ">

JavaScript が動かない環境でも、今までどおりのパソコン向けのページのまま表示されます。

2. スマホの判定:UA と matchMedia のどちらか

判定は2本立てです。どちらか一方が「スマホ」と言えば、案内を出します。

  var PHONE_UA = /iPhone|iPod|Android.+Mobile|Mobile.+Firefox|Windows Phone|IEMobile|BlackBerry|Opera Mini/i;
  var PHONE_MQ = '(max-width: 760px) and (pointer: coarse)';

  // nav … navigator、mm … matchMedia(どちらも無ければ false=パソコン扱い)
  function isPhone(nav, mm) {
    var byUA = false, byMQ = false;
    try { byUA = !!(nav && typeof nav.userAgent === 'string' && PHONE_UA.test(nav.userAgent)); } catch (e) { byUA = false; }
    try { byMQ = !!(typeof mm === 'function' && mm(PHONE_MQ).matches === true); } catch (e) { byMQ = false; }
    return byUA || byMQ;
  }
  • UA(ユーザーエージェント):iPhone や、Android のあとに Mobile が続くもの(Android のスマホ)を見ます。タブレットの Android は Mobile が付かないことが多いので、ここでは拾いません。Instagram のアプリ内ブラウザも、UA に iPhone などが入っているので、この正規表現で拾えます。
  • matchMedia:「幅が 760px 以下」かつ「指で触る端末(pointer: coarse)」のときだけ。パソコンのウィンドウを細くしただけでは、pointer が fine のままなので出ません。
  • matchMedia が無い・例外を投げる・matches が true でない、のどれでも false になります。=== true と書いているのは、結果がはっきりしないときに出さないためです。

UA だけだと、UA をパソコン風に変えている端末を取りこぼします。matchMedia だけだと、横向きや大きめの画面で外れることがあります。そのため、どちらかが当たれば出す形にしました。

3. 「自分に送る」ボタン:3段階のフォールバック

スマホで見つけたページを、あとでパソコンで開いてもらうためのボタンです。メールや LINE、メモなど、送り先は人によって違うので、端末の共有メニュー(Web Share API)に任せます。

  function sendToSelf(win, doc, say) {
    var url = pageUrl(doc, win.location), nav = win.navigator;
    function fallback() {
      return copyText(win, doc, url).then(function (ok) {
        var m = ok ? 'コピーしました。メモやメールに貼って、パソコンで開いてください。'
                   : 'コピーできませんでした。このアドレスをパソコンで開いてください:' + url;
        say(m); return m;
      });
    }
    if (nav && typeof nav.share === 'function') {
      var p;
      try { p = nav.share({ title: TITLE, url: url }); } catch (e) { return fallback(); }
      return Promise.resolve(p).then(function () { say(''); return 'shared'; }, function (e) {
        if (e && e.name === 'AbortError') { say(''); return 'canceled'; }
        return fallback();
      });
    }
    return fallback();
  }

流れは次のとおりです。

  1. navigator.share があれば、共有メニューを開く。渡すのは title と url だけ
  2. 共有メニューを本人が閉じた(AbortError)ときは、何もしない。コピーもしないし、文言も出さない
  3. share が無い、呼んだ時点で例外、AbortError 以外で失敗した、のどれかなら、URL をコピーする
  4. コピーもできなければ、URL をそのまま画面に出す

2 のところが大事です。共有をやめた人に「コピーしました」と出すと、本人がしていない操作を勝手にしたことになります。やめたのなら、そのままにしておきます。

say は、ボタンの下の role="status" の段落に文言を入れる関数です。戻り値('shared' / 'canceled' / 表示した文言)は、テストで確かめるために返しています。

送る URL

送る URL は、ページの <link rel="canonical"> から取ります。SNS から来た人の URL には ?utm_source=... などが付いていることがあるので、それを持ち回らないためです。

  function pageUrl(doc, loc) {
    var c = doc && doc.querySelector && doc.querySelector('link[rel="canonical"]');
    var h = c && c.getAttribute('href');
    if (h && /^https:\/\/tools\.equaglobal\.com\//.test(h)) return h;
    return loc ? loc.origin + loc.pathname : '';
  }

canonical が無いときは、location の origin + pathname を使います。こちらもクエリと # は付きません。

コピーの2段構え

コピーは navigator.clipboard.writeText を先に試し、使えない・失敗したときは、昔からある textarea + execCommand('copy') に切り替えます。

  function copyText(win, doc, text) {
    var nav = win.navigator;
    if (nav && nav.clipboard && typeof nav.clipboard.writeText === 'function') {
      return nav.clipboard.writeText(text).then(function () { return true; }, function () { return legacyCopy(doc, text); });
    }
    return Promise.resolve(legacyCopy(doc, text));
  }

execCommand は非推奨ですが、clipboard が使えない環境のための最後の手段として残しています。これも失敗したら false を返し、URL を表示する文言になります。

4. CSP でインラインのスクリプトを禁止しているページに入れる

このページは CSP(Content-Security-Policy)で、スクリプトを同じサイトのファイルとアクセス解析のものだけに絞っています。

script-src 'self' https://www.googletagmanager.com;

'unsafe-inline' を付けていないので、<script>...</script> に直接書いたコードは動きません。そのため、案内の処理は別ファイルにして、defer で読み込んでいます。

<script src="mobile.js?v=202610282400" defer></script>

defer なので、HTML を読み終わってから実行されます。念のため、ファイルの最後でも読み込みの状態を見ています。

  var M = { isPhone: isPhone, pageUrl: pageUrl, sendToSelf: sendToSelf, setup: setup, PHONE_MQ: PHONE_MQ, TITLE: TITLE };
  if (typeof module === 'object' && module.exports) { module.exports = M; return; }
  var run = function () { try { setup(root, root.document); } catch (e) { /* 失敗してもパソコン用の表示のまま */ } };
  if (root.document.readyState === 'loading') root.document.addEventListener('DOMContentLoaded', run); else run();
  • Node から require されたときは、関数を返すだけで画面には触りません(テスト用)
  • ブラウザでは setup を呼びますが、全体を try で包んでいるので、何かあってもパソコン用の表示のまま残ります

mobile.js 自体は通信をしません。外部への送信も計測もなく、共有やコピーは端末の機能を呼ぶだけです(ページ全体にはアクセス解析が入っていますが、それは別のファイルです)。

5. Node でテストする:window と navigator を手で作る

ブラウザの部品は、テストの中で普通のオブジェクトとして作ります。isPhone は navigator と matchMedia を引数で受け取る形にしてあるので、そのまま差し替えられます。

const mmQ = []; const mm = (hit) => (q) => { mmQ.push(q); return { matches: hit }; };
eq(M.isPhone({ userAgent: IPHONE }, mm(false)), true, '表示条件:iPhoneのUA');
eq(M.isPhone({ userAgent: MAC }, null), false, '判定できない(matchMediaなし)→パソコン扱い');
eq(M.isPhone(undefined, undefined), false, '判定できない(両方なし)→パソコン扱い');
eq(M.isPhone({}, () => { throw new Error('x'); }), false, 'matchMedia がエラー→パソコン扱い');

「出さない」側のテストを多めに入れています。Mac・Windows の UA、matchMedia が無い、例外を投げる、結果が {} で matches が無い、のどれも false になることを確かめています。

共有のテストでは、share や clipboard.writeText を、呼ばれたら記録するだけの関数にします。たとえば「共有をやめたらコピーしない」はこう書いています。

  w = winOf(IPHONE, true, { share: () => Promise.reject(Object.assign(new Error('a'), { name: 'AbortError' })), clipboard: { writeText: (t) => { copied = t; return Promise.resolve(); } } });
  eq(await M.sendToSelf(w, d.doc, (t) => (msg = t)), 'canceled', '共有をやめたら何もしない'); eq(copied, null, 'やめたときはコピーしない');

同じやり方で、次の場面を1つずつ確かめています。

  • share に渡すのが title と canonical の URL だけ(utm_source 付きの location.href を渡しても、送るのは canonical)
  • share が無い → コピーして「コピーしました」
  • share が NotAllowedError で失敗 → コピーに切り替え
  • clipboard が失敗し、execCommand も失敗 → URL を表示
  • clipboard が無く、execCommand は成功 → 「コピーしました」
  • 模擬した window の fetch が一度も呼ばれない。あわせて、mobile.js のソースに fetch(・XMLHttpRequest・sendBeacon・外部 URL などが無いことも文字列で調べる

ページ全体のテスト(スクリプトはインラインが無いか、案内の枠が hidden でダウンロードボタンより上にあるか、など)と合わせて、89件が通っています。

free/test.js: 89 passed, 0 failed

ブラウザでは、headless の Chrome で UA を iPhone にして開くと案内の hidden が外れ、Mac の UA では hidden のまま、という表示の切り替えまでを確かめました。

まとめ

  • 案内は hidden で置き、「スマホだ」と言えたときだけ出す。判定できない・エラーはパソコン扱い
  • 判定は UA と matchMedia('(max-width: 760px) and (pointer: coarse)') のどちらか
  • 「自分に送る」は share → (AbortError なら何もしない)→ clipboard → execCommand → URL 表示
  • 送る URL は canonical から取り、計測用のクエリを持ち回らない
  • CSP でインラインを禁止しているので、別ファイルを defer で読む
  • navigator などを引数で受け取る形にしておくと、Node で模擬してテストしやすい

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?