feat: remote browser login persistence + balance display + UI consistency
- Retain login state in remote browser profiles (don't delete on disconnect)
- Add GET /api/browser-sessions/{id}/clipboard for clipboard sync
- Add POST /api/browser-sessions/{id}/autofill-login for manual credential fill
- Add DELETE /api/browser-sessions/profiles/{custom_page_id} for login clear
- Add balance tracking with configurable divisor (balance_divisor)
- Health check on session reuse, idle TTL eviction, background cleanup
- Add first-frame watchdog (10s timeout) to prevent infinite loading
- Reconnect browser on active=true when session was closed
- UI: uniform text-only inline buttons (websites + upstreams pages)
- Fix page switch race with closingRemoteSessionPromise
This commit is contained in:
@@ -131,11 +131,113 @@ async def get_selection(session_id: str, _=Depends(get_current_user)):
|
||||
raise _error_from_browser(exc)
|
||||
|
||||
|
||||
class BrowserClipboardResponse(BaseModel):
|
||||
text: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/{session_id}/clipboard", response_model=BrowserClipboardResponse)
|
||||
async def session_clipboard(session_id: str, _=Depends(get_current_user)):
|
||||
"""Read text from the remote browser's clipboard."""
|
||||
from fastapi.responses import JSONResponse
|
||||
try:
|
||||
text, error = await browser_sessions.read_clipboard(session_id)
|
||||
body: dict[str, Any] = {}
|
||||
if text:
|
||||
body["text"] = text
|
||||
elif error == "denied":
|
||||
body["error"] = "远程浏览器未授予剪贴板读取权限"
|
||||
elif error == "read_failed":
|
||||
body["error"] = "读取远程剪贴板时发生内部错误"
|
||||
else:
|
||||
if error:
|
||||
logger.warning("clipboard read error for %s: %s", session_id[:12], error)
|
||||
body["error"] = "远程剪贴板为空"
|
||||
return JSONResponse(content=body, headers={"Cache-Control": "no-store"})
|
||||
except Exception as exc:
|
||||
raise _error_from_browser(exc)
|
||||
|
||||
|
||||
class AutofillLoginResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
|
||||
|
||||
@router.post("/{session_id}/autofill-login", response_model=AutofillLoginResponse)
|
||||
async def autofill_login(session_id: str, _=Depends(get_current_user)):
|
||||
"""Manually trigger login autofill for the remote browser session.
|
||||
|
||||
Uses the linked custom page's saved credentials. Never returns passwords.
|
||||
"""
|
||||
try:
|
||||
session_state = await browser_sessions.state(session_id)
|
||||
except Exception as exc:
|
||||
raise _error_from_browser(exc)
|
||||
|
||||
from app.database import SessionLocal as _Db
|
||||
from app.models.custom_page import CustomPage
|
||||
db = _Db()
|
||||
try:
|
||||
page = db.query(CustomPage).filter(
|
||||
CustomPage.id == session_state["custom_page_id"]
|
||||
).first()
|
||||
if not page or not page.enabled:
|
||||
raise HTTPException(400, "linked custom page is not available")
|
||||
if page.access_mode != "remote_browser":
|
||||
raise HTTPException(400, "linked custom page is not in remote browser mode")
|
||||
if not page.login_autofill_enabled:
|
||||
return AutofillLoginResponse(success=False, message="该页面未启用自动填充登录")
|
||||
if not page.login_username or not page.login_password:
|
||||
return AutofillLoginResponse(success=False, message="该页面未保存账号密码")
|
||||
|
||||
login_config = {
|
||||
"enabled": True,
|
||||
"username": page.login_username,
|
||||
"password": page.login_password,
|
||||
"username_selector": page.login_username_selector,
|
||||
"password_selector": page.login_password_selector,
|
||||
"submit_selector": page.login_submit_selector,
|
||||
}
|
||||
|
||||
filled = await browser_sessions.autofill_login(session_id, login_config)
|
||||
if filled:
|
||||
return AutofillLoginResponse(success=True, message="已填入账号密码")
|
||||
return AutofillLoginResponse(
|
||||
success=False,
|
||||
message="未找到登录输入框,请先关闭弹窗或进入登录页后重试",
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.delete("/{session_id}", status_code=204)
|
||||
async def close_session(session_id: str, _=Depends(get_current_user)):
|
||||
await browser_sessions.close(session_id)
|
||||
|
||||
|
||||
@router.delete("/profiles/{custom_page_id}", status_code=204)
|
||||
async def clear_profile(custom_page_id: int, _=Depends(get_current_user)):
|
||||
"""Close active session for the page and delete its profile directory.
|
||||
|
||||
On next open the browser starts fresh, losing login state.
|
||||
"""
|
||||
from app.models.custom_page import CustomPage
|
||||
from app.database import SessionLocal as _Db
|
||||
db = _Db()
|
||||
try:
|
||||
page = db.query(CustomPage).filter(CustomPage.id == custom_page_id).first()
|
||||
if not page or not page.enabled:
|
||||
raise HTTPException(404, "custom page not found")
|
||||
if page.access_mode != "remote_browser":
|
||||
raise HTTPException(400, "custom page is not in remote browser mode")
|
||||
try:
|
||||
await browser_sessions.clear_profile(custom_page_id, page.url)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(500, str(exc))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ——— WebSocket stream ———
|
||||
# Frame interval & diff detection
|
||||
_WS_MIN_INTERVAL = 0.10
|
||||
|
||||
Reference in New Issue
Block a user