Chrome拡張のマルチディスプレイ対応でハマった話〜「ディスプレイ番号1でしか動かない」をClaude Codeと直すまで【拡張機能開発ガイド】

JavaScript

自作のChrome拡張「WindowSync — Dual Tab Scroll Sync」に、あるユーザーさんからレビューが届きました。星4つ、ありがたい評価とともに、こんな改善要望が添えられていて——

サブディスプレイを使っている環境だと、メインディスプレイ(ディスプレイ番号1)でしか上下に並べて表示が有効になりません。任意の番号のディスプレイでも並べられると嬉しいです。

「あー、たしかに」と思いつつ、最初に頭をよぎったのは別の疑問でした。そもそもChrome拡張って、ディスプレイの区別なんてつくんだっけ? ここから、一見シンプルに見えて意外と引っかかりどころの多い修正作業が始まりました。

同じように「マルチディスプレイ対応どうやるの?」で手が止まっている人のために、つまずいた箇所と解決法を順を追ってまとめておきます。

レビューがきっかけだった

WindowSyncは、2つのタブを上下(または左右)に並べて、スクロールを同期させる拡張です。コードの比較やドキュメントの読み合わせに便利……なはずが、サブディスプレイ環境だと並べる場所が常にメインディスプレイに固定されてしまう。レビューで指摘されて初めて気づきました。複数モニターで開発している人ほど踏みやすい地雷で、完全に盲点でした。

① そもそも「ディスプレイの区別」はつくのか?

最初に確認したのがここ。実は「デスクトップ」には2種類あって、これを混同すると修正方針を盛大に間違えます。

  • 仮想デスクトップ(Windowsの仮想デスクトップ、macOSのSpaces。いわゆる「デスクトップ1 / デスクトップ2」)
  • 物理ディスプレイ(実際につながっているモニターそのもの。今回の「サブディスプレイ」)

結論から言うと、仮想デスクトップはChrome拡張からは一切触れませんchrome.windows APIが扱えるのはウィンドウのID・位置・サイズ・状態(normal/maximized/minimizedなど)・フォーカスくらいで、「このウィンドウがデスクトップ1にあるか2にあるか」は取得も指定もできないんです。

一方で、物理ディスプレイは chrome.system.display APIでちゃんと取得できます。各ディスプレイの座標やサイズが分かるので、今回の要望は対応可能。レビューの「ディスプレイ番号」という言葉につられて仮想デスクトップの話だと思い込んでいたら、「無理です」と返すところでした。危ない。

const displays = await chrome.system.display.getInfo();
// → 各ディスプレイの bounds(全体)と workArea(タスクバー除く)が取れる

② バグの正体は「プライマリ固定」

APIが使えると分かったので、自分のコードを見直します。ウィンドウ配置を担う handleExecute() の冒頭、ここが犯人でした。

const displays = await chrome.system.display.getInfo();
const primary  = displays.find(d => d.isPrimary) || displays[0];
const { left, top, width, height } = primary.workArea; // ← 常にメインディスプレイ

isPrimary、つまりプライマリディスプレイを問答無用で基準にしていたわけです。どのモニターで作業していようが、並べる先は常にメイン。レビュー通りの挙動で、原因は1行に集約されていました。エラーが出ていたわけではなく「仕様通りに間違っていた」ので、余計に気づきにくいタイプのバグです。

ちなみに bounds ではなく workArea を使っているのは元から正解で、こちらはタスクバーやDockの領域を除いてくれます。ウィンドウがタスクバーの裏に潜らないための地味だけど大事な配慮。

③ 直し方は「今いるディスプレイ」を基準にするだけ

方針はシンプル。「プライマリ固定」をやめて、分割元のタブがいるディスプレイを基準にします。

やることは、分割元ウィンドウの中心座標を求めて、その座標を含むディスプレイを探すだけ。

const displays = await chrome.system.display.getInfo();

// 分割元タブのウィンドウを取得し、その中心座標を求める
const srcWin = await chrome.windows.get(origTab1.windowId);
const cx = srcWin.left + Math.floor(srcWin.width  / 2);
const cy = srcWin.top  + Math.floor(srcWin.height / 2);

// 中心座標を含むディスプレイを探す(なければプライマリ → 先頭にフォールバック)
const target =
  displays.find(d =>
    cx >= d.bounds.left && cx < d.bounds.left + d.bounds.width &&
    cy >= d.bounds.top  && cy < d.bounds.top  + d.bounds.height
  ) ||
  displays.find(d => d.isPrimary) ||
  displays[0];

// bounds ではなく workArea を使う(タスクバー/Dock分を除外)
const { left, top, width, height } = target.workArea;

ポイントは2つ。

  • サブディスプレイは bounds.left1920 のようにオフセットされた座標を持つので、中心座標がどのディスプレイの矩形に収まるかで判定できる
  • 万一どこにも当てはまらなくても、isPrimarydisplays[0] の順でフォールバックするので、最悪でも従来通りには動く

これ以降の「半分に割る計算」や「ウィンドウ生成」のロジックは、上で取った left/top/width/height を使い回しているだけなので、一切触る必要なし。修正範囲が数行に収まるのが分かったところで、実装に移ります。

なお manifest.json には最初から system.display 権限を入れていたので、権限追加も不要でした(過去の自分、えらい)。

④ Claude Codeへの指示の出し方

実装はVSCode上のClaude Codeにお願いしました。AIに改修を頼むとき、雑に「マルチディスプレイ対応して」と投げると、関係ない箇所まで気を利かせて書き換えられて差分が膨らむことがあります。これを防ぐコツは、ファイル名・関数名・修正方針・触ってはいけない範囲を明示すること。

実際に出した指示はこんな具合です。

background.jshandleExecute() を修正してほしい。 現状の問題: 常にプライマリディスプレイの workArea を基準にしているため、サブディスプレイで作業していてもメインにしか並ばない。 修正方針: 基準ディスプレイを「プライマリ固定」から「分割元タブが存在するディスプレイ」に変える。origTab1.windowId のウィンドウ中心座標を求め、その座標を含むディスプレイを bounds で検索。見つからなければ isPrimary、それも無ければ displays[0] にフォールバック。座標は bounds ではなく workArea から取る。 それ以降の分割計算・ウィンドウ生成・restore処理は一切変更しないこと。

「ここだけ直して、あとは触るな」をはっきり伝えるのが効きます。曖昧なお願いほど暴走するのは、人もAIも同じですね。

⑤ 仕上がりの検証〜おまけの改善まで

返ってきた v1.2 を確認したところ、handleExecute() は狙い通りに直っていました。中心座標を含むディスプレイを探し、フォールバックも入り、workArea を参照する——指示と完全に一致。サブディスプレイ側で並べられるようになりました。

そして検証して気づいたのですが、頼んでいない改善も入っていました。

  • 入力バリデーションの追加 — タブIDが正の整数か、2つが別物か、分割モードが正しい値かをチェック
  • スクロール同期のスロットリング — 約8ms間隔で間引いて高頻度イベントの負荷を抑制

これは素直にありがたい。ただし「勝手に入った変更」は良いものでも必ず中身を読むべきで、実際もう一つ見つけました。旧バージョンにあった DIFF_SNAPSHOT(DOM差分ハイライトの中継)が削除されていたんです。

ここで慌てず、送信側のコードも確認します。

grep -n "DIFF_SNAPSHOT" content.js
# → 該当なし

content.js 側にも参照は残っておらず、送信側・中継側の両方から一貫して取り除かれていました。宙に浮いた参照や無反応になる機能はなし。クリーンな削除だったので、これはこれでOK。AIが入れた差分は、良し悪しに関わらず一度自分の目で追う——これが地味に一番大事な工程でした。

ハマらないためのコツ

今回いちばん効いたのは、修正に入る前に「そのAPIで本当に取れるのか」を確かめたことです。

  • 「デスクトップの区別」と言われたら、まず仮想デスクトップ(取れない)と物理ディスプレイ(取れる)を切り分ける
  • ウィンドウ配置は bounds(全体)と workArea(タスクバー除く)を使い分ける。並べるなら基本 workArea
  • AIへの改修依頼は「直す場所」と「触らない場所」をセットで指定する
  • AIが入れた頼んでいない変更こそ、差分を最後まで読む

まとめ

レビューの一言から始まった修正でしたが、振り返ると引っかかりどころは定番ばかりでした。

  • ディスプレイの区別 → 仮想デスクトップは不可、物理ディスプレイは chrome.system.display で可
  • 「メインでしか動かない」系のバグ → だいたい isPrimary 固定を疑う
  • 配置の基準 → 「今ユーザーがいるディスプレイ」を中心座標から判定する
  • AI改修 → 範囲を絞った指示+差分の目視確認

一度こうやって地図を描いてしまえば、次に別の拡張で同じ要望が来てもサッと対応できます。マルチディスプレイ対応で止まっている方の助けになれば幸いです……そして、盲点を教えてくれたレビュアーさんに感謝。

タイトルとURLをコピーしました