diff --git a/CHANGELOG.md b/CHANGELOG.md index f8ce07e..12f833e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # 変更履歴 +## 0.3.2 — 専用ChromeをWindowsとLinuxへ適合 + +- Linux: SSHやハーネスの実行環境に画面の変数(`WAYLAND_DISPLAY`・`DISPLAY`)が無いと専用Chromeが起動できなかった。ログイン中の画面セッションを持つユーザーのsystemdから(`systemd-run --user`)起動する。画面セッションが無い時は、15秒待たずに理由を示して止まる。 +- Windows: Grok Buildはコマンドの終了時に子孫プロセスをまとめて止めるため、`run` が終わると専用Chromeも終了していた。SSHもジョブごと止める。ChromeをWMI(`Win32_Process.Create`)から起動し、呼び出し元の子孫にもジョブにも入れない。WMIが使えない時は直接起動する。 +- 普段のChromeに0.2系の履歴拡張が残っていると、そのhostが履歴接続の待受先(Unixソケット・名前付きパイプ)を握り、専用Chromeの拡張が接続できなかった(Windowsで発生)。待受先と鍵ファイルを0.2系と分け、hostは専用Chromeの拡張から起動された時だけ待ち受ける。`install` は0.2系が普段のChrome向けに置いたマニフェスト(macOS・Linux)を消す。 +- Windowsで専用拡張のファイルを複製すると改行がCRLFに変わっていた。バイト列のまま複製する。 +- 起動に失敗した理由を `BrowserSetupError` の本文に含める。 + ## 0.3.1 — 履歴拡張の自動読込 - 固定IDの履歴拡張を、専用Chromeのloopback CDP endpointから起動ごとに読み込む。初回のデベロッパーモード操作をなくし、Chrome再起動後も履歴接続を自動で復元する。 diff --git a/README.md b/README.md index 1a90ca7..3f4c369 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,8 @@ jev-bookmarks install --typesafe-env /path/to/typesafe.env --browser-env /path/t jev-bookmarks harness install ``` +更新する時は `git pull` のあとに `uv sync --locked`、`uv tool install --editable . --force`、`jev-bookmarks harness install` を実行します。Windowsでは、`run` が起動したBrowser Harnessの常駐プロセス(`uv\tools\jev-bookmarks\Scripts\python.exe -m browser_harness.daemon`)が動いている間は入れ直しに失敗するので、先に終了させてください。次の `run` で起動し直します。 + `typesafe.env` には `TYPESAFE_API_KEY`、ブラウザ用のenvファイルには上流が必要とするモデル設定を用意します。Chrome接続先はJev Bookmarksが所有するため、`BU_CDP_URL` や `BU_CDP_WS` は読み込みません。秘密の値をこのリポジトリに置かないでください。 `install` は端末共通の設定とChrome Native Messaging hostを登録し、通常のGoogle ChromeをJev Bookmarks専用プロファイルで起動します。Chromeが発行したloopback CDP endpointから、固定IDの履歴拡張を専用プロファイルへ読み込み、Native Messaging hostとの接続まで確認します。手動でデベロッパーモードを有効にする操作はありません。拡張は `history` と `nativeMessaging` の権限を使います。完了時の `status` で `history_connected: true` を確認できます。 @@ -105,15 +107,15 @@ jev-bookmarks forget 'https://example.com/' | 環境 | 状態 | | --- | --- | -| macOS + Google Chrome | 専用プロファイル、専用Harness、公開ページの観測を確認済み。ログイン済みページでの再確認待ち | -| Windows 11 + Google Chrome | 実Chrome履歴→拡張→名前付きパイプ→CLIの往復を確認済み | -| Linux (Ubuntu 26.04) + Google Chrome | 試験用プロファイルで拡張→Unixソケット→CLIの往復を確認済み | +| macOS 27 + Google Chrome 153 | 専用Chromeの起動、履歴拡張の読込、Native Messaging接続、`run` を確認済み | +| Windows 11 + Google Chrome 153 | 同上。対話ログオンのデスクトップと、SSHの両方から確認済み | +| Linux (Ubuntu 26.04 / GNOME Wayland) + Google Chrome 152 | 同上。画面の変数が無いSSHからも、ログイン中の画面セッションへ起動できることを確認済み | | ハーネス | 状態 | | --- | --- | -| Claude Code・Codex・Cursor・Grok Build | 3 OSでスキルを入れ、各ハーネスが認識することを確認済み | +| Claude Code・Codex・Cursor・Grok Build | 0.3.2で、専用Chromeを閉じた状態から各ハーネスがスキル経由で `run` を一度呼び、完了とハーネス終了後の専用Chrome・履歴接続の維持を確認した(Windowsの Claude Code は端末のログイン切れで未確認) | -WindowsとLinuxでは、TypeSafeとブラウザ操作まで含めた `run` の実機確認がまだです。 +専用プロファイルでログインが必要な実サイトの `run` は再確認待ちです。
実機確認と既知の限界 diff --git a/bin/native-host b/bin/native-host index 7d6999b..df7f26f 100755 --- a/bin/native-host +++ b/bin/native-host @@ -1,4 +1,4 @@ #!/bin/sh set -eu PRODUCT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) -exec "$PRODUCT_DIR/.venv/bin/python" -m jev_bookmarks.native_host +exec "$PRODUCT_DIR/.venv/bin/python" -m jev_bookmarks.native_host "$@" diff --git a/docs/design.md b/docs/design.md index 5302503..2bf2b3d 100644 --- a/docs/design.md +++ b/docs/design.md @@ -66,7 +66,7 @@ sequenceDiagram | TypeSafe Jev | 有限個のURL候補から選び、操作後のページが目的に役立つかを判定する。 | | 上流 `jev-ultrafast` | 開始URLと目的を受け取り、Browser Harness経由でChromeを観測・操作する。各操作の選択は上流ループが所有する。上流 browser-use/jev-ultrafast に私たちの修正が入るまでは、フォーク quolu/jev-ultrafast の確認済みの版をコミットで固定して使う。上流に入ったら上流へ戻す。 | -Chrome拡張からホストへは `connectNative` を使う。拡張が起動したホストは同じユーザーだけが接続できるローカルIPCを開き、コマンドからの履歴要求を拡張へ渡す。macOSとLinuxは所有者だけが入れる一時ディレクトリのUnixソケット、Windowsは起動ごとの鍵で相互認証する名前付きパイプを使う。WindowsのChromeはレジストリ(HKCU)に登録したマニフェストを読む。拡張は要求時に履歴を検索して結果を返す。 +Chrome拡張からホストへは `connectNative` を使う。拡張が起動したホストは同じユーザーだけが接続できるローカルIPCを開き、コマンドからの履歴要求を拡張へ渡す。macOSとLinuxは所有者だけが入れる一時ディレクトリのUnixソケット、Windowsは起動ごとの鍵で相互認証する名前付きパイプを使う。WindowsのChromeはレジストリ(HKCU)に登録したマニフェストを読む。hostは専用拡張のoriginから起動された時だけ待ち受け、待受先と鍵ファイルは0.2系(普段のChromeへ読み込んでいた旧拡張)のものと分ける。普段のChromeに旧拡張が残っていても、専用Chromeの接続を横取りしない。`install` は0.2系が普段のChrome向けに置いたマニフェストを消す。拡張は要求時に履歴を検索して結果を返す。 Jev Bookmarksは通常のGoogle Chromeを端末共通データ配下の専用 `user-data-dir` で起動する。Chromeが書いた `DevToolsActivePort` を検証し、そのloopback endpointから固定IDの履歴拡張を起動ごとに読み込む。同じendpointを `BU_CDP_URL`、専用名 `jev-bookmarks` を `BU_NAME` として上流へ渡す。モデル設定ファイルにあるCDP endpointは読まない。これにより、履歴を持つプロファイルと `jev-ultrafast` が操作するプロファイルの一致を製品が所有する。電話帳から入口が選べれば履歴検索は行わないが、起動時には専用拡張との接続を確認する。接続できなければ `BrowserSetupError`、履歴要求の失敗は `HistoryError`、Browser Useの失敗は `BrowserUseError` を返す。 @@ -74,7 +74,7 @@ Jev Bookmarksは通常のGoogle Chromeを端末共通データ配下の専用 `u 共通コードは今のOSやハーネスで分岐しない。OSごとの違いは `platforms/` の一つのファイルに、ハーネスごとの違いは `harnesses/` の一つのファイルに閉じる。 -- OS適合(`platforms/macos.py`・`windows.py`・`linux.py`): 端末共通データの場所、Native Messagingマニフェストの置き場と登録、hostの実行ファイル、履歴接続のローカルIPC(待受と要求)、標準Chromeの探索と起動、CLI起動時の準備を持つ。WindowsはCLIをUTF-8モードで起動し直し、ハーネスへ返すJSONと読み書きするファイルをUTF-8にそろえる。 +- OS適合(`platforms/macos.py`・`windows.py`・`linux.py`): 端末共通データの場所、Native Messagingマニフェストの置き場と登録、hostの実行ファイル、履歴接続のローカルIPC(待受と要求)、標準Chromeの探索と起動、CLI起動時の準備を持つ。LinuxはChromeをログイン中の画面セッションを持つユーザーのsystemdから(`systemd-run --user`)起動し、SSHやハーネスの実行環境に画面の変数が無くても開けるようにする。systemdが使えない時は、今の環境に画面の変数がある場合だけ直接起動する。WindowsはChromeをWMI(`Win32_Process.Create`)から起動する。Grok Buildのようにコマンド終了時に子孫プロセスを止めるハーネスや、ジョブごと止めるSSHから呼ばれても、Chromeは呼び出し元の子孫にもジョブにも入らない。macOSは `open` でLaunchServicesから起動する。WindowsはCLIをUTF-8モードで起動し直し、ハーネスへ返すJSONと読み書きするファイルをUTF-8にそろえる。 - ハーネス適合(`harnesses/claude.py`・`codex.py`・`cursor.py`・`grok.py`): 親AIとなるハーネスに `run` の呼び方を教えるスキルの置き場と、そのハーネスでの実行上の注意(タイムアウト、サンドボックス)を持つ。スキル本文の共通部分は `harnesses/skill.md`。`jev-bookmarks harness install` が入れ、既存の自作スキルは上書きしない。 ## 候補の選び方 @@ -133,6 +133,8 @@ Jevが役立つと判定したら、その時に観測したページのURLを - Jevが役立つ・役立たないをどの程度正しく判定するか。機密情報を絞ったページ状態で足りるか。 - 保存したページURLの再利用性と、クエリに状態を含むページの扱い。 -WindowsではChrome履歴から拡張・名前付きパイプ経由で候補80件を、Linuxでは試験用プロファイルで拡張・Unixソケット経由の往復を確認した。Claude Code・Codex・Cursor・Grok Buildの4ハーネスがスキルを認識することも確かめた。 +0.3.2では、macOS・Windows・Linuxの3端末で、専用Chromeを閉じた状態から `install` と `run`(電話帳の入口→1操作→有用性判定→保存)を通した。Linuxは画面の変数が無いSSHから、Windowsは対話ログオンのデスクトップとSSHの両方から確認した。各端末で専用Chromeを閉じてから、Claude Code・Codex・Cursor・Grok Buildの非対話実行でスキル経由の `run` を一度ずつ呼び、11通りで完了とハーネス終了後の専用Chrome・履歴接続の維持を確かめた(WindowsのClaude Codeは端末のログイン切れで未確認)。普段のChromeに0.2系の拡張とhostが残ったWindowsでも、専用Chromeの履歴接続が通ることを確認した。 -MFの他の目的についての候補再現率と判定精度、WindowsとLinuxでのTypeSafeとブラウザ操作を含む `run` 全体はまだ確認していない。 +旧版では、WindowsではChrome履歴から拡張・名前付きパイプ経由で候補80件を、Linuxでは試験用プロファイルで拡張・Unixソケット経由の往復を確認した。Claude Code・Codex・Cursor・Grok Buildの4ハーネスがスキルを認識することも確かめた。 + +MFの他の目的についての候補再現率と判定精度、専用プロファイルでログインが必要な実サイトの `run` はまだ確認していない。 diff --git a/pyproject.toml b/pyproject.toml index 72a1b85..b154b0e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "jev-bookmarks" -version = "0.3.1" +version = "0.3.2" description = "Chrome履歴から役立つページを選び、Jevで操作してプロジェクトごとに記録する" license = "MIT" requires-python = ">=3.12" diff --git a/src/jev_bookmarks/browser.py b/src/jev_bookmarks/browser.py index 55fb0b4..2899874 100644 --- a/src/jev_bookmarks/browser.py +++ b/src/jev_bookmarks/browser.py @@ -125,18 +125,21 @@ def _start(*, history_timeout: float) -> dict: raise BrowserSetupError(str(exc)) from exc endpoint = _endpoint() if endpoint is None: - platforms.current().launch_chrome( - executable, - [ - "--remote-debugging-address=127.0.0.1", - "--remote-debugging-port=0", - f"--user-data-dir={profile}", - "--no-first-run", - "--no-default-browser-check", - "--new-window", - "about:blank", - ], - ) + try: + platforms.current().launch_chrome( + executable, + [ + "--remote-debugging-address=127.0.0.1", + "--remote-debugging-port=0", + f"--user-data-dir={profile}", + "--no-first-run", + "--no-default-browser-check", + "--new-window", + "about:blank", + ], + ) + except (OSError, RuntimeError) as exc: + raise BrowserSetupError(f"Jev Bookmarks専用Chromeを起動できませんでした: {exc}") from exc deadline = time.monotonic() + 15 while endpoint is None and time.monotonic() < deadline: time.sleep(0.1) diff --git a/src/jev_bookmarks/install.py b/src/jev_bookmarks/install.py index 00e6b17..166ee15 100644 --- a/src/jev_bookmarks/install.py +++ b/src/jev_bookmarks/install.py @@ -27,10 +27,11 @@ def extension_directory() -> Path: return data_dir() / "extension" -def _write(path: Path, content: str) -> None: +def _write(path: Path, content: bytes) -> None: fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}-", dir=path.parent) try: - with os.fdopen(fd, "w", encoding="utf-8") as handle: + # バイト列のまま書き、Windowsでも改行を変えずに元の拡張ファイルと同じ内容で置く。 + with os.fdopen(fd, "wb") as handle: handle.write(content) handle.flush() os.fsync(handle.fileno()) @@ -49,8 +50,8 @@ def install_extension() -> Path: manifest = json.loads((source / "manifest.json").read_text(encoding="utf-8")) manifest["key"] = DEDICATED_EXTENSION_KEY manifest["name"] = "Jev Bookmarks 専用Chrome履歴接続" - _write(target / "manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2) + "\n") - _write(target / "background.js", (source / "background.js").read_text(encoding="utf-8")) + _write(target / "manifest.json", (json.dumps(manifest, ensure_ascii=False, indent=2) + "\n").encode("utf-8")) + _write(target / "background.js", (source / "background.js").read_bytes()) return target @@ -82,4 +83,7 @@ def install_native_host() -> Path: if os.path.exists(temp_name): os.unlink(temp_name) system.register_host(target) + # 普段のChromeに残った旧拡張が、専用Chromeと同じhostを起動し続けないようにする。 + for legacy in system.legacy_manifest_dirs(): + (legacy / f"{HOST_NAME}.json").unlink(missing_ok=True) return target diff --git a/src/jev_bookmarks/native_host.py b/src/jev_bookmarks/native_host.py index 89ef84e..22fbb1b 100644 --- a/src/jev_bookmarks/native_host.py +++ b/src/jev_bookmarks/native_host.py @@ -4,6 +4,7 @@ import uuid from . import platforms +from .install import extension_id class Bridge: @@ -85,6 +86,9 @@ def _read_native_messages(bridge: Bridge) -> None: def main() -> None: + # 普段のChromeに残った旧拡張からも起動されうる。専用Chromeの拡張以外には履歴接続を開かない。 + if sys.argv[1:2] != [f"chrome-extension://{extension_id()}/"]: + sys.exit(1) bridge = Bridge() platforms.current().serve(lambda raw: answer(bridge, raw), lambda: _read_native_messages(bridge)) diff --git a/src/jev_bookmarks/platforms/__init__.py b/src/jev_bookmarks/platforms/__init__.py index 3aaf203..14ce847 100644 --- a/src/jev_bookmarks/platforms/__init__.py +++ b/src/jev_bookmarks/platforms/__init__.py @@ -4,6 +4,7 @@ - ``data_dir()``: 端末共通の設定を置く場所 - ``manifest_dir()``: Native Messaging hostのマニフェストを置く場所 +- ``legacy_manifest_dirs()``: 0.2系が普段のChrome向けにマニフェストを置いた場所(installが旧登録を消す) - ``host_executable(root)``: Chromeが起動するhostの実行ファイル - ``register_host(manifest)``: マニフェストをChromeへ知らせる(ファイル配置だけで済むOSは何もしない) - ``prepare_process()``: CLIの起動直後にOSの都合で必要な準備 diff --git a/src/jev_bookmarks/platforms/linux.py b/src/jev_bookmarks/platforms/linux.py index a9f69f1..82de177 100644 --- a/src/jev_bookmarks/platforms/linux.py +++ b/src/jev_bookmarks/platforms/linux.py @@ -19,6 +19,10 @@ def manifest_dir() -> Path: return configured() / "chrome-profile" / "NativeMessagingHosts" +def legacy_manifest_dirs() -> list[Path]: + return [Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "google-chrome" / "NativeMessagingHosts"] + + def host_executable(root: Path) -> Path: host = root / "bin" / "native-host" if not os.access(host, os.X_OK): @@ -47,9 +51,39 @@ def chrome_executable() -> Path: raise RuntimeError("ChromeまたはChromiumがありません。OSの公式パッケージ管理で導入してください") +def _session_environment() -> dict[str, str]: + environment = dict(os.environ) + environment.setdefault("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}") + return environment + + +def _user_manager_has_display(environment: dict[str, str]) -> bool: + try: + completed = subprocess.run( + ["systemctl", "--user", "show-environment"], + env=environment, + capture_output=True, + text=True, + timeout=5, + ) + except (OSError, subprocess.TimeoutExpired): + return False + names = {line.split("=", 1)[0] for line in completed.stdout.splitlines()} + return completed.returncode == 0 and bool(names & {"WAYLAND_DISPLAY", "DISPLAY"}) + + def launch_chrome(executable: Path, arguments: list[str]) -> None: + command = [str(executable), *arguments] + environment = _session_environment() + # ログイン中の画面セッションはユーザーのsystemdが持っている。そこから起動すれば、SSHやハーネスの + # 実行環境に画面の変数が無くても開け、ハーネスのコマンドが終わってもChromeが巻き込まれない。 + if shutil.which("systemd-run") and _user_manager_has_display(environment): + command = ["systemd-run", "--user", "--collect", "--quiet", "--", *command] + elif not (os.environ.get("WAYLAND_DISPLAY") or os.environ.get("DISPLAY")): + raise RuntimeError("画面のセッションが見つかりません。デスクトップにログインした状態で実行してください") subprocess.Popen( - [str(executable), *arguments], + command, + env=environment, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, diff --git a/src/jev_bookmarks/platforms/macos.py b/src/jev_bookmarks/platforms/macos.py index d42b010..d1b794c 100644 --- a/src/jev_bookmarks/platforms/macos.py +++ b/src/jev_bookmarks/platforms/macos.py @@ -18,6 +18,10 @@ def manifest_dir() -> Path: return configured() / "chrome-profile" / "NativeMessagingHosts" +def legacy_manifest_dirs() -> list[Path]: + return [Path.home() / "Library" / "Application Support" / "Google" / "Chrome" / "NativeMessagingHosts"] + + def host_executable(root: Path) -> Path: host = root / "bin" / "native-host" if not os.access(host, os.X_OK): diff --git a/src/jev_bookmarks/platforms/posix.py b/src/jev_bookmarks/platforms/posix.py index a1d24b1..519c0c8 100644 --- a/src/jev_bookmarks/platforms/posix.py +++ b/src/jev_bookmarks/platforms/posix.py @@ -9,9 +9,12 @@ from collections.abc import Callable from pathlib import Path +# 0.2系のhost(普段のChromeに残った旧拡張が起動したもの)と待受先を分ける。 +CHANNEL = "dedicated-chrome" + def socket_path(data_dir: Path) -> Path: - identity = hashlib.sha256(str(data_dir.resolve()).encode()).hexdigest()[:12] + identity = hashlib.sha256(f"{data_dir.resolve()}\0{CHANNEL}".encode()).hexdigest()[:12] return Path("/tmp") / f"jev-bookmarks-{os.getuid()}-{identity}" / "bridge.sock" diff --git a/src/jev_bookmarks/platforms/windows.py b/src/jev_bookmarks/platforms/windows.py index dd38a67..04aef7e 100644 --- a/src/jev_bookmarks/platforms/windows.py +++ b/src/jev_bookmarks/platforms/windows.py @@ -24,6 +24,11 @@ def manifest_dir() -> Path: return configured() / "native-messaging" +def legacy_manifest_dirs() -> list[Path]: + # レジストリの登録先は同じキーなので、manifestの書き換えで旧登録も置き換わる。 + return [] + + def host_executable(root: Path) -> Path: if not (root / ".venv" / "Scripts" / "python.exe").is_file(): raise RuntimeError("プロジェクトの .venv がありません。uv sync --locked を実行してください") @@ -61,20 +66,53 @@ def chrome_executable() -> Path: raise RuntimeError("Google Chromeがありません。公式インストーラーで導入してください") -def launch_chrome(executable: Path, arguments: list[str]) -> None: - subprocess.Popen( - [str(executable), *arguments], - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - creationflags=subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS, +def _launch_with_wmi(executable: Path, arguments: list[str]) -> bool: + command = subprocess.list2cmdline([str(executable), *arguments]) + script = ( + "$r=Invoke-CimMethod -ClassName Win32_Process -MethodName Create " + "-Arguments @{CommandLine=$env:JEV_BOOKMARKS_CHROME}; exit $r.ReturnValue" ) + try: + completed = subprocess.run( + ["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script], + env={**os.environ, "JEV_BOOKMARKS_CHROME": command}, + stdin=subprocess.DEVNULL, + capture_output=True, + timeout=30, + creationflags=subprocess.CREATE_NO_WINDOW, + ) + except (OSError, subprocess.TimeoutExpired): + return False + return completed.returncode == 0 + + +def launch_chrome(executable: Path, arguments: list[str]) -> None: + # ハーネスによってはコマンドの終了時に子孫プロセスをまとめて止める(Grok Build)。WMIに作らせた + # Chromeは呼び出し元の子孫にもジョブにも入らず、同じログオンセッションで開く。 + if _launch_with_wmi(executable, arguments): + return + detached = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS + # WMIが使えない時は直接起動する。SSHなどのジョブから抜けられるなら抜ける。 + for flags in (detached | subprocess.CREATE_BREAKAWAY_FROM_JOB, detached): + try: + subprocess.Popen( + [str(executable), *arguments], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + creationflags=flags, + ) + return + except PermissionError: + continue + raise RuntimeError("Chromeを起動できませんでした") def _identity() -> str: from ..paths import data_dir as configured - return hashlib.sha256(str(configured().resolve()).encode()).hexdigest()[:12] + # 0.2系のhost(普段のChromeに残った旧拡張が起動したもの)とパイプ名を分ける。 + return hashlib.sha256(f"{configured().resolve()}\0dedicated-chrome".encode()).hexdigest()[:12] def _address() -> str: @@ -86,7 +124,8 @@ def _key_path() -> Path: """名前付きパイプの相互認証鍵。ホストの稼働中だけ存在する。""" from ..paths import data_dir as configured - return configured() / "run" / "bridge.key" + # 0.2系のhostは終了時に自分の鍵ファイルを消すので、同じ名前を使わない。 + return configured() / "run" / "dedicated-chrome.key" def _write_key() -> bytes: diff --git a/tests/test_adapters.py b/tests/test_adapters.py index 592b8c6..769dd31 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -1,3 +1,4 @@ +import os from pathlib import Path import pytest @@ -8,6 +9,7 @@ OS_CONTRACT = { "data_dir", "manifest_dir", + "legacy_manifest_dirs", "host_executable", "register_host", "prepare_process", @@ -62,3 +64,63 @@ def test_skill_install_keeps_a_foreign_skill(home: Path): assert foreign.read_text(encoding="utf-8") == "自作のスキル\n" with pytest.raises(harnesses.HarnessError, match="未対応のハーネス"): harnesses.install(["vim"]) + + +def test_native_host_install_drops_the_normal_chrome_registration(tmp_path: Path, monkeypatch): + from jev_bookmarks import install + + legacy = tmp_path / "legacy" + legacy.mkdir() + (legacy / f"{install.HOST_NAME}.json").write_text("{}", encoding="utf-8") + (legacy / "other.host.json").write_text("{}", encoding="utf-8") + registered = [] + + class Platform: + manifest_dir = staticmethod(lambda: tmp_path / "profile" / "NativeMessagingHosts") + legacy_manifest_dirs = staticmethod(lambda: [legacy]) + host_executable = staticmethod(lambda root: Path(install.__file__)) + register_host = staticmethod(registered.append) + + monkeypatch.setattr(install.platforms, "current", lambda: Platform) + target = install.install_native_host() + assert registered == [target] + assert not (legacy / f"{install.HOST_NAME}.json").exists() + assert (legacy / "other.host.json").exists() + + +@pytest.mark.skipif(os.name == "nt", reason="Linuxの起動経路はPOSIXの端末で確かめる") +def test_linux_chrome_opens_in_the_logged_in_desktop_session(monkeypatch): + launched = [] + monkeypatch.delenv("WAYLAND_DISPLAY", raising=False) + monkeypatch.delenv("DISPLAY", raising=False) + monkeypatch.setattr(linux.shutil, "which", lambda name: f"/usr/bin/{name}") + monkeypatch.setattr(linux, "_user_manager_has_display", lambda environment: True) + monkeypatch.setattr(linux.subprocess, "Popen", lambda command, **options: launched.append(command)) + linux.launch_chrome(Path("/usr/bin/google-chrome"), ["--user-data-dir=/p"]) + systemd = ["systemd-run", "--user", "--collect", "--quiet", "--"] + assert launched == [[*systemd, "/usr/bin/google-chrome", "--user-data-dir=/p"]] + + launched.clear() + monkeypatch.setattr(linux, "_user_manager_has_display", lambda environment: False) + with pytest.raises(RuntimeError, match="画面のセッション"): + linux.launch_chrome(Path("/usr/bin/google-chrome"), []) + monkeypatch.setenv("DISPLAY", ":0") + linux.launch_chrome(Path("/usr/bin/google-chrome"), []) + assert launched == [["/usr/bin/google-chrome"]] + + +@pytest.mark.skipif(os.name != "nt", reason="Windowsの起動経路はWindowsの端末で確かめる") +def test_windows_chrome_is_created_outside_the_caller(monkeypatch): + calls = [] + + class Completed: + returncode = 0 + + def run(command, **options): + calls.append(options["env"]["JEV_BOOKMARKS_CHROME"]) + return Completed() + + monkeypatch.setattr(windows.subprocess, "run", run) + monkeypatch.setattr(windows.subprocess, "Popen", lambda *args, **options: pytest.fail("直接起動した")) + windows.launch_chrome(Path(r"C:\Program Files\Google\Chrome\Application\chrome.exe"), ["--user-data-dir=C:\\p q"]) + assert calls == ['"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe" "--user-data-dir=C:\\p q"'] diff --git a/tests/test_core.py b/tests/test_core.py index 64f23b7..8453c21 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -157,7 +157,7 @@ def test_phonebook_requires_project_home(tmp_path: Path, monkeypatch): def test_native_bridge_round_trip_with_extension_response(tmp_path: Path, monkeypatch): monkeypatch.setenv("JEV_BOOKMARKS_HOME", str(tmp_path)) process = subprocess.Popen( - [sys.executable, "-m", "jev_bookmarks.native_host"], + [sys.executable, "-m", "jev_bookmarks.native_host", f"chrome-extension://{extension_id()}/"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, @@ -195,6 +195,19 @@ def query(): assert not history_bridge.available() +def test_native_host_refuses_a_foreign_extension(tmp_path: Path, monkeypatch): + monkeypatch.setenv("JEV_BOOKMARKS_HOME", str(tmp_path)) + completed = subprocess.run( + [sys.executable, "-m", "jev_bookmarks.native_host", "chrome-extension://nbheglfcinjedkfjafpleaipcnppbnan/"], + input=b"", + capture_output=True, + env={**os.environ, "JEV_BOOKMARKS_HOME": str(tmp_path)}, + timeout=10, + ) + assert completed.returncode == 1 + assert not history_bridge.available() + + def test_unhelpful_page_returns_to_parent_without_saving(monkeypatch): page = {"url": "https://example.com/", "title": "Example Domain", "text": "Example Domain"} diff --git a/uv.lock b/uv.lock index 72541b9..84e1994 100644 --- a/uv.lock +++ b/uv.lock @@ -164,7 +164,7 @@ wheels = [ [[package]] name = "jev-bookmarks" -version = "0.3.1" +version = "0.3.2" source = { editable = "." } dependencies = [ { name = "httpx", extra = ["http2"] },