Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions src/gflow_cli/api/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1310,13 +1310,34 @@ async def _drive_image_generation(
constructing a :class:`GenerateImageRequest` with ``count>1`` and then
invoking the single-image API still receive exactly one image (no
silent discard).

When the transport returns more images than the requested count=1
(typically because the generation-settings panel was not found and
Flow used its own default, billing the account for the extra
generations), a warning is logged so the caller can investigate.
"""
import structlog as _structlog

_log = _structlog.get_logger(__name__)
req_one: GenerateImageRequest = _dc_replace(req, count=1)
images = await self._drive_images_generation(
project_id=project_id,
req=req_one,
recaptcha_action=recaptcha_action,
)
if len(images) > 1:
_log.warning(
"client.generate_image_extra_returned",
requested=1,
returned=len(images),
extra_media_ids=[img.media_name for img in images[1:]],
hint=(
"Flow generated more images than requested — the generation-settings "
"panel selector may have missed and Flow used its own default count. "
"The extra image(s) were billed to your account but not saved. "
"Use `gflow image t2i -n 2` to request and save multiple images explicitly."
),
)
return images[0]

async def generate_image(
Expand Down
23 changes: 15 additions & 8 deletions src/gflow_cli/api/transports/ui_automation.py
Original file line number Diff line number Diff line change
Expand Up @@ -386,15 +386,22 @@ async def _capture_debug_screenshot(
"button:has-text('See what's new')",
)


# Generation settings trigger — the button shows the current ratio icon.
# All 5 ratio icon names are enumerated so the selector is ratio-invariant.
# Generation settings trigger — the button shows the current ratio icon and
# opens the mode/settings dropdown (``aria-haspopup='menu'``). The
# ``aria-haspopup='menu'`` attribute is REQUIRED: without it any button that
# happens to carry a ``crop_*`` icon (e.g. an icon-only aspect thumbnail in
# the panel that just opened) can match — the result is a click on the wrong
# element and a ``gen_settings_panel_not_found`` skip that leaves Flow's own
# default count in effect (typically 2 concurrent requests billed against the
# account while the CLI only downloads 1). This list mirrors
# ``MODE_SWITCH_TRIGGER_SELECTORS`` exactly — they target the SAME button.
GEN_SETTINGS_BUTTON_SELECTORS = (
"button:has(i.google-symbols:text('crop_16_9'))",
"button:has(i.google-symbols:text('crop_9_16'))",
"button:has(i.google-symbols:text('crop_square'))",
"button:has(i.google-symbols:text('crop_portrait'))",
"button:has(i.google-symbols:text('crop_landscape'))",
"button[aria-haspopup='menu']:has(i.google-symbols:text('crop_16_9'))",
"button[aria-haspopup='menu']:has(i.google-symbols:text('crop_9_16'))",
"button[aria-haspopup='menu']:has(i.google-symbols:text('crop_square'))",
"button[aria-haspopup='menu']:has(i.google-symbols:text('crop_portrait'))",
"button[aria-haspopup='menu']:has(i.google-symbols:text('crop_landscape'))",
"button[aria-haspopup='menu']:has(i.google-symbols:text('crop_original'))",
)

# CLI string → ordered list of candidate tab labels to try in the Flow gen
Expand Down
88 changes: 76 additions & 12 deletions src/gflow_cli/browser_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,14 @@ def _find_chrome_binary() -> str:
Resolution order:
1. ``CHROME_BINARY`` env var (override)
2. ``shutil.which("chrome")`` / ``shutil.which("google-chrome")``
3. Platform-standard install paths
3. Platform-standard install paths (including Chromium as last resort for
non-Playwright-channel uses such as CDP spawning and auth login).

.. note::
This function accepts Chromium as a fallback for the auth/CDP use-case.
It must NOT be used to decide whether Playwright's ``channel="chrome"``
is available — use :func:`_is_playwright_chrome_channel_available` for
that, which checks only the exact paths Playwright hard-codes.

Raises ``ConfigurationError`` if nothing found.
"""
Expand All @@ -129,7 +136,7 @@ def _find_chrome_binary() -> str:
if env_override:
return env_override

# 2. PATH probe
# 2. PATH probe — Google Chrome names first, Chromium last-resort
for candidate in ("chrome", "google-chrome", "chromium"):
found = shutil.which(candidate)
if found:
Expand Down Expand Up @@ -168,8 +175,56 @@ def _find_chrome_binary() -> str:
)


def _is_playwright_chrome_channel_available() -> bool:
"""Return True only when Playwright's ``channel="chrome"`` can find Chrome.

Playwright's ``launch_persistent_context(channel="chrome")`` looks for
**Google Chrome proper** at platform-specific hardcoded paths — it does NOT
accept a plain Chromium binary. This function replicates those paths so
:func:`channel_for_profile` can gate the ``channel="chrome"`` argument on
a binary that Playwright will actually find, avoiding the misleading
``Chromium distribution 'chrome' is not found at /opt/google/chrome/chrome``
error that occurs when only system Chromium is present.

The ``CHROME_BINARY`` env var override is honoured for parity with
:func:`_find_chrome_binary`.
"""
env_override = os.environ.get("CHROME_BINARY")
if env_override:
return bool(env_override)

# Playwright's own resolution paths for channel="chrome" (as of Playwright
# 1.45+). Derived from playwright/_impl/_browser_type.py executables().
if sys.platform == "win32":
local_app_data = os.environ.get("LOCALAPPDATA", "")
chrome_paths: list[Path] = [
Path("C:/Program Files/Google/Chrome/Application/chrome.exe"),
Path("C:/Program Files (x86)/Google/Chrome/Application/chrome.exe"),
Path(local_app_data or "") / "Google" / "Chrome" / "Application" / "chrome.exe",
]
elif sys.platform == "darwin":
chrome_paths = [
Path("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
]
else:
# Linux — Playwright looks here for channel="chrome"
chrome_paths = [
Path("/opt/google/chrome/chrome"),
Path("/usr/bin/google-chrome"),
Path("/usr/bin/google-chrome-stable"),
]

return any(p.exists() for p in chrome_paths)


def is_chrome_available() -> bool:
"""Return True if a Google Chrome binary can be found on this system."""
"""Return True if a Google Chrome (or Chromium fallback) binary can be found.

This is used for the auth login flow and CDP spawning. It intentionally
accepts Chromium as a fallback so the auth browser can open even when only
Chromium is installed. For deciding whether Playwright's ``channel="chrome"``
can be used, call :func:`_is_playwright_chrome_channel_available` instead.
"""
try:
_find_chrome_binary()
return True
Expand All @@ -182,14 +237,20 @@ def channel_for_profile(profile_dir: Path) -> str | None:

Reads the ``.gflow_browser_strategy`` marker written by
:class:`~gflow_cli.auth.real_chrome.RealChromeStrategy`. When the marker
is ``"chrome"`` and system Chrome is available, returns ``"chrome"`` so
callers can pass it to ``launch_persistent_context(channel=...)`` —
avoiding the downgrade-cleanup exit-33 that occurs when Playwright's
bundled Chromium opens a profile created by Chrome 130+.
is ``"chrome"`` and **Google Chrome proper** is available at the paths
Playwright expects, returns ``"chrome"`` so callers can pass it to
``launch_persistent_context(channel=...)`` — avoiding the exit-33
downgrade-cleanup error that occurs when Playwright's bundled Chromium
opens a profile created by Chrome 130+.

Critically, this function uses :func:`_is_playwright_chrome_channel_available`
(not :func:`is_chrome_available`) so that a system with only Chromium
installed does NOT trigger ``channel="chrome"`` — Playwright's ``channel="chrome"``
resolves to hardcoded Google Chrome paths and would fail with
``Chromium distribution 'chrome' is not found at /opt/google/chrome/chrome``.

Logs a warning when the marker requests Chrome but Chrome is no longer
available, as the resulting launch against bundled Chromium will likely
fail with the same exit-33 error.
available at the expected Playwright paths.
"""
import structlog as _structlog

Expand All @@ -200,13 +261,16 @@ def channel_for_profile(profile_dir: Path) -> str | None:
strategy = marker.read_text(encoding="utf-8").strip()
if strategy != "chrome":
return None
if is_chrome_available():
if _is_playwright_chrome_channel_available():
return "chrome"
_log.warning(
"browser_manager.chrome_marker_but_unavailable",
profile_dir=str(profile_dir),
hint="Profile was captured with system Chrome but Chrome is not found. "
"Re-run `gflow auth login --browser chrome` after installing Chrome.",
hint="Profile was captured with system Chrome but Google Chrome is not "
"found at the paths Playwright expects (e.g. /opt/google/chrome/chrome). "
"Falling back to Playwright's bundled Chromium. "
"Re-run `gflow auth login --browser chrome` after installing Chrome if "
"you need the Chrome channel.",
)
return None

Expand Down
Loading