witbitz-code 1.8.0__tar.gz → 1.9.1__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 (117) hide show
  1. {witbitz_code-1.8.0/src/witbitz_code.egg-info → witbitz_code-1.9.1}/PKG-INFO +1 -1
  2. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/pyproject.toml +1 -1
  3. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/__init__.py +1 -1
  4. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/browse.py +47 -5
  5. witbitz_code-1.9.1/src/witbitz_code/browsetree.py +117 -0
  6. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/opencode_plugins/witbitz-browse-publisher.mjs +75 -12
  7. witbitz_code-1.9.1/src/witbitz_code/opencode_plugins/witbitz-browse-tree.mjs +303 -0
  8. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/ui.py +6 -3
  9. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/ui_static/app.css +1 -0
  10. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/ui_static/app.js +23 -0
  11. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/ui_static/index.html +9 -0
  12. {witbitz_code-1.8.0 → witbitz_code-1.9.1/src/witbitz_code.egg-info}/PKG-INFO +1 -1
  13. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code.egg-info/SOURCES.txt +3 -0
  14. witbitz_code-1.9.1/tests/test_browsetree.py +131 -0
  15. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/LICENSE +0 -0
  16. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/README.md +0 -0
  17. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/setup.cfg +0 -0
  18. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/__main__.py +0 -0
  19. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/_js.py +0 -0
  20. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/account.py +0 -0
  21. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/asks.py +0 -0
  22. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/attachments.py +0 -0
  23. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/auto.py +0 -0
  24. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/auto_runner.py +0 -0
  25. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/cli.py +0 -0
  26. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/codekeys.py +0 -0
  27. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/codetools.py +0 -0
  28. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/compress.py +0 -0
  29. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/computers.py +0 -0
  30. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/confidential.py +0 -0
  31. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/confidential_models.json +0 -0
  32. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/connector.py +0 -0
  33. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/devicelink.py +0 -0
  34. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/dirs.py +0 -0
  35. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/folders.py +0 -0
  36. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/gateway.py +0 -0
  37. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/icons/witbitz-code-wsl.ico +0 -0
  38. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/icons/witbitz-code.ico +0 -0
  39. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/images.py +0 -0
  40. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/keys.py +0 -0
  41. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/link.py +0 -0
  42. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/mkdir.py +0 -0
  43. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/models.py +0 -0
  44. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/net.py +0 -0
  45. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/offered_models.json +0 -0
  46. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
  47. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/opencode_plugins/witbitz-browse-cdp.mjs +0 -0
  48. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/opencode_plugins/witbitz-browse-local.mjs +0 -0
  49. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/opencode_plugins/witbitz-browser.js +0 -0
  50. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
  51. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
  52. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
  53. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/outputs.py +0 -0
  54. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/pair.py +0 -0
  55. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/pairings.py +0 -0
  56. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/payers.py +0 -0
  57. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/plugins.py +0 -0
  58. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/policy.py +0 -0
  59. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/ports.py +0 -0
  60. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/qr.py +0 -0
  61. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/receipt.py +0 -0
  62. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/relay.py +0 -0
  63. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/remind.py +0 -0
  64. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/seccheck.py +0 -0
  65. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/seen.py +0 -0
  66. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/selfupdate.py +0 -0
  67. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/tinfoil_read.py +0 -0
  68. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/tools_catalog.json +0 -0
  69. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/tools_probe.py +0 -0
  70. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/toolsetup.py +0 -0
  71. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/uitools.py +0 -0
  72. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/uninstall.py +0 -0
  73. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/winagent.py +0 -0
  74. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/winbg.py +0 -0
  75. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/winconsole.py +0 -0
  76. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/wizard.py +0 -0
  77. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code/wslstart.py +0 -0
  78. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
  79. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code.egg-info/entry_points.txt +0 -0
  80. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code.egg-info/requires.txt +0 -0
  81. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/src/witbitz_code.egg-info/top_level.txt +0 -0
  82. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/tests/test_asks.py +0 -0
  83. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/tests/test_attachments.py +0 -0
  84. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/tests/test_auto.py +0 -0
  85. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/tests/test_browse.py +0 -0
  86. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/tests/test_cli.py +0 -0
  87. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/tests/test_codekeys.py +0 -0
  88. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/tests/test_codetools.py +0 -0
  89. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/tests/test_confidential.py +0 -0
  90. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/tests/test_connector_e2e.py +0 -0
  91. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/tests/test_devicelink.py +0 -0
  92. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/tests/test_dirs.py +0 -0
  93. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/tests/test_files_and_compress.py +0 -0
  94. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/tests/test_folders.py +0 -0
  95. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/tests/test_images.py +0 -0
  96. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/tests/test_outputs.py +0 -0
  97. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/tests/test_pair_flows.py +0 -0
  98. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/tests/test_plugins.py +0 -0
  99. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/tests/test_policy.py +0 -0
  100. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/tests/test_ports.py +0 -0
  101. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/tests/test_ports_win.py +0 -0
  102. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/tests/test_registry.py +0 -0
  103. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/tests/test_relay_codec.py +0 -0
  104. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/tests/test_remind.py +0 -0
  105. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/tests/test_seccheck.py +0 -0
  106. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/tests/test_selfupdate.py +0 -0
  107. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/tests/test_setup.py +0 -0
  108. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/tests/test_tools_probe.py +0 -0
  109. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/tests/test_toolsetup.py +0 -0
  110. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/tests/test_ui.py +0 -0
  111. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/tests/test_uitools.py +0 -0
  112. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/tests/test_uninstall.py +0 -0
  113. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/tests/test_winagent.py +0 -0
  114. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/tests/test_winbg.py +0 -0
  115. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/tests/test_winconsole.py +0 -0
  116. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/tests/test_windows_files.py +0 -0
  117. {witbitz_code-1.8.0 → witbitz_code-1.9.1}/tests/test_wslstart.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: witbitz-code
3
- Version: 1.8.0
3
+ Version: 1.9.1
4
4
  Summary: Reach OpenCode on this computer from the Witbitz Spaces Code section, through an end-to-end sealed relay.
5
5
  Author: Witbitz
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "witbitz-code"
7
- version = "1.8.0"
7
+ version = "1.9.1"
8
8
  description = "Reach OpenCode on this computer from the Witbitz Spaces Code section, through an end-to-end sealed relay."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -4,4 +4,4 @@ The Python implementation of tools/witbitz-code.mjs (docs/opencode-relay.md phas
4
4
  vectors and cross-implementation tests.
5
5
  """
6
6
 
7
- __version__ = "1.8.0"
7
+ __version__ = "1.9.1"
@@ -5,11 +5,16 @@ ON by default; `serve --no-browser` or WB_BROWSE_DISABLE=1 turns it off (and rem
5
5
  programs are JavaScript (the cloud computer's own: browse-local, the agent's CLI, the publisher), so they run with this
6
6
  computer's `node` — as the media tools do; without node the agent works without a browser. Each pairing gets a viewer key
7
7
  derived from its secret (relay.browse_key = codeRelay.js deriveBrowseKey), so the app can Watch and Take over.
8
+
9
+ On Linux with the CLOUD COMPUTER'S browser installed (browsetree.py — the page's card, or `witbitz-browse-tree.mjs install`)
10
+ it is that one instead: the image's own Chromium, KasmVNC, PulseAudio and publisher under bwrap, sealed to the FIRST
11
+ pairing's key (the lab build seals to one). WB_BROWSE_TREE=0 keeps the laptop browser.
8
12
  """
9
13
 
10
14
  from __future__ import annotations
11
15
 
12
16
  import asyncio
17
+ import json
13
18
  import os
14
19
  import re
15
20
  import shutil
@@ -20,8 +25,8 @@ from typing import Any, Callable
20
25
 
21
26
  import httpx
22
27
 
23
- from . import plugins
24
- from .relay import browse_key
28
+ from . import browsetree, plugins
29
+ from .relay import RELAY_URL, browse_key
25
30
 
26
31
  BROWSE_DIR = "witbitz-browse"
27
32
  PLUGIN_REL = "plugins/witbitz-browser.js"
@@ -80,13 +85,16 @@ def _free_port() -> int:
80
85
 
81
86
 
82
87
  class LocalBrowser:
83
- def __init__(self, proc: asyncio.subprocess.Process, ctl: int, env: dict, http: httpx.AsyncClient) -> None:
84
- self.proc, self.ctl, self.env, self._http = proc, ctl, env, http
88
+ def __init__(self, proc: asyncio.subprocess.Process, ctl: int, env: dict, http: httpx.AsyncClient, tree: str = "") -> None:
89
+ self.proc, self.ctl, self.env, self._http, self.tree = proc, ctl, env, http, tree
85
90
  self.last: dict | None = None
86
91
  self._task: asyncio.Task | None = None
87
92
 
88
93
  def probe(self) -> dict | None:
89
- """The browser's last /status — synchronous: the connector's hello must never wait on it."""
94
+ """The browser's last /status — synchronous: the connector's hello must never wait on it. The cloud computer's
95
+ browser answers as in the enclave; `local`/`cast` tell the app to watch it as this computer's, `tree` which image."""
96
+ if self.last is not None and self.tree:
97
+ return {**self.last, "local": True, "cast": True, "tree": self.tree}
90
98
  return self.last
91
99
 
92
100
  async def _poll(self) -> None:
@@ -124,6 +132,10 @@ async def start(pairings: list[dict], *, config_dir: Path | None = None, log: Ca
124
132
  log("witbitz-code: no node on this computer — the agent works without a browser (install Node.js 22+ to add one)")
125
133
  return None
126
134
  paths = await asyncio.to_thread(install, config_dir)
135
+ if browsetree.use_tree():
136
+ b = await _start_tree(node, paths, pairings, config_dir, log)
137
+ if b:
138
+ return b
127
139
  watchable = node_major(node) >= 22 # the publisher needs a global WebSocket
128
140
  keys = [browse_key(p["secret"]) for p in pairings if p.get("secret")] if watchable else []
129
141
  ctl, gate = _free_port(), _free_port()
@@ -143,3 +155,33 @@ async def start(pairings: list[dict], *, config_dir: Path | None = None, log: Ca
143
155
  log("witbitz-code: the agent's browser is ready on this computer"
144
156
  + (" — watch it from the Code section" if watchable else " (node is older than 22: the app cannot watch it)") + "; off: serve --no-browser")
145
157
  return b
158
+
159
+
160
+ async def _start_tree(node: str, paths: dict, pairings: list[dict], config_dir: Path | None, log: Callable[[str], Any]) -> LocalBrowser | None:
161
+ """The cloud computer's browser when it is installed here; None → the laptop browser."""
162
+ key = next((browse_key(p["secret"]) for p in pairings if p.get("secret")), "")
163
+ tree = await asyncio.to_thread(browsetree.script, config_dir)
164
+ st = await asyncio.to_thread(browsetree.read_status, node, tree)
165
+ if not key or not st.get("supported") or not st.get("installed"):
166
+ return None
167
+ proc = await asyncio.create_subprocess_exec(node, str(tree), "run", "--relay", RELAY_URL, "--parent", str(os.getpid()),
168
+ env={**os.environ, "WB_BROWSE_KEY": key}, stdin=asyncio.subprocess.DEVNULL,
169
+ stdout=asyncio.subprocess.PIPE)
170
+ try:
171
+ assert proc.stdout
172
+ ready = json.loads(await asyncio.wait_for(proc.stdout.readline(), 40))
173
+ except (ValueError, asyncio.TimeoutError, AssertionError):
174
+ ready = {}
175
+ if not ready.get("ready"):
176
+ log(f"witbitz-code: the cloud computer's browser did not start ({ready.get('error') or 'no answer'}) — using the laptop browser")
177
+ if proc.returncode is None:
178
+ proc.kill()
179
+ return None
180
+ env = {k: str(v) for k, v in ready.items() if k.startswith("WB_BROWSE_")}
181
+ env["WB_BROWSE_CDP_CLI"] = str(paths["browse-cdp.mjs"])
182
+ b = LocalBrowser(proc, int(env["WB_BROWSE_CTL"].rsplit(":", 1)[1]), env, httpx.AsyncClient(), tree=str(ready.get("tag") or ""))
183
+ await b._poll()
184
+ b._task = asyncio.ensure_future(b._every())
185
+ if st.get("current") is False:
186
+ log(f"witbitz-code: this release pins the cloud browser {st['pinned']['tag']} — update it on the witbitz-code page")
187
+ return b
@@ -0,0 +1,117 @@
1
+ """THE CLOUD COMPUTER'S BROWSER ON THIS LINUX COMPUTER — the page's card and serve's choice (the owner, 2026-10-19: "why isnt
2
+ it part of the pipx install? and why isnt it installed from the witbitz code webpage?").
3
+
4
+ The program is `witbitz-browse-tree.mjs` (tools/browse-tree/cli.mjs, bundled with this release's pin by tools/browse-embed.mjs)
5
+ run with this computer's node: `status` says what is pinned and installed, `install --json` downloads the pinned image's tree
6
+ (~650 MB, both sha256s checked before anything is unpacked) with progress lines, `run` starts it under bwrap. Nothing is
7
+ downloaded until the person presses Install; a release that pins a newer image shows it as an update.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import json
14
+ import os
15
+ import shutil
16
+ import subprocess
17
+ import sys
18
+ import time
19
+ from pathlib import Path
20
+ from typing import Any, Callable
21
+
22
+ from . import plugins
23
+
24
+ SCRIPT_REL = "witbitz-browse/browse-tree.mjs"
25
+ PACKAGED = "witbitz-browse-tree.mjs"
26
+
27
+
28
+ def script(config_dir: Path | None = None) -> Path:
29
+ """The program in OpenCode's config folder, beside the other browser files (an identical file is left alone)."""
30
+ target = (Path(config_dir) if config_dir else plugins.opencode_config_dir()) / SCRIPT_REL
31
+ text = plugins.packaged(PACKAGED)
32
+ try:
33
+ same = target.read_text(encoding="utf-8") == text
34
+ except (OSError, UnicodeDecodeError):
35
+ same = False
36
+ if not same:
37
+ target.parent.mkdir(parents=True, exist_ok=True)
38
+ target.write_text(text, encoding="utf-8", newline="")
39
+ return target
40
+
41
+
42
+ def read_status(node: str, path: Path) -> dict:
43
+ """`status` → {linux, needs, supported, pinned, installed, current, why}; {error} when it could not be asked."""
44
+ try:
45
+ r = subprocess.run([node, str(path), "status"], capture_output=True, text=True, timeout=20, check=False) # noqa: S603
46
+ return json.loads(r.stdout.strip().splitlines()[-1])
47
+ except (OSError, ValueError, IndexError, subprocess.SubprocessError) as e:
48
+ return {"error": f"could not check ({str(e)[:80]})"}
49
+
50
+
51
+ class Tree:
52
+ """The page's card: {shown, state: idle|installing|done|failed, step, pct, error, ...status}. Outside reaches are injected."""
53
+
54
+ def __init__(self, *, node: Callable[[], str | None] = lambda: shutil.which("node"),
55
+ script_path: Callable[[], Path] = script, read: Callable[[str, Path], dict] = read_status,
56
+ platform: str = sys.platform) -> None:
57
+ self._node, self._script, self._read, self._platform = node, script_path, read, platform
58
+ self.state, self.step, self.pct, self.error, self.tag = "idle", "", 0.0, "", ""
59
+ self.task: asyncio.Task | None = None
60
+ self._cache: tuple[float, dict] | None = None
61
+
62
+ def status(self) -> dict:
63
+ if not self._platform.startswith("linux"):
64
+ return {"shown": False}
65
+ live = {"state": self.state, "step": self.step, "pct": self.pct, "error": self.error, "tag": self.tag}
66
+ if self._cache and time.monotonic() - self._cache[0] < 15 and self.state != "installing":
67
+ return {**self._cache[1], **live}
68
+ node = self._node()
69
+ if not node:
70
+ st = {"shown": True, "supported": False, "needs": ["Node.js 22.15+"]}
71
+ else:
72
+ try:
73
+ st = {"shown": True, **self._read(node, self._script())}
74
+ except OSError as e:
75
+ st = {"shown": True, "error": f"could not check ({str(e)[:80]})"}
76
+ self._cache = (time.monotonic(), st)
77
+ return {**st, **live}
78
+
79
+ async def install(self) -> dict:
80
+ if self.task and not self.task.done():
81
+ return {"ok": True}
82
+ node = self._node()
83
+ if not node or not self._platform.startswith("linux"):
84
+ return {"ok": False, "why": "the cloud computer's browser needs Linux and Node.js"}
85
+ self.state, self.step, self.pct, self.error = "installing", "download", 0.0, ""
86
+ self.task = asyncio.ensure_future(self._install(node))
87
+ return {"ok": True}
88
+
89
+ async def _install(self, node: str) -> None:
90
+ try:
91
+ proc = await asyncio.create_subprocess_exec(node, str(self._script()), "install", "--json", stdin=asyncio.subprocess.DEVNULL,
92
+ stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL)
93
+ assert proc.stdout
94
+ async for raw in proc.stdout:
95
+ try:
96
+ m: dict[str, Any] = json.loads(raw)
97
+ except ValueError:
98
+ continue
99
+ if m.get("error"):
100
+ self.error = str(m["error"])[:300]
101
+ elif m.get("ok"):
102
+ self.tag = str(m.get("tag") or "")
103
+ else:
104
+ self.step, self.pct = str(m.get("step") or ""), float(m.get("pct") or 0)
105
+ await proc.wait()
106
+ if proc.returncode and not self.error:
107
+ self.error = f"the install stopped ({proc.returncode})"
108
+ except (OSError, asyncio.CancelledError) as e:
109
+ self.error = self.error or f"could not run it ({str(e)[:120]})"
110
+ finally:
111
+ self._cache = None
112
+ self.state = "failed" if self.error else "done"
113
+
114
+
115
+ def use_tree(env: dict | None = None, platform: str = sys.platform) -> bool:
116
+ env = os.environ if env is None else env
117
+ return platform.startswith("linux") and str(env.get("WB_BROWSE_TREE", "")) != "0"
@@ -258,16 +258,28 @@ function createCast({ cdpHttp, onFrame, onState = () => {
258
258
  const sh = c.h + BARS(c.h);
259
259
  await onPage("Emulation.setDeviceMetricsOverride", { width: c.w, height: pageFull ? sh : c.h, deviceScaleFactor: c.dpr, mobile, screenWidth: c.w, screenHeight: sh });
260
260
  await onPage("Emulation.setTouchEmulationEnabled", mobile ? { enabled: true, maxTouchPoints: 5 } : { enabled: false });
261
- if (desktopUA) await onPage("Emulation.setUserAgentOverride", { userAgent: mobile ? phoneUA(desktopUA) : desktopUA });
261
+ if (desktopUA) await onPage("Emulation.setUserAgentOverride", uaOverride(desktopUA, await realHints(), mobile));
262
262
  } else {
263
263
  await onPage("Emulation.clearDeviceMetricsOverride").catch(() => {
264
264
  });
265
265
  await onPage("Emulation.setTouchEmulationEnabled", { enabled: false }).catch(() => {
266
266
  });
267
- if (desktopUA) await onPage("Emulation.setUserAgentOverride", { userAgent: desktopUA }).catch(() => {
267
+ if (desktopUA) await onPage("Emulation.setUserAgentOverride", uaOverride(desktopUA, await realHints(), false)).catch(() => {
268
268
  });
269
269
  }
270
270
  }
271
+ let hintsOf = null;
272
+ async function realHints() {
273
+ if (hintsOf) return hintsOf;
274
+ const r = await onPage("Runtime.evaluate", { expression: HINTS_NOW, returnByValue: true, awaitPromise: true }).catch(() => null);
275
+ const v = r && r.result && r.result.value;
276
+ if (v && Array.isArray(v.brands) && v.brands.length) hintsOf = v;
277
+ if (!hintsOf) {
278
+ const b = await send("Browser.getVersion").catch(() => null);
279
+ hintsOf = hintsFromVersion(b && b.product, desktopUA);
280
+ }
281
+ return hintsOf;
282
+ }
271
283
  async function hookFullscreen() {
272
284
  await onPage("Runtime.enable");
273
285
  await onPage("Runtime.addBinding", { name: FS_BINDING });
@@ -467,6 +479,20 @@ function norm(v) {
467
479
  return { w, h, dpr: dpr >= 1 && dpr <= 4 ? dpr : 1, mobile: !!v.mobile };
468
480
  }
469
481
  var PHONE = { w: 412, h: 915, dpr: 2, mobile: true };
482
+ var HINTS_NOW = `(async () => { const d = navigator.userAgentData; if (!d) return null; let h = {}; try { h = await d.getHighEntropyValues(['platformVersion', 'architecture', 'bitness', 'model', 'fullVersionList', 'wow64']) } catch (e) {} return { brands: d.brands, fullVersionList: h.fullVersionList || d.brands, platform: d.platform, platformVersion: h.platformVersion || '', architecture: h.architecture || '', bitness: h.bitness || '', model: h.model || '', wow64: !!h.wow64 } })()`;
483
+ function hintsFromVersion(product, ua = "") {
484
+ const full = (/\/(\d+(?:\.\d+){1,3})/.exec(String(product || "")) || [])[1];
485
+ if (!full) return null;
486
+ const major = full.split(".")[0];
487
+ const platform = /Windows/.test(ua) ? "Windows" : /Mac OS X/.test(ua) ? "macOS" : /CrOS/.test(ua) ? "Chrome OS" : "Linux";
488
+ return { brands: [{ brand: "Chromium", version: major }, { brand: "Not)A;Brand", version: "99" }], fullVersionList: [{ brand: "Chromium", version: full }, { brand: "Not)A;Brand", version: "99.0.0.0" }], platform, platformVersion: "", architecture: /arm|aarch64/i.test(ua) ? "arm" : "x86", bitness: "64", model: "", wow64: false };
489
+ }
490
+ function uaOverride(desktop, real, mobile) {
491
+ const userAgent = mobile ? phoneUA(desktop) : desktop;
492
+ if (!real || !Array.isArray(real.brands) || !real.brands.length) return { userAgent };
493
+ const meta = { brands: real.brands, fullVersionList: real.fullVersionList || real.brands, platform: real.platform || "", platformVersion: real.platformVersion || "", architecture: real.architecture || "", model: real.model || "", mobile: false, bitness: real.bitness || "", wow64: !!real.wow64 };
494
+ return mobile ? { userAgent, platform: "Linux armv8l", userAgentMetadata: { ...meta, platform: "Android", platformVersion: "14.0.0", architecture: "", bitness: "", model: "Pixel 8", mobile: true, wow64: false } } : { userAgent, userAgentMetadata: meta };
495
+ }
470
496
  function phoneUA(desktop) {
471
497
  return desktop.replace(/\([^)]*\)/, "(Linux; Android 14; Pixel 8)").replace(/ Safari\//, " Mobile Safari/");
472
498
  }
@@ -476,6 +502,7 @@ var BINDING = "__wbm";
476
502
  var MAX_BATCH = 8 << 20;
477
503
  var MAX_SINCE_SNAPSHOT = 48 << 20;
478
504
  var ASSET_CACHE = 96 << 20;
505
+ var MAX_ANSWER = 2900 << 10;
479
506
  var MAX_SIDE2 = 1600;
480
507
  var STILL_MS = 500;
481
508
  var STILL_SIDE = 480;
@@ -607,12 +634,19 @@ function createMirrors({ liveHttp, mirrorHttp, rrwebUmd, onFrame, log: log2 = ()
607
634
  for (const h of c.handlers) h(m);
608
635
  };
609
636
  ws.onclose = () => {
637
+ c.closed = true;
638
+ log2("mirror: the CDP connection to", http, "closed");
610
639
  for (const p of c.pending.values()) p.reject(new Error("cdp: closed"));
611
640
  c.pending.clear();
612
641
  };
613
642
  return c;
614
643
  }
615
644
  async function attachLive() {
645
+ if (live && live.closed) {
646
+ live = null;
647
+ liveSid = null;
648
+ liveTargetId = null;
649
+ }
616
650
  const t = liveTarget();
617
651
  if (!t || t === liveTargetId) return;
618
652
  if (!live) {
@@ -698,8 +732,14 @@ function createMirrors({ liveHttp, mirrorHttp, rrwebUmd, onFrame, log: log2 = ()
698
732
  body: body ? Buffer.from(body).toString("base64") : ""
699
733
  }, tab.sid).catch(() => {
700
734
  });
735
+ const tooBig = (b) => b && Buffer.byteLength(b) > MAX_ANSWER;
736
+ const send = (status, headers, body) => tooBig(body) ? (tab.refused++, log2("mirror: not sent,", Math.round(Buffer.byteLength(body) / 1024), "KB \u2014", url.slice(0, 100)), done(404, {}, "")) : done(status, headers, body);
701
737
  if (url === "http://mirror.invalid/") return done(200, { "content-type": "text/html; charset=utf-8" }, shell);
702
738
  if (p.request.method !== "GET" || !/^https?:\/\//.test(url)) return done(404, {}, "");
739
+ if (p.resourceType === "Media") {
740
+ tab.refused++;
741
+ return done(404, {}, "");
742
+ }
703
743
  if (p.resourceType === "Document") {
704
744
  let host = "";
705
745
  try {
@@ -714,14 +754,14 @@ function createMirrors({ liveHttp, mirrorHttp, rrwebUmd, onFrame, log: log2 = ()
714
754
  try {
715
755
  const r = await live.send("Network.getResponseBody", { requestId: known.requestId }, liveSid);
716
756
  tab.served++;
717
- return done(known.status || 200, { "content-type": known.mime || "application/octet-stream" }, r.base64Encoded ? Buffer.from(r.body, "base64") : r.body);
757
+ return send(known.status || 200, { "content-type": known.mime || "application/octet-stream" }, r.base64Encoded ? Buffer.from(r.body, "base64") : r.body);
718
758
  } catch {
719
759
  }
720
760
  }
721
761
  if (assets.has(url)) {
722
762
  tab.served++;
723
763
  const a = assets.get(url);
724
- return done(a.status, a.headers, a.body);
764
+ return send(a.status, a.headers, a.body);
725
765
  }
726
766
  if (!STATIC.has(p.resourceType) || !liveSid) {
727
767
  tab.refused++;
@@ -730,7 +770,7 @@ function createMirrors({ liveHttp, mirrorHttp, rrwebUmd, onFrame, log: log2 = ()
730
770
  try {
731
771
  const a = await loadCredentialless(url);
732
772
  tab.loaded++;
733
- return done(a.status, a.headers, a.body);
773
+ return send(a.status, a.headers, a.body);
734
774
  } catch (e) {
735
775
  tab.refused++;
736
776
  log2("mirror: could not load", url.slice(0, 100), "\u2014", String(e.message || e).slice(0, 120));
@@ -816,7 +856,7 @@ function createMirrors({ liveHttp, mirrorHttp, rrwebUmd, onFrame, log: log2 = ()
816
856
  });
817
857
  }
818
858
  async function open2(stream, shape) {
819
- if (!mirror) {
859
+ if (!mirror || mirror.closed) {
820
860
  mirror = await connect(mirrorHttp);
821
861
  mirror.handlers.push(onMirror);
822
862
  }
@@ -881,6 +921,10 @@ function createMirrors({ liveHttp, mirrorHttp, rrwebUmd, onFrame, log: log2 = ()
881
921
  /** Make the set of mirrors equal `wanted` (stream → shape). Opens, reshapes and closes as needed. */
882
922
  async set(wanted) {
883
923
  await attachLive();
924
+ if (mirror && mirror.closed) {
925
+ mirror = null;
926
+ tabs.clear();
927
+ }
884
928
  for (const [stream] of tabs) if (!wanted.has(stream)) await close(stream);
885
929
  for (const [stream, shape] of wanted) {
886
930
  const tab = tabs.get(stream);
@@ -945,6 +989,7 @@ var tellControl = async (path, body) => {
945
989
  }
946
990
  };
947
991
  var said = null;
992
+ var holderVid = null;
948
993
  var tellTheViewerWhoHasTheControls = async (why) => {
949
994
  let st = null;
950
995
  try {
@@ -954,16 +999,17 @@ var tellTheViewerWhoHasTheControls = async (why) => {
954
999
  }
955
1000
  held = !!st.takeover;
956
1001
  if (!held && cast && cast.incognito && cast.incognito()) closeIncognito("the controls lapsed");
1002
+ if (!held) holderVid = null;
957
1003
  if (!held && cast && cast.controller()) {
958
1004
  for (const v of castViewers.values()) v.driving = false;
959
1005
  cast.setController(null).then(assign).catch(() => {
960
1006
  });
961
1007
  }
962
- const now = JSON.stringify({ on: !!st.takeover, web: !!st.web });
1008
+ const now = JSON.stringify({ on: !!st.takeover, web: !!st.web, holder: holderVid });
963
1009
  if (why === "poll" && now === said) return;
964
1010
  said = now;
965
1011
  if (relay && relay.readyState === 1) {
966
- relay.send(await sealCtl(key, { wb: "takeover", on: !!st.takeover, web: !!st.web, audio: !!PULSE, ack: true, cast: !!CDP, site: siteNow(), incognito: !!(cast && cast.incognito && cast.incognito()) }));
1012
+ relay.send(await sealCtl(key, { wb: "takeover", on: !!st.takeover, web: !!st.web, ...st.takeover && holderVid ? { holder: holderVid } : {}, audio: !!PULSE, ack: true, cast: !!CDP, site: siteNow(), incognito: !!(cast && cast.incognito && cast.incognito()) }));
967
1013
  }
968
1014
  log(
969
1015
  why === "poll" ? "the state changed \u2014" : "a viewer arrived \u2014",
@@ -1223,7 +1269,22 @@ async function assignNow() {
1223
1269
  } catch {
1224
1270
  }
1225
1271
  }
1226
- if (mirrors) await mirrors.set(wanted);
1272
+ if (!mirrors) return;
1273
+ try {
1274
+ await mirrors.set(wanted);
1275
+ } catch (e) {
1276
+ const why = "mirror could not open: " + String(e.message || e).slice(0, 80);
1277
+ for (const [vid, s] of assigned) {
1278
+ if (!wanted.has(s)) continue;
1279
+ assigned.set(vid, "live");
1280
+ if (relay && relay.readyState === 1) relay.send(await sealCtl(key, { wb: "stream", vid, stream: "live", why }));
1281
+ }
1282
+ const l = cast && cast.last();
1283
+ if (l) outOrder(async () => {
1284
+ if (relay && relay.readyState === 1) relay.send(await seal(key, KIND_FRAME, packFrame(l.header, l.jpeg)));
1285
+ });
1286
+ throw e;
1287
+ }
1227
1288
  }
1228
1289
  async function startCast() {
1229
1290
  const c = ensureCast();
@@ -1327,12 +1388,13 @@ function connectRelay() {
1327
1388
  if (!c.on) await closeIncognito("the controls were given back");
1328
1389
  await tellControl("/takeover", { on: !!c.on });
1329
1390
  held = !!c.on;
1391
+ holderVid = c.on && typeof c.vid === "string" ? c.vid.slice(0, 64) : null;
1330
1392
  log("take-over", c.on ? "ON \u2014 the agent's CDP session is being closed" : "released");
1331
1393
  if (cast) await cast.setController(c.on ? norm(c.view) || largestView() : null).catch(() => {
1332
1394
  });
1333
1395
  for (const [vid, v] of castViewers) v.driving = !!c.on && vid === c.vid;
1334
1396
  if (cast) assign();
1335
- if (relay && relay.readyState === 1) relay.send(await sealCtl(key, { wb: "takeover", on: !!c.on, audio: !!PULSE, ack: true, cast: !!cast, site: siteNow(), incognito: !!(cast && cast.incognito && cast.incognito()), close: true }));
1397
+ if (relay && relay.readyState === 1) relay.send(await sealCtl(key, { wb: "takeover", on: !!c.on, ...holderVid ? { holder: holderVid } : {}, audio: !!PULSE, ack: true, cast: !!cast, site: siteNow(), incognito: !!(cast && cast.incognito && cast.incognito()), close: true }));
1336
1398
  } else if (c.wb === "incognito" && cast) {
1337
1399
  if (c.on && !held) {
1338
1400
  log("incognito: refused \u2014 nobody holds the controls");
@@ -1357,12 +1419,13 @@ function connectRelay() {
1357
1419
  if (relay && relay.readyState === 1) relay.send(await sealCtl(key, { wb: "site", mode }));
1358
1420
  } else if (c.wb === "hello" && c.cast && typeof c.vid === "string" && CDP) {
1359
1421
  const known = castViewers.has(c.vid);
1360
- castViewers.set(c.vid.slice(0, 64), { view: norm(c.view), at: Date.now(), driving: !!(held && c.driving) });
1422
+ const mine = held && c.driving && (!holderVid || c.vid.slice(0, 64) === holderVid);
1423
+ castViewers.set(c.vid.slice(0, 64), { view: norm(c.view), at: Date.now(), driving: !!mine });
1361
1424
  if (castViewers.size > 8) castViewers.delete(castViewers.keys().next().value);
1362
1425
  if (!known) log("cast: a viewer said hello \u2014", JSON.stringify(norm(c.view)));
1363
1426
  if (relay && relay.readyState === 1) relay.send(await sealCtl(key, { wb: "cast", on: true, close: true }));
1364
1427
  await startCast();
1365
- if (held && cast && c.driving) await cast.setController(norm(c.view)).catch(() => {
1428
+ if (mine && cast) await cast.setController(norm(c.view)).catch(() => {
1366
1429
  });
1367
1430
  assign();
1368
1431
  } else if (c.wb === "in" && cast) {