diff --git a/apps/codex-plus-manager/src/renderer-inject.test.ts b/apps/codex-plus-manager/src/renderer-inject.test.ts new file mode 100644 index 000000000..cee40b43a --- /dev/null +++ b/apps/codex-plus-manager/src/renderer-inject.test.ts @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { readFile } from "node:fs/promises"; + +describe("renderer injection header compatibility", () => { + it("anchors the Codex++ menu to current and legacy application top bars only", async () => { + const renderer = await readFile(new URL("../../../assets/inject/renderer-inject.js", import.meta.url), "utf8"); + + assert.match(renderer, /appHeader:\s*'[^"]*\[class\*="ApplicationMenuTopBar"\][^']*\.app-header-tint'/); + assert.doesNotMatch(renderer, /document\.querySelector\(["']header["']\)/); + assert.match(renderer, /isApplicationMenuTopBar\s*\?\s*Math\.max\(4, headerRect\.top\)/); + assert.match(renderer, /isApplicationMenuTopBar\s*\?\s*28\s*:\s*headerRect\.height/); + }); + + it("does not install Codex++ UI in embedded browser documents", async () => { + const renderer = await readFile(new URL("../../../assets/inject/renderer-inject.js", import.meta.url), "utf8"); + + assert.match(renderer, /window\.top\s*!==\s*window/); + assert.match(renderer, /!window\.electronBridge/); + assert.ok(renderer.includes("/^app:\\\/\\\/\\-\\//i.test(window.location.href)")); + assert.match(renderer, /codexPlusIsNodeTestHarness/); + }); +}); diff --git a/assets/inject/renderer-inject.js b/assets/inject/renderer-inject.js index fda3287e9..763181a58 100644 --- a/assets/inject/renderer-inject.js +++ b/assets/inject/renderer-inject.js @@ -1,4 +1,8 @@ (() => { + // The launcher targets the Codex app page, but keep a renderer-side guard + // so this bundle cannot create UI in embedded browser documents. + const codexPlusIsNodeTestHarness = typeof process === "object" && !!process.versions?.node; + if (!codexPlusIsNodeTestHarness && (window.top !== window || window.self !== window || !window.electronBridge || !/^app:\/\/\-\//i.test(window.location.href))) return; const codexPlusIsWindowsPlatform = /\bWindows\b/i.test(navigator.userAgent || ""); function installCodexPlusFastStartup() { @@ -586,7 +590,7 @@ const selectors = { sidebarThread: "[data-app-action-sidebar-thread-id]", threadTitle: "[data-thread-title]", - appHeader: ".app-header-tint", + appHeader: '[class*="ApplicationMenuTopBar"], .app-header-tint', nativeMenuBar: "[class*=\"ms-auto\"][class*=\"flex\"][class*=\"items-center\"]", headerContextMenuSurface: '[data-testid="app-shell-header-context-menu-surface"]', archiveNav: 'button[aria-label="已归档对话"], button[aria-label="Archived conversations"]', @@ -3888,7 +3892,7 @@ function updateFloatingCodexPlusMenuPosition(menu) { if (!menu?.classList?.contains(codexPlusMenuFloatingClass)) return; - const header = document.querySelector(selectors.appHeader) || document.querySelector("header"); + const header = document.querySelector(selectors.appHeader); if (!header) return; const toolbarButtons = Array.from(header.querySelectorAll("button")) .map((button) => ({ button, rect: button.getBoundingClientRect() })) @@ -3907,8 +3911,9 @@ const headerRect = header.getBoundingClientRect(); if (headerRect.height) { - setCssPropIfChanged(menu, "--codex-plus-menu-top", `${headerRect.top}px`); - setCssPropIfChanged(menu, "--codex-plus-menu-height", `${headerRect.height}px`); + const isApplicationMenuTopBar = header.matches?.('[class*="ApplicationMenuTopBar"]'); + setCssPropIfChanged(menu, "--codex-plus-menu-top", `${isApplicationMenuTopBar ? Math.max(4, headerRect.top) : headerRect.top}px`); + setCssPropIfChanged(menu, "--codex-plus-menu-height", `${isApplicationMenuTopBar ? 28 : headerRect.height}px`); } menu.style.removeProperty("--codex-plus-menu-right"); } diff --git a/crates/codex-plus-core/src/cdp.rs b/crates/codex-plus-core/src/cdp.rs index b8f9b358d..9a813e979 100644 --- a/crates/codex-plus-core/src/cdp.rs +++ b/crates/codex-plus-core/src/cdp.rs @@ -253,18 +253,29 @@ pub fn pick_page_target(targets: &[CdpTarget]) -> anyhow::Result { } pub fn pick_injectable_codex_page_target(targets: &[CdpTarget]) -> anyhow::Result { - for target in targets - .iter() - .filter(|target| is_injectable_page_target(target)) - { - if is_primary_codex_page_target(target) { - return Ok(target.clone()); - } + // Only inject into Codex's own app:// page (or the supported ChatGPT + // desktop page). Embedded browser pages can have titles or URLs containing + // "Codex" (for example a GitHub PR), but they must never become the target. + if let Some(target) = targets.iter().find(|target| { + is_injectable_page_target(target) + && is_primary_codex_page_target(target) + && (is_codex_app_page_target(target) + || is_chatgpt_desktop_page(&target.title, &target.url)) + }) { + return Ok(target.clone()); } - bail!("No injectable Codex page target found") } +fn is_codex_app_page_target(target: &CdpTarget) -> bool { + let Ok(url) = reqwest::Url::parse(target.url.trim()) else { + return false; + }; + url.scheme().eq_ignore_ascii_case("app") + && url.host_str() == Some("-") + && url.path().eq_ignore_ascii_case("/index.html") +} + pub fn is_injectable_page_target(target: &CdpTarget) -> bool { target.target_type == "page" && target diff --git a/crates/codex-plus-core/src/user_scripts.rs b/crates/codex-plus-core/src/user_scripts.rs index e670850c6..7692c04ba 100644 --- a/crates/codex-plus-core/src/user_scripts.rs +++ b/crates/codex-plus-core/src/user_scripts.rs @@ -327,6 +327,8 @@ fn wrap_script(script: &UserScriptFile, source: &str) -> String { format!( r#" (() => {{ + const codexPlusIsNodeTestHarness = typeof process === "object" && !!process.versions?.node; + if (!codexPlusIsNodeTestHarness && (window.top !== window || window.self !== window || !window.electronBridge || !/^app:\/\/\-\//i.test(window.location.href))) return; window.__codexPlusUserScripts = window.__codexPlusUserScripts || {{ scripts: {{}} }}; const key = {key}; window.__codexPlusUserScripts.scripts[key] = {{ key, name: {name}, source: {source_name}, status: "loading", error: "", loadedAt: new Date().toISOString() }}; diff --git a/crates/codex-plus-core/tests/cdp_bridge.rs b/crates/codex-plus-core/tests/cdp_bridge.rs index d988f23f5..ca8a779ba 100644 --- a/crates/codex-plus-core/tests/cdp_bridge.rs +++ b/crates/codex-plus-core/tests/cdp_bridge.rs @@ -58,6 +58,8 @@ fn screenshot_command_uses_png_from_surface() { fn injection_script_prefixes_helper_url_and_sponsor_images() { let script = assets::injection_script(57321); + assert!(script.contains("!window.electronBridge")); + assert!(script.contains(r#"!/^app:\/\/\-\//i.test(window.location.href)"#)); assert!(script.contains("window.__CODEX_SESSION_DELETE_HELPER__")); assert!(script.contains("http://127.0.0.1:57321")); assert!(script.contains("window.__CODEX_PLUS_SPONSOR_IMAGES__")); @@ -2285,6 +2287,51 @@ fn pick_injectable_codex_page_target_rejects_non_codex_pages() { ); } +#[test] +fn pick_injectable_codex_page_target_ignores_embedded_browser_page_named_codex() { + let targets = vec![ + target( + "browser-pr", + "page", + "Fix Codex++ menu anchoring · Pull Request", + "https://github.com/BigPizzaV3/CodexPlusPlus/pull/1743", + Some("ws://browser-pr"), + ), + target( + "main", + "page", + "Codex", + "app://-/index.html", + Some("ws://main"), + ), + ]; + + let picked = pick_injectable_codex_page_target(&targets) + .expect("Codex app page should win over embedded browser content"); + + assert_eq!(picked.id, "main"); +} + +#[test] +fn pick_injectable_codex_page_target_rejects_embedded_browser_only_page() { + let targets = vec![target( + "browser-pr", + "page", + "Fix Codex++ menu anchoring · Pull Request", + "https://github.com/BigPizzaV3/CodexPlusPlus/pull/1743", + Some("ws://browser-pr"), + )]; + + let error = pick_injectable_codex_page_target(&targets) + .expect_err("embedded browser content must not be selected for injection"); + + assert!( + error + .to_string() + .contains("No injectable Codex page target found") + ); +} + #[test] fn pick_injectable_codex_page_target_accepts_chatgpt_desktop_page() { let targets = vec![target( diff --git a/crates/codex-plus-core/tests/upstream_theme_assets.rs b/crates/codex-plus-core/tests/upstream_theme_assets.rs index 4ffa044e2..87d85c54a 100644 --- a/crates/codex-plus-core/tests/upstream_theme_assets.rs +++ b/crates/codex-plus-core/tests/upstream_theme_assets.rs @@ -10,7 +10,19 @@ fn assert_sha256(relative_path: &str, expected: &str) { path.display() ) }); - let actual = format!("{:X}", Sha256::digest(bytes)); + // Git may materialize these text assets with CRLF on Windows. Hash the + // repository's canonical LF representation so the guard is platform + // independent while still detecting substantive asset changes. + let mut normalized = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'\r' && bytes.get(index + 1) == Some(&b'\n') { + index += 1; + } + normalized.push(bytes[index]); + index += 1; + } + let actual = format!("{:X}", Sha256::digest(normalized)); assert_eq!(actual, expected, "upstream asset changed: {relative_path}"); }