📌 AI関連

【続編】自作Chrome拡張を「毎日開く道具」に育てた|ポップアップの伸縮・通知・営業日計算で踏んだ罠

v1.3のポップアップ。左下に営業日カウンターと検索欄、右側に日付コピーとチェックボックスが並ぶ

以前、日本の祝日に対応したカレンダー時計のChrome拡張を自作した記録を書きました。

作ったところまでは良かったのですが、正直に書くとそこまで毎日は開いていませんでした。カレンダーを見たいときに開く、たまにメモを書く。それだけなら、わざわざ自作しなくてもよかったかもしれません。

原因ははっきりしていて、「開かないと何も分からない道具」だったからです。開かなければ予定も出ないし、そもそも開くきっかけがない。

そこで今回は、「毎日開く理由をつくる」方向に育ててみました。この記事はその記録です。前編と同じく、日記で終わらせずにそのまま持ち帰れる情報を軸に構成します。今回お渡しできるのは主にこの3つです。

  • Chrome 137以降、拡張機能のヘッドレステストが動かなくなった件と、その回避方法(コードあり)
  • 「右上が固定」のポップアップを伸び縮みさせるときに踏む座標の罠
  • Manifest V3のサービスワーカーで「開いていなくても働く」を軽く作る型
v1.3のポップアップ。左下に営業日カウンターと検索欄、右側に日付コピーとチェックボックスが並ぶ

目次

  1. 何を足したのか
  2. 【本題1】Chrome 137以降、拡張機能のヘッドレステストが壊れる
  3. 【本題2】ポップアップを伸び縮みさせるときの3つの罠
  4. 【本題3】開いていなくても働かせる(Manifest V3)
  5. 【本題4】祝日を計算していたおかげで「営業日」が数えられた
  6. 高さ600pxの予算管理:機能を足すと詰む
  7. まとめ

何を足したのか

先に一覧です。この記事で技術的に触れるのは太字の3つですが、何のために足したのかが分かるほうが読みやすいと思うので、全部並べます。

足したもの 何のため
アイコンのバッジ 開かなくても「今日の残り件数」が分かる
時刻のお知らせ メモに 10:00 打ち合わせ と書くと5分前に通知
キーボードショートカット Alt+C で開く
ドラッグで伸び縮み 幅・全体の大きさ・時計とメモの取り分
営業日カウンター 「月の営業日」「今日から何営業日後」
日付コピー 2026/09/03 9/3(木) 20260903 をクリックでコピー
メモの検索 過去のメモを全文検索して、その日へ飛ぶ
チェックボックス - [ ] と書いた行をクリックで消せる
クイックリンク 毎日開くページをボタンで並べる

いちばん効いたのはバッジでした。開かなくても数字が見えるようになると、「数字があるから開く」に変わります。道具として毎日使われるかどうかは、機能の多さではなく開くきっかけがあるかで決まるのだと実感しました。


【本題1】Chrome 137以降、拡張機能のヘッドレステストが壊れる

ここがいちばん、他の方の時間を節約できる話だと思います。

症状:拡張機能が読み込まれない

Chrome拡張を自動でテストするとき、これまでは Puppeteer からこう起動するのが定番でした。

const browser = await puppeteer.launch({
  headless: 'new',
  args: [
    `--disable-extensions-except=${EXT}`,
    `--load-extension=${EXT}`,      // ← これ
  ],
});

そのうえで、拡張機能のサービスワーカー(裏で動く部分)を捕まえて操作します。ところが今回、いくら待っても見つかりませんでした。

const target = browser.targets()
  .find(t => t.type() === 'service_worker');
// → undefined

browser.targets() を全部出してみると、返ってくるのは browser と page about:blank の2つだけ。拡張機能そのものが読み込まれていません。

原因:セキュリティ対策で無効化された

調べると、--load-extension はマルウェア対策としてChrome側で無効化されたという話に行き当たります。悪意のあるインストーラーがこのオプションを使って、ユーザーの知らないうちに拡張機能を仕込む手口が実際にあったためです。

無効化されたバージョンについては情報が分かれていて、私自身で確認できたのは手元のChrome 152では動かないという事実だけです。お使いのバージョンで確かめる場合は、上のように browser.targets() を丸ごと出力してみるのがいちばん早いです。

なお、headless: false(画面を出す普通の起動)でも同じでした。ヘッドレスかどうかの問題ではありません。

回避策:Chromeに読み込ませず、APIのほうを偽物にする

拡張機能として読み込めないなら、拡張機能のフリをさせる方向に切り替えます。やり方は2つに分かれます。

(A)画面のあるHTMLは、ローカルサーバーで直接開く

popup.html は普通のHTMLです。ローカルサーバーで配ってやれば、ただのウェブページとして開けます。足りないのは chrome.storage などのAPIだけなので、そこを偽物で埋めます。

# 拡張機能のフォルダで
python -m http.server 8765
await page.evaluateOnNewDocument(() => {
  const box = {};                       // 保存の中身をただのオブジェクトで持つ
  window.__box = box;
  window.chrome = {
    storage: { local: {
      async get(k) { return k in box ? { [k]: box[k] } : {}; },
      async set(o) { Object.assign(box, o); },
      async remove(k) { delete box[k]; },
    }},
    tabs: { create() {} },
    sidePanel: { async open() {} },
  };
});
await page.goto('http://127.0.0.1:8765/popup.html');

これだけで、レイアウトの実測、クリック、ドラッグ、スクリーンショットが全部できます。この記事に載せている数値は、ほぼこの方法で採ったものです。

window.__box を外に出しておくと、「保存が何回走ったか」まで数えられます。あとで出てくる「ドラッグ中の保存は0回」という確認は、この仕掛けでやりました。

(B)裏で動くファイルは、Node.jsの中で読み込む

サービスワーカー(background.js)には画面がありません。こちらはNode.jsで直接importするほうが早いです。importの前にグローバルの chrome を偽物で用意しておくのがコツです。

// bg_test.mjs
const alarms = new Map();
const badge = { text: '', bg: null };
const notifications = [];

globalThis.chrome = {
  action: {
    async setBadgeText({ text }) { badge.text = text; },
    async setBadgeBackgroundColor({ color }) { badge.bg = color; },
  },
  alarms: {
    create(name, opts) { alarms.set(name, opts.when); },
    async getAll() { return [...alarms].map(([name, when]) =>
      ({ name, scheduledTime: when })); },
    async clear(name) { return alarms.delete(name); },
    onAlarm: { addListener: f => listeners.alarm.push(f) },
  },
  notifications: {
    async create(id, opts) { notifications.push({ id, ...opts }); return id; },
  },
  // storage も同様に用意する
};

await import('file:///C:/…/js/background.js');

あとは、偽物の chrome.storage にメモを書き込んで、listeners.alarm に登録された関数を自分で呼べば、「予定の時刻が来たときに何が起きるか」まで確かめられます。時計の針を進める必要すらありません。

実際にこの方法で確認できたのは、こういったことです。

  • 過ぎた予定・チェック済みの行を除いて、バッジの数字が正しいか
  • 15分以内に迫っているときにバッジが赤くなるか
  • 通知の予約時刻が「予定の5分前」になっているか。設定を15分前に変えたら追従するか
  • 設定でオフにしたとき、予約が消えるか
  • 関係ない設定を書き換えたときに、無駄に数え直していないか

この方法の限界

正直に書いておくと、この方法では確かめられないことがあります。

  • 権限(permissions)が足りているか。偽物のAPIは権限を見ないので通ってしまう
  • 通知が実際にデスクトップに出るか。OS側の通知設定に左右される
  • 拡張機能としてのインストール・更新まわり

つまり「ロジックは自動で、つなぎ目は手で」の分担になります。私は最後に、設定画面に「オンにしたらテスト通知を1つ出す」という動きを入れました。自動で確かめられないところは、人が1クリックで確かめられる形にしておく、という妥協です。

設定画面。クイックリンクとお知らせの設定。オンにするとテスト通知が1つ出る

【本題2】ポップアップを伸び縮みさせるときの3つの罠

「文字が小さい」「メモをもっと大きく」と思ったときに、いちいちCSSを書き換えるのは面倒です。そこでドラッグで大きさを変えられるようにしました。結果的に、ここがいちばん学びの多い部分になりました。

左端をドラッグで幅、右下の角で全体の大きさが変わる。3段階の比較

罠1:ポップアップは「右上が固定」

まず幅から手を付けたのですが、最初はうまくいきませんでした。頭では分かっていたつもりでも、実際に触ると違和感がある。

理由はこれです。拡張機能のポップアップは、ツールバーのアイコンの位置を基準に、右上を固定して開きます。つまり幅を広げると、右端はその場に残り、左端だけが左へ伸びていく。

ここに気づくと、置くべき場所が決まります。

  • つまみを右端に置くと、右へ引いても右端は動かない。ウィンドウは左へ伸びるので、カーソルから逃げていくように見える
  • つまみを左端に置くと、左へ引いた方向にそのまま左端が付いてくる。窓の縁を掴んで引っぱる感覚とぴったり合う

そこで、幅を変えるつまみは左端の細い帯にしました。サイドパネルの内側の端をドラッグするのと同じ操作になり、説明しなくても伝わります。

「ボタンをどこに置くか」は見た目の問題だと思われがちですが、ドラッグ操作では物理的な正しさのほうが効きます。動く辺に、つまみを置く。それだけです。

罠2:ページ内の座標を使うと暴走する

次が本命です。ドラッグの距離は、普通なら clientX(ページ内の座標)で測ります。ところが今回、これを使うと止まらなくなりました。

理由は、罠1と同じところにあります。

  1. 幅を10px広げる
  2. ウィンドウの左端が10px左へ動く
  3. カーソルは動いていないのに、ページ内での座標(clientX)が10px増える
  4. その増分をドラッグ量として読んで、さらに10px広げる
  5. 1へ戻る

自分が広げたぶんを自分で拾ってしまう、いわゆる正のフィードバックです。

直し方は、画面全体を基準にした座標(screenX / screenY)を使うこと。こちらはウィンドウが動いても影響を受けません。

handle.addEventListener('pointerdown', (event) => {
  startX = event.screenX;              // clientX ではなく screenX
  startWidth = width;
  handle.setPointerCapture(event.pointerId);
});

handle.addEventListener('pointermove', (event) => {
  // 左へ引くと広がる(右上が固定なので、左端がカーソルに付いてくる)
  width = clamp(startWidth - (event.screenX - startX));
  schedule(width);
});

「ドラッグしている対象が、ウィンドウ自身の大きさ」のときは、ページ内座標を使ってはいけない。これは拡張機能に限らず、ウィンドウのリサイズを自前で書くときに共通する話だと思います。

罠3:高さは倍率に比例しない

全体の大きさを変えるほうにも罠がありました。

ポップアップは高さ600pxが上限なので、「どこまで拡大できるか」を計算で出す必要があります。最初はこう考えました。

いまの高さ 377px ÷ いまの倍率 0.75 = 倍率1あたり 503px
上限600px ÷ 503 = 1.19倍まで拡大できる

ところが実際に1.05倍まで拡大して測ると、高さは589px。もう一度この式で上限を出すと、589 ÷ 1.05 = 561 → 上限0.95倍と、さっきより小さい値が出てきます。拡大するたびに上限が下がっていく。ドラッグしていると「引いているのに縮む」という珍現象になります。

原因は、高さが倍率にきれいに比例しないからです。枠線の1pxや行間の端数、最小の高さ指定など、倍率を上げても伸びない部分が混じっています。式にすると比例(高さ = a × 倍率)ではなく一次関数です。

高さ = a × 倍率 + b     ← b が「倍率で伸びない部分」

だから、1点だけ測って割り算をしてはいけません。2点測って直線に直します。

function measure() {
  const s1 = 0.6, s2 = 1.2;
  apply(s1); const h1 = document.body.offsetHeight;   // 1点目
  apply(s2); const h2 = document.body.offsetHeight;   // 2点目
  apply(scale);                                       // すぐ戻す

  const a = (h2 - h1) / (s2 - s1);   // 倍率1あたり伸びる高さ
  const b = h1 - a * s1;             // 伸びない部分
  maxScale = (600 - b) / a;
}

倍率を一瞬だけ動かして測っていますが、同じ処理の中で元に戻しているので画面はちらつきません。ブラウザは処理が終わってからまとめて描き直すためです。この測定はドラッグを始めるときの1回だけで、動かしている間は測りません。

ちなみに実測すると a ≈ 707、b ≈ -153 でした。b がマイナスなのが面白いところで、「倍率が小さいときほど、比例よりも高さが出る」ことを意味します。最小の高さ指定が効いているぶんですね。1点測りだと、この b を丸ごと a に押し込んでしまうので誤差が出ていたわけです。

おまけ:ドラッグを重くしないための3つの決めごと

「処理は重たくさせずに」という前提で作ったので、決めごとを3つ置きました。

  1. 動かしている間に触るのはCSS変数1つだけ。要素を作り直さない
  2. requestAnimationFrame で1フレーム1回に間引く。マウスが動くたびに描き直さない
  3. 保存はドラッグを離したときだけ

3つ目が地味に大事です。ドラッグ中に保存すると、1秒間に何十回もストレージへ書きに行くことになります。

数えてみた結果がこれです。

マウス移動 CSS変数の書き込み 保存
ドラッグ中 60回 44回 0回
離したとき ― 1回 1回

移動60回に対して書き込みが44回で済んでいるのは、間引きに加えて「ほとんど変わっていないときは書かない」を入れているからです。

function apply(value) {
  // 0.004 未満の差では書き込まない(マウスの細かい揺れで動かさない)
  if (applied !== null && Math.abs(value - applied) < 0.004) return;
  applied = value;
  root.style.setProperty('--ui-scale', String(value));
}

テストで踏んだ小さな罠

ついでに2つ。どちらもAIに書かせたコードの検証中に出たものです。

pointerdown で preventDefault() を呼ぶと、ダブルクリックが死にます。「ダブルクリックで元の大きさに戻す」を付けたのに効かず、しばらく悩みました。文字の選択を止めたいだけなら、CSSの user-select: none と touch-action: none で足ります。

もうひとつ。Puppeteerは本物のダブルクリックを作れません。clickCount: 2 を指定しても click 止まりで dblclick は発生しないので、テストではイベントを直接投げて確かめました。

await page.evaluate(() =>
  document.querySelector('.grip')
    .dispatchEvent(new MouseEvent('dblclick', { bubbles: true })));

「動かない」と判断する前に、テストの道具のほうができないだけではないかを疑うと早いです。


【本題3】開いていなくても働かせる(Manifest V3)

ここからは、バッジと通知の話です。

サービスワーカーは「止まる」ものとして書く

Manifest V3の拡張機能で裏側を担当するのはサービスワーカーです。ここでいちばん大事なのは、用がないとChromeが止めてしまうということです。

つまり、こういう書き方は動きません。

// これはダメ。止められたら消える
let 今日の件数 = 0;
setInterval(() => { 数え直す(); }, 60 * 1000);

変数に覚えさせても消えますし、setInterval も止まります。状態を持たず、起こされたら毎回いちから数え直すのが正解です。

起こしてもらうきっかけは3つに絞りました。

  • chrome.alarms … 予定の時刻が来た/日付が変わった
  • chrome.storage.onChanged … メモが書き換わった
  • chrome.runtime.onStartup … ブラウザが起動した

1分ごとのタイマーは使わない

「バッジの数字を最新に保つ」ために、1分ごとに起こす作りにしたくなります。でもそれは1日1440回、無駄に起こすということです。

代わりに、変わる瞬間にだけ目覚ましを置きました。メモに 10:00 打ち合わせ と書いてあるなら、必要なのはこの3つだけです。

目覚まし いつ 何のため
remind|10:00 9:55 通知を出す
pass|10:00 10:00 過ぎたのでバッジの数字を1つ減らす
roll 翌0:00 日付が変わったので組み直す

予定が3件なら目覚ましは7個。1日中でも数十個で済みます。

予約は「全部消して張り直す」

メモを書き換えるたびに差分を計算して、要らなくなった予約を消して……という作りにすると、必ずどこかでズレます。

そこで毎回すべて消して、いまのメモから作り直すことにしました。乱暴に見えますが、目覚ましの数が数十個なら一瞬ですし、ズレようがないのが何よりの利点です。

async function rearm(day) {
  const olds = await chrome.alarms.getAll();
  await Promise.all(olds
    .filter(a => a.name !== 'roll')
    .map(a => chrome.alarms.clear(a.name)));

  for (const it of day.items) {          // いまのメモから作り直す
    if (it.minutes === null || it.done) continue;
    // …予定の n 分前と、予定の時刻ちょうどに置く
  }
}

同じ考え方は、書き換えのたびに走らせる処理では大抵うまくいきます。差分を管理するより、作り直すほうが壊れにくい。

バッジは「残り」だけ数える

アイコンに出す数字は、今日まだ残っているものに絞りました。

  • 時刻つきの行で、まだ時刻が来ていないもの
  • チェックの付いていないやること(- [ ])
  • 今日の案件の予定

過ぎた予定まで数えると、夕方になっても数字が減らず、見ても嬉しくない数字になります。「今日はもう終わり」を数字のゼロで表せることが、道具として使われるかどうかを分けると思っています。

15分以内に迫っているものがあると赤くしました。数字だけだと切迫感が伝わらないためです。

メモの書き方は「ゆるく」読む

時刻の書き方を厳密に決めると、書くのが面倒になって使わなくなります。そこで、ありがちな書き方を全部受け付けるようにしました。

  • 10:00 打ち合わせ
  • 10:00-11:00 会議(範囲)
  • 10:00 打ち合わせ(全角)
  • - [ ] 10:00 資料を出す(やること+時刻)

全角は、半角に直してから読むだけです。日本語入力のまま書けることは、使い続けられるかどうかにかなり効きます。

const normalize = (text) => String(text || '')
  .replace(/[0-9]/g, c => String.fromCharCode(c.charCodeAt(0) - 0xfee0))
  .replace(/[:]/g, ':');

正直に書いておくこと

通知はChromeを起動している間だけ出ます。閉じている時間帯の予定には出ません。ここは拡張機能の限界なので、設定画面にもそのまま書きました。「動くはず」と思って使われて、出なかったときのほうが困ります。


【本題4】祝日を計算していたおかげで「営業日」が数えられた

前編で、日本の祝日を一覧表ではなく祝日法から計算する形にしました。これが思わぬ形で効きました。

祝日が計算で出せるということは、「土日でも祝日でもない日」も計算で出せるということです。つまり営業日が数えられます。

/** その日が休みか(土日 or 祝日) */
export function isDayOff(date) {
  return date.getDay() === 0 || date.getDay() === 6
      || getHoliday(date) !== null;
}

/** from の翌日から to までにある営業日の数(過去ならマイナス) */
export function workdaysBetween(from, to) {
  // …1日ずつ進めて、休みでない日を数える
}

これで、カレンダーの下にこう出せるようになりました。

9月の営業日 19日 / 残り 17日 / 選んだ日は 今日から3営業日後

実務で「校了は3営業日前まで」といった逆算をする場面があるので、日付をクリックするだけで営業日の距離が出るのは想像以上に便利でした。一覧表を持たない作りにしておくと、来年でも再来年でも数えられるのが効いています。

数え方の定義を先に決める

作るときに悩んだのが「3営業日後」の定義です。今日を含むのか含まないのか。土曜を選んだら何と表示するのか。

決めたのはこの3つです。

  • 今日は0とし、今日より後の営業日を数える
  • 選んだ日が土日祝なら、数を出さずに「お休み」と出す
  • 過去なら「◯営業日前」

数え方に唯一の正解はないので、決めて、画面に書いておくのが実用的でした。「今日から3営業日後」と主語を明示すれば、読み間違えようがありません。

なお、この計算に会社独自の休み(年末年始や夏季休暇)は入りません。ここも正直に書いておかないと、年末に事故ります。


高さ600pxの予算管理:機能を足すと詰む

最後に、機能を足していくと必ずぶつかる話を。

前編で書いたとおり、ポップアップは800×600pxが上限です。機能を足すたびに縦が伸びるので、あるところで上限に当たります。

実測はこうでした。

状態 高さ
前編の時点 377px
今回の機能を足した直後 477px
案件スケジュールをつないだ状態 622px(上限オーバー)

600pxを超えると、Chromeが600pxで切ってスクロールバーを出します。使えないわけではありませんが、開いた瞬間にスクロールバーが出るのは負けた感じがします。

削りどころを探して、実際に手を入れたのは2か所でした。

  • 案件一覧の高さを5件ぶんから2〜3件ぶんに減らした(もともとスクロールする作りなので、実害が小さい)
  • リンクのボタンを折り返さないようにした(flex-wrap: nowrap + 横スクロール)

2つ目は少し説明が要ります。ボタンを折り返すと、登録した数によって高さが変わってしまう。8個登録した人だけポップアップがはみ出す、という事故が起きます。折り返さず横スクロールにすれば、何個登録しても1行のままです。

結果、いちばん条件の悪いとき(6週になる月+案件連携あり+リンク8個)で592pxに収まりました。ここは全14か月ぶん送って確認しています。

「上限で止める」か「はみ出しを許す」か

もうひとつ判断が要ったのが、拡大の上限です。

当初は600pxに収まる範囲でしか拡大できないようにしていました。ところが機能を足して中身が増えると、収まる上限が現在の大きさとほぼ同じになり、引いても大きくならない状態になってしまいました。

安全ではあるのですが、ユーザーから見ると「壊れている」のと区別がつきません。そこで、既定の1.4倍までは必ず拡大できるようにして、はみ出したぶんは縦スクロールを許すことにしました。

// 収まる上限と、最低限確保したい範囲の、大きいほう
maxScale = Math.min(HARD_MAX, Math.max(fit, base * 1.4));

自分で操作した結果なら、はみ出しても納得できる。勝手に止められるほうが困る。触っていて分かったことでした。


まとめ

前編は「作った」話でしたが、今回は「使い続けられる形にした」話でした。持ち帰っていただきたいポイントを整理します。

  • --load-extension は無効化された。拡張機能のテストは、popup.html をローカルサーバーで開いて window.chrome を偽装する方法と、background.js をNodeで globalThis.chrome を偽装してimportする方法の2本立てに切り替える
  • ウィンドウ自身の大きさをドラッグで変えるときは、ページ内座標(clientX)を使わない。自分が広げたぶんを拾って暴走する。画面基準の screenX を使う
  • 拡張機能のポップアップは右上が固定。左端が動くので、幅のつまみは左端に置くと操作が自然になる
  • 高さは倍率に比例しない。1点測って割ると上限がじわじわ下がる。2点測って一次関数に直す
  • Manifest V3のサービスワーカーは止まる。状態を持たず、起こされたら毎回数え直す。定期タイマーではなく「変わる瞬間」に目覚ましを置く
  • 書き換えのたびに走らせる処理は、差分を管理するより全部作り直すほうが壊れにくい
  • 祝日を計算で持っておくと、営業日の計算という副産物が付いてくる
  • 機能を足すときは高さの予算を意識する。折り返すUIは、登録数で高さが変わる事故を起こす

道具として毎日使われるかどうかは、機能の数ではなく開くきっかけがあるかどうかでした。アイコンに数字が出るようにしただけで、開く回数がはっきり変わっています。同じように「作ったけど使っていない」ものをお持ちなら、開かなくても分かる部分を1つ足すのが、いちばん費用対効果が高いかもしれません。

前編もあわせてどうぞ。祝日を計算で出す部分のコードは、そちらに全部載せています。

※当サイトはアフィリエイト広告を利用しています。商品の価格・仕様は変更になる場合があります。