Fix deploy.sh CRLF line endings for Linux compatibility

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-27 10:59:59 +08:00
parent 65f5caf4d9
commit b9ee546bc1
20 changed files with 2142 additions and 2118 deletions
+9 -9
View File
@@ -1,9 +1,9 @@
# 服务端口(映射到宿主机) # 服务端口(映射到宿主机)
APP_PORT=32450 APP_PORT=32450
# 应用配置 # 应用配置
MAX_SESSIONS=1 MAX_SESSIONS=1
SESSION_IDLE_TIMEOUT=1800 SESSION_IDLE_TIMEOUT=1800
VIEWPORT_WIDTH=1280 VIEWPORT_WIDTH=1280
VIEWPORT_HEIGHT=720 VIEWPORT_HEIGHT=720
SCREENCAST_QUALITY=80 SCREENCAST_QUALITY=80
+10
View File
@@ -0,0 +1,10 @@
* text=auto
*.sh text eol=lf
Dockerfile text eol=lf
*.yml text eol=lf
*.py text eol=lf
*.md text eol=lf
*.html text eol=lf
*.js text eol=lf
*.css text eol=lf
.env.example text eol=lf
+7 -7
View File
@@ -1,7 +1,7 @@
.env .env
data/ data/
__pycache__/ __pycache__/
*.pyc *.pyc
.pytest_cache/ .pytest_cache/
.venv/ .venv/
venv/ venv/
+241 -226
View File
@@ -1,226 +1,241 @@
# 云端浏览器部署文档 # 云端浏览器部署文档
本文档说明如何在 Linux 服务器(含宝塔面板环境)上部署云端浏览器。 本文档说明如何在 Linux 服务器(含宝塔面板环境)上部署云端浏览器。
- 安装目录:`/opt/cloud-browser` - 安装目录:`/opt/cloud-browser`
- 运行用户:`root` - 运行用户:`root`
- 默认端口:`32450` - 默认端口:`32450`
- 默认账号:`admin` / `admin` - 默认账号:`admin` / `admin`
- 代码仓库:[https://git.bz121.com/dekun/cloud-browser.git](https://git.bz121.com/dekun/cloud-browser.git) - 代码仓库:[https://git.bz121.com/dekun/cloud-browser.git](https://git.bz121.com/dekun/cloud-browser.git)
--- ---
## 一、环境要求 ## 一、环境要求
| 项目 | 要求 | | 项目 | 要求 |
|------|------| |------|------|
| 系统 | Ubuntu 20.04+ / Debian 11+ / CentOS 7+ | | 系统 | Ubuntu 20.04+ / Debian 11+ / CentOS 7+ |
| 内存 | 最低 1GB,推荐 2GB | | 内存 | 最低 1GB,推荐 2GB |
| Docker | 20.10+ | | Docker | 20.10+ |
| Docker Compose | v2+ | | Docker Compose | v2+ |
| 端口 | 32450(或自定义,需在 `.env` 中修改) | | 端口 | 32450(或自定义,需在 `.env` 中修改) |
> 服务器已安装宝塔面板、Docker、Nginx 均可,**无需额外配置 Nginx**,服务直接监听端口,反向代理请自行操作。 > 服务器已安装宝塔面板、Docker、Nginx 均可,**无需额外配置 Nginx**,服务直接监听端口,反向代理请自行操作。
--- ---
## 二、一键部署(推荐) ## 二、一键部署(推荐)
**root** 用户 SSH 登录服务器,执行: **root** 用户 SSH 登录服务器,执行:
```bash ```bash
curl -fsSL https://git.bz121.com/dekun/cloud-browser/raw/branch/main/deploy.sh -o /tmp/deploy.sh curl -fsSL https://git.bz121.com/dekun/cloud-browser/raw/branch/main/deploy.sh -o /tmp/deploy.sh
bash /tmp/deploy.sh bash /tmp/deploy.sh
``` ```
若仓库尚未推送或 curl 不可用,也可手动克隆后执行: 若仓库尚未推送或 curl 不可用,也可手动克隆后执行:
```bash ```bash
git clone https://git.bz121.com/dekun/cloud-browser.git /opt/cloud-browser git clone https://git.bz121.com/dekun/cloud-browser.git /opt/cloud-browser
bash /opt/cloud-browser/deploy.sh bash /opt/cloud-browser/deploy.sh
``` ```
### 一键脚本会自动完成 ### 一键脚本会自动完成
1. 检测并安装 Docker(如未安装) 1. 检测并安装 Docker(如未安装)
2. 克隆/更新代码到 `/opt/cloud-browser` 2. 克隆/更新代码到 `/opt/cloud-browser`
3. 创建 `.env``data/` 数据目录 3. 创建 `.env``data/` 数据目录
4. 构建并启动 Docker 容器 4. 构建并启动 Docker 容器
5. 等待健康检查通过 5. 等待健康检查通过
6. 输出访问地址和默认账号 6. 输出访问地址和默认账号
### 部署成功示例输出 ### 部署成功示例输出
``` ```
========================================== ==========================================
云端浏览器部署完成 云端浏览器部署完成
========================================== ==========================================
访问地址: http://1.2.3.4:32450 访问地址: http://1.2.3.4:32450
默认账号: admin 默认账号: admin
默认密码: admin 默认密码: admin
安装目录: /opt/cloud-browser 安装目录: /opt/cloud-browser
========================================== ==========================================
``` ```
--- ---
## 三、手动部署 ## 三、手动部署
### 1. 克隆代码 ### 1. 克隆代码
```bash ```bash
git clone https://git.bz121.com/dekun/cloud-browser.git /opt/cloud-browser git clone https://git.bz121.com/dekun/cloud-browser.git /opt/cloud-browser
cd /opt/cloud-browser cd /opt/cloud-browser
``` ```
### 2. 配置环境变量(可选) ### 2. 配置环境变量(可选)
```bash ```bash
cp .env.example .env cp .env.example .env
nano .env nano .env
``` ```
| 变量 | 说明 | 默认值 | | 变量 | 说明 | 默认值 |
|------|------|--------| |------|------|--------|
| `APP_PORT` | 宿主机端口 | `32450` | | `APP_PORT` | 宿主机端口 | `32450` |
| `MAX_SESSIONS` | 最大并发会话 | `1` | | `MAX_SESSIONS` | 最大并发会话 | `1` |
| `SESSION_IDLE_TIMEOUT` | 空闲超时(秒) | `1800` | | `SESSION_IDLE_TIMEOUT` | 空闲超时(秒) | `1800` |
| `VIEWPORT_WIDTH` | 浏览器宽度 | `1280` | | `VIEWPORT_WIDTH` | 浏览器宽度 | `1280` |
| `VIEWPORT_HEIGHT` | 浏览器高度 | `720` | | `VIEWPORT_HEIGHT` | 浏览器高度 | `720` |
| `SCREENCAST_QUALITY` | 画面质量 10-100 | `80` | | `SCREENCAST_QUALITY` | 画面质量 10-100 | `80` |
### 3. 启动服务 ### 3. 启动服务
```bash ```bash
mkdir -p data mkdir -p data
docker compose up -d --build docker compose up -d --build
``` ```
### 4. 验证 ### 4. 验证
```bash ```bash
curl http://127.0.0.1:32450/api/health curl http://127.0.0.1:32450/api/health
# {"status":"ok","sessions":0} # {"status":"ok","sessions":0}
``` ```
浏览器访问 `http://服务器IP:32450`,使用 `admin` / `admin` 登录。 浏览器访问 `http://服务器IP:32450`,使用 `admin` / `admin` 登录。
--- ---
## 四、登录与修改密码 ## 四、登录与修改密码
1. 打开首页,输入默认账号 `admin`、密码 `admin` 登录 1. 打开首页,输入默认账号 `admin`、密码 `admin` 登录
2. 登录后点击右上角 **「账号设置」** 2. 登录后点击右上角 **「账号设置」**
3. 填写当前用户名、当前密码、新用户名、新密码 3. 填写当前用户名、当前密码、新用户名、新密码
4. 保存后自动退出,使用新凭据重新登录 4. 保存后自动退出,使用新凭据重新登录
账号信息保存在 `/opt/cloud-browser/data/auth.json`,容器重启后不会丢失。 账号信息保存在 `/opt/cloud-browser/data/auth.json`,容器重启后不会丢失。
--- ---
## 五、反向代理(自行配置) ## 五、反向代理(自行配置)
服务默认监听 `32450` 端口。若需通过域名 + HTTPS 访问,请在宝塔/Nginx 中自行配置反向代理。 服务默认监听 `32450` 端口。若需通过域名 + HTTPS 访问,请在宝塔/Nginx 中自行配置反向代理。
目标地址: 目标地址:
``` ```
http://127.0.0.1:32450 http://127.0.0.1:32450
``` ```
**注意**:浏览页面使用 WebSocket(路径 `/ws/`),反代时需开启 WebSocket 支持(Upgrade 头)。 **注意**:浏览页面使用 WebSocket(路径 `/ws/`),反代时需开启 WebSocket 支持(Upgrade 头)。
--- ---
## 六、运维命令 ## 六、运维命令
```bash ```bash
cd /opt/cloud-browser cd /opt/cloud-browser
# 查看运行状态 # 查看运行状态
docker compose ps docker compose ps
# 查看日志 # 查看日志
docker compose logs -f app docker compose logs -f app
# 重启 # 重启
docker compose restart docker compose restart
# 停止 # 停止
docker compose down docker compose down
# 更新代码并重新部署 # 更新代码并重新部署
bash deploy.sh bash deploy.sh
``` ```
--- ---
## 七、防火墙 ## 七、防火墙
确保服务器防火墙 / 安全组放行 **32450** 端口(若直接通过 IP 访问): 确保服务器防火墙 / 安全组放行 **32450** 端口(若直接通过 IP 访问):
```bash ```bash
# ufw 示例 # ufw 示例
ufw allow 32450/tcp ufw allow 32450/tcp
# firewalld 示例 # firewalld 示例
firewall-cmd --permanent --add-port=32450/tcp firewall-cmd --permanent --add-port=32450/tcp
firewall-cmd --reload firewall-cmd --reload
``` ```
若仅通过 Nginx 反代访问,可不对公网开放 32450,仅本机 `127.0.0.1` 访问即可。 若仅通过 Nginx 反代访问,可不对公网开放 32450,仅本机 `127.0.0.1` 访问即可。
--- ---
## 八、常见问题 ## 八、常见问题
### 部署脚本提示非 root 用户 ### 部署脚本报错 `set: pipefail: invalid option`
```bash 脚本在 Windows 编辑后可能带 CRLF 换行符,在 Linux 上会报错。修复:
sudo su -
bash deploy.sh ```bash
``` sed -i 's/\r$//' /opt/cloud-browser/deploy.sh
bash /opt/cloud-browser/deploy.sh
### 端口被占用 ```
修改 `/opt/cloud-browser/.env` 中的 `APP_PORT`,然后 或重新拉取最新代码
```bash ```bash
cd /opt/cloud-browser cd /opt/cloud-browser && git pull && bash deploy.sh
docker compose down ```
docker compose up -d
``` ---
### 页面黑屏 ```bash
sudo su -
```bash bash deploy.sh
docker compose logs -f app ```
```
### 端口被占用
常见原因:内存不足(建议 ≥ 2GB)、Chromium 启动失败。
修改 `/opt/cloud-browser/.env` 中的 `APP_PORT`,然后:
### WebSocket 连接失败
```bash
若使用了 Nginx 反代,检查是否配置了 WebSocket Upgrade 支持。 cd /opt/cloud-browser
docker compose down
### 忘记密码 docker compose up -d
```
```bash
cd /opt/cloud-browser ### 页面黑屏
docker compose down
rm -f data/auth.json data/secret.key ```bash
docker compose up -d docker compose logs -f app
``` ```
将恢复为默认账号 `admin` / `admin`**会清除已修改的密码** 常见原因:内存不足(建议 ≥ 2GB)、Chromium 启动失败
--- ### WebSocket 连接失败
## 九、卸载 若使用了 Nginx 反代,检查是否配置了 WebSocket Upgrade 支持。
```bash ### 忘记密码
cd /opt/cloud-browser
docker compose down ```bash
cd / cd /opt/cloud-browser
rm -rf /opt/cloud-browser docker compose down
``` rm -f data/auth.json data/secret.key
docker compose up -d
```
将恢复为默认账号 `admin` / `admin`**会清除已修改的密码**)。
---
## 九、卸载
```bash
cd /opt/cloud-browser
docker compose down
cd /
rm -rf /opt/cloud-browser
```
+14 -14
View File
@@ -1,14 +1,14 @@
FROM mcr.microsoft.com/playwright/python:v1.49.1-jammy FROM mcr.microsoft.com/playwright/python:v1.49.1-jammy
WORKDIR /app WORKDIR /app
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/ COPY app/ ./app/
COPY static/ ./static/ COPY static/ ./static/
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
EXPOSE 8000 EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+65 -65
View File
@@ -1,65 +1,65 @@
# 云端浏览器 # 云端浏览器
在境外 VPS 上自部署的轻量远程浏览器:登录后输入网址,由云端 Chromium 打开页面,画面通过 WebSocket 实时回传,支持鼠标键盘操作。 在境外 VPS 上自部署的轻量远程浏览器:登录后输入网址,由云端 Chromium 打开页面,画面通过 WebSocket 实时回传,支持鼠标键盘操作。
## 功能特性 ## 功能特性
- 输入网址即可在云端打开并远程操作 - 输入网址即可在云端打开并远程操作
- 内置登录鉴权,默认账号 `admin` / `admin` - 内置登录鉴权,默认账号 `admin` / `admin`
- 前端可修改用户名和密码 - 前端可修改用户名和密码
- Docker 部署,默认端口 **32450** - Docker 部署,默认端口 **32450**
- 反向代理(HTTPS/域名)请自行在宝塔/Nginx 配置 - 反向代理(HTTPS/域名)请自行在宝塔/Nginx 配置
## 快速体验 ## 快速体验
部署完成后访问: 部署完成后访问:
``` ```
http://服务器IP:32450 http://服务器IP:32450
``` ```
默认账号密码均为 `admin`**登录后请立即修改**。 默认账号密码均为 `admin`**登录后请立即修改**。
## 文档 ## 文档
- [部署文档 DEPLOY.md](DEPLOY.md) — 含一键部署说明 - [部署文档 DEPLOY.md](DEPLOY.md) — 含一键部署说明
## 技术架构 ## 技术架构
``` ```
浏览器 → :32450 → FastAPI → Playwright Chromium 浏览器 → :32450 → FastAPI → Playwright Chromium
↑ WebSocket 画面流 / 输入事件 ↑ WebSocket 画面流 / 输入事件
``` ```
## 目录结构 ## 目录结构
``` ```
cloud-browser/ cloud-browser/
├── app/ # 后端(FastAPI + Playwright ├── app/ # 后端(FastAPI + Playwright
├── static/ # 前端页面 ├── static/ # 前端页面
├── deploy.sh # 一键部署脚本 ├── deploy.sh # 一键部署脚本
├── docker-compose.yml ├── docker-compose.yml
├── Dockerfile ├── Dockerfile
└── DEPLOY.md # 部署文档 └── DEPLOY.md # 部署文档
``` ```
## 常用命令 ## 常用命令
```bash ```bash
cd /opt/cloud-browser cd /opt/cloud-browser
docker compose logs -f app # 查看日志 docker compose logs -f app # 查看日志
docker compose restart # 重启 docker compose restart # 重启
docker compose down # 停止 docker compose down # 停止
bash deploy.sh # 更新并重新部署 bash deploy.sh # 更新并重新部署
``` ```
## 安全说明 ## 安全说明
- 首次部署后务必修改默认密码 - 首次部署后务必修改默认密码
- 内置 SSRF 防护,禁止访问内网地址 - 内置 SSRF 防护,禁止访问内网地址
- 账号数据保存在 `data/auth.json`Docker 卷持久化) - 账号数据保存在 `data/auth.json`Docker 卷持久化)
- 建议通过 Nginx/宝塔配置 HTTPS 后再对外使用 - 建议通过 Nginx/宝塔配置 HTTPS 后再对外使用
## 许可证 ## 许可证
MIT MIT
+109 -109
View File
@@ -1,109 +1,109 @@
import json import json
import os import os
import secrets import secrets
import time import time
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
import bcrypt import bcrypt
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
DEFAULT_USERNAME = "admin" DEFAULT_USERNAME = "admin"
DEFAULT_PASSWORD = "admin" DEFAULT_PASSWORD = "admin"
TOKEN_MAX_AGE = 60 * 60 * 24 * 7 # 7 days TOKEN_MAX_AGE = 60 * 60 * 24 * 7 # 7 days
class AuthManager: class AuthManager:
def __init__(self, data_dir: Path) -> None: def __init__(self, data_dir: Path) -> None:
self.data_dir = data_dir self.data_dir = data_dir
self.auth_file = data_dir / "auth.json" self.auth_file = data_dir / "auth.json"
self.secret_file = data_dir / "secret.key" self.secret_file = data_dir / "secret.key"
self._serializer: Optional[URLSafeTimedSerializer] = None self._serializer: Optional[URLSafeTimedSerializer] = None
self._ensure_data_dir() self._ensure_data_dir()
self._ensure_secret() self._ensure_secret()
self._ensure_default_user() self._ensure_default_user()
def _ensure_data_dir(self) -> None: def _ensure_data_dir(self) -> None:
self.data_dir.mkdir(parents=True, exist_ok=True) self.data_dir.mkdir(parents=True, exist_ok=True)
def _ensure_secret(self) -> None: def _ensure_secret(self) -> None:
if not self.secret_file.exists(): if not self.secret_file.exists():
self.secret_file.write_text(secrets.token_hex(32), encoding="utf-8") self.secret_file.write_text(secrets.token_hex(32), encoding="utf-8")
secret = self.secret_file.read_text(encoding="utf-8").strip() secret = self.secret_file.read_text(encoding="utf-8").strip()
self._serializer = URLSafeTimedSerializer(secret, salt="cloud-browser-auth") self._serializer = URLSafeTimedSerializer(secret, salt="cloud-browser-auth")
def _ensure_default_user(self) -> None: def _ensure_default_user(self) -> None:
if not self.auth_file.exists(): if not self.auth_file.exists():
self._save_credentials(DEFAULT_USERNAME, DEFAULT_PASSWORD) self._save_credentials(DEFAULT_USERNAME, DEFAULT_PASSWORD)
def _hash_password(self, password: str) -> str: def _hash_password(self, password: str) -> str:
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def _verify_password(self, password: str, password_hash: str) -> bool: def _verify_password(self, password: str, password_hash: str) -> bool:
try: try:
return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8")) return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8"))
except ValueError: except ValueError:
return False return False
def _load_credentials(self) -> dict: def _load_credentials(self) -> dict:
return json.loads(self.auth_file.read_text(encoding="utf-8")) return json.loads(self.auth_file.read_text(encoding="utf-8"))
def _save_credentials(self, username: str, password: str) -> None: def _save_credentials(self, username: str, password: str) -> None:
payload = { payload = {
"username": username, "username": username,
"password_hash": self._hash_password(password), "password_hash": self._hash_password(password),
} }
self.auth_file.write_text( self.auth_file.write_text(
json.dumps(payload, ensure_ascii=False, indent=2), json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8", encoding="utf-8",
) )
def get_username(self) -> str: def get_username(self) -> str:
return self._load_credentials()["username"] return self._load_credentials()["username"]
def authenticate(self, username: str, password: str) -> bool: def authenticate(self, username: str, password: str) -> bool:
creds = self._load_credentials() creds = self._load_credentials()
if username != creds["username"]: if username != creds["username"]:
return False return False
return self._verify_password(password, creds["password_hash"]) return self._verify_password(password, creds["password_hash"])
def create_token(self, username: str) -> str: def create_token(self, username: str) -> str:
assert self._serializer is not None assert self._serializer is not None
return self._serializer.dumps({"username": username, "ts": time.time()}) return self._serializer.dumps({"username": username, "ts": time.time()})
def verify_token(self, token: str) -> Optional[str]: def verify_token(self, token: str) -> Optional[str]:
if not token or self._serializer is None: if not token or self._serializer is None:
return None return None
try: try:
data = self._serializer.loads(token, max_age=TOKEN_MAX_AGE) data = self._serializer.loads(token, max_age=TOKEN_MAX_AGE)
except (BadSignature, SignatureExpired): except (BadSignature, SignatureExpired):
return None return None
username = data.get("username") username = data.get("username")
if username != self.get_username(): if username != self.get_username():
return None return None
return username return username
def change_credentials( def change_credentials(
self, self,
current_username: str, current_username: str,
current_password: str, current_password: str,
new_username: str, new_username: str,
new_password: str, new_password: str,
) -> None: ) -> None:
creds = self._load_credentials() creds = self._load_credentials()
if current_username != creds["username"]: if current_username != creds["username"]:
raise ValueError("当前用户名不正确") raise ValueError("当前用户名不正确")
if not self._verify_password(current_password, creds["password_hash"]): if not self._verify_password(current_password, creds["password_hash"]):
raise ValueError("当前密码不正确") raise ValueError("当前密码不正确")
if len(new_username.strip()) < 2: if len(new_username.strip()) < 2:
raise ValueError("新用户名至少 2 个字符") raise ValueError("新用户名至少 2 个字符")
if len(new_password) < 4: if len(new_password) < 4:
raise ValueError("新密码至少 4 个字符") raise ValueError("新密码至少 4 个字符")
self._save_credentials(new_username.strip(), new_password) self._save_credentials(new_username.strip(), new_password)
def get_data_dir() -> Path: def get_data_dir() -> Path:
return Path(os.getenv("DATA_DIR", "/app/data")) return Path(os.getenv("DATA_DIR", "/app/data"))
auth_manager = AuthManager(get_data_dir()) auth_manager = AuthManager(get_data_dir())
+267 -267
View File
@@ -1,267 +1,267 @@
import asyncio import asyncio
import base64 import base64
import time import time
import uuid import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Optional from typing import Any, Optional
from playwright.async_api import Browser, BrowserContext, Page, Playwright, async_playwright from playwright.async_api import Browser, BrowserContext, Page, Playwright, async_playwright
from app.security import get_idle_timeout, get_screencast_quality, get_viewport_size from app.security import get_idle_timeout, get_screencast_quality, get_viewport_size
@dataclass @dataclass
class BrowserSession: class BrowserSession:
session_id: str session_id: str
url: str url: str
playwright: Playwright playwright: Playwright
browser: Browser browser: Browser
context: BrowserContext context: BrowserContext
page: Page page: Page
cdp: Any cdp: Any
created_at: float = field(default_factory=time.time) created_at: float = field(default_factory=time.time)
last_activity: float = field(default_factory=time.time) last_activity: float = field(default_factory=time.time)
subscribers: set[asyncio.Queue] = field(default_factory=set) subscribers: set[asyncio.Queue] = field(default_factory=set)
screencast_task: Optional[asyncio.Task] = None screencast_task: Optional[asyncio.Task] = None
idle_task: Optional[asyncio.Task] = None idle_task: Optional[asyncio.Task] = None
closed: bool = False closed: bool = False
viewport_width: int = 1280 viewport_width: int = 1280
viewport_height: int = 720 viewport_height: int = 720
class BrowserManager: class BrowserManager:
def __init__(self, max_sessions: int = 1) -> None: def __init__(self, max_sessions: int = 1) -> None:
self.max_sessions = max_sessions self.max_sessions = max_sessions
self._sessions: dict[str, BrowserSession] = {} self._sessions: dict[str, BrowserSession] = {}
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
async def create_session(self, url: str) -> BrowserSession: async def create_session(self, url: str) -> BrowserSession:
async with self._lock: async with self._lock:
if len(self._sessions) >= self.max_sessions: if len(self._sessions) >= self.max_sessions:
raise RuntimeError( raise RuntimeError(
f"已达最大会话数 ({self.max_sessions}),请先关闭现有会话" f"已达最大会话数 ({self.max_sessions}),请先关闭现有会话"
) )
session_id = str(uuid.uuid4()) session_id = str(uuid.uuid4())
width, height = get_viewport_size() width, height = get_viewport_size()
quality = get_screencast_quality() quality = get_screencast_quality()
playwright = await async_playwright().start() playwright = await async_playwright().start()
browser = await playwright.chromium.launch( browser = await playwright.chromium.launch(
headless=True, headless=True,
args=[ args=[
"--no-sandbox", "--no-sandbox",
"--disable-setuid-sandbox", "--disable-setuid-sandbox",
"--disable-dev-shm-usage", "--disable-dev-shm-usage",
"--disable-gpu", "--disable-gpu",
], ],
) )
context = await browser.new_context( context = await browser.new_context(
viewport={"width": width, "height": height}, viewport={"width": width, "height": height},
user_agent=( user_agent=(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) " "AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/131.0.0.0 Safari/537.36" "Chrome/131.0.0.0 Safari/537.36"
), ),
) )
page = await context.new_page() page = await context.new_page()
cdp = await context.new_cdp_session(page) cdp = await context.new_cdp_session(page)
session = BrowserSession( session = BrowserSession(
session_id=session_id, session_id=session_id,
url=url, url=url,
playwright=playwright, playwright=playwright,
browser=browser, browser=browser,
context=context, context=context,
page=page, page=page,
cdp=cdp, cdp=cdp,
viewport_width=width, viewport_width=width,
viewport_height=height, viewport_height=height,
) )
await page.goto(url, wait_until="domcontentloaded", timeout=60000) await page.goto(url, wait_until="domcontentloaded", timeout=60000)
session.url = page.url session.url = page.url
page.on( page.on(
"framenavigated", "framenavigated",
lambda frame: asyncio.create_task( lambda frame: asyncio.create_task(
self._on_frame_navigated(session, frame) self._on_frame_navigated(session, frame)
), ),
) )
session.screencast_task = asyncio.create_task( session.screencast_task = asyncio.create_task(
self._run_screencast(session, quality) self._run_screencast(session, quality)
) )
session.idle_task = asyncio.create_task(self._watch_idle(session)) session.idle_task = asyncio.create_task(self._watch_idle(session))
self._sessions[session_id] = session self._sessions[session_id] = session
return session return session
def session_count(self) -> int: def session_count(self) -> int:
return len(self._sessions) return len(self._sessions)
async def get_session(self, session_id: str) -> Optional[BrowserSession]: async def get_session(self, session_id: str) -> Optional[BrowserSession]:
return self._sessions.get(session_id) return self._sessions.get(session_id)
async def close_session(self, session_id: str) -> None: async def close_session(self, session_id: str) -> None:
async with self._lock: async with self._lock:
session = self._sessions.pop(session_id, None) session = self._sessions.pop(session_id, None)
if session: if session:
await self._cleanup_session(session) await self._cleanup_session(session)
async def close_all(self) -> None: async def close_all(self) -> None:
async with self._lock: async with self._lock:
session_ids = list(self._sessions.keys()) session_ids = list(self._sessions.keys())
for session_id in session_ids: for session_id in session_ids:
await self.close_session(session_id) await self.close_session(session_id)
def touch(self, session: BrowserSession) -> None: def touch(self, session: BrowserSession) -> None:
session.last_activity = time.time() session.last_activity = time.time()
async def navigate(self, session: BrowserSession, url: str) -> str: async def navigate(self, session: BrowserSession, url: str) -> str:
self.touch(session) self.touch(session)
await session.page.goto(url, wait_until="domcontentloaded", timeout=60000) await session.page.goto(url, wait_until="domcontentloaded", timeout=60000)
session.url = session.page.url session.url = session.page.url
return session.url return session.url
async def go_back(self, session: BrowserSession) -> str: async def go_back(self, session: BrowserSession) -> str:
self.touch(session) self.touch(session)
await session.page.go_back(wait_until="domcontentloaded", timeout=30000) await session.page.go_back(wait_until="domcontentloaded", timeout=30000)
session.url = session.page.url session.url = session.page.url
return session.url return session.url
async def go_forward(self, session: BrowserSession) -> str: async def go_forward(self, session: BrowserSession) -> str:
self.touch(session) self.touch(session)
await session.page.go_forward(wait_until="domcontentloaded", timeout=30000) await session.page.go_forward(wait_until="domcontentloaded", timeout=30000)
session.url = session.page.url session.url = session.page.url
return session.url return session.url
async def reload(self, session: BrowserSession) -> str: async def reload(self, session: BrowserSession) -> str:
self.touch(session) self.touch(session)
await session.page.reload(wait_until="domcontentloaded", timeout=60000) await session.page.reload(wait_until="domcontentloaded", timeout=60000)
session.url = session.page.url session.url = session.page.url
return session.url return session.url
def subscribe(self, session: BrowserSession) -> asyncio.Queue: def subscribe(self, session: BrowserSession) -> asyncio.Queue:
queue: asyncio.Queue = asyncio.Queue(maxsize=8) queue: asyncio.Queue = asyncio.Queue(maxsize=8)
session.subscribers.add(queue) session.subscribers.add(queue)
return queue return queue
def unsubscribe(self, session: BrowserSession, queue: asyncio.Queue) -> None: def unsubscribe(self, session: BrowserSession, queue: asyncio.Queue) -> None:
session.subscribers.discard(queue) session.subscribers.discard(queue)
async def _broadcast(self, session: BrowserSession, message: dict) -> None: async def _broadcast(self, session: BrowserSession, message: dict) -> None:
dead: list[asyncio.Queue] = [] dead: list[asyncio.Queue] = []
for queue in session.subscribers: for queue in session.subscribers:
try: try:
queue.put_nowait(message) queue.put_nowait(message)
except asyncio.QueueFull: except asyncio.QueueFull:
try: try:
queue.get_nowait() queue.get_nowait()
queue.put_nowait(message) queue.put_nowait(message)
except asyncio.QueueEmpty: except asyncio.QueueEmpty:
pass pass
except Exception: except Exception:
dead.append(queue) dead.append(queue)
for queue in dead: for queue in dead:
session.subscribers.discard(queue) session.subscribers.discard(queue)
async def _on_frame_navigated(self, session: BrowserSession, frame) -> None: async def _on_frame_navigated(self, session: BrowserSession, frame) -> None:
if session.closed or frame != session.page.main_frame: if session.closed or frame != session.page.main_frame:
return return
session.url = session.page.url session.url = session.page.url
await self._broadcast(session, {"type": "url_update", "url": session.url}) await self._broadcast(session, {"type": "url_update", "url": session.url})
async def _run_screencast(self, session: BrowserSession, quality: int) -> None: async def _run_screencast(self, session: BrowserSession, quality: int) -> None:
async def on_screencast_frame(params: dict) -> None: async def on_screencast_frame(params: dict) -> None:
if session.closed: if session.closed:
return return
data = params.get("data", "") data = params.get("data", "")
session_id = params.get("sessionId") session_id = params.get("sessionId")
try: try:
await session.cdp.send( await session.cdp.send(
"Page.screencastFrameAck", {"sessionId": session_id} "Page.screencastFrameAck", {"sessionId": session_id}
) )
except Exception: except Exception:
return return
try: try:
frame_bytes = base64.b64decode(data) frame_bytes = base64.b64decode(data)
except Exception: except Exception:
return return
await self._broadcast( await self._broadcast(
session, session,
{ {
"type": "frame", "type": "frame",
"data": frame_bytes, "data": frame_bytes,
"url": session.url, "url": session.url,
"width": session.viewport_width, "width": session.viewport_width,
"height": session.viewport_height, "height": session.viewport_height,
}, },
) )
def schedule_frame(params: dict) -> None: def schedule_frame(params: dict) -> None:
asyncio.create_task(on_screencast_frame(params)) asyncio.create_task(on_screencast_frame(params))
session.cdp.on("Page.screencastFrame", schedule_frame) session.cdp.on("Page.screencastFrame", schedule_frame)
await session.cdp.send( await session.cdp.send(
"Page.startScreencast", "Page.startScreencast",
{ {
"format": "jpeg", "format": "jpeg",
"quality": quality, "quality": quality,
"maxWidth": session.viewport_width, "maxWidth": session.viewport_width,
"maxHeight": session.viewport_height, "maxHeight": session.viewport_height,
"everyNthFrame": 1, "everyNthFrame": 1,
}, },
) )
try: try:
while not session.closed: while not session.closed:
await asyncio.sleep(1) await asyncio.sleep(1)
finally: finally:
try: try:
await session.cdp.send("Page.stopScreencast") await session.cdp.send("Page.stopScreencast")
except Exception: except Exception:
pass pass
async def _watch_idle(self, session: BrowserSession) -> None: async def _watch_idle(self, session: BrowserSession) -> None:
timeout = get_idle_timeout() timeout = get_idle_timeout()
try: try:
while not session.closed: while not session.closed:
await asyncio.sleep(30) await asyncio.sleep(30)
if time.time() - session.last_activity > timeout: if time.time() - session.last_activity > timeout:
await self.close_session(session.session_id) await self.close_session(session.session_id)
break break
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
async def _cleanup_session(self, session: BrowserSession) -> None: async def _cleanup_session(self, session: BrowserSession) -> None:
if session.closed: if session.closed:
return return
session.closed = True session.closed = True
for task in (session.screencast_task, session.idle_task): for task in (session.screencast_task, session.idle_task):
if task and not task.done(): if task and not task.done():
task.cancel() task.cancel()
try: try:
await task await task
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
await self._broadcast(session, {"type": "closed", "reason": "session_ended"}) await self._broadcast(session, {"type": "closed", "reason": "session_ended"})
try: try:
await session.context.close() await session.context.close()
except Exception: except Exception:
pass pass
try: try:
await session.browser.close() await session.browser.close()
except Exception: except Exception:
pass pass
try: try:
await session.playwright.stop() await session.playwright.stop()
except Exception: except Exception:
pass pass
browser_manager = BrowserManager() browser_manager = BrowserManager()
+70 -70
View File
@@ -1,70 +1,70 @@
from typing import Any from typing import Any
from app.browser_manager import BrowserManager, BrowserSession from app.browser_manager import BrowserManager, BrowserSession
async def handle_input( async def handle_input(
manager: BrowserManager, manager: BrowserManager,
session: BrowserSession, session: BrowserSession,
payload: dict[str, Any], payload: dict[str, Any],
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
action = payload.get("action") action = payload.get("action")
if not action: if not action:
return None return None
manager.touch(session) manager.touch(session)
page = session.page page = session.page
if action == "click": if action == "click":
x = float(payload.get("x", 0)) x = float(payload.get("x", 0))
y = float(payload.get("y", 0)) y = float(payload.get("y", 0))
button = payload.get("button", "left") button = payload.get("button", "left")
await page.mouse.click(x, y, button=button) await page.mouse.click(x, y, button=button)
return {"type": "ack", "action": action} return {"type": "ack", "action": action}
if action == "dblclick": if action == "dblclick":
x = float(payload.get("x", 0)) x = float(payload.get("x", 0))
y = float(payload.get("y", 0)) y = float(payload.get("y", 0))
await page.mouse.dblclick(x, y) await page.mouse.dblclick(x, y)
return {"type": "ack", "action": action} return {"type": "ack", "action": action}
if action == "mousemove": if action == "mousemove":
x = float(payload.get("x", 0)) x = float(payload.get("x", 0))
y = float(payload.get("y", 0)) y = float(payload.get("y", 0))
await page.mouse.move(x, y) await page.mouse.move(x, y)
return None return None
if action == "wheel": if action == "wheel":
delta_x = float(payload.get("deltaX", 0)) delta_x = float(payload.get("deltaX", 0))
delta_y = float(payload.get("deltaY", 0)) delta_y = float(payload.get("deltaY", 0))
await page.mouse.wheel(delta_x, delta_y) await page.mouse.wheel(delta_x, delta_y)
return {"type": "ack", "action": action} return {"type": "ack", "action": action}
if action == "keydown": if action == "keydown":
key = payload.get("key") key = payload.get("key")
if not key: if not key:
return None return None
await page.keyboard.down(key) await page.keyboard.down(key)
return {"type": "ack", "action": action} return {"type": "ack", "action": action}
if action == "keyup": if action == "keyup":
key = payload.get("key") key = payload.get("key")
if not key: if not key:
return None return None
await page.keyboard.up(key) await page.keyboard.up(key)
return {"type": "ack", "action": action} return {"type": "ack", "action": action}
if action == "type": if action == "type":
text = payload.get("text", "") text = payload.get("text", "")
if text: if text:
await page.keyboard.type(text) await page.keyboard.type(text)
return {"type": "ack", "action": action} return {"type": "ack", "action": action}
if action == "press": if action == "press":
key = payload.get("key") key = payload.get("key")
if not key: if not key:
return None return None
await page.keyboard.press(key) await page.keyboard.press(key)
return {"type": "ack", "action": action} return {"type": "ack", "action": action}
return {"type": "error", "message": f"未知操作: {action}"} return {"type": "error", "message": f"未知操作: {action}"}
+306 -306
View File
@@ -1,306 +1,306 @@
import asyncio import asyncio
import json import json
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from fastapi import Cookie, Depends, FastAPI, HTTPException, WebSocket, WebSocketDisconnect from fastapi import Cookie, Depends, FastAPI, HTTPException, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.auth import auth_manager from app.auth import auth_manager
from app.browser_manager import browser_manager from app.browser_manager import browser_manager
from app.input_handler import handle_input from app.input_handler import handle_input
from app.security import SecurityError, get_max_sessions, validate_url from app.security import SecurityError, get_max_sessions, validate_url
STATIC_DIR = Path(__file__).resolve().parent.parent / "static" STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
SESSION_COOKIE = "cloud_browser_token" SESSION_COOKIE = "cloud_browser_token"
class CreateSessionRequest(BaseModel): class CreateSessionRequest(BaseModel):
url: str = Field(..., min_length=1, max_length=2048) url: str = Field(..., min_length=1, max_length=2048)
class CreateSessionResponse(BaseModel): class CreateSessionResponse(BaseModel):
session_id: str session_id: str
url: str url: str
class NavigateRequest(BaseModel): class NavigateRequest(BaseModel):
url: str = Field(..., min_length=1, max_length=2048) url: str = Field(..., min_length=1, max_length=2048)
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
username: str = Field(..., min_length=1, max_length=64) username: str = Field(..., min_length=1, max_length=64)
password: str = Field(..., min_length=1, max_length=128) password: str = Field(..., min_length=1, max_length=128)
class ChangeCredentialsRequest(BaseModel): class ChangeCredentialsRequest(BaseModel):
current_username: str = Field(..., min_length=1, max_length=64) current_username: str = Field(..., min_length=1, max_length=64)
current_password: str = Field(..., min_length=1, max_length=128) current_password: str = Field(..., min_length=1, max_length=128)
new_username: str = Field(..., min_length=2, max_length=64) new_username: str = Field(..., min_length=2, max_length=64)
new_password: str = Field(..., min_length=4, max_length=128) new_password: str = Field(..., min_length=4, max_length=128)
def get_current_user(token: Optional[str] = Cookie(None, alias=SESSION_COOKIE)) -> str: def get_current_user(token: Optional[str] = Cookie(None, alias=SESSION_COOKIE)) -> str:
username = auth_manager.verify_token(token or "") username = auth_manager.verify_token(token or "")
if not username: if not username:
raise HTTPException(status_code=401, detail="未登录或登录已过期") raise HTTPException(status_code=401, detail="未登录或登录已过期")
return username return username
def _set_auth_cookie(response: JSONResponse, token: str) -> JSONResponse: def _set_auth_cookie(response: JSONResponse, token: str) -> JSONResponse:
response.set_cookie( response.set_cookie(
key=SESSION_COOKIE, key=SESSION_COOKIE,
value=token, value=token,
httponly=True, httponly=True,
samesite="lax", samesite="lax",
max_age=60 * 60 * 24 * 7, max_age=60 * 60 * 24 * 7,
path="/", path="/",
) )
return response return response
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
browser_manager.max_sessions = get_max_sessions() browser_manager.max_sessions = get_max_sessions()
yield yield
await browser_manager.close_all() await browser_manager.close_all()
app = FastAPI(title="Cloud Browser", lifespan=lifespan) app = FastAPI(title="Cloud Browser", lifespan=lifespan)
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
async def index(): async def index():
return FileResponse(STATIC_DIR / "index.html") return FileResponse(STATIC_DIR / "index.html")
@app.get("/view/{session_id}", response_class=HTMLResponse) @app.get("/view/{session_id}", response_class=HTMLResponse)
async def view_page(session_id: str): async def view_page(session_id: str):
session = await browser_manager.get_session(session_id) session = await browser_manager.get_session(session_id)
if not session: if not session:
raise HTTPException(status_code=404, detail="会话不存在或已过期") raise HTTPException(status_code=404, detail="会话不存在或已过期")
return FileResponse(STATIC_DIR / "viewer.html") return FileResponse(STATIC_DIR / "viewer.html")
@app.get("/api/health") @app.get("/api/health")
async def health(): async def health():
return {"status": "ok", "sessions": browser_manager.session_count} return {"status": "ok", "sessions": browser_manager.session_count}
@app.get("/api/auth/me") @app.get("/api/auth/me")
async def auth_me(user: str = Depends(get_current_user)): async def auth_me(user: str = Depends(get_current_user)):
return {"username": user} return {"username": user}
@app.post("/api/auth/login") @app.post("/api/auth/login")
async def auth_login(body: LoginRequest): async def auth_login(body: LoginRequest):
if not auth_manager.authenticate(body.username, body.password): if not auth_manager.authenticate(body.username, body.password):
raise HTTPException(status_code=401, detail="用户名或密码错误") raise HTTPException(status_code=401, detail="用户名或密码错误")
token = auth_manager.create_token(body.username) token = auth_manager.create_token(body.username)
response = JSONResponse({"username": body.username, "message": "登录成功"}) response = JSONResponse({"username": body.username, "message": "登录成功"})
return _set_auth_cookie(response, token) return _set_auth_cookie(response, token)
@app.post("/api/auth/logout") @app.post("/api/auth/logout")
async def auth_logout(): async def auth_logout():
response = JSONResponse({"message": "已退出登录"}) response = JSONResponse({"message": "已退出登录"})
response.delete_cookie(SESSION_COOKIE, path="/") response.delete_cookie(SESSION_COOKIE, path="/")
return response return response
@app.post("/api/auth/change-credentials") @app.post("/api/auth/change-credentials")
async def change_credentials( async def change_credentials(
body: ChangeCredentialsRequest, body: ChangeCredentialsRequest,
user: str = Depends(get_current_user), user: str = Depends(get_current_user),
): ):
if body.current_username != user: if body.current_username != user:
raise HTTPException(status_code=403, detail="当前用户名与登录账号不一致") raise HTTPException(status_code=403, detail="当前用户名与登录账号不一致")
try: try:
auth_manager.change_credentials( auth_manager.change_credentials(
body.current_username, body.current_username,
body.current_password, body.current_password,
body.new_username, body.new_username,
body.new_password, body.new_password,
) )
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
token = auth_manager.create_token(body.new_username) token = auth_manager.create_token(body.new_username)
response = JSONResponse({"username": body.new_username, "message": "账号已更新"}) response = JSONResponse({"username": body.new_username, "message": "账号已更新"})
return _set_auth_cookie(response, token) return _set_auth_cookie(response, token)
@app.post("/api/session", response_model=CreateSessionResponse) @app.post("/api/session", response_model=CreateSessionResponse)
async def create_session(body: CreateSessionRequest, user: str = Depends(get_current_user)): async def create_session(body: CreateSessionRequest, user: str = Depends(get_current_user)):
try: try:
url = validate_url(body.url) url = validate_url(body.url)
except SecurityError as exc: except SecurityError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
try: try:
session = await browser_manager.create_session(url) session = await browser_manager.create_session(url)
except RuntimeError as exc: except RuntimeError as exc:
raise HTTPException(status_code=429, detail=str(exc)) from exc raise HTTPException(status_code=429, detail=str(exc)) from exc
except Exception as exc: except Exception as exc:
raise HTTPException(status_code=500, detail=f"创建会话失败: {exc}") from exc raise HTTPException(status_code=500, detail=f"创建会话失败: {exc}") from exc
return CreateSessionResponse(session_id=session.session_id, url=session.url) return CreateSessionResponse(session_id=session.session_id, url=session.url)
@app.delete("/api/session/{session_id}") @app.delete("/api/session/{session_id}")
async def delete_session(session_id: str, user: str = Depends(get_current_user)): async def delete_session(session_id: str, user: str = Depends(get_current_user)):
session = await browser_manager.get_session(session_id) session = await browser_manager.get_session(session_id)
if not session: if not session:
raise HTTPException(status_code=404, detail="会话不存在") raise HTTPException(status_code=404, detail="会话不存在")
await browser_manager.close_session(session_id) await browser_manager.close_session(session_id)
return {"status": "closed"} return {"status": "closed"}
@app.post("/api/session/{session_id}/navigate") @app.post("/api/session/{session_id}/navigate")
async def navigate_session( async def navigate_session(
session_id: str, session_id: str,
body: NavigateRequest, body: NavigateRequest,
user: str = Depends(get_current_user), user: str = Depends(get_current_user),
): ):
session = await browser_manager.get_session(session_id) session = await browser_manager.get_session(session_id)
if not session: if not session:
raise HTTPException(status_code=404, detail="会话不存在") raise HTTPException(status_code=404, detail="会话不存在")
try: try:
url = validate_url(body.url) url = validate_url(body.url)
except SecurityError as exc: except SecurityError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
try: try:
current_url = await browser_manager.navigate(session, url) current_url = await browser_manager.navigate(session, url)
except Exception as exc: except Exception as exc:
raise HTTPException(status_code=500, detail=f"导航失败: {exc}") from exc raise HTTPException(status_code=500, detail=f"导航失败: {exc}") from exc
return {"url": current_url} return {"url": current_url}
@app.post("/api/session/{session_id}/back") @app.post("/api/session/{session_id}/back")
async def go_back(session_id: str, user: str = Depends(get_current_user)): async def go_back(session_id: str, user: str = Depends(get_current_user)):
session = await browser_manager.get_session(session_id) session = await browser_manager.get_session(session_id)
if not session: if not session:
raise HTTPException(status_code=404, detail="会话不存在") raise HTTPException(status_code=404, detail="会话不存在")
try: try:
url = await browser_manager.go_back(session) url = await browser_manager.go_back(session)
except Exception as exc: except Exception as exc:
raise HTTPException(status_code=400, detail=f"无法后退: {exc}") from exc raise HTTPException(status_code=400, detail=f"无法后退: {exc}") from exc
return {"url": url} return {"url": url}
@app.post("/api/session/{session_id}/forward") @app.post("/api/session/{session_id}/forward")
async def go_forward(session_id: str, user: str = Depends(get_current_user)): async def go_forward(session_id: str, user: str = Depends(get_current_user)):
session = await browser_manager.get_session(session_id) session = await browser_manager.get_session(session_id)
if not session: if not session:
raise HTTPException(status_code=404, detail="会话不存在") raise HTTPException(status_code=404, detail="会话不存在")
try: try:
url = await browser_manager.go_forward(session) url = await browser_manager.go_forward(session)
except Exception as exc: except Exception as exc:
raise HTTPException(status_code=400, detail=f"无法前进: {exc}") from exc raise HTTPException(status_code=400, detail=f"无法前进: {exc}") from exc
return {"url": url} return {"url": url}
@app.post("/api/session/{session_id}/reload") @app.post("/api/session/{session_id}/reload")
async def reload_page(session_id: str, user: str = Depends(get_current_user)): async def reload_page(session_id: str, user: str = Depends(get_current_user)):
session = await browser_manager.get_session(session_id) session = await browser_manager.get_session(session_id)
if not session: if not session:
raise HTTPException(status_code=404, detail="会话不存在") raise HTTPException(status_code=404, detail="会话不存在")
try: try:
url = await browser_manager.reload(session) url = await browser_manager.reload(session)
except Exception as exc: except Exception as exc:
raise HTTPException(status_code=500, detail=f"刷新失败: {exc}") from exc raise HTTPException(status_code=500, detail=f"刷新失败: {exc}") from exc
return {"url": url} return {"url": url}
@app.websocket("/ws/{session_id}") @app.websocket("/ws/{session_id}")
async def websocket_stream( async def websocket_stream(
websocket: WebSocket, websocket: WebSocket,
session_id: str, session_id: str,
cloud_browser_token: Optional[str] = Cookie(None), cloud_browser_token: Optional[str] = Cookie(None),
): ):
if not auth_manager.verify_token(cloud_browser_token or ""): if not auth_manager.verify_token(cloud_browser_token or ""):
await websocket.close(code=4401, reason="未登录") await websocket.close(code=4401, reason="未登录")
return return
session = await browser_manager.get_session(session_id) session = await browser_manager.get_session(session_id)
if not session: if not session:
await websocket.close(code=4404, reason="会话不存在") await websocket.close(code=4404, reason="会话不存在")
return return
await websocket.accept() await websocket.accept()
queue = browser_manager.subscribe(session) queue = browser_manager.subscribe(session)
await websocket.send_json( await websocket.send_json(
{ {
"type": "init", "type": "init",
"url": session.url, "url": session.url,
"width": session.viewport_width, "width": session.viewport_width,
"height": session.viewport_height, "height": session.viewport_height,
} }
) )
async def forward_frames(): async def forward_frames():
while not session.closed: while not session.closed:
try: try:
message = await asyncio.wait_for(queue.get(), timeout=30) message = await asyncio.wait_for(queue.get(), timeout=30)
except asyncio.TimeoutError: except asyncio.TimeoutError:
continue continue
if message.get("type") == "frame": if message.get("type") == "frame":
await websocket.send_bytes(message["data"]) await websocket.send_bytes(message["data"])
if message.get("url") and message["url"] != session.url: if message.get("url") and message["url"] != session.url:
await websocket.send_json({"type": "url", "url": message["url"]}) await websocket.send_json({"type": "url", "url": message["url"]})
elif message.get("type") == "closed": elif message.get("type") == "closed":
await websocket.send_json(message) await websocket.send_json(message)
break break
forward_task = asyncio.create_task(forward_frames()) forward_task = asyncio.create_task(forward_frames())
try: try:
while True: while True:
raw = await websocket.receive_text() raw = await websocket.receive_text()
try: try:
payload = json.loads(raw) payload = json.loads(raw)
except json.JSONDecodeError: except json.JSONDecodeError:
await websocket.send_json({"type": "error", "message": "无效 JSON"}) await websocket.send_json({"type": "error", "message": "无效 JSON"})
continue continue
action_type = payload.get("type", "input") action_type = payload.get("type", "input")
if action_type == "ping": if action_type == "ping":
browser_manager.touch(session) browser_manager.touch(session)
await websocket.send_json({"type": "pong"}) await websocket.send_json({"type": "pong"})
continue continue
if action_type == "navigate": if action_type == "navigate":
try: try:
url = validate_url(payload.get("url", "")) url = validate_url(payload.get("url", ""))
current_url = await browser_manager.navigate(session, url) current_url = await browser_manager.navigate(session, url)
await websocket.send_json({"type": "url", "url": current_url}) await websocket.send_json({"type": "url", "url": current_url})
except SecurityError as exc: except SecurityError as exc:
await websocket.send_json({"type": "error", "message": str(exc)}) await websocket.send_json({"type": "error", "message": str(exc)})
except Exception as exc: except Exception as exc:
await websocket.send_json( await websocket.send_json(
{"type": "error", "message": f"导航失败: {exc}"} {"type": "error", "message": f"导航失败: {exc}"}
) )
continue continue
result = await handle_input(browser_manager, session, payload) result = await handle_input(browser_manager, session, payload)
if result: if result:
await websocket.send_json(result) await websocket.send_json(result)
except WebSocketDisconnect: except WebSocketDisconnect:
pass pass
finally: finally:
forward_task.cancel() forward_task.cancel()
try: try:
await forward_task await forward_task
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
browser_manager.unsubscribe(session, queue) browser_manager.unsubscribe(session, queue)
+93 -93
View File
@@ -1,93 +1,93 @@
import ipaddress import ipaddress
import os import os
import re import re
from urllib.parse import urlparse from urllib.parse import urlparse
BLOCKED_HOSTNAMES = { BLOCKED_HOSTNAMES = {
"localhost", "localhost",
"localhost.localdomain", "localhost.localdomain",
"metadata.google.internal", "metadata.google.internal",
} }
PRIVATE_NETWORKS = [ PRIVATE_NETWORKS = [
ipaddress.ip_network("0.0.0.0/8"), ipaddress.ip_network("0.0.0.0/8"),
ipaddress.ip_network("10.0.0.0/8"), ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("127.0.0.0/8"), ipaddress.ip_network("127.0.0.0/8"),
ipaddress.ip_network("169.254.0.0/16"), ipaddress.ip_network("169.254.0.0/16"),
ipaddress.ip_network("172.16.0.0/12"), ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.168.0.0/16"), ipaddress.ip_network("192.168.0.0/16"),
ipaddress.ip_network("::1/128"), ipaddress.ip_network("::1/128"),
ipaddress.ip_network("fc00::/7"), ipaddress.ip_network("fc00::/7"),
ipaddress.ip_network("fe80::/10"), ipaddress.ip_network("fe80::/10"),
] ]
ALLOWED_SCHEMES = {"http", "https"} ALLOWED_SCHEMES = {"http", "https"}
class SecurityError(ValueError): class SecurityError(ValueError):
pass pass
def _normalize_url(raw_url: str) -> str: def _normalize_url(raw_url: str) -> str:
url = raw_url.strip() url = raw_url.strip()
if not url: if not url:
raise SecurityError("URL 不能为空") raise SecurityError("URL 不能为空")
if not re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*://", url): if not re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*://", url):
url = f"https://{url}" url = f"https://{url}"
parsed = urlparse(url) parsed = urlparse(url)
if parsed.scheme not in ALLOWED_SCHEMES: if parsed.scheme not in ALLOWED_SCHEMES:
raise SecurityError("仅允许 http/https 协议") raise SecurityError("仅允许 http/https 协议")
if not parsed.netloc: if not parsed.netloc:
raise SecurityError("URL 格式无效") raise SecurityError("URL 格式无效")
if parsed.username or parsed.password: if parsed.username or parsed.password:
raise SecurityError("URL 中不允许包含用户名或密码") raise SecurityError("URL 中不允许包含用户名或密码")
hostname = parsed.hostname hostname = parsed.hostname
if not hostname: if not hostname:
raise SecurityError("无法解析主机名") raise SecurityError("无法解析主机名")
hostname_lower = hostname.lower() hostname_lower = hostname.lower()
if hostname_lower in BLOCKED_HOSTNAMES: if hostname_lower in BLOCKED_HOSTNAMES:
raise SecurityError("不允许访问该主机") raise SecurityError("不允许访问该主机")
if hostname_lower.endswith(".local") or hostname_lower.endswith(".internal"): if hostname_lower.endswith(".local") or hostname_lower.endswith(".internal"):
raise SecurityError("不允许访问内网域名") raise SecurityError("不允许访问内网域名")
try: try:
ip = ipaddress.ip_address(hostname) ip = ipaddress.ip_address(hostname)
except ValueError: except ValueError:
return url return url
for network in PRIVATE_NETWORKS: for network in PRIVATE_NETWORKS:
if ip in network: if ip in network:
raise SecurityError("不允许访问内网或本地地址") raise SecurityError("不允许访问内网或本地地址")
return url return url
def validate_url(raw_url: str) -> str: def validate_url(raw_url: str) -> str:
return _normalize_url(raw_url) return _normalize_url(raw_url)
def get_max_sessions() -> int: def get_max_sessions() -> int:
return max(1, int(os.getenv("MAX_SESSIONS", "1"))) return max(1, int(os.getenv("MAX_SESSIONS", "1")))
def get_idle_timeout() -> int: def get_idle_timeout() -> int:
return max(60, int(os.getenv("SESSION_IDLE_TIMEOUT", "1800"))) return max(60, int(os.getenv("SESSION_IDLE_TIMEOUT", "1800")))
def get_viewport_size() -> tuple[int, int]: def get_viewport_size() -> tuple[int, int]:
width = max(800, int(os.getenv("VIEWPORT_WIDTH", "1280"))) width = max(800, int(os.getenv("VIEWPORT_WIDTH", "1280")))
height = max(600, int(os.getenv("VIEWPORT_HEIGHT", "720"))) height = max(600, int(os.getenv("VIEWPORT_HEIGHT", "720")))
return width, height return width, height
def get_screencast_quality() -> int: def get_screencast_quality() -> int:
quality = int(os.getenv("SCREENCAST_QUALITY", "80")) quality = int(os.getenv("SCREENCAST_QUALITY", "80"))
return min(100, max(10, quality)) return min(100, max(10, quality))
+125 -126
View File
@@ -1,126 +1,125 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# #
# 云端浏览器一键部署脚本 # 云端浏览器一键部署脚本
# 安装路径: /opt/cloud-browser # 安装路径: /opt/cloud-browser
# 运行用户: root # 运行用户: root
# 默认端口: 32450 # 默认端口: 32450
# 默认账号: admin / admin # 默认账号: admin / admin
# #
set -euo pipefail set -eu
INSTALL_DIR="/opt/cloud-browser" INSTALL_DIR="/opt/cloud-browser"
REPO_URL="https://git.bz121.com/dekun/cloud-browser.git" REPO_URL="https://git.bz121.com/dekun/cloud-browser.git"
APP_PORT="32450" APP_PORT="32450"
RED='\033[0;31m' RED='\033[0;31m'
GREEN='\033[0;32m' GREEN='\033[0;32m'
YELLOW='\033[1;33m' YELLOW='\033[1;33m'
NC='\033[0m' NC='\033[0m'
log() { echo -e "${GREEN}[INFO]${NC} $*"; } log() { echo -e "${GREEN}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
err() { echo -e "${RED}[ERROR]${NC} $*" >&2; } err() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
if [[ "$(id -u)" -ne 0 ]]; then if [ "$(id -u)" -ne 0 ]; then
err "请使用 root 用户运行此脚本" err "请使用 root 用户运行此脚本"
exit 1 exit 1
fi fi
install_docker() { install_docker() {
if command -v docker &>/dev/null; then if command -v docker >/dev/null 2>&1; then
log "Docker 已安装: $(docker --version)" log "Docker 已安装: $(docker --version)"
return return
fi fi
warn "未检测到 Docker,正在安装..." warn "未检测到 Docker,正在安装..."
curl -fsSL https://get.docker.com | sh curl -fsSL https://get.docker.com | sh
systemctl enable docker systemctl enable docker
systemctl start docker systemctl start docker
log "Docker 安装完成" log "Docker 安装完成"
} }
install_compose() { install_compose() {
if docker compose version &>/dev/null; then if docker compose version >/dev/null 2>&1; then
log "Docker Compose 已就绪" log "Docker Compose 已就绪"
return return
fi fi
err "Docker Compose 不可用,请手动安装后重试" err "Docker Compose 不可用,请手动安装后重试"
exit 1 exit 1
} }
clone_or_update() { clone_or_update() {
if [[ -d "${INSTALL_DIR}/.git" ]]; then if [ -d "${INSTALL_DIR}/.git" ]; then
log "更新代码: ${INSTALL_DIR}" log "更新代码: ${INSTALL_DIR}"
git -C "${INSTALL_DIR}" pull --rebase git -C "${INSTALL_DIR}" pull --rebase
else else
log "克隆代码到 ${INSTALL_DIR}" log "克隆代码到 ${INSTALL_DIR}"
mkdir -p "$(dirname "${INSTALL_DIR}")" mkdir -p "$(dirname "${INSTALL_DIR}")"
git clone "${REPO_URL}" "${INSTALL_DIR}" git clone "${REPO_URL}" "${INSTALL_DIR}"
fi fi
} }
setup_env() { setup_env() {
cd "${INSTALL_DIR}" cd "${INSTALL_DIR}"
if [[ ! -f .env ]]; then if [ ! -f .env ]; then
cp .env.example .env cp .env.example .env
log "已创建 .env 配置文件" log "已创建 .env 配置文件"
fi fi
mkdir -p data mkdir -p data
chmod 700 data chmod 700 data
} }
start_service() { start_service() {
cd "${INSTALL_DIR}" cd "${INSTALL_DIR}"
log "构建并启动容器..." log "构建并启动容器..."
docker compose up -d --build docker compose up -d --build
} }
wait_health() { wait_health() {
local retries=30 retries=30
log "等待服务启动..." log "等待服务启动..."
while [[ $retries -gt 0 ]]; do while [ "$retries" -gt 0 ]; do
if curl -sf "http://127.0.0.1:${APP_PORT}/api/health" &>/dev/null; then if curl -sf "http://127.0.0.1:${APP_PORT}/api/health" >/dev/null 2>&1; then
log "服务已就绪" log "服务已就绪"
return 0 return 0
fi fi
retries=$((retries - 1)) retries=$((retries - 1))
sleep 2 sleep 2
done done
err "服务启动超时,请检查日志: docker compose -f ${INSTALL_DIR}/docker-compose.yml logs -f app" err "服务启动超时,请检查日志: docker compose -f ${INSTALL_DIR}/docker-compose.yml logs -f app"
exit 1 exit 1
} }
print_summary() { print_summary() {
local ip ip=$(curl -s --max-time 3 ifconfig.me 2>/dev/null || hostname -I 2>/dev/null | awk '{print $1}' || echo "YOUR_SERVER_IP")
ip=$(curl -s --max-time 3 ifconfig.me 2>/dev/null || hostname -I 2>/dev/null | awk '{print $1}' || echo "YOUR_SERVER_IP") echo ""
echo "" echo "=========================================="
echo "==========================================" echo " 云端浏览器部署完成"
echo " 云端浏览器部署完成" echo "=========================================="
echo "==========================================" echo " 访问地址: http://${ip}:${APP_PORT}"
echo " 访问地址: http://${ip}:${APP_PORT}" echo " 默认账号: admin"
echo " 默认账号: admin" echo " 默认密码: admin"
echo " 默认密码: admin" echo " 安装目录: ${INSTALL_DIR}"
echo " 安装目录: ${INSTALL_DIR}" echo ""
echo "" echo " 登录后请立即修改用户名和密码"
echo " 登录后请立即修改用户名和密码" echo " 反向代理请自行在宝塔/Nginx 中配置"
echo " 反向代理请自行在宝塔/Nginx 中配置" echo ""
echo "" echo " 常用命令:"
echo " 常用命令:" echo " 查看日志: cd ${INSTALL_DIR} && docker compose logs -f app"
echo " 查看日志: cd ${INSTALL_DIR} && docker compose logs -f app" echo " 重启服务: cd ${INSTALL_DIR} && docker compose restart"
echo " 重启服务: cd ${INSTALL_DIR} && docker compose restart" echo " 停止服务: cd ${INSTALL_DIR} && docker compose down"
echo " 停止服务: cd ${INSTALL_DIR} && docker compose down" echo " 更新部署: bash ${INSTALL_DIR}/deploy.sh"
echo " 更新部署: bash ${INSTALL_DIR}/deploy.sh" echo "=========================================="
echo "==========================================" }
}
main() {
main() { log "开始部署云端浏览器..."
log "开始部署云端浏览器..." install_docker
install_docker install_compose
install_compose clone_or_update
clone_or_update setup_env
setup_env start_service
start_service wait_health
wait_health print_summary
print_summary }
}
main "$@"
main "$@"
+17 -17
View File
@@ -1,17 +1,17 @@
services: services:
app: app:
build: . build: .
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${APP_PORT:-32450}:8000" - "${APP_PORT:-32450}:8000"
env_file: .env env_file: .env
environment: environment:
- DATA_DIR=/app/data - DATA_DIR=/app/data
- MAX_SESSIONS=${MAX_SESSIONS:-1} - MAX_SESSIONS=${MAX_SESSIONS:-1}
- SESSION_IDLE_TIMEOUT=${SESSION_IDLE_TIMEOUT:-1800} - SESSION_IDLE_TIMEOUT=${SESSION_IDLE_TIMEOUT:-1800}
- VIEWPORT_WIDTH=${VIEWPORT_WIDTH:-1280} - VIEWPORT_WIDTH=${VIEWPORT_WIDTH:-1280}
- VIEWPORT_HEIGHT=${VIEWPORT_HEIGHT:-720} - VIEWPORT_HEIGHT=${VIEWPORT_HEIGHT:-720}
- SCREENCAST_QUALITY=${SCREENCAST_QUALITY:-80} - SCREENCAST_QUALITY=${SCREENCAST_QUALITY:-80}
volumes: volumes:
- ./data:/app/data - ./data:/app/data
shm_size: "1gb" shm_size: "1gb"
+7 -7
View File
@@ -1,7 +1,7 @@
fastapi==0.115.6 fastapi==0.115.6
uvicorn[standard]==0.34.0 uvicorn[standard]==0.34.0
playwright==1.49.1 playwright==1.49.1
python-dotenv==1.0.1 python-dotenv==1.0.1
pydantic==2.10.4 pydantic==2.10.4
bcrypt==4.2.1 bcrypt==4.2.1
itsdangerous==2.2.0 itsdangerous==2.2.0
+35 -35
View File
@@ -1,35 +1,35 @@
const AUTH = { const AUTH = {
async me() { async me() {
const res = await fetch("/api/auth/me", { credentials: "include" }); const res = await fetch("/api/auth/me", { credentials: "include" });
if (!res.ok) return null; if (!res.ok) return null;
return res.json(); return res.json();
}, },
async login(username, password) { async login(username, password) {
const res = await fetch("/api/auth/login", { const res = await fetch("/api/auth/login", {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }), body: JSON.stringify({ username, password }),
}); });
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.detail || "登录失败"); if (!res.ok) throw new Error(data.detail || "登录失败");
return data; return data;
}, },
async logout() { async logout() {
await fetch("/api/auth/logout", { method: "POST", credentials: "include" }); await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
}, },
async changeCredentials(payload) { async changeCredentials(payload) {
const res = await fetch("/api/auth/change-credentials", { const res = await fetch("/api/auth/change-credentials", {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.detail || "修改失败"); if (!res.ok) throw new Error(data.detail || "修改失败");
return data; return data;
}, },
}; };
+88 -88
View File
@@ -1,88 +1,88 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>云端浏览器</title> <title>云端浏览器</title>
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
</head> </head>
<body> <body>
<main class="container"> <main class="container">
<header class="header"> <header class="header">
<h1>云端浏览器</h1> <h1>云端浏览器</h1>
<p class="subtitle">输入网址,在境外服务器上打开并远程操作</p> <p class="subtitle">输入网址,在境外服务器上打开并远程操作</p>
</header> </header>
<!-- 登录 --> <!-- 登录 -->
<section id="login-section" class="panel hidden"> <section id="login-section" class="panel hidden">
<h2>登录</h2> <h2>登录</h2>
<form id="login-form" class="stack-form"> <form id="login-form" class="stack-form">
<label for="login-user">用户名</label> <label for="login-user">用户名</label>
<input id="login-user" type="text" autocomplete="username" required> <input id="login-user" type="text" autocomplete="username" required>
<label for="login-pass">密码</label> <label for="login-pass">密码</label>
<input id="login-pass" type="password" autocomplete="current-password" required> <input id="login-pass" type="password" autocomplete="current-password" required>
<button type="submit">登录</button> <button type="submit">登录</button>
<p id="login-error" class="error hidden"></p> <p id="login-error" class="error hidden"></p>
</form> </form>
<p class="hint">默认账号:admin / admin,登录后请尽快修改</p> <p class="hint">默认账号:admin / admin,登录后请尽快修改</p>
</section> </section>
<!-- 主界面 --> <!-- 主界面 -->
<section id="main-section" class="hidden"> <section id="main-section" class="hidden">
<div class="top-bar"> <div class="top-bar">
<span id="welcome-user" class="welcome"></span> <span id="welcome-user" class="welcome"></span>
<button id="btn-settings" type="button" class="btn-secondary">账号设置</button> <button id="btn-settings" type="button" class="btn-secondary">账号设置</button>
<button id="btn-logout" type="button" class="btn-secondary">退出</button> <button id="btn-logout" type="button" class="btn-secondary">退出</button>
</div> </div>
<form id="start-form" class="start-form"> <form id="start-form" class="start-form">
<label for="url-input">目标网址</label> <label for="url-input">目标网址</label>
<div class="input-row"> <div class="input-row">
<input <input
id="url-input" id="url-input"
type="text" type="text"
placeholder="https://example.com" placeholder="https://example.com"
autocomplete="off" autocomplete="off"
required required
> >
<button type="submit" id="start-btn">进入</button> <button type="submit" id="start-btn">进入</button>
</div> </div>
<p id="error-msg" class="error hidden"></p> <p id="error-msg" class="error hidden"></p>
</form> </form>
<section class="tips"> <section class="tips">
<h2>使用说明</h2> <h2>使用说明</h2>
<ul> <ul>
<li>页面将在云服务器 Chromium 中加载,画面实时回传</li> <li>页面将在云服务器 Chromium 中加载,画面实时回传</li>
<li>可同时登录账号、搜索和浏览你的数据</li> <li>可同时登录账号、搜索和浏览你的数据</li>
<li>会话空闲 30 分钟后自动关闭</li> <li>会话空闲 30 分钟后自动关闭</li>
</ul> </ul>
</section> </section>
</section> </section>
<!-- 修改账号 --> <!-- 修改账号 -->
<section id="settings-section" class="panel hidden"> <section id="settings-section" class="panel hidden">
<h2>修改用户名和密码</h2> <h2>修改用户名和密码</h2>
<form id="settings-form" class="stack-form"> <form id="settings-form" class="stack-form">
<label for="cur-user">当前用户名</label> <label for="cur-user">当前用户名</label>
<input id="cur-user" type="text" required> <input id="cur-user" type="text" required>
<label for="cur-pass">当前密码</label> <label for="cur-pass">当前密码</label>
<input id="cur-pass" type="password" required> <input id="cur-pass" type="password" required>
<label for="new-user">新用户名</label> <label for="new-user">新用户名</label>
<input id="new-user" type="text" required> <input id="new-user" type="text" required>
<label for="new-pass">新密码</label> <label for="new-pass">新密码</label>
<input id="new-pass" type="password" required> <input id="new-pass" type="password" required>
<div class="btn-row"> <div class="btn-row">
<button type="submit">保存</button> <button type="submit">保存</button>
<button id="btn-settings-cancel" type="button" class="btn-secondary">取消</button> <button id="btn-settings-cancel" type="button" class="btn-secondary">取消</button>
</div> </div>
<p id="settings-error" class="error hidden"></p> <p id="settings-error" class="error hidden"></p>
<p id="settings-success" class="success hidden"></p> <p id="settings-success" class="success hidden"></p>
</form> </form>
</section> </section>
</main> </main>
<script src="/static/auth.js"></script> <script src="/static/auth.js"></script>
<script src="/static/index.js"></script> <script src="/static/index.js"></script>
</body> </body>
</html> </html>
+163 -163
View File
@@ -1,163 +1,163 @@
const loginSection = document.getElementById("login-section"); const loginSection = document.getElementById("login-section");
const mainSection = document.getElementById("main-section"); const mainSection = document.getElementById("main-section");
const settingsSection = document.getElementById("settings-section"); const settingsSection = document.getElementById("settings-section");
const loginForm = document.getElementById("login-form"); const loginForm = document.getElementById("login-form");
const loginError = document.getElementById("login-error"); const loginError = document.getElementById("login-error");
const welcomeUser = document.getElementById("welcome-user"); const welcomeUser = document.getElementById("welcome-user");
const settingsForm = document.getElementById("settings-form"); const settingsForm = document.getElementById("settings-form");
const settingsError = document.getElementById("settings-error"); const settingsError = document.getElementById("settings-error");
const settingsSuccess = document.getElementById("settings-success"); const settingsSuccess = document.getElementById("settings-success");
const form = document.getElementById("start-form"); const form = document.getElementById("start-form");
const urlInput = document.getElementById("url-input"); const urlInput = document.getElementById("url-input");
const startBtn = document.getElementById("start-btn"); const startBtn = document.getElementById("start-btn");
const errorMsg = document.getElementById("error-msg"); const errorMsg = document.getElementById("error-msg");
function show(el) { function show(el) {
el.classList.remove("hidden"); el.classList.remove("hidden");
} }
function hide(el) { function hide(el) {
el.classList.add("hidden"); el.classList.add("hidden");
} }
function showPanelError(el, message) { function showPanelError(el, message) {
el.textContent = message; el.textContent = message;
show(el); show(el);
} }
function hidePanelError(el) { function hidePanelError(el) {
hide(el); hide(el);
} }
function showMain(username) { function showMain(username) {
hide(loginSection); hide(loginSection);
hide(settingsSection); hide(settingsSection);
show(mainSection); show(mainSection);
welcomeUser.textContent = `当前用户:${username}`; welcomeUser.textContent = `当前用户:${username}`;
document.getElementById("cur-user").value = username; document.getElementById("cur-user").value = username;
urlInput.focus(); urlInput.focus();
} }
function showLogin() { function showLogin() {
hide(mainSection); hide(mainSection);
hide(settingsSection); hide(settingsSection);
show(loginSection); show(loginSection);
} }
async function init() { async function init() {
const user = await AUTH.me(); const user = await AUTH.me();
if (user) { if (user) {
showMain(user.username); showMain(user.username);
} else { } else {
showLogin(); showLogin();
} }
} }
loginForm.addEventListener("submit", async (e) => { loginForm.addEventListener("submit", async (e) => {
e.preventDefault(); e.preventDefault();
hidePanelError(loginError); hidePanelError(loginError);
const username = document.getElementById("login-user").value.trim(); const username = document.getElementById("login-user").value.trim();
const password = document.getElementById("login-pass").value; const password = document.getElementById("login-pass").value;
try { try {
const data = await AUTH.login(username, password); const data = await AUTH.login(username, password);
showMain(data.username); showMain(data.username);
} catch (err) { } catch (err) {
showPanelError(loginError, err.message); showPanelError(loginError, err.message);
} }
}); });
document.getElementById("btn-logout").addEventListener("click", async () => { document.getElementById("btn-logout").addEventListener("click", async () => {
await AUTH.logout(); await AUTH.logout();
showLogin(); showLogin();
}); });
document.getElementById("btn-settings").addEventListener("click", () => { document.getElementById("btn-settings").addEventListener("click", () => {
hide(mainSection); hide(mainSection);
hidePanelError(settingsError); hidePanelError(settingsError);
hide(settingsSuccess); hide(settingsSuccess);
show(settingsSection); show(settingsSection);
}); });
document.getElementById("btn-settings-cancel").addEventListener("click", () => { document.getElementById("btn-settings-cancel").addEventListener("click", () => {
const user = welcomeUser.textContent.replace("当前用户:", ""); const user = welcomeUser.textContent.replace("当前用户:", "");
showMain(user); showMain(user);
}); });
settingsForm.addEventListener("submit", async (e) => { settingsForm.addEventListener("submit", async (e) => {
e.preventDefault(); e.preventDefault();
hidePanelError(settingsError); hidePanelError(settingsError);
hide(settingsSuccess); hide(settingsSuccess);
try { try {
const data = await AUTH.changeCredentials({ const data = await AUTH.changeCredentials({
current_username: document.getElementById("cur-user").value.trim(), current_username: document.getElementById("cur-user").value.trim(),
current_password: document.getElementById("cur-pass").value, current_password: document.getElementById("cur-pass").value,
new_username: document.getElementById("new-user").value.trim(), new_username: document.getElementById("new-user").value.trim(),
new_password: document.getElementById("new-pass").value, new_password: document.getElementById("new-pass").value,
}); });
document.getElementById("cur-pass").value = ""; document.getElementById("cur-pass").value = "";
document.getElementById("new-pass").value = ""; document.getElementById("new-pass").value = "";
settingsSuccess.textContent = "账号已更新,请使用新凭据登录"; settingsSuccess.textContent = "账号已更新,请使用新凭据登录";
show(settingsSuccess); show(settingsSuccess);
setTimeout(async () => { setTimeout(async () => {
await AUTH.logout(); await AUTH.logout();
showLogin(); showLogin();
}, 1500); }, 1500);
welcomeUser.textContent = `当前用户:${data.username}`; welcomeUser.textContent = `当前用户:${data.username}`;
} catch (err) { } catch (err) {
showPanelError(settingsError, err.message); showPanelError(settingsError, err.message);
} }
}); });
function showError(message) { function showError(message) {
errorMsg.textContent = message; errorMsg.textContent = message;
show(errorMsg); show(errorMsg);
} }
function hideError() { function hideError() {
hide(errorMsg); hide(errorMsg);
} }
form.addEventListener("submit", async (event) => { form.addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
hideError(); hideError();
const url = urlInput.value.trim(); const url = urlInput.value.trim();
if (!url) { if (!url) {
showError("请输入网址"); showError("请输入网址");
return; return;
} }
startBtn.disabled = true; startBtn.disabled = true;
startBtn.textContent = "启动中..."; startBtn.textContent = "启动中...";
try { try {
const response = await fetch("/api/session", { const response = await fetch("/api/session", {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url }), body: JSON.stringify({ url }),
}); });
const data = await response.json().catch(() => ({})); const data = await response.json().catch(() => ({}));
if (response.status === 401) { if (response.status === 401) {
showLogin(); showLogin();
showPanelError(loginError, "登录已过期,请重新登录"); showPanelError(loginError, "登录已过期,请重新登录");
return; return;
} }
if (!response.ok) { if (!response.ok) {
showError(data.detail || "创建会话失败"); showError(data.detail || "创建会话失败");
return; return;
} }
window.location.href = `/view/${data.session_id}`; window.location.href = `/view/${data.session_id}`;
} catch (err) { } catch (err) {
showError("网络错误,请稍后重试"); showError("网络错误,请稍后重试");
} finally { } finally {
startBtn.disabled = false; startBtn.disabled = false;
startBtn.textContent = "进入"; startBtn.textContent = "进入";
} }
}); });
init(); init();
+269 -269
View File
@@ -1,269 +1,269 @@
* { * {
box-sizing: border-box; box-sizing: border-box;
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
body { body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: #0f1419; background: #0f1419;
color: #e7e9ea; color: #e7e9ea;
min-height: 100vh; min-height: 100vh;
} }
.container { .container {
max-width: 640px; max-width: 640px;
margin: 0 auto; margin: 0 auto;
padding: 48px 24px; padding: 48px 24px;
} }
.header h1 { .header h1 {
font-size: 2rem; font-size: 2rem;
margin-bottom: 8px; margin-bottom: 8px;
} }
.subtitle { .subtitle {
color: #8b98a5; color: #8b98a5;
margin-bottom: 32px; margin-bottom: 32px;
} }
.start-form label { .start-form label {
display: block; display: block;
margin-bottom: 8px; margin-bottom: 8px;
font-weight: 500; font-weight: 500;
} }
.input-row { .input-row {
display: flex; display: flex;
gap: 8px; gap: 8px;
} }
.input-row input { .input-row input {
flex: 1; flex: 1;
padding: 12px 16px; padding: 12px 16px;
border: 1px solid #38444d; border: 1px solid #38444d;
border-radius: 8px; border-radius: 8px;
background: #192734; background: #192734;
color: #e7e9ea; color: #e7e9ea;
font-size: 1rem; font-size: 1rem;
} }
.input-row input:focus { .input-row input:focus {
outline: none; outline: none;
border-color: #1d9bf0; border-color: #1d9bf0;
} }
button { button {
padding: 12px 20px; padding: 12px 20px;
border: none; border: none;
border-radius: 8px; border-radius: 8px;
background: #1d9bf0; background: #1d9bf0;
color: #fff; color: #fff;
font-size: 1rem; font-size: 1rem;
cursor: pointer; cursor: pointer;
font-weight: 500; font-weight: 500;
} }
button:hover { button:hover {
background: #1a8cd8; background: #1a8cd8;
} }
button:disabled { button:disabled {
opacity: 0.6; opacity: 0.6;
cursor: not-allowed; cursor: not-allowed;
} }
.error { .error {
color: #f4212e; color: #f4212e;
margin-top: 12px; margin-top: 12px;
font-size: 0.9rem; font-size: 0.9rem;
} }
.hidden { .hidden {
display: none !important; display: none !important;
} }
.tips { .tips {
margin-top: 48px; margin-top: 48px;
padding: 24px; padding: 24px;
background: #192734; background: #192734;
border-radius: 12px; border-radius: 12px;
border: 1px solid #38444d; border: 1px solid #38444d;
} }
.tips h2 { .tips h2 {
font-size: 1rem; font-size: 1rem;
margin-bottom: 12px; margin-bottom: 12px;
} }
.tips ul { .tips ul {
padding-left: 20px; padding-left: 20px;
color: #8b98a5; color: #8b98a5;
line-height: 1.8; line-height: 1.8;
} }
/* Viewer */ /* Viewer */
.viewer-body { .viewer-body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100vh; height: 100vh;
overflow: hidden; overflow: hidden;
} }
.toolbar { .toolbar {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 8px 12px; padding: 8px 12px;
background: #192734; background: #192734;
border-bottom: 1px solid #38444d; border-bottom: 1px solid #38444d;
flex-shrink: 0; flex-shrink: 0;
} }
.toolbar button { .toolbar button {
padding: 8px 12px; padding: 8px 12px;
min-width: 36px; min-width: 36px;
} }
.toolbar #address-bar { .toolbar #address-bar {
flex: 1; flex: 1;
padding: 8px 12px; padding: 8px 12px;
border: 1px solid #38444d; border: 1px solid #38444d;
border-radius: 6px; border-radius: 6px;
background: #0f1419; background: #0f1419;
color: #e7e9ea; color: #e7e9ea;
font-size: 0.9rem; font-size: 0.9rem;
} }
.toolbar #address-bar:focus { .toolbar #address-bar:focus {
outline: none; outline: none;
border-color: #1d9bf0; border-color: #1d9bf0;
} }
.status { .status {
font-size: 0.8rem; font-size: 0.8rem;
color: #8b98a5; color: #8b98a5;
white-space: nowrap; white-space: nowrap;
} }
.btn-danger { .btn-danger {
background: #f4212e; background: #f4212e;
} }
.btn-danger:hover { .btn-danger:hover {
background: #dc1d28; background: #dc1d28;
} }
.viewport-wrap { .viewport-wrap {
flex: 1; flex: 1;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: #000; background: #000;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
} }
#screen { #screen {
max-width: 100%; max-width: 100%;
max-height: 100%; max-height: 100%;
cursor: default; cursor: default;
outline: none; outline: none;
} }
.overlay { .overlay {
position: absolute; position: absolute;
inset: 0; inset: 0;
background: rgba(0, 0, 0, 0.85); background: rgba(0, 0, 0, 0.85);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 16px; gap: 16px;
} }
.overlay a { .overlay a {
color: #1d9bf0; color: #1d9bf0;
text-decoration: none; text-decoration: none;
} }
.overlay a:hover { .overlay a:hover {
text-decoration: underline; text-decoration: underline;
} }
.panel { .panel {
margin-bottom: 24px; margin-bottom: 24px;
padding: 24px; padding: 24px;
background: #192734; background: #192734;
border-radius: 12px; border-radius: 12px;
border: 1px solid #38444d; border: 1px solid #38444d;
} }
.panel h2 { .panel h2 {
font-size: 1.1rem; font-size: 1.1rem;
margin-bottom: 16px; margin-bottom: 16px;
} }
.stack-form label { .stack-form label {
display: block; display: block;
margin: 12px 0 6px; margin: 12px 0 6px;
font-weight: 500; font-weight: 500;
} }
.stack-form input { .stack-form input {
width: 100%; width: 100%;
padding: 10px 14px; padding: 10px 14px;
border: 1px solid #38444d; border: 1px solid #38444d;
border-radius: 8px; border-radius: 8px;
background: #0f1419; background: #0f1419;
color: #e7e9ea; color: #e7e9ea;
font-size: 1rem; font-size: 1rem;
} }
.stack-form input:focus { .stack-form input:focus {
outline: none; outline: none;
border-color: #1d9bf0; border-color: #1d9bf0;
} }
.stack-form button { .stack-form button {
margin-top: 16px; margin-top: 16px;
} }
.btn-row { .btn-row {
display: flex; display: flex;
gap: 8px; gap: 8px;
margin-top: 16px; margin-top: 16px;
} }
.btn-secondary { .btn-secondary {
background: #38444d; background: #38444d;
} }
.btn-secondary:hover { .btn-secondary:hover {
background: #4a5560; background: #4a5560;
} }
.top-bar { .top-bar {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
margin-bottom: 24px; margin-bottom: 24px;
} }
.welcome { .welcome {
flex: 1; flex: 1;
color: #8b98a5; color: #8b98a5;
font-size: 0.9rem; font-size: 0.9rem;
} }
.hint { .hint {
margin-top: 12px; margin-top: 12px;
color: #8b98a5; color: #8b98a5;
font-size: 0.85rem; font-size: 0.85rem;
} }
.success { .success {
color: #00ba7c; color: #00ba7c;
margin-top: 12px; margin-top: 12px;
font-size: 0.9rem; font-size: 0.9rem;
} }
+30 -30
View File
@@ -1,30 +1,30 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>浏览中 - 云端浏览器</title> <title>浏览中 - 云端浏览器</title>
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
</head> </head>
<body class="viewer-body"> <body class="viewer-body">
<div class="toolbar"> <div class="toolbar">
<button id="btn-back" title="后退" type="button"></button> <button id="btn-back" title="后退" type="button"></button>
<button id="btn-forward" title="前进" type="button"></button> <button id="btn-forward" title="前进" type="button"></button>
<button id="btn-reload" title="刷新" type="button"></button> <button id="btn-reload" title="刷新" type="button"></button>
<input id="address-bar" type="text" placeholder="输入网址..." autocomplete="off"> <input id="address-bar" type="text" placeholder="输入网址..." autocomplete="off">
<button id="btn-go" type="button">前往</button> <button id="btn-go" type="button">前往</button>
<span id="status" class="status">连接中...</span> <span id="status" class="status">连接中...</span>
<button id="btn-close" class="btn-danger" type="button">关闭会话</button> <button id="btn-close" class="btn-danger" type="button">关闭会话</button>
</div> </div>
<div id="viewport-wrap" class="viewport-wrap"> <div id="viewport-wrap" class="viewport-wrap">
<canvas id="screen" tabindex="0"></canvas> <canvas id="screen" tabindex="0"></canvas>
<div id="overlay" class="overlay hidden"> <div id="overlay" class="overlay hidden">
<p id="overlay-msg">会话已结束</p> <p id="overlay-msg">会话已结束</p>
<a href="/">返回首页</a> <a href="/">返回首页</a>
</div> </div>
</div> </div>
<script src="/static/viewer.js"></script> <script src="/static/viewer.js"></script>
</body> </body>
</html> </html>
+217 -217
View File
@@ -1,217 +1,217 @@
(function () { (function () {
const sessionId = window.location.pathname.split("/").pop(); const sessionId = window.location.pathname.split("/").pop();
const canvas = document.getElementById("screen"); const canvas = document.getElementById("screen");
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
const addressBar = document.getElementById("address-bar"); const addressBar = document.getElementById("address-bar");
const statusEl = document.getElementById("status"); const statusEl = document.getElementById("status");
const overlay = document.getElementById("overlay"); const overlay = document.getElementById("overlay");
const overlayMsg = document.getElementById("overlay-msg"); const overlayMsg = document.getElementById("overlay-msg");
let ws = null; let ws = null;
let viewportWidth = 1280; let viewportWidth = 1280;
let viewportHeight = 720; let viewportHeight = 720;
let scaleX = 1; let scaleX = 1;
let scaleY = 1; let scaleY = 1;
let pingTimer = null; let pingTimer = null;
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${wsProtocol}//${window.location.host}/ws/${sessionId}`; const wsUrl = `${wsProtocol}//${window.location.host}/ws/${sessionId}`;
function setStatus(text) { function setStatus(text) {
statusEl.textContent = text; statusEl.textContent = text;
} }
function showOverlay(message) { function showOverlay(message) {
overlayMsg.textContent = message; overlayMsg.textContent = message;
overlay.classList.remove("hidden"); overlay.classList.remove("hidden");
} }
function mapCoords(clientX, clientY) { function mapCoords(clientX, clientY) {
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const x = (clientX - rect.left) / scaleX; const x = (clientX - rect.left) / scaleX;
const y = (clientY - rect.top) / scaleY; const y = (clientY - rect.top) / scaleY;
return { x, y }; return { x, y };
} }
function send(payload) { function send(payload) {
if (ws && ws.readyState === WebSocket.OPEN) { if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(payload)); ws.send(JSON.stringify(payload));
} }
} }
function drawFrame(blob) { function drawFrame(blob) {
const img = new Image(); const img = new Image();
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
img.onload = () => { img.onload = () => {
if (canvas.width !== img.width || canvas.height !== img.height) { if (canvas.width !== img.width || canvas.height !== img.height) {
canvas.width = img.width; canvas.width = img.width;
canvas.height = img.height; canvas.height = img.height;
viewportWidth = img.width; viewportWidth = img.width;
viewportHeight = img.height; viewportHeight = img.height;
updateScale(); updateScale();
} }
ctx.drawImage(img, 0, 0); ctx.drawImage(img, 0, 0);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}; };
img.src = url; img.src = url;
} }
function updateScale() { function updateScale() {
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
scaleX = rect.width / viewportWidth; scaleX = rect.width / viewportWidth;
scaleY = rect.height / viewportHeight; scaleY = rect.height / viewportHeight;
} }
function connect() { function connect() {
ws = new WebSocket(wsUrl); ws = new WebSocket(wsUrl);
ws.binaryType = "arraybuffer"; ws.binaryType = "arraybuffer";
ws.onopen = () => { ws.onopen = () => {
setStatus("已连接"); setStatus("已连接");
pingTimer = setInterval(() => send({ type: "ping" }), 60000); pingTimer = setInterval(() => send({ type: "ping" }), 60000);
}; };
ws.onmessage = (event) => { ws.onmessage = (event) => {
if (event.data instanceof ArrayBuffer) { if (event.data instanceof ArrayBuffer) {
drawFrame(new Blob([event.data], { type: "image/jpeg" })); drawFrame(new Blob([event.data], { type: "image/jpeg" }));
return; return;
} }
try { try {
const msg = JSON.parse(event.data); const msg = JSON.parse(event.data);
if (msg.type === "init") { if (msg.type === "init") {
viewportWidth = msg.width; viewportWidth = msg.width;
viewportHeight = msg.height; viewportHeight = msg.height;
addressBar.value = msg.url || ""; addressBar.value = msg.url || "";
updateScale(); updateScale();
} else if (msg.type === "url" || msg.type === "url_update") { } else if (msg.type === "url" || msg.type === "url_update") {
addressBar.value = msg.url || ""; addressBar.value = msg.url || "";
} else if (msg.type === "closed") { } else if (msg.type === "closed") {
showOverlay("会话已结束"); showOverlay("会话已结束");
ws.close(); ws.close();
} else if (msg.type === "error") { } else if (msg.type === "error") {
setStatus(msg.message); setStatus(msg.message);
} }
} catch (_) { } catch (_) {
/* ignore */ /* ignore */
} }
}; };
ws.onclose = () => { ws.onclose = () => {
setStatus("已断开"); setStatus("已断开");
clearInterval(pingTimer); clearInterval(pingTimer);
}; };
ws.onerror = () => { ws.onerror = () => {
setStatus("连接错误"); setStatus("连接错误");
}; };
} }
canvas.addEventListener("click", (e) => { canvas.addEventListener("click", (e) => {
canvas.focus(); canvas.focus();
const { x, y } = mapCoords(e.clientX, e.clientY); const { x, y } = mapCoords(e.clientX, e.clientY);
send({ action: "click", x, y, button: "left" }); send({ action: "click", x, y, button: "left" });
}); });
canvas.addEventListener("dblclick", (e) => { canvas.addEventListener("dblclick", (e) => {
e.preventDefault(); e.preventDefault();
const { x, y } = mapCoords(e.clientX, e.clientY); const { x, y } = mapCoords(e.clientX, e.clientY);
send({ action: "dblclick", x, y }); send({ action: "dblclick", x, y });
}); });
canvas.addEventListener("wheel", (e) => { canvas.addEventListener("wheel", (e) => {
e.preventDefault(); e.preventDefault();
send({ action: "wheel", deltaX: e.deltaX, deltaY: e.deltaY }); send({ action: "wheel", deltaX: e.deltaX, deltaY: e.deltaY });
}, { passive: false }); }, { passive: false });
canvas.addEventListener("mousemove", (e) => { canvas.addEventListener("mousemove", (e) => {
const { x, y } = mapCoords(e.clientX, e.clientY); const { x, y } = mapCoords(e.clientX, e.clientY);
send({ action: "mousemove", x, y }); send({ action: "mousemove", x, y });
}); });
const specialKeys = new Set([ const specialKeys = new Set([
"Enter", "Backspace", "Delete", "Tab", "Escape", "Enter", "Backspace", "Delete", "Tab", "Escape",
"ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight",
"Home", "End", "PageUp", "PageDown", "Home", "End", "PageUp", "PageDown",
]); ]);
canvas.addEventListener("keydown", (e) => { canvas.addEventListener("keydown", (e) => {
e.preventDefault(); e.preventDefault();
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
send({ action: "type", text: e.key }); send({ action: "type", text: e.key });
} else if (specialKeys.has(e.key)) { } else if (specialKeys.has(e.key)) {
send({ action: "press", key: e.key }); send({ action: "press", key: e.key });
} else { } else {
send({ action: "keydown", key: e.key }); send({ action: "keydown", key: e.key });
} }
}); });
canvas.addEventListener("keyup", (e) => { canvas.addEventListener("keyup", (e) => {
e.preventDefault(); e.preventDefault();
if (e.key.length > 1 || e.ctrlKey || e.metaKey || e.altKey) { if (e.key.length > 1 || e.ctrlKey || e.metaKey || e.altKey) {
send({ action: "keyup", key: e.key }); send({ action: "keyup", key: e.key });
} }
}); });
window.addEventListener("resize", updateScale); window.addEventListener("resize", updateScale);
async function apiPost(path) { async function apiPost(path) {
const res = await fetch(path, { method: "POST", credentials: "include" }); const res = await fetch(path, { method: "POST", credentials: "include" });
if (res.status === 401) { if (res.status === 401) {
window.location.href = "/"; window.location.href = "/";
return null; return null;
} }
return res.json(); return res.json();
} }
async function ensureAuth() { async function ensureAuth() {
const res = await fetch("/api/auth/me", { credentials: "include" }); const res = await fetch("/api/auth/me", { credentials: "include" });
if (!res.ok) { if (!res.ok) {
window.location.href = "/"; window.location.href = "/";
return false; return false;
} }
return true; return true;
} }
document.getElementById("btn-back").addEventListener("click", async () => { document.getElementById("btn-back").addEventListener("click", async () => {
const data = await apiPost(`/api/session/${sessionId}/back`); const data = await apiPost(`/api/session/${sessionId}/back`);
if (data) addressBar.value = data.url; if (data) addressBar.value = data.url;
}); });
document.getElementById("btn-forward").addEventListener("click", async () => { document.getElementById("btn-forward").addEventListener("click", async () => {
const data = await apiPost(`/api/session/${sessionId}/forward`); const data = await apiPost(`/api/session/${sessionId}/forward`);
if (data) addressBar.value = data.url; if (data) addressBar.value = data.url;
}); });
document.getElementById("btn-reload").addEventListener("click", async () => { document.getElementById("btn-reload").addEventListener("click", async () => {
const data = await apiPost(`/api/session/${sessionId}/reload`); const data = await apiPost(`/api/session/${sessionId}/reload`);
if (data) addressBar.value = data.url; if (data) addressBar.value = data.url;
}); });
function navigateTo(url) { function navigateTo(url) {
send({ type: "navigate", url }); send({ type: "navigate", url });
} }
document.getElementById("btn-go").addEventListener("click", () => { document.getElementById("btn-go").addEventListener("click", () => {
navigateTo(addressBar.value.trim()); navigateTo(addressBar.value.trim());
}); });
addressBar.addEventListener("keydown", (e) => { addressBar.addEventListener("keydown", (e) => {
if (e.key === "Enter") { if (e.key === "Enter") {
navigateTo(addressBar.value.trim()); navigateTo(addressBar.value.trim());
} }
}); });
document.getElementById("btn-close").addEventListener("click", async () => { document.getElementById("btn-close").addEventListener("click", async () => {
await fetch(`/api/session/${sessionId}`, { method: "DELETE", credentials: "include" }); await fetch(`/api/session/${sessionId}`, { method: "DELETE", credentials: "include" });
showOverlay("会话已关闭"); showOverlay("会话已关闭");
if (ws) ws.close(); if (ws) ws.close();
}); });
ensureAuth().then((ok) => { ensureAuth().then((ok) => {
if (ok) { if (ok) {
connect(); connect();
canvas.focus(); canvas.focus();
} }
}); });
})(); })();