agent-webview 0.1.3__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.
Files changed (51) hide show
  1. {agent_webview-0.1.3 → agent_webview-0.1.4}/.github/workflows/ci.yml +2 -2
  2. {agent_webview-0.1.3 → agent_webview-0.1.4}/.github/workflows/publish.yml +3 -3
  3. {agent_webview-0.1.3 → agent_webview-0.1.4}/CHANGELOG.md +18 -0
  4. {agent_webview-0.1.3 → agent_webview-0.1.4}/CONTRIBUTING.md +4 -0
  5. {agent_webview-0.1.3 → agent_webview-0.1.4}/PKG-INFO +45 -8
  6. {agent_webview-0.1.3 → agent_webview-0.1.4}/README.md +42 -5
  7. {agent_webview-0.1.3 → agent_webview-0.1.4}/README_EN.md +48 -6
  8. {agent_webview-0.1.3 → agent_webview-0.1.4}/SECURITY.md +5 -0
  9. {agent_webview-0.1.3 → agent_webview-0.1.4}/pyproject.toml +2 -2
  10. agent_webview-0.1.4/scripts/smoke_proxy_switch.py +200 -0
  11. {agent_webview-0.1.3 → agent_webview-0.1.4}/src/agent_webview/__init__.py +1 -1
  12. {agent_webview-0.1.3 → agent_webview-0.1.4}/src/agent_webview/cli.py +5 -4
  13. {agent_webview-0.1.3 → agent_webview-0.1.4}/src/agent_webview/controller_api.py +5 -0
  14. {agent_webview-0.1.3 → agent_webview-0.1.4}/src/agent_webview/models.py +35 -2
  15. agent_webview-0.1.4/src/agent_webview/proxy_relay.py +219 -0
  16. {agent_webview-0.1.3 → agent_webview-0.1.4}/src/agent_webview/runtime.py +68 -11
  17. {agent_webview-0.1.3 → agent_webview-0.1.4}/src/agent_webview/sessions.py +6 -1
  18. {agent_webview-0.1.3 → agent_webview-0.1.4}/src/agent_webview/worker.py +18 -1
  19. {agent_webview-0.1.3 → agent_webview-0.1.4}/src/agent_webview/worker_api.py +5 -0
  20. {agent_webview-0.1.3 → agent_webview-0.1.4}/tests/test_controller_api.py +25 -0
  21. {agent_webview-0.1.3 → agent_webview-0.1.4}/tests/test_models.py +21 -0
  22. agent_webview-0.1.4/tests/test_proxy_relay.py +237 -0
  23. {agent_webview-0.1.3 → agent_webview-0.1.4}/tests/test_runtime.py +90 -0
  24. {agent_webview-0.1.3 → agent_webview-0.1.4}/tests/test_sessions.py +20 -0
  25. {agent_webview-0.1.3 → agent_webview-0.1.4}/tests/test_worker_api.py +13 -0
  26. {agent_webview-0.1.3 → agent_webview-0.1.4}/uv.lock +44 -7
  27. {agent_webview-0.1.3 → agent_webview-0.1.4}/.github/dependabot.yml +0 -0
  28. {agent_webview-0.1.3 → agent_webview-0.1.4}/.gitignore +0 -0
  29. {agent_webview-0.1.3 → agent_webview-0.1.4}/LICENSE +0 -0
  30. {agent_webview-0.1.3 → agent_webview-0.1.4}/docs/api-stability.md +0 -0
  31. {agent_webview-0.1.3 → agent_webview-0.1.4}/examples/debug_page.html +0 -0
  32. {agent_webview-0.1.3 → agent_webview-0.1.4}/scripts/release_check.py +0 -0
  33. {agent_webview-0.1.3 → agent_webview-0.1.4}/scripts/smoke_service.py +0 -0
  34. {agent_webview-0.1.3 → agent_webview-0.1.4}/src/agent_webview/__main__.py +0 -0
  35. {agent_webview-0.1.3 → agent_webview-0.1.4}/src/agent_webview/auth.py +0 -0
  36. {agent_webview-0.1.3 → agent_webview-0.1.4}/src/agent_webview/bridge.py +0 -0
  37. {agent_webview-0.1.3 → agent_webview-0.1.4}/src/agent_webview/cookies.py +0 -0
  38. {agent_webview-0.1.3 → agent_webview-0.1.4}/src/agent_webview/errors.py +0 -0
  39. {agent_webview-0.1.3 → agent_webview-0.1.4}/src/agent_webview/events.py +0 -0
  40. {agent_webview-0.1.3 → agent_webview-0.1.4}/src/agent_webview/files.py +0 -0
  41. {agent_webview-0.1.3 → agent_webview-0.1.4}/src/agent_webview/http_errors.py +0 -0
  42. {agent_webview-0.1.3 → agent_webview-0.1.4}/src/agent_webview/js.py +0 -0
  43. {agent_webview-0.1.3 → agent_webview-0.1.4}/src/agent_webview/logging.py +0 -0
  44. {agent_webview-0.1.3 → agent_webview-0.1.4}/src/agent_webview/proxy.py +0 -0
  45. {agent_webview-0.1.3 → agent_webview-0.1.4}/tests/test_bridge.py +0 -0
  46. {agent_webview-0.1.3 → agent_webview-0.1.4}/tests/test_cli.py +0 -0
  47. {agent_webview-0.1.3 → agent_webview-0.1.4}/tests/test_cookies.py +0 -0
  48. {agent_webview-0.1.3 → agent_webview-0.1.4}/tests/test_events.py +0 -0
  49. {agent_webview-0.1.3 → agent_webview-0.1.4}/tests/test_files.py +0 -0
  50. {agent_webview-0.1.3 → agent_webview-0.1.4}/tests/test_js.py +0 -0
  51. {agent_webview-0.1.3 → agent_webview-0.1.4}/tests/test_proxy.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@11d5960a326750d5838078e36cf38b85af677262 # v4
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@11d5960a326750d5838078e36cf38b85af677262 # v4
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@11d5960a326750d5838078e36cf38b85af677262 # v4
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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
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@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
37
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
38
38
  with:
39
39
  name: python-package
40
40
  path: dist/
@@ -4,6 +4,24 @@
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
+
7
25
  ## [0.1.3] - 2026-09-25
8
26
 
9
27
  ### Changed
@@ -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
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<7,>=6; extra == 'dev'
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,8 +261,42 @@ session = client.post(
260
261
  ```
261
262
 
262
263
  全局代理优先于创建请求中的窗口代理,保证该控制器下的所有窗口使用同一代理。
263
- 代理地址支持 `http://` 和 `https://`,暂不支持 SOCKS、用户名密码认证、路径或查询
264
- 参数。Windows 需要 WebView2;macOS 的原生代理能力需要 macOS 14 或更高版本。
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。查询成功后,可见窗口标题会变为类似:
@@ -292,7 +327,9 @@ if proxy["state"] == "active":
292
327
  - `active`:固定查询服务已通过该代理成功返回出口 IP 和归属地。
293
328
  - `unavailable`:本次查询没有结果;页面继续正常工作,该状态本身不能断定代理失败。
294
329
 
295
- 查询完成时也会产生 `proxy` 事件,可通过现有事件长轮询接口监听。查询服务地址固定
330
+ 切换应用时产生包含 `revision` 和无账密 `server` 的 `changed` 代理事件;
331
+ 旧版本的后台查询结果不会覆盖新版本状态。查询完成时也会产生 `proxy` 事件,
332
+ 可通过现有事件长轮询接口监听。查询服务地址固定
296
333
  在实现内部,不接受 CLI 或 HTTP API 覆盖。
297
334
 
298
335
  ## 🔎 页面观测
@@ -364,8 +401,8 @@ Cookie 快照和运行信息文件都应按敏感数据处理,不应提交到
364
401
  - 控制器重启不会恢复仍在运行的 worker 会话。
365
402
  - DOM `click()` 不等同于操作系统级鼠标事件。
366
403
  - 当前不提供截图接口,因为 pywebview 没有统一的跨后端页面截图 API。
367
- - 该项目可以把 WebView 流量转交给已有代理,但本身不是代理服务器,也不是完整的
368
- 浏览器自动化框架。
404
+ - 单窗口代理包含本机转发入口,仅用于当前会话;本项目不提供公共代理服务或代理
405
+ IP 资源,也不是完整的浏览器自动化框架。
369
406
 
370
407
  公共兼容范围和版本规则见
371
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,8 +207,42 @@ session = client.post(
206
207
  ```
207
208
 
208
209
  全局代理优先于创建请求中的窗口代理,保证该控制器下的所有窗口使用同一代理。
209
- 代理地址支持 `http://` 和 `https://`,暂不支持 SOCKS、用户名密码认证、路径或查询
210
- 参数。Windows 需要 WebView2;macOS 的原生代理能力需要 macOS 14 或更高版本。
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。查询成功后,可见窗口标题会变为类似:
@@ -238,7 +273,9 @@ if proxy["state"] == "active":
238
273
  - `active`:固定查询服务已通过该代理成功返回出口 IP 和归属地。
239
274
  - `unavailable`:本次查询没有结果;页面继续正常工作,该状态本身不能断定代理失败。
240
275
 
241
- 查询完成时也会产生 `proxy` 事件,可通过现有事件长轮询接口监听。查询服务地址固定
276
+ 切换应用时产生包含 `revision` 和无账密 `server` 的 `changed` 代理事件;
277
+ 旧版本的后台查询结果不会覆盖新版本状态。查询完成时也会产生 `proxy` 事件,
278
+ 可通过现有事件长轮询接口监听。查询服务地址固定
242
279
  在实现内部,不接受 CLI 或 HTTP API 覆盖。
243
280
 
244
281
  ## 🔎 页面观测
@@ -310,8 +347,8 @@ Cookie 快照和运行信息文件都应按敏感数据处理,不应提交到
310
347
  - 控制器重启不会恢复仍在运行的 worker 会话。
311
348
  - DOM `click()` 不等同于操作系统级鼠标事件。
312
349
  - 当前不提供截图接口,因为 pywebview 没有统一的跨后端页面截图 API。
313
- - 该项目可以把 WebView 流量转交给已有代理,但本身不是代理服务器,也不是完整的
314
- 浏览器自动化框架。
350
+ - 单窗口代理包含本机转发入口,仅用于当前会话;本项目不提供公共代理服务或代理
351
+ IP 资源,也不是完整的浏览器自动化框架。
315
352
 
316
353
  公共兼容范围和版本规则见
317
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, username/password authentication, paths, and query strings
220
- are not currently supported. Windows requires WebView2, and native proxy
221
- configuration on macOS requires macOS 14 or later.
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
@@ -255,7 +294,9 @@ waits only on that status request, never on the WebView. States mean:
255
294
  - `unavailable`: this lookup produced no result. The page continues normally,
256
295
  and this state alone does not prove the proxy failed.
257
296
 
258
- Completion also emits a `proxy` event through the existing event stream. The
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
259
300
  lookup service URL is fixed internally and cannot be overridden through the
260
301
  CLI or HTTP API.
261
302
 
@@ -342,8 +383,9 @@ browser cookies. Keep it within these boundaries:
342
383
  - DOM `click()` is not equivalent to an operating-system mouse event.
343
384
  - Screenshots are not currently exposed because pywebview has no uniform
344
385
  cross-backend page screenshot API.
345
- - This project can route WebView traffic to an existing proxy, but it is not a
346
- proxy server or a complete browser automation framework.
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.
347
389
 
348
390
  See the
349
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
- "filelock>=3.16,<4",
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,<7",
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()
@@ -1,3 +1,3 @@
1
1
  """Agent 控制的 pywebview 调试服务。"""
2
2
 
3
- __version__ = "0.1.3"
3
+ __version__ = "0.1.4"
@@ -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
- lock = FileLock(f"{path}.lock")
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 Timeout as error:
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()
@@ -27,6 +27,7 @@ from agent_webview.models import (
27
27
  JavaScriptRequest,
28
28
  NavigateRequest,
29
29
  ProxyStatus,
30
+ ProxyUpdateRequest,
30
31
  SessionCreateRequest,
31
32
  SessionDescription,
32
33
  SessionDestroyResponse,
@@ -137,6 +138,10 @@ def create_controller_app(manager: SessionManager, token: str) -> FastAPI:
137
138
  def destroy_session(session_id: str) -> dict[str, Any]:
138
139
  return manager.destroy(session_id)
139
140
 
141
+ @router.put("/sessions/{session_id}/proxy", response_model=ProxyStatus)
142
+ def update_proxy(session_id: str, request: ProxyUpdateRequest) -> dict[str, Any]:
143
+ return call(session_id, "PUT", "/v1/proxy", body=request.model_dump())
144
+
140
145
  @router.post("/sessions/{session_id}/window/show", response_model=WindowStatus)
141
146
  def show_window(session_id: str) -> dict[str, Any]:
142
147
  return call(session_id, "POST", "/v1/window/show")