witbitz-code 1.5.0__tar.gz → 1.6.0__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.
- {witbitz_code-1.5.0/src/witbitz_code.egg-info → witbitz_code-1.6.0}/PKG-INFO +1 -1
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/pyproject.toml +1 -1
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/__init__.py +1 -1
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/cli.py +14 -5
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/ui.py +88 -10
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/ui_static/app.css +2 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/ui_static/app.js +52 -3
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/ui_static/index.html +12 -1
- witbitz_code-1.6.0/src/witbitz_code/uitools.py +108 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/uninstall.py +3 -0
- witbitz_code-1.6.0/src/witbitz_code/winbg.py +97 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/wizard.py +15 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/wslstart.py +62 -9
- {witbitz_code-1.5.0 → witbitz_code-1.6.0/src/witbitz_code.egg-info}/PKG-INFO +1 -1
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code.egg-info/SOURCES.txt +4 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_setup.py +22 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_ui.py +71 -0
- witbitz_code-1.6.0/tests/test_uitools.py +71 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_uninstall.py +5 -3
- witbitz_code-1.6.0/tests/test_winbg.py +71 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_wslstart.py +25 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/LICENSE +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/README.md +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/setup.cfg +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/__main__.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/_js.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/account.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/asks.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/attachments.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/auto.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/auto_runner.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/codekeys.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/codetools.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/compress.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/computers.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/confidential.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/confidential_models.json +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/connector.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/devicelink.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/dirs.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/folders.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/gateway.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/keys.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/link.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/mkdir.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/models.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/net.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/offered_models.json +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/outputs.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/pair.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/pairings.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/payers.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/plugins.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/policy.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/ports.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/qr.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/receipt.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/relay.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/seen.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/tinfoil_read.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/tools_catalog.json +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/tools_probe.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/toolsetup.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/winagent.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code/winconsole.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code.egg-info/entry_points.txt +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code.egg-info/requires.txt +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/src/witbitz_code.egg-info/top_level.txt +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_asks.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_attachments.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_auto.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_cli.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_codekeys.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_codetools.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_confidential.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_connector_e2e.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_devicelink.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_dirs.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_files_and_compress.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_folders.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_outputs.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_pair_flows.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_plugins.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_policy.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_ports.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_ports_win.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_registry.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_relay_codec.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_tools_probe.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_toolsetup.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_winagent.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_winconsole.py +0 -0
- {witbitz_code-1.5.0 → witbitz_code-1.6.0}/tests/test_windows_files.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "witbitz-code"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.6.0"
|
|
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"
|
|
@@ -26,17 +26,20 @@ import httpx
|
|
|
26
26
|
from . import __version__
|
|
27
27
|
from . import pair as pair_cmd
|
|
28
28
|
from . import pairings as pp
|
|
29
|
-
from . import codekeys, codetools, confidential, keys, models, plugins, policy, ports, toolsetup, uninstall as uninstall_mod, winagent, winconsole, wizard
|
|
29
|
+
from . import codekeys, codetools, confidential, keys, models, plugins, policy, ports, toolsetup, uninstall as uninstall_mod, winagent, winbg, winconsole, wizard, wslstart
|
|
30
30
|
from ._js import to_number, truthy
|
|
31
31
|
from .connector import pairings_for_port, start_connector
|
|
32
32
|
from .payers import SessionPayers
|
|
33
33
|
|
|
34
34
|
HELP = f"""witbitz-code {__version__} — reach OpenCode on this computer from the Spaces Code section, end-to-end encrypted.
|
|
35
35
|
|
|
36
|
-
setup [--port <n>]
|
|
37
|
-
keys from your Spaces account — then serves
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
setup [--port <n>] [--here] start here: installs OpenCode if needed, pairs this computer — which gives it its own
|
|
37
|
+
keys from your Spaces account — then serves. Windows and WSL: after OpenCode, the page
|
|
38
|
+
opens and does the rest, in the background (--here: every step in this terminal)
|
|
39
|
+
ui [--no-browser] [--background]
|
|
40
|
+
the same in a page in your browser: status, pair / unpair / pair again, start and stop,
|
|
41
|
+
start when I log in (Windows and WSL); it serves while it runs. --background (Windows):
|
|
42
|
+
no window — the page's Quit stops it
|
|
40
43
|
pair [--name <name>] show a QR code; scan it in Spaces (Settings → Back up & recovery → Add a device)
|
|
41
44
|
serve [--port <n>] [--no-opencode]
|
|
42
45
|
start OpenCode on 127.0.0.1 with your account's keys, and the connector
|
|
@@ -327,6 +330,8 @@ async def setup(args: list[str]) -> int:
|
|
|
327
330
|
keys_state=keys_state,
|
|
328
331
|
run_tools=lambda plan: toolsetup.run_plan(plan, say=_err), refresh_path=refresh_path,
|
|
329
332
|
is_listening=is_listening, serve_here=lambda p: serve(["--port", str(p)]),
|
|
333
|
+
background_ui=None if "--here" in args else (lambda: winbg.launch_ui()) if sys.platform == "win32"
|
|
334
|
+
else (lambda: wslstart.launch_background()) if ports.on_wsl() else None,
|
|
330
335
|
)
|
|
331
336
|
try:
|
|
332
337
|
r = await wizard.run_setup(d)
|
|
@@ -392,6 +397,10 @@ async def ui_command(args: list[str]) -> int:
|
|
|
392
397
|
start when I log in. It runs serve inside itself; --no-browser serves without opening the page (a login start)."""
|
|
393
398
|
from . import net, ui # noqa: PLC0415
|
|
394
399
|
|
|
400
|
+
if "--background" in args: # Windows: hand off to a windowless copy (winbg.py) and return — this window may close
|
|
401
|
+
ok, why = winbg.launch_ui([a for a in args if a != "--background"])
|
|
402
|
+
_err("witbitz-code: running in the background — it opens in your browser" if ok else f"witbitz-code: could not start it in the background: {why}")
|
|
403
|
+
return 0 if ok else 1
|
|
395
404
|
http = net.new_client()
|
|
396
405
|
keys = pair_cmd._KeyCalls(http)
|
|
397
406
|
try:
|
|
@@ -35,7 +35,7 @@ from importlib import resources
|
|
|
35
35
|
from pathlib import Path
|
|
36
36
|
from typing import Any, Awaitable, Callable
|
|
37
37
|
|
|
38
|
-
from . import __version__, devicelink, link, net, ports, qr, wizard, wslstart
|
|
38
|
+
from . import __version__, devicelink, link, net, ports, qr, uitools, uninstall, winbg, wizard, wslstart
|
|
39
39
|
from . import pair as pair_cmd
|
|
40
40
|
from . import pairings as pp
|
|
41
41
|
|
|
@@ -91,7 +91,7 @@ class _Tee(io.TextIOBase):
|
|
|
91
91
|
while "\n" in self._part:
|
|
92
92
|
line, self._part = self._part.split("\n", 1)
|
|
93
93
|
if line.strip() and "\x1b[" not in line:
|
|
94
|
-
self.sink.append(line.rstrip()[:
|
|
94
|
+
self.sink.append(line.rstrip()[:600])
|
|
95
95
|
return len(s)
|
|
96
96
|
|
|
97
97
|
def flush(self) -> None:
|
|
@@ -122,9 +122,14 @@ class Manager:
|
|
|
122
122
|
login_state: Callable[[dict], str] = wslstart.startup_state,
|
|
123
123
|
login_enable: Callable[[dict], tuple] = wslstart.startup_enable,
|
|
124
124
|
login_disable: Callable[[dict], bool] = wslstart.startup_disable,
|
|
125
|
-
|
|
125
|
+
port_owners: Callable[[int], list] | None = None, stop_pid: Callable[[int], bool] | None = None,
|
|
126
|
+
tools: uitools.Tools | None = None, label: dict | None = None, log: deque | None = None) -> None:
|
|
126
127
|
self._serve, self._is_listening, self._pair, self._link = serve, is_listening, pair, link_node
|
|
127
128
|
self._load, self._keys_line = load_pairings, keys_line
|
|
129
|
+
self.tools = tools or uitools.Tools()
|
|
130
|
+
self._owners = port_owners or ports.port_owners
|
|
131
|
+
self._stop_pid = stop_pid or uninstall.stop_process
|
|
132
|
+
self._elsewhere_cache: tuple[float, dict | None] | None = None
|
|
128
133
|
self._login_spec, self._login_state, self._login_enable, self._login_disable = login_spec, login_state, login_enable, login_disable
|
|
129
134
|
self.label = label or computer_label()
|
|
130
135
|
self.log: deque = log if log is not None else deque(maxlen=60)
|
|
@@ -139,6 +144,8 @@ class Manager:
|
|
|
139
144
|
self._keys_cache: dict[str, tuple[float, str]] = {}
|
|
140
145
|
self._login_cache: tuple[float, dict] | None = None
|
|
141
146
|
self.busy = "" # the action running now (the page greys its buttons)
|
|
147
|
+
self.background = False # Windows, no window (winbg.py): the page's Quit is the only way to stop it
|
|
148
|
+
self.quit_event: asyncio.Event | None = None
|
|
142
149
|
|
|
143
150
|
# ── reading ────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
144
151
|
def pairings(self) -> list:
|
|
@@ -196,15 +203,67 @@ class Manager:
|
|
|
196
203
|
for p in ps:
|
|
197
204
|
paired.append({"name": p.get("name") or "", "account": p.get("account") or "", "port": wizard.port_of(p), "keys": await self.keys(p)})
|
|
198
205
|
pair = {k: v for k, v in self.pair_state.items() if k != "log"}
|
|
199
|
-
|
|
200
|
-
|
|
206
|
+
serving = self.serving()
|
|
207
|
+
if serving["state"] == "stopped" and ps:
|
|
208
|
+
el = await self.elsewhere()
|
|
209
|
+
if el:
|
|
210
|
+
serving = {"state": "elsewhere", "port": el["port"], "owners": el["owners"]}
|
|
211
|
+
return {"version": __version__, "computer": self.label, "paired": paired, "serving": serving, "pair": pair,
|
|
212
|
+
"login": self.login(), "busy": self.busy, "background": self.background,
|
|
213
|
+
"tools": await asyncio.to_thread(self.tools.status), "log": list(self.log)[-14:]}
|
|
214
|
+
|
|
215
|
+
async def elsewhere(self, fresh: bool = False) -> dict | None:
|
|
216
|
+
"""{port, owners} when THIS person's other process holds OpenCode's port or the model proxy's (port + 100) while this
|
|
217
|
+
page serves nothing — `witbitz-code setup` serving in its own window, a login-started copy, an earlier serve (the owner,
|
|
218
|
+
2026-10-09: the installer's setup was serving and the page said Stopped; its Start then failed "held by python.exe").
|
|
219
|
+
Another user's program, or WSL's copy (Windows cannot name it), is not listed: Start then fails and says why."""
|
|
220
|
+
if self.serve_task and not self.serve_task.done():
|
|
221
|
+
return None
|
|
222
|
+
hit = self._elsewhere_cache
|
|
223
|
+
if hit and not fresh and time.monotonic() - hit[0] < 5:
|
|
224
|
+
return hit[1]
|
|
225
|
+
port, owners = self.port(), []
|
|
226
|
+
for q in (port, port + 100):
|
|
227
|
+
if not await self._is_listening(q):
|
|
228
|
+
continue
|
|
229
|
+
try:
|
|
230
|
+
found = await asyncio.to_thread(self._owners, q)
|
|
231
|
+
except Exception: # noqa: BLE001 — a reading, never a failure of the page
|
|
232
|
+
found = []
|
|
233
|
+
for o in found:
|
|
234
|
+
if all(o["pid"] != x["pid"] for x in owners):
|
|
235
|
+
owners.append({"pid": int(o["pid"]), "cmd": str(o.get("cmd") or "")[:80]})
|
|
236
|
+
out = {"port": port, "owners": owners} if owners else None
|
|
237
|
+
self._elsewhere_cache = (time.monotonic(), out)
|
|
238
|
+
return out
|
|
239
|
+
|
|
240
|
+
async def _stop_elsewhere(self, el: dict) -> list[str]:
|
|
241
|
+
"""Stop what elsewhere() named, then wait (≤ 6 s) for both ports to come free. The lines that say what happened."""
|
|
242
|
+
said = []
|
|
243
|
+
for o in el["owners"]:
|
|
244
|
+
ok = await asyncio.to_thread(self._stop_pid, o["pid"])
|
|
245
|
+
said.append(f"{'stopped' if ok else 'could not stop'} {o['cmd'] or 'a program'} (process {o['pid']})")
|
|
246
|
+
self.log.append("witbitz-code: " + said[-1])
|
|
247
|
+
for _ in range(30):
|
|
248
|
+
if not [q for q in (el["port"], el["port"] + 100) if await self._is_listening(q)]:
|
|
249
|
+
break
|
|
250
|
+
await asyncio.sleep(0.2)
|
|
251
|
+
self._elsewhere_cache = None
|
|
252
|
+
return said
|
|
201
253
|
|
|
202
254
|
# ── serving ────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
203
|
-
async def start_serving(self) -> dict:
|
|
255
|
+
async def start_serving(self, takeover: bool = False) -> dict:
|
|
256
|
+
"""Serve from this page. Another copy of witbitz-code already serving here: the page's Start (takeover) stops it
|
|
257
|
+
and runs here instead; the launch's own start leaves it alone (the page then shows where it runs)."""
|
|
204
258
|
if self.serve_task and not self.serve_task.done():
|
|
205
259
|
return {"ok": True}
|
|
206
260
|
if not self.pairings():
|
|
207
261
|
return {"ok": False, "why": "pair this computer first"}
|
|
262
|
+
el = await self.elsewhere(fresh=True)
|
|
263
|
+
if el and not takeover:
|
|
264
|
+
return {"ok": False, "why": "already running in another window", "elsewhere": el}
|
|
265
|
+
if el:
|
|
266
|
+
await self._stop_elsewhere(el)
|
|
208
267
|
self.serve_stop, self.serve_exit, self.live = _StopFlag(), None, {}
|
|
209
268
|
self.serve_port = self.port()
|
|
210
269
|
self.serve_task = asyncio.ensure_future(self._serve_run(self.serve_port))
|
|
@@ -216,7 +275,7 @@ class Manager:
|
|
|
216
275
|
except asyncio.CancelledError:
|
|
217
276
|
raise
|
|
218
277
|
except Exception as e: # noqa: BLE001
|
|
219
|
-
self.serve_exit = {"code": 1, "why": str(e)[:
|
|
278
|
+
self.serve_exit = {"code": 1, "why": str(e)[:500]}
|
|
220
279
|
return 1
|
|
221
280
|
last = next((ln for ln in reversed(self.log) if ln.startswith("witbitz-code:")), "")
|
|
222
281
|
self.serve_exit = {"code": code, "why": "" if code == 0 else last.replace("witbitz-code: ", "", 1)}
|
|
@@ -225,6 +284,9 @@ class Manager:
|
|
|
225
284
|
async def stop_serving(self) -> dict:
|
|
226
285
|
t = self.serve_task
|
|
227
286
|
if t is None or t.done():
|
|
287
|
+
el = await self.elsewhere(fresh=True)
|
|
288
|
+
if el:
|
|
289
|
+
return {"ok": True, "said": await self._stop_elsewhere(el)}
|
|
228
290
|
return {"ok": True}
|
|
229
291
|
self.serve_stop.event.set()
|
|
230
292
|
try:
|
|
@@ -323,6 +385,13 @@ class Manager:
|
|
|
323
385
|
self._login_cache = None
|
|
324
386
|
return {"ok": bool(ok), **({"why": why} if not ok else {})}
|
|
325
387
|
|
|
388
|
+
async def quit(self) -> dict:
|
|
389
|
+
"""Stop serving and end this manager (the page's Quit — in the background there is no window to close)."""
|
|
390
|
+
await self.stop_serving()
|
|
391
|
+
if self.quit_event:
|
|
392
|
+
asyncio.get_running_loop().call_later(0.3, self.quit_event.set) # after this answer reaches the page
|
|
393
|
+
return {"ok": True}
|
|
394
|
+
|
|
326
395
|
async def shutdown(self) -> None:
|
|
327
396
|
if self.pair_task and not self.pair_task.done():
|
|
328
397
|
self.pair_task.cancel()
|
|
@@ -455,11 +524,13 @@ class Server:
|
|
|
455
524
|
return 400, {"content-type": "application/json"}, b'{"error":"not json"}'
|
|
456
525
|
data = data if isinstance(data, dict) else {}
|
|
457
526
|
actions: dict[str, Callable[[], Awaitable[dict]]] = {
|
|
458
|
-
"/api/serve": lambda: self.m.start_serving() if data.get("on") else self.m.stop_serving(),
|
|
527
|
+
"/api/serve": lambda: self.m.start_serving(takeover=True) if data.get("on") else self.m.stop_serving(),
|
|
459
528
|
"/api/pair": self.m.start_pairing,
|
|
460
529
|
"/api/pair/cancel": self.m.cancel_pairing,
|
|
461
530
|
"/api/unpair": lambda: self.m.unpair(str(data.get("account") or "")),
|
|
462
531
|
"/api/login": lambda: self.m.set_login(bool(data.get("on"))),
|
|
532
|
+
"/api/quit": self.m.quit,
|
|
533
|
+
"/api/tools/install": self.m.tools.install,
|
|
463
534
|
}
|
|
464
535
|
act = actions.get(path)
|
|
465
536
|
if not act:
|
|
@@ -509,22 +580,29 @@ async def run(args: list[str], *, serve: Callable[..., Awaitable[int]], is_liste
|
|
|
509
580
|
if not no_browser:
|
|
510
581
|
open_browser(url)
|
|
511
582
|
return 0
|
|
583
|
+
background = winbg.prepare(lock_path().parent / "ui.log") # Windows, pythonw: a log file, windowless children
|
|
584
|
+
if "--hidden" in args and not background: # WSL, started by a hidden wsl.exe (wslstart.py): no window either
|
|
585
|
+
winbg.log_to(lock_path().parent / "ui.log")
|
|
586
|
+
background = True
|
|
512
587
|
log: deque = deque(maxlen=60)
|
|
513
588
|
tee = _Tee(sys.stderr, log)
|
|
514
589
|
old_err = sys.stderr
|
|
515
590
|
sys.stderr = tee
|
|
516
591
|
m = Manager(serve=serve, is_listening=is_listening, keys_line=keys_line, log=log)
|
|
592
|
+
m.background = background
|
|
593
|
+
stop = asyncio.Event()
|
|
594
|
+
m.quit_event = stop
|
|
517
595
|
srv = Server(m)
|
|
518
596
|
try:
|
|
519
597
|
await srv.start()
|
|
520
598
|
pp.write_private(lock_path(), json.dumps({"port": srv.port, "token": srv.token, "pid": os.getpid()}) + "\n")
|
|
521
599
|
print(f"witbitz-code: {m.label['label']} — open {srv.url}", file=sys.stderr, flush=True)
|
|
522
|
-
print("witbitz-code:
|
|
600
|
+
print("witbitz-code: running in the background — Quit on the page stops it" if background else
|
|
601
|
+
"witbitz-code: keep this window open (closing it stops witbitz-code). Ctrl-C stops it.", file=sys.stderr, flush=True)
|
|
523
602
|
if m.pairings():
|
|
524
603
|
await m.start_serving()
|
|
525
604
|
if not no_browser and not open_browser(srv.url):
|
|
526
605
|
print("witbitz-code: open the address above in your browser", file=sys.stderr, flush=True)
|
|
527
|
-
stop = asyncio.Event()
|
|
528
606
|
loop = asyncio.get_running_loop()
|
|
529
607
|
import signal # noqa: PLC0415
|
|
530
608
|
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
@@ -49,3 +49,5 @@ pre { white-space: pre-wrap; overflow-wrap: anywhere; font: 12px/1.45 ui-monospa
|
|
|
49
49
|
footer { font-size: 13px; text-align: center; }
|
|
50
50
|
@media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
|
|
51
51
|
.actions:not(:has(button:not([hidden]))) { display: none; }
|
|
52
|
+
button.small { font-size: 13px; padding: 2px 10px; margin-left: 8px; }
|
|
53
|
+
pre.cmd { background: var(--off-bg); color: var(--text); padding: 8px 10px; border-radius: 8px; }
|
|
@@ -31,11 +31,19 @@ const PILL = {
|
|
|
31
31
|
connecting: ['Connecting…', 'connecting', 'reaching the relay'],
|
|
32
32
|
starting: ['Starting…', 'starting', 'starting OpenCode'],
|
|
33
33
|
stopped: ['Stopped', '', 'not reachable from Spaces'],
|
|
34
|
+
elsewhere: ['Running in another window', 'online', ''],
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Another copy of witbitz-code serves this computer (setup's window, a login-started one): say which, so Start/Stop make sense.
|
|
38
|
+
function elsewhereText(sv) {
|
|
39
|
+
const who = (sv.owners || []).map((o) => `${o.cmd || 'a program'} (process ${o.pid})`).join(', ')
|
|
40
|
+
return `witbitz-code is running in another window${who ? ' — ' + who : ''}. Run it here to manage it from this page, or Stop it.`
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
function render(s) {
|
|
37
44
|
state = s
|
|
38
45
|
$('computer').textContent = s.computer.label + (s.computer.kind && !s.computer.label.includes(s.computer.kind) ? ' · ' + s.computer.kind : '')
|
|
46
|
+
document.title = s.computer.kind && s.computer.kind.startsWith('WSL') ? `Witbitz Code (${s.computer.kind})` : 'Witbitz Code'
|
|
39
47
|
$('version').textContent = s.version
|
|
40
48
|
const paired = s.paired.length > 0
|
|
41
49
|
const run = s.serving.state
|
|
@@ -43,7 +51,7 @@ function render(s) {
|
|
|
43
51
|
const pill = $('pill')
|
|
44
52
|
pill.textContent = label
|
|
45
53
|
pill.className = 'pill ' + cls
|
|
46
|
-
$('stateText').textContent = run === 'stopped' && s.serving.why ? s.serving.why : hint + (s.serving.port && s.serving.port !== 4096 ? ` · port ${s.serving.port}` : '')
|
|
54
|
+
$('stateText').textContent = run === 'elsewhere' ? elsewhereText(s.serving) : run === 'stopped' && s.serving.why ? s.serving.why : hint + (s.serving.port && s.serving.port !== 4096 ? ` · port ${s.serving.port}` : '')
|
|
47
55
|
|
|
48
56
|
const list = $('paired')
|
|
49
57
|
list.textContent = ''
|
|
@@ -58,7 +66,8 @@ function render(s) {
|
|
|
58
66
|
const waiting = s.pair.state === 'waiting'
|
|
59
67
|
const busy = !!s.busy
|
|
60
68
|
show($('pair'), !paired && !waiting)
|
|
61
|
-
show($('start'), paired && run === 'stopped' && !waiting)
|
|
69
|
+
show($('start'), paired && (run === 'stopped' || run === 'elsewhere') && !waiting)
|
|
70
|
+
$('start').textContent = run === 'elsewhere' ? 'Run it here' : 'Start'
|
|
62
71
|
show($('stop'), paired && run !== 'stopped')
|
|
63
72
|
show($('pairAgain'), paired && !waiting)
|
|
64
73
|
show($('unpair'), paired && !waiting)
|
|
@@ -83,6 +92,11 @@ function render(s) {
|
|
|
83
92
|
$('login').disabled = busy || !lg.available
|
|
84
93
|
$('login').checked = !!lg.on
|
|
85
94
|
if (!lg.available && lg.why) $('loginNote').textContent = 'Not available: ' + lg.why
|
|
95
|
+
else if (s.background) $('loginNote').textContent = 'Starts in the background when you log in and keeps this computer reachable.'
|
|
96
|
+
// Windows, no window (winbg.py): nothing to keep open — Quit is how it stops
|
|
97
|
+
$('footerText').textContent = s.background ? 'runs in the background — Start menu → Witbitz Code opens this page' : 'keep its window open — closing it stops witbitz-code'
|
|
98
|
+
show($('quit'), !!s.background)
|
|
99
|
+
renderTools(s.tools, paired)
|
|
86
100
|
|
|
87
101
|
$('log').textContent = (s.log || []).join('\n')
|
|
88
102
|
}
|
|
@@ -97,7 +111,8 @@ async function refresh() {
|
|
|
97
111
|
} catch (e) {
|
|
98
112
|
$('pill').textContent = 'Not running'
|
|
99
113
|
$('pill').className = 'pill bad'
|
|
100
|
-
$('stateText').textContent = 'Witbitz Code
|
|
114
|
+
$('stateText').textContent = quitting || (state && state.background) ? 'Witbitz Code is not running — open it again from the Start menu.'
|
|
115
|
+
: 'Witbitz Code’s window was closed — open it again from the Start menu.'
|
|
101
116
|
}
|
|
102
117
|
}
|
|
103
118
|
|
|
@@ -123,7 +138,41 @@ function accountOf() {
|
|
|
123
138
|
return pick || null
|
|
124
139
|
}
|
|
125
140
|
|
|
141
|
+
// setup's Tools step, on the page (uitools.py): what is missing, what Install runs, its progress.
|
|
142
|
+
function renderTools(t, paired) {
|
|
143
|
+
if (!t) return show($('toolsCard'), false)
|
|
144
|
+
const missing = t.missing || []
|
|
145
|
+
const running = t.state === 'installing'
|
|
146
|
+
show($('toolsCard'), paired && (missing.length > 0 || running || t.state === 'done' || !!t.error))
|
|
147
|
+
$('toolsText').textContent = t.error ? t.error
|
|
148
|
+
: running ? 'Installing…'
|
|
149
|
+
: missing.length ? `Missing here: ${missing.join(', ')}. The agent uses them to work with your files.`
|
|
150
|
+
: 'Everything the agent uses for these is here ✓'
|
|
151
|
+
const steps = t.steps || []
|
|
152
|
+
show($('toolsSteps'), !running && missing.length > 0 && steps.length > 0)
|
|
153
|
+
$('toolsSteps').textContent = steps.join('\n')
|
|
154
|
+
show($('toolsInstall'), !running && !!t.installable)
|
|
155
|
+
$('toolsInstall').disabled = !!state.busy
|
|
156
|
+
show($('toolsAdmin'), !!t.admin && !running)
|
|
157
|
+
show($('toolsAdminCmd'), !!t.admin && !running)
|
|
158
|
+
$('toolsAdminCmd').textContent = t.admin || ''
|
|
159
|
+
const lines = t.lines || []
|
|
160
|
+
show($('toolsLog'), lines.length > 0)
|
|
161
|
+
$('toolsLog').textContent = lines.join('\n')
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let quitting = false
|
|
165
|
+
async function quit() {
|
|
166
|
+
if (!window.confirm('Quit Witbitz Code? Code in Spaces will not reach this computer until you open Witbitz Code from the Start menu again.')) return
|
|
167
|
+
quitting = true
|
|
168
|
+
try { await api('/api/quit', {}) } catch { /* it is going away */ }
|
|
169
|
+
note('Witbitz Code stopped. Start menu → Witbitz Code starts it again.', 'bad')
|
|
170
|
+
for (const id of ['pair', 'start', 'stop', 'pairAgain', 'unpair', 'quit']) $(id).disabled = true
|
|
171
|
+
}
|
|
172
|
+
|
|
126
173
|
document.addEventListener('DOMContentLoaded', () => {
|
|
174
|
+
$('quit').onclick = quit
|
|
175
|
+
$('toolsInstall').onclick = () => act('/api/tools/install', {}, 'Install them now? Windows may ask once to allow an installer.')
|
|
127
176
|
$('pair').onclick = () => act('/api/pair', {})
|
|
128
177
|
$('start').onclick = () => act('/api/serve', { on: true })
|
|
129
178
|
$('stop').onclick = () => act('/api/serve', { on: false }, 'Stop? Code in Spaces will not reach this computer until you start it again.')
|
|
@@ -41,6 +41,16 @@
|
|
|
41
41
|
<div class="actions"><button id="pairCancel" type="button" class="ghost">Cancel</button><button id="pairRetry" type="button" hidden>Show a new code</button></div>
|
|
42
42
|
</section>
|
|
43
43
|
|
|
44
|
+
<section class="card" id="toolsCard" hidden>
|
|
45
|
+
<h2>Tools for audio, video, PDFs and scans</h2>
|
|
46
|
+
<p id="toolsText" class="muted"></p>
|
|
47
|
+
<pre id="toolsSteps" class="cmd" hidden></pre>
|
|
48
|
+
<div class="actions"><button id="toolsInstall" type="button" hidden>Install them</button></div>
|
|
49
|
+
<p id="toolsAdmin" class="muted" hidden>These need your password — run this in a terminal:</p>
|
|
50
|
+
<pre id="toolsAdminCmd" class="cmd" hidden></pre>
|
|
51
|
+
<pre id="toolsLog" class="cmd" hidden></pre>
|
|
52
|
+
</section>
|
|
53
|
+
|
|
44
54
|
<section class="card" id="loginCard" hidden>
|
|
45
55
|
<label class="switch-row" for="login">
|
|
46
56
|
<span>
|
|
@@ -56,7 +66,8 @@
|
|
|
56
66
|
<pre id="log"></pre>
|
|
57
67
|
</details>
|
|
58
68
|
|
|
59
|
-
<footer class="muted">witbitz-code <span id="version"></span> · keep its window open — closing it stops witbitz-code</
|
|
69
|
+
<footer class="muted">witbitz-code <span id="version"></span> · <span id="footerText">keep its window open — closing it stops witbitz-code</span>
|
|
70
|
+
<button id="quit" type="button" class="ghost small" hidden>Quit</button></footer>
|
|
60
71
|
</main>
|
|
61
72
|
</body>
|
|
62
73
|
</html>
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""The page's Tools card (ui.py): what the agent uses for audio, video, PDFs and scans, and one Install button — setup's
|
|
2
|
+
Tools step without a terminal (the owner, 2026-10-09: "the installer still asks its questions in the terminal - why the
|
|
3
|
+
terminal?"). The same plan as setup (toolsetup.install_plan); the same runner (toolsetup.run_plan), with each command's
|
|
4
|
+
output kept for the page instead of a terminal. A step that needs an administrator's password (Linux's apt/dnf/pacman
|
|
5
|
+
line) is never run from here: the page shows it for the person to run in a terminal.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import os
|
|
12
|
+
import subprocess
|
|
13
|
+
import time
|
|
14
|
+
from collections import deque
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from . import toolsetup, tools_probe
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def run_captured(argv: list[str], log: Callable[[str], Any]) -> int:
|
|
22
|
+
"""A command with no terminal: stdin closed, its last lines kept → its exit code (-1: it could not start)."""
|
|
23
|
+
try:
|
|
24
|
+
r = subprocess.run(argv, check=False, stdin=subprocess.DEVNULL, capture_output=True, text=True, # noqa: S603 — argv from install_plan
|
|
25
|
+
errors="replace", timeout=1800)
|
|
26
|
+
except (OSError, ValueError, subprocess.SubprocessError) as e:
|
|
27
|
+
log(f" ✖ could not start it ({str(e)[:120]})")
|
|
28
|
+
return -1
|
|
29
|
+
for line in [ln.strip() for ln in (r.stdout + "\n" + r.stderr).splitlines() if ln.strip()][-4:]:
|
|
30
|
+
log(" " + line[:200])
|
|
31
|
+
return r.returncode
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def refresh_path() -> None:
|
|
35
|
+
"""Windows: what winget just installed is on the registry's PATH, not yet on this process's."""
|
|
36
|
+
reg = toolsetup.registry_path()
|
|
37
|
+
if reg:
|
|
38
|
+
os.environ["PATH"] = toolsetup.merged_path(os.environ.get("PATH", ""), reg)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def describe(step: dict) -> str:
|
|
42
|
+
if step["kind"] == "cmd":
|
|
43
|
+
return " ".join(step["argv"][:4]) if step["argv"][0] != "sh" else step["argv"][2]
|
|
44
|
+
if step["kind"] == "release":
|
|
45
|
+
return f"{toolsetup.label(step['id'])}: a {step['mb']} MB download from its publisher, checked against its SHA-256"
|
|
46
|
+
return ""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Tools:
|
|
50
|
+
"""{state: idle|installing|done, missing, steps, admin, lines}. Everything outside is injected for tests."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, *, probe: Callable[[], dict] = tools_probe.probe_tools,
|
|
53
|
+
run_plan: Callable[..., dict] = toolsetup.run_plan, run: Callable[[list[str], Callable], int] = run_captured,
|
|
54
|
+
refresh: Callable[[], None] = refresh_path) -> None:
|
|
55
|
+
self._probe, self._run_plan, self._run, self._refresh = probe, run_plan, run, refresh
|
|
56
|
+
self.state = "idle"
|
|
57
|
+
self.lines: deque = deque(maxlen=40)
|
|
58
|
+
self.task: asyncio.Task | None = None
|
|
59
|
+
self._refreshed = False
|
|
60
|
+
self._cache: tuple[float, tuple] | None = None # the page asks every few seconds; a probe reads PATH's folders
|
|
61
|
+
|
|
62
|
+
def _plan(self) -> tuple[list[dict], list[dict]]:
|
|
63
|
+
if not self._refreshed: # once: a tool an earlier install put on PATH must not read as missing
|
|
64
|
+
self._refreshed = True
|
|
65
|
+
try:
|
|
66
|
+
self._refresh()
|
|
67
|
+
except Exception: # noqa: BLE001
|
|
68
|
+
pass
|
|
69
|
+
if self._cache and time.monotonic() - self._cache[0] < 15:
|
|
70
|
+
return self._cache[1]
|
|
71
|
+
report = self._probe()
|
|
72
|
+
missing = toolsetup.missing_tools(report)
|
|
73
|
+
plan = toolsetup.install_plan([t["id"] for t in missing], report.get("platform") or {}) if missing else []
|
|
74
|
+
self._cache = (time.monotonic(), (missing, plan))
|
|
75
|
+
return missing, plan
|
|
76
|
+
|
|
77
|
+
def status(self) -> dict:
|
|
78
|
+
try:
|
|
79
|
+
missing, plan = self._plan()
|
|
80
|
+
except Exception as e: # noqa: BLE001 — a reading, never a failure of the page
|
|
81
|
+
return {"state": self.state, "error": f"could not check ({str(e)[:80]})", "missing": [], "lines": list(self.lines)}
|
|
82
|
+
runnable = [s for s in plan if s["kind"] in ("cmd", "release") and not s.get("admin")]
|
|
83
|
+
admin = next((s["argv"][2] for s in plan if s.get("admin")), "")
|
|
84
|
+
return {"state": self.state, "missing": [t["label"] for t in missing], "steps": [describe(s) for s in runnable],
|
|
85
|
+
"admin": admin, "installable": bool(runnable), "lines": list(self.lines)}
|
|
86
|
+
|
|
87
|
+
async def install(self) -> dict:
|
|
88
|
+
if self.task and not self.task.done():
|
|
89
|
+
return {"ok": True}
|
|
90
|
+
_missing, plan = self._plan()
|
|
91
|
+
steps = [s for s in plan if not s.get("admin")]
|
|
92
|
+
if not any(s["kind"] in ("cmd", "release") for s in steps):
|
|
93
|
+
return {"ok": False, "why": "nothing here can install them from this page"}
|
|
94
|
+
self.state, self._refreshed = "installing", False
|
|
95
|
+
self.lines.clear()
|
|
96
|
+
self.task = asyncio.ensure_future(asyncio.to_thread(self._install, steps))
|
|
97
|
+
return {"ok": True}
|
|
98
|
+
|
|
99
|
+
def _install(self, steps: list[dict]) -> None:
|
|
100
|
+
say = self.lines.append
|
|
101
|
+
try:
|
|
102
|
+
self._run_plan(steps, say=say, run=lambda argv: self._run(argv, say))
|
|
103
|
+
self._refresh()
|
|
104
|
+
except Exception as e: # noqa: BLE001
|
|
105
|
+
say(f" ✖ {str(e)[:160]}")
|
|
106
|
+
finally:
|
|
107
|
+
self._cache = None
|
|
108
|
+
self.state = "done"
|
|
@@ -294,6 +294,7 @@ class Deps:
|
|
|
294
294
|
remove_package: Callable[[dict], Any] = remove_package # may be async: the CLI closes its network client first
|
|
295
295
|
login_start: Callable[[], Any] = lambda: __import__("witbitz_code.wslstart", fromlist=["startup_here"]).startup_here() # WSL only
|
|
296
296
|
login_start_disable: Callable[[dict], bool] = lambda spec: __import__("witbitz_code.wslstart", fromlist=["startup_disable"]).startup_disable(spec)
|
|
297
|
+
menu_remove: Callable[[], bool] = lambda: __import__("witbitz_code.wslstart", fromlist=["menu_remove"]).menu_remove() # WSL only
|
|
297
298
|
|
|
298
299
|
|
|
299
300
|
async def run_uninstall(d: Deps) -> dict:
|
|
@@ -393,6 +394,8 @@ async def run_uninstall(d: Deps) -> dict:
|
|
|
393
394
|
spec = d.login_start() # WSL: the Windows Startup shortcut setup made (wslstart.py)
|
|
394
395
|
if spec and d.login_start_disable(spec):
|
|
395
396
|
d.say(f"✓ Removed the Windows Startup shortcut \"{spec['name'][:-4]}\" — it no longer starts when you log in")
|
|
397
|
+
if spec and d.menu_remove():
|
|
398
|
+
d.say(f"✓ Removed \"{spec['name'][:-4]}\" from the Start menu")
|
|
396
399
|
d.remove_path(d.code_dir)
|
|
397
400
|
d.say(f"✓ Deleted {d.code_dir}")
|
|
398
401
|
pkg_gone = False
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Windows: witbitz-code's page manager (`witbitz-code ui`) runs in the BACKGROUND — no terminal window that stops it when
|
|
2
|
+
closed (the owner, 2026-10-09: "currently it opens a terminal and it depends on it. can this be run in the background?").
|
|
3
|
+
|
|
4
|
+
It runs under pythonw.exe, which has no console. Two things follow, both handled here at start:
|
|
5
|
+
- stdout/stderr are None — what it says goes to a log file beside the pairings (ui.log, cut at 1 MB);
|
|
6
|
+
- a console program it starts (OpenCode, netstat, tasklist, taskkill, winget …) would each open a NEW window — every child
|
|
7
|
+
is started with CREATE_NO_WINDOW instead (one patch of subprocess.Popen, which asyncio's Windows subprocesses use too; a
|
|
8
|
+
child of such a process inherits its windowless console, so OpenCode's own children stay hidden as well).
|
|
9
|
+
Nothing here runs off Windows, or in a process that has a console (a `witbitz-code ui` typed into a terminal).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
CREATE_NO_WINDOW = 0x08000000
|
|
21
|
+
DETACHED_PROCESS = 0x00000008
|
|
22
|
+
CREATE_NEW_PROCESS_GROUP = 0x00000200
|
|
23
|
+
LOG_MAX = 1024 * 1024
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def has_console(platform: str | None = None) -> bool:
|
|
27
|
+
if (sys.platform if platform is None else platform) != "win32":
|
|
28
|
+
return True
|
|
29
|
+
try:
|
|
30
|
+
import ctypes # noqa: PLC0415 — Windows only
|
|
31
|
+
|
|
32
|
+
return bool(ctypes.windll.kernel32.GetConsoleWindow()) # type: ignore[attr-defined]
|
|
33
|
+
except Exception: # noqa: BLE001
|
|
34
|
+
return True
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def pythonw(executable: str | None = None) -> Path | None:
|
|
38
|
+
"""pythonw.exe beside this Python (a uv tool's venv has both in Scripts), or None."""
|
|
39
|
+
p = Path(executable or sys.executable).with_name("pythonw.exe")
|
|
40
|
+
return p if p.exists() else None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def quiet_children(popen: type = subprocess.Popen) -> bool:
|
|
44
|
+
"""Every child from now on starts with CREATE_NO_WINDOW (unless it asked for a console or detachment itself)."""
|
|
45
|
+
if getattr(popen, "_witbitz_quiet", False):
|
|
46
|
+
return False
|
|
47
|
+
original = popen.__init__
|
|
48
|
+
|
|
49
|
+
def __init__(self, *args, **kwargs): # noqa: ANN001, ANN002, ANN003
|
|
50
|
+
flags = kwargs.get("creationflags", 0) or 0
|
|
51
|
+
if not flags & (DETACHED_PROCESS | 0x00000010): # 0x10 = CREATE_NEW_CONSOLE: the caller wants a window
|
|
52
|
+
kwargs["creationflags"] = flags | CREATE_NO_WINDOW
|
|
53
|
+
original(self, *args, **kwargs)
|
|
54
|
+
|
|
55
|
+
popen.__init__ = __init__
|
|
56
|
+
popen._witbitz_quiet = True
|
|
57
|
+
return True
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def log_to(path: Path) -> None:
|
|
61
|
+
"""stdout/stderr → `path` (appended; started over past LOG_MAX), line-buffered so the page's tee sees each line."""
|
|
62
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
try:
|
|
64
|
+
if path.stat().st_size > LOG_MAX:
|
|
65
|
+
path.unlink()
|
|
66
|
+
except OSError:
|
|
67
|
+
pass
|
|
68
|
+
f = open(path, "a", encoding="utf-8", errors="replace", buffering=1) # noqa: SIM115 — lives as long as the process
|
|
69
|
+
sys.stdout = f
|
|
70
|
+
sys.stderr = f
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def prepare(log_path: Path, *, platform: str | None = None, console: Callable[[], bool] | None = None) -> bool:
|
|
74
|
+
"""In a windowless Windows process: log to a file and keep every child windowless. → whether it is in the background."""
|
|
75
|
+
if (sys.platform if platform is None else platform) != "win32":
|
|
76
|
+
return False
|
|
77
|
+
if (console or has_console)():
|
|
78
|
+
return False
|
|
79
|
+
if sys.stderr is None or sys.stdout is None:
|
|
80
|
+
log_to(log_path)
|
|
81
|
+
quiet_children()
|
|
82
|
+
return True
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def launch_ui(extra: list[str] | None = None, *, popen: Callable[..., object] = subprocess.Popen,
|
|
86
|
+
executable: str | None = None) -> tuple[bool, str]:
|
|
87
|
+
"""Start `witbitz-code ui` in the background (pythonw, detached — closing this window does not stop it). → (ok, why)."""
|
|
88
|
+
w = pythonw(executable)
|
|
89
|
+
if not w:
|
|
90
|
+
return False, "pythonw.exe is not beside this Python"
|
|
91
|
+
try:
|
|
92
|
+
popen([str(w), "-m", "witbitz_code", "ui", *(extra or [])], cwd=os.path.expanduser("~"),
|
|
93
|
+
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, close_fds=True,
|
|
94
|
+
creationflags=DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP)
|
|
95
|
+
except OSError as e:
|
|
96
|
+
return False, str(e)[:120]
|
|
97
|
+
return True, ""
|