agent-webview 0.1.2__tar.gz → 0.1.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {agent_webview-0.1.2 → agent_webview-0.1.4}/.github/workflows/ci.yml +2 -2
- {agent_webview-0.1.2 → agent_webview-0.1.4}/.github/workflows/publish.yml +3 -3
- {agent_webview-0.1.2 → agent_webview-0.1.4}/CHANGELOG.md +25 -0
- {agent_webview-0.1.2 → agent_webview-0.1.4}/CONTRIBUTING.md +4 -0
- {agent_webview-0.1.2 → agent_webview-0.1.4}/PKG-INFO +48 -9
- {agent_webview-0.1.2 → agent_webview-0.1.4}/README.md +45 -6
- {agent_webview-0.1.2 → agent_webview-0.1.4}/README_EN.md +52 -7
- {agent_webview-0.1.2 → agent_webview-0.1.4}/SECURITY.md +5 -0
- {agent_webview-0.1.2 → agent_webview-0.1.4}/pyproject.toml +2 -2
- agent_webview-0.1.4/scripts/smoke_proxy_switch.py +200 -0
- {agent_webview-0.1.2 → agent_webview-0.1.4}/src/agent_webview/__init__.py +1 -1
- {agent_webview-0.1.2 → agent_webview-0.1.4}/src/agent_webview/cli.py +5 -4
- {agent_webview-0.1.2 → agent_webview-0.1.4}/src/agent_webview/controller_api.py +5 -0
- {agent_webview-0.1.2 → agent_webview-0.1.4}/src/agent_webview/models.py +35 -2
- {agent_webview-0.1.2 → agent_webview-0.1.4}/src/agent_webview/proxy.py +3 -8
- agent_webview-0.1.4/src/agent_webview/proxy_relay.py +219 -0
- {agent_webview-0.1.2 → agent_webview-0.1.4}/src/agent_webview/runtime.py +68 -11
- {agent_webview-0.1.2 → agent_webview-0.1.4}/src/agent_webview/sessions.py +6 -1
- {agent_webview-0.1.2 → agent_webview-0.1.4}/src/agent_webview/worker.py +18 -1
- {agent_webview-0.1.2 → agent_webview-0.1.4}/src/agent_webview/worker_api.py +5 -0
- {agent_webview-0.1.2 → agent_webview-0.1.4}/tests/test_controller_api.py +25 -0
- {agent_webview-0.1.2 → agent_webview-0.1.4}/tests/test_models.py +21 -0
- {agent_webview-0.1.2 → agent_webview-0.1.4}/tests/test_proxy.py +6 -2
- agent_webview-0.1.4/tests/test_proxy_relay.py +237 -0
- {agent_webview-0.1.2 → agent_webview-0.1.4}/tests/test_runtime.py +93 -5
- {agent_webview-0.1.2 → agent_webview-0.1.4}/tests/test_sessions.py +20 -0
- {agent_webview-0.1.2 → agent_webview-0.1.4}/tests/test_worker_api.py +13 -0
- {agent_webview-0.1.2 → agent_webview-0.1.4}/uv.lock +44 -7
- {agent_webview-0.1.2 → agent_webview-0.1.4}/.github/dependabot.yml +0 -0
- {agent_webview-0.1.2 → agent_webview-0.1.4}/.gitignore +0 -0
- {agent_webview-0.1.2 → agent_webview-0.1.4}/LICENSE +0 -0
- {agent_webview-0.1.2 → agent_webview-0.1.4}/docs/api-stability.md +0 -0
- {agent_webview-0.1.2 → agent_webview-0.1.4}/examples/debug_page.html +0 -0
- {agent_webview-0.1.2 → agent_webview-0.1.4}/scripts/release_check.py +0 -0
- {agent_webview-0.1.2 → agent_webview-0.1.4}/scripts/smoke_service.py +0 -0
- {agent_webview-0.1.2 → agent_webview-0.1.4}/src/agent_webview/__main__.py +0 -0
- {agent_webview-0.1.2 → agent_webview-0.1.4}/src/agent_webview/auth.py +0 -0
- {agent_webview-0.1.2 → agent_webview-0.1.4}/src/agent_webview/bridge.py +0 -0
- {agent_webview-0.1.2 → agent_webview-0.1.4}/src/agent_webview/cookies.py +0 -0
- {agent_webview-0.1.2 → agent_webview-0.1.4}/src/agent_webview/errors.py +0 -0
- {agent_webview-0.1.2 → agent_webview-0.1.4}/src/agent_webview/events.py +0 -0
- {agent_webview-0.1.2 → agent_webview-0.1.4}/src/agent_webview/files.py +0 -0
- {agent_webview-0.1.2 → agent_webview-0.1.4}/src/agent_webview/http_errors.py +0 -0
- {agent_webview-0.1.2 → agent_webview-0.1.4}/src/agent_webview/js.py +0 -0
- {agent_webview-0.1.2 → agent_webview-0.1.4}/src/agent_webview/logging.py +0 -0
- {agent_webview-0.1.2 → agent_webview-0.1.4}/tests/test_bridge.py +0 -0
- {agent_webview-0.1.2 → agent_webview-0.1.4}/tests/test_cli.py +0 -0
- {agent_webview-0.1.2 → agent_webview-0.1.4}/tests/test_cookies.py +0 -0
- {agent_webview-0.1.2 → agent_webview-0.1.4}/tests/test_events.py +0 -0
- {agent_webview-0.1.2 → agent_webview-0.1.4}/tests/test_files.py +0 -0
- {agent_webview-0.1.2 → agent_webview-0.1.4}/tests/test_js.py +0 -0
|
@@ -17,7 +17,7 @@ jobs:
|
|
|
17
17
|
os: [ubuntu-latest, windows-latest, macos-latest]
|
|
18
18
|
python: ["3.10", "3.13"]
|
|
19
19
|
steps:
|
|
20
|
-
- uses: actions/checkout@
|
|
20
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
21
21
|
- uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
|
|
22
22
|
with:
|
|
23
23
|
python-version: ${{ matrix.python }}
|
|
@@ -34,7 +34,7 @@ jobs:
|
|
|
34
34
|
release-check:
|
|
35
35
|
runs-on: ubuntu-latest
|
|
36
36
|
steps:
|
|
37
|
-
- uses: actions/checkout@
|
|
37
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
38
38
|
- uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
|
|
39
39
|
with:
|
|
40
40
|
python-version: "3.13"
|
|
@@ -11,7 +11,7 @@ jobs:
|
|
|
11
11
|
build:
|
|
12
12
|
runs-on: ubuntu-latest
|
|
13
13
|
steps:
|
|
14
|
-
- uses: actions/checkout@
|
|
14
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
15
15
|
with:
|
|
16
16
|
ref: ${{ github.event.release.tag_name }}
|
|
17
17
|
- uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
|
|
@@ -19,7 +19,7 @@ jobs:
|
|
|
19
19
|
python-version: "3.13"
|
|
20
20
|
- run: python scripts/release_check.py
|
|
21
21
|
- run: uv build --out-dir dist
|
|
22
|
-
- uses: actions/upload-artifact@
|
|
22
|
+
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
23
23
|
with:
|
|
24
24
|
name: python-package
|
|
25
25
|
path: dist/
|
|
@@ -34,7 +34,7 @@ jobs:
|
|
|
34
34
|
permissions:
|
|
35
35
|
id-token: write
|
|
36
36
|
steps:
|
|
37
|
-
- uses: actions/download-artifact@
|
|
37
|
+
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
38
38
|
with:
|
|
39
39
|
name: python-package
|
|
40
40
|
path: dist/
|
|
@@ -4,6 +4,31 @@
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.1.4] - 2026-10-30
|
|
8
|
+
|
|
9
|
+
### Security
|
|
10
|
+
|
|
11
|
+
- 用兼容 Python 3.10 的 portalocker 替换存在已知漏洞的 filelock,保留控制器互斥锁;更新锁定的 certifi。
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- 新增 `PUT /v1/sessions/{id}/proxy`,已打开的单窗口代理会话可更换上游,保留窗口、Cookie 和页面状态。
|
|
16
|
+
- 单窗口代理支持独立的用户名密码字段和 HTTP Basic 认证。
|
|
17
|
+
- 代理状态增加版本、可修改标识和认证标识;可用预期版本防止并发更新覆盖。
|
|
18
|
+
- 增加真实窗口切换冒烟测试 `scripts/smoke_proxy_switch.py`。
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- 单窗口代理经本机转发入口连接上游;切换时断开旧连接,不自动刷新页面或重放请求,不回退直连。
|
|
23
|
+
- 后台出口查询丢弃旧版本结果。全局代理仍不可修改,无代理会话保留原行为。
|
|
24
|
+
|
|
25
|
+
## [0.1.3] - 2026-09-25
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- 代理查询成功后的窗口标题仅显示出口 IP 和城市,移除“已进入代理模式”提示。
|
|
30
|
+
- 归属地仅保留查询结果中的城市;缺少城市时,窗口标题仅显示出口 IP。
|
|
31
|
+
|
|
7
32
|
## [0.1.2] - 2026-08-15
|
|
8
33
|
|
|
9
34
|
### Added
|
|
@@ -8,10 +8,14 @@ uv run ruff check src tests
|
|
|
8
8
|
uv run mypy
|
|
9
9
|
uv run pytest
|
|
10
10
|
uv run python scripts/smoke_service.py
|
|
11
|
+
uv run python scripts/smoke_proxy_switch.py
|
|
11
12
|
```
|
|
12
13
|
|
|
13
14
|
代码注释、日志、调试输出和用户可见错误使用简短中文。提交应保持通用,不加入
|
|
14
15
|
特定网站选择器、账号流程或应用专属假设。
|
|
15
16
|
|
|
17
|
+
代理切换冒烟测试使用两个本机模拟代理和虚构域名,不需要真实代理账密或付费资源。
|
|
18
|
+
应分别在支持的原生 WebView 平台验证窗口状态保留;仅单元测试通过不能证明原生路由生效。
|
|
19
|
+
|
|
16
20
|
提交前请确认没有加入 Cookie、Token、Storage State、HAR、请求日志、运行信息文件
|
|
17
21
|
或真实服务域名。
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agent-webview
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
4
|
Summary: A hidden pywebview browser controlled through an agent-friendly HTTP debugging API.
|
|
5
5
|
Project-URL: Homepage, https://github.com/Yuv96/agent-webview
|
|
6
6
|
Project-URL: Repository, https://github.com/Yuv96/agent-webview.git
|
|
@@ -28,9 +28,9 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
28
28
|
Classifier: Topic :: Software Development :: Debuggers
|
|
29
29
|
Requires-Python: >=3.10
|
|
30
30
|
Requires-Dist: fastapi<1,>=0.115
|
|
31
|
-
Requires-Dist: filelock<4,>=3.16
|
|
32
31
|
Requires-Dist: httpx<1,>=0.28
|
|
33
32
|
Requires-Dist: platformdirs<5,>=4.3
|
|
33
|
+
Requires-Dist: portalocker<4,>=3.2
|
|
34
34
|
Requires-Dist: psutil<8,>=6.1
|
|
35
35
|
Requires-Dist: pydantic<3,>=2.9
|
|
36
36
|
Requires-Dist: pyobjc-framework-network>=10; sys_platform == 'darwin'
|
|
@@ -45,7 +45,7 @@ Requires-Dist: pip-audit<3,>=2.9; extra == 'dev'
|
|
|
45
45
|
Requires-Dist: pytest-cov<8,>=6; extra == 'dev'
|
|
46
46
|
Requires-Dist: pytest<10,>=8.3; extra == 'dev'
|
|
47
47
|
Requires-Dist: ruff<1,>=0.12; extra == 'dev'
|
|
48
|
-
Requires-Dist: twine<
|
|
48
|
+
Requires-Dist: twine<8,>=6; extra == 'dev'
|
|
49
49
|
Provides-Extra: linux-gtk
|
|
50
50
|
Requires-Dist: pywebview[gtk]<7,>=6.2.1; (sys_platform == 'linux') and extra == 'linux-gtk'
|
|
51
51
|
Provides-Extra: linux-qt
|
|
@@ -219,6 +219,7 @@ with httpx.Client(
|
|
|
219
219
|
| `GET` | `/v1/sessions` | 列出会话和进程信息 |
|
|
220
220
|
| `GET` | `/v1/sessions/{id}` | 获取窗口、句柄和进程状态 |
|
|
221
221
|
| `GET` | `/v1/sessions/{id}/proxy` | 查询代理配置、出口 IP 和归属地 |
|
|
222
|
+
| `PUT` | `/v1/sessions/{id}/proxy` | 为已打开的单窗口代理会话切换上游代理 |
|
|
222
223
|
| `DELETE` | `/v1/sessions/{id}` | 销毁会话 |
|
|
223
224
|
| `POST` | `/v1/sessions/{id}/navigate` | 导航到新地址 |
|
|
224
225
|
| `POST` | `/v1/sessions/{id}/javascript/evaluate` | 执行表达式并返回结果 |
|
|
@@ -260,16 +261,52 @@ session = client.post(
|
|
|
260
261
|
```
|
|
261
262
|
|
|
262
263
|
全局代理优先于创建请求中的窗口代理,保证该控制器下的所有窗口使用同一代理。
|
|
263
|
-
代理地址支持 `http://` 和 `https://`,暂不支持 SOCKS
|
|
264
|
-
|
|
264
|
+
代理地址支持 `http://` 和 `https://`,暂不支持 SOCKS、路径或查询参数。单窗口代理
|
|
265
|
+
支持 HTTP Basic 账密认证:创建请求中同时传入 `proxy_username`、`proxy_password`,
|
|
266
|
+
不要把账密拼进 `proxy` URL。Windows 需要 WebView2;macOS 需要 macOS 14 或更高版本。
|
|
267
|
+
|
|
268
|
+
### 在已打开窗口中切换代理
|
|
269
|
+
|
|
270
|
+
创建时配置了单窗口 `proxy` 的会话,可以保持窗口、Cookie、页面 JS 状态和输入内容,
|
|
271
|
+
通过以下接口切换上游,不会自动刷新页面:
|
|
272
|
+
|
|
273
|
+
```python
|
|
274
|
+
current = client.get(f"/v1/sessions/{session_id}/proxy").raise_for_status().json()
|
|
275
|
+
changed = client.put(
|
|
276
|
+
f"/v1/sessions/{session_id}/proxy",
|
|
277
|
+
json={
|
|
278
|
+
"proxy": "http://127.0.0.1:7891",
|
|
279
|
+
"proxy_username": proxy_username,
|
|
280
|
+
"proxy_password": proxy_password,
|
|
281
|
+
"expected_revision": current["revision"],
|
|
282
|
+
},
|
|
283
|
+
).raise_for_status().json()
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
不需要认证时省略两个账密字段;省略会清除旧认证,不会继承旧密码。
|
|
287
|
+
`expected_revision` 可选,指定时版本不匹配返回 `409`,避免并发切换相互覆盖。
|
|
288
|
+
`proxy` 必填,不接受 `null`,不会隐式切换为直连。
|
|
289
|
+
|
|
290
|
+
响应沿用 `ProxyStatus`,增加 `revision`(从 0 开始)、`mutable`、`authenticated`。
|
|
291
|
+
成功响应表示上游已替换,`state=checking`,出口信息异步更新;它不保证上游已经连通。
|
|
292
|
+
未配置代理、使用全局代理或已经关闭的窗口不支持此操作,返回 `409`。
|
|
293
|
+
全局代理仍保持不可修改。状态、标题和代理事件不返回用户名或密码。
|
|
294
|
+
|
|
295
|
+
单窗口会话通过仅监听 `127.0.0.1` 的固定入口连接上游。切换会断开所有旧 HTTP
|
|
296
|
+
连接和 CONNECT 隧道,包括 WebSocket 或正在传输的请求;新请求使用新上游。
|
|
297
|
+
组件不自动重试被中断的提交,也不在认证或连接失败时回退直连。
|
|
298
|
+
调用方应在关键提交结束后切换;Cookie 保留不代表目标网站一定接受换 IP 后的登录态。
|
|
299
|
+
这项能力不负责购买、提取或自动续期代理 IP。
|
|
265
300
|
|
|
266
301
|
窗口会立即加载目标页面。出口 IP 与归属地由后台线程通过固定的第三方服务查询,
|
|
267
302
|
不会等待查询结束才放行 WebView。查询成功后,可见窗口标题会变为类似:
|
|
268
303
|
|
|
269
304
|
```text
|
|
270
|
-
[
|
|
305
|
+
[203.0.113.8 · 上海] Agent Webview
|
|
271
306
|
```
|
|
272
307
|
|
|
308
|
+
归属地仅保留城市;查询结果没有城市时,标题只显示出口 IP。
|
|
309
|
+
|
|
273
310
|
隐藏窗口或程序化调用应使用独立状态接口:
|
|
274
311
|
|
|
275
312
|
```python
|
|
@@ -290,7 +327,9 @@ if proxy["state"] == "active":
|
|
|
290
327
|
- `active`:固定查询服务已通过该代理成功返回出口 IP 和归属地。
|
|
291
328
|
- `unavailable`:本次查询没有结果;页面继续正常工作,该状态本身不能断定代理失败。
|
|
292
329
|
|
|
293
|
-
|
|
330
|
+
切换应用时产生包含 `revision` 和无账密 `server` 的 `changed` 代理事件;
|
|
331
|
+
旧版本的后台查询结果不会覆盖新版本状态。查询完成时也会产生 `proxy` 事件,
|
|
332
|
+
可通过现有事件长轮询接口监听。查询服务地址固定
|
|
294
333
|
在实现内部,不接受 CLI 或 HTTP API 覆盖。
|
|
295
334
|
|
|
296
335
|
## 🔎 页面观测
|
|
@@ -362,8 +401,8 @@ Cookie 快照和运行信息文件都应按敏感数据处理,不应提交到
|
|
|
362
401
|
- 控制器重启不会恢复仍在运行的 worker 会话。
|
|
363
402
|
- DOM `click()` 不等同于操作系统级鼠标事件。
|
|
364
403
|
- 当前不提供截图接口,因为 pywebview 没有统一的跨后端页面截图 API。
|
|
365
|
-
-
|
|
366
|
-
|
|
404
|
+
- 单窗口代理包含本机转发入口,仅用于当前会话;本项目不提供公共代理服务或代理
|
|
405
|
+
IP 资源,也不是完整的浏览器自动化框架。
|
|
367
406
|
|
|
368
407
|
公共兼容范围和版本规则见
|
|
369
408
|
[API 稳定性说明](https://github.com/Yuv96/agent-webview/blob/main/docs/api-stability.md)。
|
|
@@ -165,6 +165,7 @@ with httpx.Client(
|
|
|
165
165
|
| `GET` | `/v1/sessions` | 列出会话和进程信息 |
|
|
166
166
|
| `GET` | `/v1/sessions/{id}` | 获取窗口、句柄和进程状态 |
|
|
167
167
|
| `GET` | `/v1/sessions/{id}/proxy` | 查询代理配置、出口 IP 和归属地 |
|
|
168
|
+
| `PUT` | `/v1/sessions/{id}/proxy` | 为已打开的单窗口代理会话切换上游代理 |
|
|
168
169
|
| `DELETE` | `/v1/sessions/{id}` | 销毁会话 |
|
|
169
170
|
| `POST` | `/v1/sessions/{id}/navigate` | 导航到新地址 |
|
|
170
171
|
| `POST` | `/v1/sessions/{id}/javascript/evaluate` | 执行表达式并返回结果 |
|
|
@@ -206,16 +207,52 @@ session = client.post(
|
|
|
206
207
|
```
|
|
207
208
|
|
|
208
209
|
全局代理优先于创建请求中的窗口代理,保证该控制器下的所有窗口使用同一代理。
|
|
209
|
-
代理地址支持 `http://` 和 `https://`,暂不支持 SOCKS
|
|
210
|
-
|
|
210
|
+
代理地址支持 `http://` 和 `https://`,暂不支持 SOCKS、路径或查询参数。单窗口代理
|
|
211
|
+
支持 HTTP Basic 账密认证:创建请求中同时传入 `proxy_username`、`proxy_password`,
|
|
212
|
+
不要把账密拼进 `proxy` URL。Windows 需要 WebView2;macOS 需要 macOS 14 或更高版本。
|
|
213
|
+
|
|
214
|
+
### 在已打开窗口中切换代理
|
|
215
|
+
|
|
216
|
+
创建时配置了单窗口 `proxy` 的会话,可以保持窗口、Cookie、页面 JS 状态和输入内容,
|
|
217
|
+
通过以下接口切换上游,不会自动刷新页面:
|
|
218
|
+
|
|
219
|
+
```python
|
|
220
|
+
current = client.get(f"/v1/sessions/{session_id}/proxy").raise_for_status().json()
|
|
221
|
+
changed = client.put(
|
|
222
|
+
f"/v1/sessions/{session_id}/proxy",
|
|
223
|
+
json={
|
|
224
|
+
"proxy": "http://127.0.0.1:7891",
|
|
225
|
+
"proxy_username": proxy_username,
|
|
226
|
+
"proxy_password": proxy_password,
|
|
227
|
+
"expected_revision": current["revision"],
|
|
228
|
+
},
|
|
229
|
+
).raise_for_status().json()
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
不需要认证时省略两个账密字段;省略会清除旧认证,不会继承旧密码。
|
|
233
|
+
`expected_revision` 可选,指定时版本不匹配返回 `409`,避免并发切换相互覆盖。
|
|
234
|
+
`proxy` 必填,不接受 `null`,不会隐式切换为直连。
|
|
235
|
+
|
|
236
|
+
响应沿用 `ProxyStatus`,增加 `revision`(从 0 开始)、`mutable`、`authenticated`。
|
|
237
|
+
成功响应表示上游已替换,`state=checking`,出口信息异步更新;它不保证上游已经连通。
|
|
238
|
+
未配置代理、使用全局代理或已经关闭的窗口不支持此操作,返回 `409`。
|
|
239
|
+
全局代理仍保持不可修改。状态、标题和代理事件不返回用户名或密码。
|
|
240
|
+
|
|
241
|
+
单窗口会话通过仅监听 `127.0.0.1` 的固定入口连接上游。切换会断开所有旧 HTTP
|
|
242
|
+
连接和 CONNECT 隧道,包括 WebSocket 或正在传输的请求;新请求使用新上游。
|
|
243
|
+
组件不自动重试被中断的提交,也不在认证或连接失败时回退直连。
|
|
244
|
+
调用方应在关键提交结束后切换;Cookie 保留不代表目标网站一定接受换 IP 后的登录态。
|
|
245
|
+
这项能力不负责购买、提取或自动续期代理 IP。
|
|
211
246
|
|
|
212
247
|
窗口会立即加载目标页面。出口 IP 与归属地由后台线程通过固定的第三方服务查询,
|
|
213
248
|
不会等待查询结束才放行 WebView。查询成功后,可见窗口标题会变为类似:
|
|
214
249
|
|
|
215
250
|
```text
|
|
216
|
-
[
|
|
251
|
+
[203.0.113.8 · 上海] Agent Webview
|
|
217
252
|
```
|
|
218
253
|
|
|
254
|
+
归属地仅保留城市;查询结果没有城市时,标题只显示出口 IP。
|
|
255
|
+
|
|
219
256
|
隐藏窗口或程序化调用应使用独立状态接口:
|
|
220
257
|
|
|
221
258
|
```python
|
|
@@ -236,7 +273,9 @@ if proxy["state"] == "active":
|
|
|
236
273
|
- `active`:固定查询服务已通过该代理成功返回出口 IP 和归属地。
|
|
237
274
|
- `unavailable`:本次查询没有结果;页面继续正常工作,该状态本身不能断定代理失败。
|
|
238
275
|
|
|
239
|
-
|
|
276
|
+
切换应用时产生包含 `revision` 和无账密 `server` 的 `changed` 代理事件;
|
|
277
|
+
旧版本的后台查询结果不会覆盖新版本状态。查询完成时也会产生 `proxy` 事件,
|
|
278
|
+
可通过现有事件长轮询接口监听。查询服务地址固定
|
|
240
279
|
在实现内部,不接受 CLI 或 HTTP API 覆盖。
|
|
241
280
|
|
|
242
281
|
## 🔎 页面观测
|
|
@@ -308,8 +347,8 @@ Cookie 快照和运行信息文件都应按敏感数据处理,不应提交到
|
|
|
308
347
|
- 控制器重启不会恢复仍在运行的 worker 会话。
|
|
309
348
|
- DOM `click()` 不等同于操作系统级鼠标事件。
|
|
310
349
|
- 当前不提供截图接口,因为 pywebview 没有统一的跨后端页面截图 API。
|
|
311
|
-
-
|
|
312
|
-
|
|
350
|
+
- 单窗口代理包含本机转发入口,仅用于当前会话;本项目不提供公共代理服务或代理
|
|
351
|
+
IP 资源,也不是完整的浏览器自动化框架。
|
|
313
352
|
|
|
314
353
|
公共兼容范围和版本规则见
|
|
315
354
|
[API 稳定性说明](https://github.com/Yuv96/agent-webview/blob/main/docs/api-stability.md)。
|
|
@@ -171,6 +171,7 @@ Once the service is running, open the `docs_url` from the runtime file or visit
|
|
|
171
171
|
| `GET` | `/v1/sessions` | List sessions and process information |
|
|
172
172
|
| `GET` | `/v1/sessions/{id}` | Read window, handle, and process state |
|
|
173
173
|
| `GET` | `/v1/sessions/{id}/proxy` | Read proxy configuration, exit IP, and location |
|
|
174
|
+
| `PUT` | `/v1/sessions/{id}/proxy` | Change the upstream proxy for an open per-window proxy session |
|
|
174
175
|
| `DELETE` | `/v1/sessions/{id}` | Destroy a session |
|
|
175
176
|
| `POST` | `/v1/sessions/{id}/navigate` | Navigate to another URL |
|
|
176
177
|
| `POST` | `/v1/sessions/{id}/javascript/evaluate` | Evaluate an expression and return its value |
|
|
@@ -216,9 +217,47 @@ session = client.post(
|
|
|
216
217
|
|
|
217
218
|
The global proxy takes precedence over a per-window value so that every window
|
|
218
219
|
under that controller uses the same route. Proxy URLs may use `http://` or
|
|
219
|
-
`https://`. SOCKS,
|
|
220
|
-
|
|
221
|
-
|
|
220
|
+
`https://`. SOCKS, paths, and query strings are not supported. Per-window proxies
|
|
221
|
+
support HTTP Basic credentials: supply both `proxy_username` and `proxy_password`
|
|
222
|
+
on creation, rather than embedding credentials in the URL. Windows requires
|
|
223
|
+
WebView2; macOS requires macOS 14 or later.
|
|
224
|
+
|
|
225
|
+
### Change an open window's proxy
|
|
226
|
+
|
|
227
|
+
Sessions created with a per-window `proxy` can switch upstreams while preserving
|
|
228
|
+
the window, cookies, JavaScript state, and unfinished inputs. No page reload occurs:
|
|
229
|
+
|
|
230
|
+
```python
|
|
231
|
+
current = client.get(f"/v1/sessions/{session_id}/proxy").raise_for_status().json()
|
|
232
|
+
changed = client.put(
|
|
233
|
+
f"/v1/sessions/{session_id}/proxy",
|
|
234
|
+
json={
|
|
235
|
+
"proxy": "http://127.0.0.1:7891",
|
|
236
|
+
"proxy_username": proxy_username,
|
|
237
|
+
"proxy_password": proxy_password,
|
|
238
|
+
"expected_revision": current["revision"],
|
|
239
|
+
},
|
|
240
|
+
).raise_for_status().json()
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Omit both credential fields for an unauthenticated upstream; omission clears
|
|
244
|
+
previous credentials. Optional `expected_revision` rejects concurrent changes
|
|
245
|
+
with `409` when the revision differs. `proxy` is required and cannot be `null`;
|
|
246
|
+
the operation never implicitly enables a direct connection.
|
|
247
|
+
|
|
248
|
+
`ProxyStatus` adds `revision` (starting at 0), `mutable`, and `authenticated`.
|
|
249
|
+
A successful update means the upstream was replaced; `state=checking` while
|
|
250
|
+
exit lookup runs asynchronously. It does not guarantee upstream connectivity.
|
|
251
|
+
Unconfigured, globally proxied, or closed windows return `409`. Global proxies
|
|
252
|
+
remain immutable. Status, titles, and proxy events never return credentials.
|
|
253
|
+
|
|
254
|
+
Each per-window proxy uses a fixed loopback relay bound to `127.0.0.1`. Switching
|
|
255
|
+
closes every old HTTP connection and CONNECT tunnel, including WebSockets and
|
|
256
|
+
in-flight requests. New requests use the new upstream. Interrupted submissions
|
|
257
|
+
are not automatically retried, and authentication/connection failure never falls
|
|
258
|
+
back to direct networking. Switch after critical submissions finish. Preserved
|
|
259
|
+
cookies do not guarantee that a website accepts a changed login IP. The component
|
|
260
|
+
does not purchase, extract, or automatically renew proxy IPs.
|
|
222
261
|
|
|
223
262
|
The target page starts loading immediately. A background thread queries a
|
|
224
263
|
fixed third-party service through the proxy for its exit IP and location; page
|
|
@@ -226,9 +265,12 @@ loading never waits for this lookup. After success, a visible window title
|
|
|
226
265
|
looks like:
|
|
227
266
|
|
|
228
267
|
```text
|
|
229
|
-
[
|
|
268
|
+
[203.0.113.8 · Shanghai] Agent Webview
|
|
230
269
|
```
|
|
231
270
|
|
|
271
|
+
Only the city is retained as the location. If no city is returned, the title
|
|
272
|
+
shows only the exit IP.
|
|
273
|
+
|
|
232
274
|
For hidden windows and programmatic callers, use the dedicated status endpoint:
|
|
233
275
|
|
|
234
276
|
```python
|
|
@@ -252,7 +294,9 @@ waits only on that status request, never on the WebView. States mean:
|
|
|
252
294
|
- `unavailable`: this lookup produced no result. The page continues normally,
|
|
253
295
|
and this state alone does not prove the proxy failed.
|
|
254
296
|
|
|
255
|
-
|
|
297
|
+
Applying a switch emits a `changed` proxy event with `revision` and a credential-free
|
|
298
|
+
`server`. Stale lookups cannot overwrite the current revision. Completion also
|
|
299
|
+
emits a `proxy` event through the existing event stream. The
|
|
256
300
|
lookup service URL is fixed internally and cannot be overridden through the
|
|
257
301
|
CLI or HTTP API.
|
|
258
302
|
|
|
@@ -339,8 +383,9 @@ browser cookies. Keep it within these boundaries:
|
|
|
339
383
|
- DOM `click()` is not equivalent to an operating-system mouse event.
|
|
340
384
|
- Screenshots are not currently exposed because pywebview has no uniform
|
|
341
385
|
cross-backend page screenshot API.
|
|
342
|
-
-
|
|
343
|
-
proxy
|
|
386
|
+
- Per-window proxies include a local relay for that session only. This project
|
|
387
|
+
does not provide a public proxy service, proxy IP inventory, or a complete
|
|
388
|
+
browser automation framework.
|
|
344
389
|
|
|
345
390
|
See the
|
|
346
391
|
[API stability policy](https://github.com/Yuv96/agent-webview/blob/main/docs/api-stability.md)
|
|
@@ -19,3 +19,8 @@
|
|
|
19
19
|
|
|
20
20
|
代理会话会把出口 IP 发送给内置的第三方归属地查询服务。全局代理地址会记录在权限
|
|
21
21
|
受限的运行信息文件中;不要共享该文件。
|
|
22
|
+
|
|
23
|
+
单窗口代理账密通过受令牌保护的 API 传入,创建时保存在权限受限的 worker 配置中;
|
|
24
|
+
后续切换账密只保留在进程内存,不进入状态响应或代理事件。窗口使用随机本机端口
|
|
25
|
+
转发到上游,端口不面向外部网络;本机其他进程仍属于可信边界。HTTP Basic 仅编码
|
|
26
|
+
认证数据;需要保护到上游的传输时应使用 `https://` 代理,组件会验证其 TLS 证书。
|
|
@@ -30,7 +30,7 @@ classifiers = [
|
|
|
30
30
|
]
|
|
31
31
|
dependencies = [
|
|
32
32
|
"fastapi>=0.115,<1",
|
|
33
|
-
"
|
|
33
|
+
"portalocker>=3.2,<4",
|
|
34
34
|
"httpx>=0.28,<1",
|
|
35
35
|
"platformdirs>=4.3,<5",
|
|
36
36
|
"psutil>=6.1,<8",
|
|
@@ -58,7 +58,7 @@ dev = [
|
|
|
58
58
|
"pytest-cov>=6,<8",
|
|
59
59
|
"pytest>=8.3,<10",
|
|
60
60
|
"ruff>=0.12,<1",
|
|
61
|
-
"twine>=6,<
|
|
61
|
+
"twine>=6,<8",
|
|
62
62
|
]
|
|
63
63
|
linux-gtk = [
|
|
64
64
|
"pywebview[gtk]>=6.2.1,<7; sys_platform == 'linux'",
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import base64
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import socketserver
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from threading import Thread
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
from smoke_service import free_port, stop_process, wait_until
|
|
17
|
+
|
|
18
|
+
from agent_webview.proxy_relay import _read_headers
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@contextmanager
|
|
22
|
+
def test_proxy(label: str):
|
|
23
|
+
expected = b"Basic " + base64.b64encode(f"test:{label}".encode())
|
|
24
|
+
requests = []
|
|
25
|
+
|
|
26
|
+
class Handler(socketserver.BaseRequestHandler):
|
|
27
|
+
def handle(self):
|
|
28
|
+
self.request.settimeout(5)
|
|
29
|
+
try:
|
|
30
|
+
header, _ = _read_headers(self.request)
|
|
31
|
+
if b"Proxy-Authorization: " + expected not in header:
|
|
32
|
+
self.request.sendall(
|
|
33
|
+
b"HTTP/1.1 407 Proxy Authentication Required\r\nContent-Length: 0\r\n\r\n"
|
|
34
|
+
)
|
|
35
|
+
return
|
|
36
|
+
method, destination, _ = header.split(b"\r\n")[0].split(b" ")
|
|
37
|
+
if method == b"CONNECT":
|
|
38
|
+
if destination != b"proxy-switch.invalid:80":
|
|
39
|
+
self.request.sendall(
|
|
40
|
+
b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n"
|
|
41
|
+
)
|
|
42
|
+
return
|
|
43
|
+
self.request.sendall(b"HTTP/1.1 200 Connection Established\r\n\r\n")
|
|
44
|
+
header, _ = _read_headers(self.request)
|
|
45
|
+
requests.append(header)
|
|
46
|
+
path = header.split(b"\r\n")[0].split(b" ")[1]
|
|
47
|
+
if b"/identity" in path:
|
|
48
|
+
body = label.encode()
|
|
49
|
+
mime = b"text/plain"
|
|
50
|
+
else:
|
|
51
|
+
body = b'<html><head><title>Proxy test</title></head><body><input id="draft"><div id="marker">original</div></body></html>'
|
|
52
|
+
mime = b"text/html"
|
|
53
|
+
self.request.sendall(
|
|
54
|
+
b"HTTP/1.1 200 OK\r\nConnection: close\r\nCache-Control: no-store\r\nContent-Type: "
|
|
55
|
+
+ mime
|
|
56
|
+
+ b"\r\nContent-Length: "
|
|
57
|
+
+ str(len(body)).encode()
|
|
58
|
+
+ b"\r\n\r\n"
|
|
59
|
+
+ body
|
|
60
|
+
)
|
|
61
|
+
except (OSError, ValueError):
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
server = socketserver.ThreadingTCPServer(("127.0.0.1", 0), Handler)
|
|
65
|
+
server.daemon_threads = True
|
|
66
|
+
thread = Thread(
|
|
67
|
+
target=server.serve_forever, kwargs={"poll_interval": 0.05}, daemon=True
|
|
68
|
+
)
|
|
69
|
+
thread.start()
|
|
70
|
+
try:
|
|
71
|
+
yield f"http://127.0.0.1:{server.server_address[1]}", requests
|
|
72
|
+
finally:
|
|
73
|
+
server.shutdown()
|
|
74
|
+
server.server_close()
|
|
75
|
+
thread.join(2)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def main():
|
|
79
|
+
with (
|
|
80
|
+
tempfile.TemporaryDirectory(prefix="webview-proxy-smoke-") as name,
|
|
81
|
+
test_proxy("first") as (first, first_requests),
|
|
82
|
+
test_proxy("second") as (second, second_requests),
|
|
83
|
+
):
|
|
84
|
+
root = Path(name)
|
|
85
|
+
runtime_file = root / "controller.json"
|
|
86
|
+
log_file = root / "controller.log"
|
|
87
|
+
port = free_port()
|
|
88
|
+
with log_file.open("w") as output:
|
|
89
|
+
process = subprocess.Popen(
|
|
90
|
+
[
|
|
91
|
+
sys.executable,
|
|
92
|
+
"-m",
|
|
93
|
+
"agent_webview",
|
|
94
|
+
"--port",
|
|
95
|
+
str(port),
|
|
96
|
+
"--data-dir",
|
|
97
|
+
str(root / "data"),
|
|
98
|
+
"--runtime-dir",
|
|
99
|
+
str(root / "runtime"),
|
|
100
|
+
"--runtime-file",
|
|
101
|
+
str(runtime_file),
|
|
102
|
+
],
|
|
103
|
+
stdout=output,
|
|
104
|
+
stderr=subprocess.STDOUT,
|
|
105
|
+
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP
|
|
106
|
+
if os.name == "nt"
|
|
107
|
+
else 0,
|
|
108
|
+
)
|
|
109
|
+
try:
|
|
110
|
+
wait_until(runtime_file.exists, timeout=20, message="控制器启动超时")
|
|
111
|
+
token = json.loads(runtime_file.read_text())["token"]
|
|
112
|
+
with httpx.Client(
|
|
113
|
+
base_url=f"http://127.0.0.1:{port}",
|
|
114
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
115
|
+
timeout=55,
|
|
116
|
+
trust_env=False,
|
|
117
|
+
) as client:
|
|
118
|
+
response = client.post(
|
|
119
|
+
"/v1/sessions",
|
|
120
|
+
json={
|
|
121
|
+
"url": "http://proxy-switch.invalid/",
|
|
122
|
+
"visible": False,
|
|
123
|
+
"proxy": first,
|
|
124
|
+
"proxy_username": "test",
|
|
125
|
+
"proxy_password": "first", # pragma: allowlist secret
|
|
126
|
+
},
|
|
127
|
+
)
|
|
128
|
+
response.raise_for_status()
|
|
129
|
+
initial = response.json()
|
|
130
|
+
path = f"/v1/sessions/{initial['session_id']}"
|
|
131
|
+
|
|
132
|
+
def ready():
|
|
133
|
+
result = client.get(path)
|
|
134
|
+
result.raise_for_status()
|
|
135
|
+
window = result.json().get("window") or {}
|
|
136
|
+
return window.get("ready")
|
|
137
|
+
|
|
138
|
+
wait_until(ready, timeout=40, message="窗口页面加载超时")
|
|
139
|
+
|
|
140
|
+
def evaluate(code):
|
|
141
|
+
result = client.post(
|
|
142
|
+
path + "/javascript/evaluate",
|
|
143
|
+
json={"code": code, "timeout": 10},
|
|
144
|
+
)
|
|
145
|
+
result.raise_for_status()
|
|
146
|
+
return result.json()["value"]
|
|
147
|
+
|
|
148
|
+
assert (
|
|
149
|
+
evaluate("fetch('/identity', {cache:'no-store'}).then(r=>r.text())")
|
|
150
|
+
== "first"
|
|
151
|
+
)
|
|
152
|
+
evaluate(
|
|
153
|
+
"(() => { document.cookie='smoke=preserved; path=/'; window.marker=42; document.querySelector('#draft').value='unfinished'; return true; })()"
|
|
154
|
+
)
|
|
155
|
+
before = client.get(path).json()
|
|
156
|
+
changed = client.put(
|
|
157
|
+
path + "/proxy",
|
|
158
|
+
json={
|
|
159
|
+
"proxy": second,
|
|
160
|
+
"proxy_username": "test",
|
|
161
|
+
"proxy_password": "second", # pragma: allowlist secret
|
|
162
|
+
"expected_revision": 0,
|
|
163
|
+
},
|
|
164
|
+
)
|
|
165
|
+
changed.raise_for_status()
|
|
166
|
+
assert changed.json()["revision"] == 1
|
|
167
|
+
assert changed.json()["mutable"] is True
|
|
168
|
+
assert (
|
|
169
|
+
evaluate("fetch('/identity', {cache:'no-store'}).then(r=>r.text())")
|
|
170
|
+
== "second"
|
|
171
|
+
)
|
|
172
|
+
assert evaluate(
|
|
173
|
+
"({cookie:document.cookie, marker:window.marker, draft:document.querySelector('#draft').value})"
|
|
174
|
+
) == {
|
|
175
|
+
"cookie": "smoke=preserved",
|
|
176
|
+
"marker": 42,
|
|
177
|
+
"draft": "unfinished",
|
|
178
|
+
}
|
|
179
|
+
after = client.get(path).json()
|
|
180
|
+
assert before["pid"] == after["pid"]
|
|
181
|
+
assert before["window_id"] == after["window_id"]
|
|
182
|
+
assert (
|
|
183
|
+
before["window"]["native_window_id"]
|
|
184
|
+
== after["window"]["native_window_id"]
|
|
185
|
+
)
|
|
186
|
+
assert first_requests and second_requests
|
|
187
|
+
response = client.delete(path)
|
|
188
|
+
response.raise_for_status()
|
|
189
|
+
except Exception:
|
|
190
|
+
print(log_file.read_text()[-4000:], file=sys.stderr)
|
|
191
|
+
raise
|
|
192
|
+
finally:
|
|
193
|
+
stop_process(process)
|
|
194
|
+
print(
|
|
195
|
+
"真实窗口代理切换通过:账密验证、出口路径切换、Cookie、JS 状态和未提交输入均保留"
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
if __name__ == "__main__":
|
|
200
|
+
main()
|
|
@@ -12,8 +12,8 @@ from typing import Any
|
|
|
12
12
|
|
|
13
13
|
import structlog
|
|
14
14
|
import uvicorn
|
|
15
|
-
from filelock import FileLock, Timeout
|
|
16
15
|
from platformdirs import PlatformDirs
|
|
16
|
+
from portalocker import Lock, LockException
|
|
17
17
|
|
|
18
18
|
from agent_webview.controller_api import create_controller_app
|
|
19
19
|
from agent_webview.cookies import SnapshotStore
|
|
@@ -73,13 +73,14 @@ def _base_url(host: str, port: int) -> str:
|
|
|
73
73
|
@contextmanager
|
|
74
74
|
def _claim_runtime_file(path: Path) -> Iterator[None]:
|
|
75
75
|
ensure_private_parent(path.parent)
|
|
76
|
-
|
|
76
|
+
lock_path = Path(f"{path}.lock")
|
|
77
|
+
lock = Lock(lock_path, mode="a", timeout=0)
|
|
77
78
|
try:
|
|
78
79
|
lock.acquire(timeout=0)
|
|
79
|
-
except
|
|
80
|
+
except LockException as error:
|
|
80
81
|
raise RuntimeFileInUseError("运行信息文件已被其他服务占用") from error
|
|
81
|
-
restrict_file_permissions(Path(lock.lock_file))
|
|
82
82
|
try:
|
|
83
|
+
restrict_file_permissions(lock_path)
|
|
83
84
|
yield
|
|
84
85
|
finally:
|
|
85
86
|
lock.release()
|