diff --git a/docs/audit/2026-06-11-global-floating-browser.md b/docs/audit/2026-06-11-global-floating-browser.md new file mode 100644 index 00000000..3f4b0811 --- /dev/null +++ b/docs/audit/2026-06-11-global-floating-browser.md @@ -0,0 +1,30 @@ +# 审计 — 全局浮动浏览器 + +## 范围(文件清单) + +| 文件 | 变更 | +|------|------| +| `server/utils/browser_ui_state.py` | URL 校验、visit 上限 | +| `server/api/browser.py` | `/api/browser/state` | +| `frontend/src/composables/useGlobalBrowser.ts` | 全局状态 | +| `frontend/src/components/GlobalBrowserPanel.vue` | 浮动面板 | +| `tests/test_browser_ui_state.py` | 单测 | + +## Step 3 规则扫描 + +| H | 规则 | 结论 | +|---|------|------| +| H1 | 无 SSRF | PASS — 无服务端 fetch | +| H2 | URL 白名单 | PASS — http/https only | +| H3 | 按管理员隔离 | PASS — admin_ui_preferences | +| H4 | iframe sandbox | PASS | + +## DoD + +- [x] pytest +- [x] type-check +- [x] changelog + +## 验证 + +浮动打开 → 最小化右下角 → 左下角展开 → 切换菜单 iframe 仍在。 diff --git a/docs/changelog/2026-06-11-global-floating-browser.md b/docs/changelog/2026-06-11-global-floating-browser.md new file mode 100644 index 00000000..8bd02d73 --- /dev/null +++ b/docs/changelog/2026-06-11-global-floating-browser.md @@ -0,0 +1,41 @@ +# 2026-06-11 全局浮动浏览器与记录持久化 + +## 摘要 + +内置浏览器改为 **全局浮动面板**:可拖动/缩放、最小化到**右下角**、**左下角**常驻按钮随时展开;切换侧栏页面时保持活动。浏览记录、标签、窗口位置同步至 MySQL(`admin_ui_preferences`)。 + +## 动机 + +用户需要在运维任意页面内预览站点,最小化后不占全屏,且换页不丢失;历史记录需跨浏览器/清缓存保留。 + +## 涉及文件 + +| 文件 | 变更 | +|------|------| +| `server/utils/browser_ui_state.py` | 状态解析与 visit 记录 | +| `server/api/browser.py` | `GET/PUT /api/browser/state` | +| `server/main.py` | 注册路由 | +| `frontend/src/composables/useGlobalBrowser.ts` | 全局单例状态 | +| `frontend/src/components/GlobalBrowserPanel.vue` | 浮动 UI | +| `frontend/src/pages/BrowserPage.vue` | 打开全局浏览器并返回 | +| `frontend/src/App.vue` | 挂载全局组件 | +| `frontend/src/pages/ServersPage.vue` | 「站点」直接打开浮动窗 | +| `tests/test_browser_ui_state.py` | 单测 | + +## 迁移 / 重启 + +- 无需新表(复用 `admin_ui_preferences`) +- 需 API 重启 + 前端 build + +## 验证 + +```bash +pytest tests/test_browser_ui_state.py -q +cd frontend && npm run type-check +``` + +左下角地球按钮打开;最小化后在右下角;历史按钮可见记录;换账号后记录隔离。 + +## 说明 + +iframe 在用户当前的 Chrome/Firefox 等引擎中渲染,非独立安装 Firefox;禁止嵌入的站点请用「新标签打开」。 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 870a71b7..991ddf70 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -223,6 +223,8 @@ {{ snackbar.text }} + + @@ -247,6 +249,8 @@ import { } from '@/composables/useScriptSubmitToast' import { clearCachedQueries } from '@/composables/useCachedQuery' import { scheduleRoutePrefetch, cancelRoutePrefetch } from '@/composables/useRoutePrefetch' +import GlobalBrowserPanel from '@/components/GlobalBrowserPanel.vue' +import { useGlobalBrowser } from '@/composables/useGlobalBrowser' import { CACHED_PAGE_NAMES } from '@/constants/cachedPages' import { api } from '@/api' @@ -327,8 +331,7 @@ provide('nexusDrawer', drawer) const mainLayoutClass = computed(() => ({ 'nexus-terminal-main': route.path === '/terminal', - 'nexus-browser-main': route.path === '/browser', - 'nexus-page-main': route.path !== '/terminal' && route.path !== '/browser' && route.path !== '/login', + 'nexus-page-main': route.path !== '/terminal' && route.path !== '/login', })) const scriptExecBarOffset = computed(() => { @@ -417,31 +420,6 @@ window.$snackbar = (text: string, color = 'success') => { padding: 0; } -.v-application .v-main.nexus-browser-main { - display: flex; - flex-direction: column; - box-sizing: border-box; - height: 100dvh; - max-height: 100dvh; - min-height: 0; - max-width: 100%; - overflow: hidden; - padding-top: var(--v-layout-top, 64px) !important; - padding-bottom: 0 !important; - transition: - padding 0.2s cubic-bezier(0.4, 0, 0.2, 1), - height 0.2s cubic-bezier(0.4, 0, 0.2, 1); -} -.v-application .v-main.nexus-browser-main > .v-container { - flex: 1 1 auto; - min-height: 0; - min-width: 0; - max-width: 100%; - width: 100%; - padding: 0; - height: 100%; -} - /* 仪表盘等业务页:随侧栏 --v-layout-left 缩进,表格可横向滚动 */ .v-application .v-main.nexus-page-main { box-sizing: border-box; diff --git a/frontend/src/components/GlobalBrowserPanel.vue b/frontend/src/components/GlobalBrowserPanel.vue new file mode 100644 index 00000000..fefeefb9 --- /dev/null +++ b/frontend/src/components/GlobalBrowserPanel.vue @@ -0,0 +1,388 @@ + + + + + + + mdi-drag + + + + + {{ tabLabel(tab) }} + + + + + + + + + + + + + + + + + + + + + 打开 + + + + + {{ lastError }} + + + + + + + + + 输入地址后按回车或点「打开」 + + 页面在您当前的 Chrome / Firefox 等浏览器引擎中渲染;切换侧栏菜单时浏览器保持活动。 + + + + + + 若空白,可能禁止 iframe 嵌入 — 请用「新标签打开」。 + + + + + + + + + + + + + {{ activeTab ? tabLabel(activeTab) : '浏览器' }} + + {{ tabs.length }} + + + + + + + + + + + + diff --git a/frontend/src/composables/useEmbeddedBrowser.ts b/frontend/src/composables/useEmbeddedBrowser.ts deleted file mode 100644 index 65fe2544..00000000 --- a/frontend/src/composables/useEmbeddedBrowser.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { computed, ref, watch } from 'vue' -import { normalizeBrowserUrl } from '@/utils/browserUrl' - -const HISTORY_KEY = 'nexus_browser_history_v1' -const MAX_HISTORY = 32 - -function readHistory(): string[] { - try { - const raw = sessionStorage.getItem(HISTORY_KEY) - const parsed = raw ? (JSON.parse(raw) as unknown) : [] - return Array.isArray(parsed) - ? parsed.filter((u): u is string => typeof u === 'string') - : [] - } catch { - return [] - } -} - -function writeHistory(items: string[]) { - sessionStorage.setItem(HISTORY_KEY, JSON.stringify(items.slice(-MAX_HISTORY))) -} - -export function useEmbeddedBrowser(initialUrl?: string) { - const history = ref(readHistory()) - const historyIndex = ref(-1) - const addressDraft = ref('') - const frameUrl = ref(null) - const frameKey = ref(0) - const lastError = ref(null) - - function pushHistory(url: string) { - const list = history.value.slice(0, historyIndex.value + 1) - if (list[list.length - 1] !== url) list.push(url) - history.value = list - historyIndex.value = list.length - 1 - writeHistory(list) - } - - function navigate(input: string, replace = false): boolean { - const normalized = normalizeBrowserUrl(input) - if (!normalized) { - lastError.value = '请输入有效的 http 或 https 地址' - return false - } - lastError.value = null - addressDraft.value = normalized - frameUrl.value = normalized - frameKey.value += 1 - if (!replace) { - pushHistory(normalized) - } else if (history.value.length === 0) { - history.value = [normalized] - historyIndex.value = 0 - writeHistory(history.value) - } - return true - } - - function refresh() { - if (!frameUrl.value) return - frameKey.value += 1 - } - - function goBack() { - if (historyIndex.value <= 0) return - historyIndex.value -= 1 - const url = history.value[historyIndex.value] - if (!url) return - addressDraft.value = url - frameUrl.value = url - frameKey.value += 1 - lastError.value = null - } - - function goForward() { - if (historyIndex.value >= history.value.length - 1) return - historyIndex.value += 1 - const url = history.value[historyIndex.value] - if (!url) return - addressDraft.value = url - frameUrl.value = url - frameKey.value += 1 - lastError.value = null - } - - const canGoBack = computed(() => historyIndex.value > 0) - const canGoForward = computed(() => historyIndex.value >= 0 && historyIndex.value < history.value.length - 1) - - function openExternal() { - if (!frameUrl.value) return - window.open(frameUrl.value, '_blank', 'noopener,noreferrer') - } - - if (initialUrl) { - navigate(initialUrl, true) - } - - watch(frameUrl, (url) => { - if (url) addressDraft.value = url - }) - - return { - addressDraft, - frameUrl, - frameKey, - lastError, - canGoBack, - canGoForward, - navigate, - refresh, - goBack, - goForward, - openExternal, - } -} diff --git a/frontend/src/composables/useGlobalBrowser.ts b/frontend/src/composables/useGlobalBrowser.ts new file mode 100644 index 00000000..3ad6ede0 --- /dev/null +++ b/frontend/src/composables/useGlobalBrowser.ts @@ -0,0 +1,368 @@ +/** + * Global floating browser — persists tabs/history to MySQL via /api/browser/state. + * Singleton module state survives route changes. + */ +import { computed, ref } from 'vue' +import { http } from '@/api' +import { normalizeBrowserUrl } from '@/utils/browserUrl' +import type { FloatRect } from '@/composables/useFloatingPanel' + +const LEGACY_HISTORY_KEY = 'nexus_browser_history_v1' +const SAVE_DEBOUNCE_MS = 700 + +export interface BrowserVisit { + url: string + title: string + at: string +} + +export interface BrowserTab { + id: string + url: string + title: string + stack: string[] + stackIndex: number + frameKey: number +} + +export interface BrowserStateResponse { + url_history: string[] + visits: BrowserVisit[] + tabs: Array<{ + id: string + url: string + title?: string + stack?: string[] + stack_index?: number + }> + active_tab_id: string | null + minimized: boolean + rect: FloatRect | null +} + +const bootstrapped = ref(false) +const saving = ref(false) +const panelOpen = ref(false) +const minimized = ref(false) +const maximized = ref(false) +const lastError = ref(null) +const addressDraft = ref('') +const urlHistory = ref([]) +const visits = ref([]) +const tabs = ref([]) +const activeTabId = ref(null) +const panelRect = ref(null) + +let saveTimer: ReturnType | null = null + +function tabLabel(tab: BrowserTab): string { + try { + return tab.title || new URL(tab.url).hostname + } catch { + return tab.title || tab.url + } +} + +function newTabId(): string { + return crypto.randomUUID() +} + +function serializeTabs(): BrowserStateResponse['tabs'] { + return tabs.value.map((t) => ({ + id: t.id, + url: t.url, + title: t.title, + stack: t.stack, + stack_index: t.stackIndex, + })) +} + +function applyServerState(data: BrowserStateResponse) { + urlHistory.value = data.url_history || [] + visits.value = (data.visits || []).map((v) => ({ + url: v.url, + title: v.title || v.url, + at: v.at, + })) + tabs.value = (data.tabs || []).map((t) => ({ + id: t.id, + url: t.url, + title: t.title || t.url, + stack: t.stack?.length ? t.stack : [t.url], + stackIndex: typeof t.stack_index === 'number' ? t.stack_index : (t.stack?.length ?? 1) - 1, + frameKey: 0, + })) + activeTabId.value = data.active_tab_id + if (!activeTabId.value && tabs.value.length) { + activeTabId.value = tabs.value[0]!.id + } + minimized.value = Boolean(data.minimized) + panelRect.value = data.rect + panelOpen.value = tabs.value.length > 0 && !minimized.value + syncAddressFromActive() +} + +function syncAddressFromActive() { + const tab = activeTab.value + addressDraft.value = tab?.url ?? '' +} + +const activeTab = computed(() => tabs.value.find((t) => t.id === activeTabId.value) ?? null) + +const isActive = computed(() => tabs.value.length > 0 || panelOpen.value) + +const canGoBack = computed(() => { + const tab = activeTab.value + return tab ? tab.stackIndex > 0 : false +}) + +const canGoForward = computed(() => { + const tab = activeTab.value + return tab ? tab.stackIndex < tab.stack.length - 1 : false +}) + +function scheduleSave(extra?: { recordUrl?: string; recordTitle?: string }) { + if (saveTimer) clearTimeout(saveTimer) + saveTimer = setTimeout(() => { + void persistState(extra) + }, SAVE_DEBOUNCE_MS) +} + +async function persistState(extra?: { recordUrl?: string; recordTitle?: string }) { + saving.value = true + try { + const body: Record = { + tabs: serializeTabs(), + active_tab_id: activeTabId.value, + minimized: minimized.value, + rect: panelRect.value, + } + if (extra?.recordUrl) { + body.record_url = extra.recordUrl + if (extra.recordTitle) body.record_title = extra.recordTitle + } else { + body.url_history = urlHistory.value + body.visits = visits.value + } + const res = await http.put('/browser/state', body) + applyServerState(res) + } catch { + /* keep local state */ + } finally { + saving.value = false + } +} + +async function migrateLegacyHistory() { + try { + const raw = sessionStorage.getItem(LEGACY_HISTORY_KEY) + if (!raw) return + const parsed = JSON.parse(raw) as unknown + if (!Array.isArray(parsed)) return + const urls = parsed.filter((u): u is string => typeof u === 'string' && !!normalizeBrowserUrl(u)) + if (!urls.length) return + for (const url of [...urls].reverse()) { + const normalized = normalizeBrowserUrl(url) + if (normalized) { + await persistState({ recordUrl: normalized, recordTitle: normalized }) + } + } + sessionStorage.removeItem(LEGACY_HISTORY_KEY) + } catch { + /* ignore */ + } +} + +async function bootstrap() { + if (bootstrapped.value) return + try { + const res = await http.get('/browser/state') + applyServerState(res) + if (!visits.value.length && !urlHistory.value.length) { + await migrateLegacyHistory() + } + } catch { + /* offline */ + } finally { + bootstrapped.value = true + } +} + +function openPanel() { + panelOpen.value = true + minimized.value = false + scheduleSave() +} + +function minimizePanel() { + minimized.value = true + panelOpen.value = false + scheduleSave() +} + +function closePanel() { + tabs.value = [] + activeTabId.value = null + panelOpen.value = false + minimized.value = false + maximized.value = false + addressDraft.value = '' + scheduleSave() +} + +function openUrl(input: string, opts?: { title?: string; newTab?: boolean }) { + const normalized = normalizeBrowserUrl(input) + if (!normalized) { + lastError.value = '请输入有效的 http 或 https 地址' + return false + } + lastError.value = null + + if (opts?.newTab || !tabs.value.length || !activeTab.value) { + const tab: BrowserTab = { + id: newTabId(), + url: normalized, + title: opts?.title || normalized, + stack: [normalized], + stackIndex: 0, + frameKey: 0, + } + tabs.value = [...tabs.value, tab].slice(-12) + activeTabId.value = tab.id + } else { + const tab = activeTab.value + const stack = tab.stack.slice(0, tab.stackIndex + 1) + if (!stack.length || stack[stack.length - 1] !== normalized) stack.push(normalized) + tab.url = normalized + if (opts?.title) tab.title = opts.title + tab.stack = stack + tab.stackIndex = stack.length - 1 + tab.frameKey += 1 + } + + addressDraft.value = normalized + openPanel() + scheduleSave({ recordUrl: normalized, recordTitle: opts?.title }) + return true +} + +function navigate(input: string) { + return openUrl(input) +} + +function refreshActive() { + const tab = activeTab.value + if (!tab) return + tab.frameKey += 1 +} + +function goBack() { + const tab = activeTab.value + if (!tab || tab.stackIndex <= 0) return + tab.stackIndex -= 1 + tab.url = tab.stack[tab.stackIndex]! + tab.frameKey += 1 + addressDraft.value = tab.url + scheduleSave() +} + +function goForward() { + const tab = activeTab.value + if (!tab || tab.stackIndex >= tab.stack.length - 1) return + tab.stackIndex += 1 + tab.url = tab.stack[tab.stackIndex]! + tab.frameKey += 1 + addressDraft.value = tab.url + scheduleSave() +} + +function openExternal() { + const tab = activeTab.value + if (!tab) return + window.open(tab.url, '_blank', 'noopener,noreferrer') +} + +function switchTab(id: string) { + activeTabId.value = id + syncAddressFromActive() + scheduleSave() +} + +function closeTab(id: string) { + const idx = tabs.value.findIndex((t) => t.id === id) + if (idx < 0) return + const next = tabs.value.filter((t) => t.id !== id) + tabs.value = next + if (activeTabId.value === id) { + activeTabId.value = next[idx]?.id ?? next[idx - 1]?.id ?? null + } + if (!next.length) { + closePanel() + return + } + syncAddressFromActive() + scheduleSave() +} + +function newEmptyTab() { + const tab: BrowserTab = { + id: newTabId(), + url: '', + title: '新标签', + stack: [], + stackIndex: -1, + frameKey: 0, + } + tabs.value = [...tabs.value, tab].slice(-12) + activeTabId.value = tab.id + addressDraft.value = '' + openPanel() + scheduleSave() +} + +function openFromHistory(url: string) { + openUrl(url) +} + +function savePanelRect(rect: FloatRect) { + panelRect.value = rect + scheduleSave() +} + +export function useGlobalBrowser() { + return { + bootstrapped, + saving, + panelOpen, + minimized, + maximized, + lastError, + addressDraft, + urlHistory, + visits, + tabs, + activeTabId, + activeTab, + panelRect, + isActive, + canGoBack, + canGoForward, + tabLabel, + bootstrap, + openPanel, + minimizePanel, + closePanel, + openUrl, + navigate, + refreshActive, + goBack, + goForward, + openExternal, + switchTab, + closeTab, + newEmptyTab, + openFromHistory, + savePanelRect, + scheduleSave, + } +} diff --git a/frontend/src/pages/BrowserPage.vue b/frontend/src/pages/BrowserPage.vue index 06e02b65..d77b0dae 100644 --- a/frontend/src/pages/BrowserPage.vue +++ b/frontend/src/pages/BrowserPage.vue @@ -1,160 +1,29 @@ - - - - - - - - - 打开 - - - - - {{ lastError }} - - - - - 输入地址预览站点,或从服务器列表点击「站点」 - - 选择服务器 - - - - - - - 若页面空白,可能该站点禁止 iframe 嵌入,请使用右上角「新标签打开」。 - - - + 正在打开浏览器… - - diff --git a/frontend/src/pages/ServersPage.vue b/frontend/src/pages/ServersPage.vue index 6bd9f2df..78362d4a 100644 --- a/frontend/src/pages/ServersPage.vue +++ b/frontend/src/pages/ServersPage.vue @@ -672,6 +672,7 @@ import type { AddByIpResponse, AddByIpsBatchResult, BatchJobStarted, PendingServ import { normalizeServerIds } from '@/utils/serverSelection' import { formatApiError } from '@/utils/apiError' import { guessSiteUrlFromDomain } from '@/utils/browserUrl' +import { useGlobalBrowser } from '@/composables/useGlobalBrowser' import { registerServerBatchJob, onScriptExecutionComplete } from '@/composables/useScriptExecutionQueue' import { showScriptSubmitToast } from '@/composables/useScriptSubmitToast' import StatCardsRow from '@/components/StatCardsRow.vue' @@ -745,6 +746,7 @@ const dataTablePageOptions = [...DATA_TABLE_ITEMS_PER_PAGE_OPTIONS] const snackbar = useSnackbar() const router = useRouter() +const globalBrowser = useGlobalBrowser() const route = useRoute() const showCredentialsDialog = ref(false) @@ -1413,7 +1415,7 @@ function openBrowser(item: ServerApiItem) { snackbar('该服务器无有效域名', 'warning') return } - router.push({ path: '/browser', query: { url } }) + globalBrowser.openUrl(url, { title: item.name }) } function openFiles(item: ServerApiItem) { router.push({ path: '/files', query: { server_id: String(item.id) } }) diff --git a/server/api/browser.py b/server/api/browser.py new file mode 100644 index 00000000..c2e9db19 --- /dev/null +++ b/server/api/browser.py @@ -0,0 +1,84 @@ +"""Embedded browser UI preferences API.""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends +from pydantic import BaseModel, Field +from sqlalchemy.ext.asyncio import AsyncSession + +from server.api.auth_jwt import get_current_admin +from server.api.dependencies import get_db +from server.domain.models import Admin +from server.infrastructure.database.admin_ui_preference_repo import AdminUiPreferenceRepositoryImpl +from server.utils.browser_ui_state import ( + BROWSER_UI_CONTEXT, + merge_browser_state, + parse_browser_state, + record_visit, +) + +router = APIRouter(prefix="/api/browser", tags=["browser"]) + + +class BrowserVisitRecord(BaseModel): + url: str + title: str | None = None + + +class BrowserTabState(BaseModel): + id: str + url: str + title: str | None = None + stack: list[str] | None = None + stack_index: int | None = None + + +class BrowserPanelRect(BaseModel): + x: float + y: float + w: float + h: float + + +class BrowserStateUpdate(BaseModel): + url_history: list[str] | None = None + visits: list[dict] | None = None + tabs: list[BrowserTabState] | None = None + active_tab_id: str | None = None + minimized: bool | None = None + rect: BrowserPanelRect | None = None + record_url: str | None = Field(default=None, description="Append one visit + history entry") + record_title: str | None = None + + +@router.get("/state") +async def get_browser_state( + admin: Admin = Depends(get_current_admin), + db: AsyncSession = Depends(get_db), +): + repo = AdminUiPreferenceRepositoryImpl(db) + raw = await repo.get(admin.id, BROWSER_UI_CONTEXT) + return parse_browser_state(raw) + + +@router.put("/state") +async def put_browser_state( + payload: BrowserStateUpdate, + admin: Admin = Depends(get_current_admin), + db: AsyncSession = Depends(get_db), +): + repo = AdminUiPreferenceRepositoryImpl(db) + current = parse_browser_state(await repo.get(admin.id, BROWSER_UI_CONTEXT)) + + incoming = payload.model_dump(exclude_none=True) + if payload.tabs is not None: + incoming["tabs"] = [t.model_dump(exclude_none=True) for t in payload.tabs] + if payload.rect is not None: + incoming["rect"] = payload.rect.model_dump() + + merged = merge_browser_state(current, incoming) + if payload.record_url: + merged = record_visit(merged, payload.record_url, payload.record_title) + + await repo.upsert(admin.id, BROWSER_UI_CONTEXT, merged) + return merged diff --git a/server/main.py b/server/main.py index a2549651..d78f71c9 100644 --- a/server/main.py +++ b/server/main.py @@ -687,6 +687,11 @@ app.include_router(search_router) # Terminal Quick Commands app.include_router(terminal_router) +# Embedded browser UI state +from server.api.browser import router as browser_router # noqa: E402 + +app.include_router(browser_router) + # ── Custom 404: return blank HTML to hide backend identity ── @app.exception_handler(StarletteHTTPException) diff --git a/server/utils/browser_ui_state.py b/server/utils/browser_ui_state.py new file mode 100644 index 00000000..8e565473 --- /dev/null +++ b/server/utils/browser_ui_state.py @@ -0,0 +1,153 @@ +"""Embedded browser UI state — history, tabs, panel layout.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any +from urllib.parse import urlparse + +BROWSER_UI_CONTEXT = "embedded_browser" +MAX_URL_HISTORY = 128 +MAX_VISIT_LOG = 512 +MAX_TABS = 12 + + +def _utcnow_iso() -> str: + return datetime.now(timezone.utc).replace(tzinfo=None).isoformat(timespec="seconds") + + +def _is_safe_url(url: str) -> bool: + try: + parsed = urlparse(url.strip()) + except Exception: + return False + if parsed.scheme not in ("http", "https"): + return False + if parsed.username or parsed.password: + return False + return bool(parsed.netloc) + + +def _hostname(url: str) -> str: + try: + return urlparse(url).hostname or url + except Exception: + return url + + +def empty_browser_state() -> dict[str, Any]: + return { + "url_history": [], + "visits": [], + "tabs": [], + "active_tab_id": None, + "minimized": False, + "rect": None, + } + + +def parse_browser_state(raw: dict | None) -> dict[str, Any]: + base = empty_browser_state() + if not raw or not isinstance(raw, dict): + return base + + url_history = [ + u for u in raw.get("url_history") or [] + if isinstance(u, str) and _is_safe_url(u) + ][:MAX_URL_HISTORY] + + visits: list[dict[str, str]] = [] + for item in raw.get("visits") or []: + if not isinstance(item, dict): + continue + url = item.get("url") + if not isinstance(url, str) or not _is_safe_url(url): + continue + visits.append({ + "url": url, + "title": str(item.get("title") or _hostname(url))[:200], + "at": str(item.get("at") or _utcnow_iso()), + }) + visits = visits[:MAX_VISIT_LOG] + + tabs: list[dict[str, Any]] = [] + for item in raw.get("tabs") or []: + if not isinstance(item, dict): + continue + tab_id = item.get("id") + url = item.get("url") + if not isinstance(tab_id, str) or not tab_id.strip(): + continue + if not isinstance(url, str) or not _is_safe_url(url): + continue + stack = [ + u for u in item.get("stack") or [url] + if isinstance(u, str) and _is_safe_url(u) + ] or [url] + stack_index = item.get("stack_index", len(stack) - 1) + if not isinstance(stack_index, int): + stack_index = len(stack) - 1 + stack_index = max(0, min(stack_index, len(stack) - 1)) + tabs.append({ + "id": tab_id.strip()[:64], + "url": url, + "title": str(item.get("title") or _hostname(url))[:200], + "stack": stack[-64:], + "stack_index": stack_index, + }) + tabs = tabs[:MAX_TABS] + + active_tab_id = raw.get("active_tab_id") + if not isinstance(active_tab_id, str) or not any(t["id"] == active_tab_id for t in tabs): + active_tab_id = tabs[0]["id"] if tabs else None + + rect = raw.get("rect") + if rect is not None and not isinstance(rect, dict): + rect = None + if isinstance(rect, dict): + try: + rect = { + "x": float(rect.get("x", 0)), + "y": float(rect.get("y", 0)), + "w": float(rect.get("w", 960)), + "h": float(rect.get("h", 640)), + } + except (TypeError, ValueError): + rect = None + + minimized = bool(raw.get("minimized")) + + return { + "url_history": url_history, + "visits": visits, + "tabs": tabs, + "active_tab_id": active_tab_id, + "minimized": minimized, + "rect": rect, + } + + +def merge_browser_state(current: dict[str, Any], incoming: dict[str, Any]) -> dict[str, Any]: + """Merge client state; incoming wins for tabs/active/minimized/rect.""" + parsed = parse_browser_state({**current, **incoming}) + if incoming.get("url_history") is not None: + parsed["url_history"] = parse_browser_state({"url_history": incoming["url_history"]})["url_history"] + if incoming.get("visits") is not None: + parsed["visits"] = parse_browser_state({"visits": incoming["visits"]})["visits"] + return parsed + + +def record_visit(state: dict[str, Any], url: str, title: str | None = None) -> dict[str, Any]: + if not _is_safe_url(url): + return state + next_state = parse_browser_state(state) + hist = [url] + [u for u in next_state["url_history"] if u != url] + next_state["url_history"] = hist[:MAX_URL_HISTORY] + visit = { + "url": url, + "title": (title or _hostname(url))[:200], + "at": _utcnow_iso(), + } + visits = [visit] + next_state["visits"] + next_state["visits"] = visits[:MAX_VISIT_LOG] + return next_state diff --git a/tests/test_browser_ui_state.py b/tests/test_browser_ui_state.py new file mode 100644 index 00000000..279c94de --- /dev/null +++ b/tests/test_browser_ui_state.py @@ -0,0 +1,36 @@ +"""Tests for embedded browser UI state.""" + +from server.utils.browser_ui_state import ( + empty_browser_state, + parse_browser_state, + record_visit, +) + + +def test_empty_browser_state(): + assert parse_browser_state(None) == empty_browser_state() + + +def test_parse_filters_unsafe_urls(): + raw = { + "url_history": ["https://ok.example", "javascript:alert(1)", "ftp://x"], + "tabs": [ + {"id": "t1", "url": "https://a.com", "stack": ["https://a.com"], "stack_index": 0}, + {"id": "t2", "url": "data:text/html,x"}, + ], + } + state = parse_browser_state(raw) + assert state["url_history"] == ["https://ok.example"] + assert len(state["tabs"]) == 1 + assert state["tabs"][0]["id"] == "t1" + + +def test_record_visit_dedupes_history(): + state = empty_browser_state() + state = record_visit(state, "https://a.example", "A") + state = record_visit(state, "https://b.example", "B") + state = record_visit(state, "https://a.example", "A again") + assert state["url_history"][0] == "https://a.example" + assert state["url_history"][1] == "https://b.example" + assert len(state["visits"]) == 3 + assert state["visits"][0]["title"] == "A again"
输入地址后按回车或点「打开」
+ 页面在您当前的 Chrome / Firefox 等浏览器引擎中渲染;切换侧栏菜单时浏览器保持活动。 +
+ 若空白,可能禁止 iframe 嵌入 — 请用「新标签打开」。 +
输入地址预览站点,或从服务器列表点击「站点」
- 若页面空白,可能该站点禁止 iframe 嵌入,请使用右上角「新标签打开」。 -