fix(accounts): keep first login launch in one window
This commit is contained in:
+159
-22
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
@@ -12,6 +13,15 @@ from . import config as account_config
|
||||
|
||||
|
||||
DEFAULT_REGION_HOST = editor.DEFAULT_REGION_HOST
|
||||
INITIAL_LOGIN_TAB_WAIT_SECONDS = 2.0
|
||||
INITIAL_LOGIN_TAB_POLL_SECONDS = 0.1
|
||||
SAFE_STARTUP_PAGE_PREFIXES = (
|
||||
"about:blank",
|
||||
"chrome://newtab",
|
||||
"chrome://new-tab-page",
|
||||
"chrome://welcome",
|
||||
"chrome://intro",
|
||||
)
|
||||
|
||||
|
||||
class AccountError(RuntimeError):
|
||||
@@ -222,13 +232,19 @@ def _seller_portal_url(account) -> str:
|
||||
return f"https://{normalize_region_host(_account_value(account, 'region_host'))}/portal/"
|
||||
|
||||
|
||||
def _find_login_tab(account, host):
|
||||
def _page_tabs(host):
|
||||
return [
|
||||
tab
|
||||
for tab in cdp.http_get("/json", host=host)
|
||||
if tab.get("type") == "page"
|
||||
]
|
||||
|
||||
|
||||
def _find_login_tab_in_tabs(account, tabs):
|
||||
region_host = normalize_region_host(_account_value(account, 'region_host')).lower()
|
||||
seller_tabs = []
|
||||
login_tabs = []
|
||||
for tab in cdp.http_get("/json", host=host):
|
||||
if tab.get("type") != "page":
|
||||
continue
|
||||
for tab in tabs:
|
||||
url = str(tab.get("url") or "")
|
||||
lower_url = url.lower()
|
||||
if region_host in lower_url:
|
||||
@@ -238,38 +254,157 @@ def _find_login_tab(account, host):
|
||||
return (seller_tabs or login_tabs or [None])[0]
|
||||
|
||||
|
||||
def _open_or_activate_login_tab(account):
|
||||
host = _debug_host(account)
|
||||
portal_url = _seller_portal_url(account)
|
||||
tab = _find_login_tab(account, host)
|
||||
if tab:
|
||||
target_id = tab.get("id")
|
||||
if target_id:
|
||||
cdp.activate_tab(target_id, host=host)
|
||||
return {
|
||||
"target_id": target_id,
|
||||
"url": tab.get("url") or portal_url,
|
||||
"created_tab": False,
|
||||
}
|
||||
def _find_login_tab(account, host):
|
||||
return _find_login_tab_in_tabs(account, _page_tabs(host))
|
||||
|
||||
tab = cdp.create_tab_info(portal_url, host=host)
|
||||
|
||||
def _wait_for_initial_login_tab(account, host, timeout=INITIAL_LOGIN_TAB_WAIT_SECONDS):
|
||||
timeout = max(0.0, float(timeout))
|
||||
deadline = time.monotonic() + timeout
|
||||
last_tabs = []
|
||||
probe_error = None
|
||||
first_probe = True
|
||||
while first_probe or time.monotonic() < deadline:
|
||||
first_probe = False
|
||||
try:
|
||||
last_tabs = _page_tabs(host)
|
||||
probe_error = None
|
||||
except Exception as exc:
|
||||
probe_error = exc.__class__.__name__
|
||||
tab = _find_login_tab_in_tabs(account, last_tabs)
|
||||
if tab:
|
||||
return tab, last_tabs, probe_error
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
break
|
||||
time.sleep(min(INITIAL_LOGIN_TAB_POLL_SECONDS, remaining))
|
||||
return None, last_tabs, probe_error
|
||||
|
||||
|
||||
def _safe_startup_page(tabs):
|
||||
if len(tabs) != 1:
|
||||
return None
|
||||
tab = tabs[0]
|
||||
url = str(tab.get("url") or "").strip().lower()
|
||||
if not url or any(url.startswith(prefix) for prefix in SAFE_STARTUP_PAGE_PREFIXES):
|
||||
return tab
|
||||
return None
|
||||
|
||||
|
||||
def _navigate_page_target(tab, url):
|
||||
websocket_url = tab.get("webSocketDebuggerUrl")
|
||||
if not websocket_url:
|
||||
raise AccountError("Chrome 初始页面缺少 CDP 地址,无法复用")
|
||||
client = cdp.CDP(websocket_url)
|
||||
try:
|
||||
result = client.send("Page.navigate", {"url": url})
|
||||
finally:
|
||||
client.close()
|
||||
if result.get("errorText"):
|
||||
raise AccountError("Chrome 初始页面导航到蝦皮卖家中心失败")
|
||||
|
||||
|
||||
def _activate_login_tab_result(
|
||||
tab,
|
||||
host,
|
||||
portal_url,
|
||||
*,
|
||||
created_tab=False,
|
||||
startup_target_reused=False,
|
||||
startup_page_navigated=False,
|
||||
page_target_count=0,
|
||||
fallback_reason=None,
|
||||
target_probe_error=None,
|
||||
):
|
||||
target_id = tab.get("id")
|
||||
if target_id:
|
||||
cdp.activate_tab(target_id, host=host)
|
||||
return {
|
||||
"target_id": target_id,
|
||||
"url": tab.get("url") or portal_url,
|
||||
"created_tab": True,
|
||||
"created_tab": bool(created_tab),
|
||||
"startup_target_reused": bool(startup_target_reused),
|
||||
"startup_page_navigated": bool(startup_page_navigated),
|
||||
"page_target_count": int(page_target_count),
|
||||
"fallback_reason": fallback_reason,
|
||||
"target_probe_error": target_probe_error,
|
||||
}
|
||||
|
||||
|
||||
def _open_or_activate_login_tab(
|
||||
account,
|
||||
*,
|
||||
newly_launched=False,
|
||||
startup_wait_timeout=INITIAL_LOGIN_TAB_WAIT_SECONDS,
|
||||
):
|
||||
host = _debug_host(account)
|
||||
portal_url = _seller_portal_url(account)
|
||||
if newly_launched:
|
||||
tab, tabs, probe_error = _wait_for_initial_login_tab(
|
||||
account,
|
||||
host,
|
||||
timeout=startup_wait_timeout,
|
||||
)
|
||||
else:
|
||||
tabs = _page_tabs(host)
|
||||
tab = _find_login_tab_in_tabs(account, tabs)
|
||||
probe_error = None
|
||||
if tab:
|
||||
return _activate_login_tab_result(
|
||||
tab,
|
||||
host,
|
||||
portal_url,
|
||||
startup_target_reused=newly_launched,
|
||||
page_target_count=len(tabs),
|
||||
target_probe_error=probe_error,
|
||||
)
|
||||
|
||||
fallback_reason = "existing_chrome_no_login_tab"
|
||||
if newly_launched:
|
||||
startup_tab = _safe_startup_page(tabs)
|
||||
if startup_tab:
|
||||
try:
|
||||
_navigate_page_target(startup_tab, portal_url)
|
||||
except Exception as exc:
|
||||
fallback_reason = "startup_navigation_failed"
|
||||
probe_error = probe_error or exc.__class__.__name__
|
||||
else:
|
||||
navigated_tab = dict(startup_tab)
|
||||
navigated_tab["url"] = portal_url
|
||||
return _activate_login_tab_result(
|
||||
navigated_tab,
|
||||
host,
|
||||
portal_url,
|
||||
startup_target_reused=True,
|
||||
startup_page_navigated=True,
|
||||
page_target_count=len(tabs),
|
||||
target_probe_error=probe_error,
|
||||
)
|
||||
elif tabs:
|
||||
fallback_reason = "startup_page_not_safe"
|
||||
else:
|
||||
fallback_reason = "startup_page_missing"
|
||||
|
||||
tab = cdp.create_tab_info(portal_url, host=host)
|
||||
return _activate_login_tab_result(
|
||||
tab,
|
||||
host,
|
||||
portal_url,
|
||||
created_tab=True,
|
||||
page_target_count=len(tabs) + 1,
|
||||
fallback_reason=fallback_reason,
|
||||
target_probe_error=probe_error,
|
||||
)
|
||||
|
||||
|
||||
def launch_for_login(account_or_alias, path=None, config=None):
|
||||
account = resolve_account(account_or_alias, path=path, config=config)
|
||||
port = normalize_debug_port(_account_value(account, "debug_port"))
|
||||
alias = _account_value(account, "alias")
|
||||
portal_url = _seller_portal_url(account)
|
||||
|
||||
if chrome.is_running(port):
|
||||
tab = _open_or_activate_login_tab(account)
|
||||
tab = _open_or_activate_login_tab(account, newly_launched=False)
|
||||
return {
|
||||
"ok": True,
|
||||
"action": "reused",
|
||||
@@ -278,14 +413,15 @@ def launch_for_login(account_or_alias, path=None, config=None):
|
||||
"alias": alias,
|
||||
"debug_port": port,
|
||||
"pid": None,
|
||||
"initial_url": portal_url,
|
||||
**tab,
|
||||
}
|
||||
|
||||
process = chrome.launch_chrome(account, config=config)
|
||||
process = chrome.launch_chrome(account, config=config, initial_url=portal_url)
|
||||
timeout = appconfig.cdp_ready_timeout(config)
|
||||
if not chrome.wait_debug_ready(port, timeout=timeout):
|
||||
raise AccountError(f"Chrome 已启动,但 CDP 端口 {port} 未在 {timeout} 秒内就绪")
|
||||
tab = _open_or_activate_login_tab(account)
|
||||
tab = _open_or_activate_login_tab(account, newly_launched=True)
|
||||
return {
|
||||
"ok": True,
|
||||
"action": "launched",
|
||||
@@ -294,6 +430,7 @@ def launch_for_login(account_or_alias, path=None, config=None):
|
||||
"alias": alias,
|
||||
"debug_port": port,
|
||||
"pid": getattr(process, "pid", None),
|
||||
"initial_url": portal_url,
|
||||
**tab,
|
||||
}
|
||||
|
||||
|
||||
+30
-4
@@ -10,6 +10,7 @@ import subprocess
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from . import appconfig
|
||||
from . import config as account_config
|
||||
@@ -209,7 +210,27 @@ def _user_data_dir(account, config=None) -> str:
|
||||
return account_config.ensure_user_data_dir(slug, config=config)
|
||||
|
||||
|
||||
def build_launch_args(account, config=None) -> list:
|
||||
def _validated_initial_url(value):
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
parsed = urlsplit(text)
|
||||
except ValueError as exc:
|
||||
raise ChromeLaunchError("Chrome 初始页面必须是有效的蝦皮 http/https 地址") from exc
|
||||
hostname = str(parsed.hostname or "").lower()
|
||||
is_shopee_host = hostname.startswith("shopee.") or ".shopee." in hostname
|
||||
if (
|
||||
parsed.scheme.lower() not in {"http", "https"}
|
||||
or not is_shopee_host
|
||||
or parsed.username
|
||||
or parsed.password
|
||||
):
|
||||
raise ChromeLaunchError("Chrome 初始页面必须是有效的蝦皮 http/https 地址")
|
||||
return text
|
||||
|
||||
|
||||
def build_launch_args(account, config=None, initial_url=None) -> list:
|
||||
"""Build Chrome command arguments for one account."""
|
||||
|
||||
chrome_path = appconfig.chrome_path(config)
|
||||
@@ -217,12 +238,17 @@ def build_launch_args(account, config=None) -> list:
|
||||
raise ChromeLaunchError("chrome_path 不能为空")
|
||||
port = _debug_port(account)
|
||||
user_data_dir = _user_data_dir(account, config=config)
|
||||
return [
|
||||
args = [
|
||||
chrome_path,
|
||||
f"--remote-debugging-port={port}",
|
||||
"--remote-allow-origins=*",
|
||||
f"--user-data-dir={user_data_dir}",
|
||||
]
|
||||
startup_url = _validated_initial_url(initial_url)
|
||||
if startup_url:
|
||||
args.extend(("--no-first-run", "--no-default-browser-check"))
|
||||
args.append(startup_url)
|
||||
return args
|
||||
|
||||
|
||||
def _safe_shortcut_name(value) -> str:
|
||||
@@ -300,10 +326,10 @@ def create_shortcut(account, shortcut_path=None, desktop_dir=None, name=None, co
|
||||
return shortcut_path
|
||||
|
||||
|
||||
def launch_chrome(account, config=None) -> subprocess.Popen:
|
||||
def launch_chrome(account, config=None, initial_url=None) -> subprocess.Popen:
|
||||
"""Launch Chrome for one account and return the process handle."""
|
||||
|
||||
args = build_launch_args(account, config=config)
|
||||
args = build_launch_args(account, config=config, initial_url=initial_url)
|
||||
try:
|
||||
return subprocess.Popen(args)
|
||||
except OSError as exc:
|
||||
|
||||
@@ -110,6 +110,9 @@ class AccountsTab(QWidget):
|
||||
self.account_rows = []
|
||||
self.login_statuses = {}
|
||||
self.threads = []
|
||||
self._launching_accounts = set()
|
||||
self._launch_guard_until = {}
|
||||
self._launch_run_ids = {}
|
||||
|
||||
self.table = QTableWidget(0, len(self.COLUMNS))
|
||||
self.table.setHorizontalHeaderLabels(self.COLUMNS)
|
||||
@@ -173,6 +176,9 @@ class AccountsTab(QWidget):
|
||||
|
||||
def _update_button_state(self):
|
||||
has_selection = self._selected_account() is not None
|
||||
launch_in_progress = bool(self._launching_accounts)
|
||||
self.table.setEnabled(not launch_in_progress)
|
||||
self.add_button.setEnabled(not launch_in_progress)
|
||||
for button in (
|
||||
self.edit_button,
|
||||
self.delete_button,
|
||||
@@ -180,7 +186,33 @@ class AccountsTab(QWidget):
|
||||
self.check_button,
|
||||
self.shortcut_button,
|
||||
):
|
||||
button.setEnabled(has_selection)
|
||||
button.setEnabled(has_selection and not launch_in_progress)
|
||||
|
||||
def _launch_guard_seconds(self):
|
||||
app = QApplication.instance()
|
||||
if app is None:
|
||||
return 0.5
|
||||
return max(0.25, app.doubleClickInterval() / 1000.0)
|
||||
|
||||
def _ignore_reentrant_launch(self, account):
|
||||
alias = account.alias
|
||||
now = time.monotonic()
|
||||
if (
|
||||
alias not in self._launching_accounts
|
||||
and now >= self._launch_guard_until.get(alias, 0.0)
|
||||
):
|
||||
return False
|
||||
_safe_add_run_log_event(
|
||||
self._launch_run_ids.get(alias),
|
||||
(
|
||||
f"step=launch_chrome result=ignored detail=账号 {alias} "
|
||||
"reason=launch_in_progress"
|
||||
),
|
||||
db_path=self.db_path,
|
||||
account=account,
|
||||
)
|
||||
self._set_status("该账号 Chrome 正在启动,请稍候", level="info")
|
||||
return True
|
||||
|
||||
def refresh_accounts(self):
|
||||
try:
|
||||
@@ -297,12 +329,27 @@ class AccountsTab(QWidget):
|
||||
account = self._selected_account()
|
||||
if account is None:
|
||||
return
|
||||
if self._ignore_reentrant_launch(account):
|
||||
return
|
||||
run_id = _safe_create_run_log(
|
||||
"chrome_launch",
|
||||
db_path=self.db_path,
|
||||
total=1,
|
||||
options={"alias": account.alias, "debug_port": account.debug_port},
|
||||
)
|
||||
self._launching_accounts.add(account.alias)
|
||||
self._launch_run_ids[account.alias] = run_id
|
||||
self._update_button_state()
|
||||
try:
|
||||
self._launch_login_once(account, run_id)
|
||||
finally:
|
||||
self._launching_accounts.discard(account.alias)
|
||||
self._launch_guard_until[account.alias] = (
|
||||
time.monotonic() + self._launch_guard_seconds()
|
||||
)
|
||||
self._update_button_state()
|
||||
|
||||
def _launch_login_once(self, account, run_id):
|
||||
started = time.monotonic()
|
||||
_safe_add_run_log_event(
|
||||
run_id,
|
||||
@@ -346,13 +393,27 @@ class AccountsTab(QWidget):
|
||||
action = (launch_result or {}).get("action") or "launched"
|
||||
pid = (launch_result or {}).get("pid")
|
||||
target_id = (launch_result or {}).get("target_id") or ""
|
||||
url = (launch_result or {}).get("url") or ""
|
||||
url = diagnostics.redact_log_text((launch_result or {}).get("url") or "")
|
||||
created_tab = bool((launch_result or {}).get("created_tab"))
|
||||
startup_target_reused = bool(
|
||||
(launch_result or {}).get("startup_target_reused")
|
||||
)
|
||||
startup_page_navigated = bool(
|
||||
(launch_result or {}).get("startup_page_navigated")
|
||||
)
|
||||
page_target_count = int((launch_result or {}).get("page_target_count") or 0)
|
||||
fallback_reason = (launch_result or {}).get("fallback_reason") or ""
|
||||
event_result = "reused" if action == "reused" else "launched"
|
||||
_safe_add_run_log_event(
|
||||
run_id,
|
||||
(
|
||||
f"step=launch_chrome result={event_result} detail=账号 {account.alias} "
|
||||
f"pid={pid or ''} target_id={target_id} url={url} elapsed_ms={elapsed_ms}"
|
||||
f"pid={pid or ''} target_id={target_id} url={url} "
|
||||
f"created_tab={int(created_tab)} "
|
||||
f"startup_target_reused={int(startup_target_reused)} "
|
||||
f"startup_page_navigated={int(startup_page_navigated)} "
|
||||
f"page_target_count={page_target_count} "
|
||||
f"fallback_reason={fallback_reason} elapsed_ms={elapsed_ms}"
|
||||
),
|
||||
db_path=self.db_path,
|
||||
account=account,
|
||||
@@ -372,6 +433,11 @@ class AccountsTab(QWidget):
|
||||
"pid": pid,
|
||||
"target_id": target_id,
|
||||
"url": url,
|
||||
"created_tab": created_tab,
|
||||
"startup_target_reused": startup_target_reused,
|
||||
"startup_page_navigated": startup_page_navigated,
|
||||
"page_target_count": page_target_count,
|
||||
"fallback_reason": fallback_reason or None,
|
||||
},
|
||||
)
|
||||
self.login_statuses[account.alias] = "已启动"
|
||||
@@ -447,4 +513,3 @@ class AccountsTab(QWidget):
|
||||
PLAINTEXT_SECRET_TITLE,
|
||||
PLAINTEXT_PASSWORD_WARNING,
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user