witbitz-code 1.5.1__tar.gz → 1.6.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.
- {witbitz_code-1.5.1/src/witbitz_code.egg-info → witbitz_code-1.6.1}/PKG-INFO +1 -1
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/pyproject.toml +1 -1
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/__init__.py +1 -1
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/cli.py +14 -5
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/ui.py +25 -5
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/ui_static/app.css +2 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/ui_static/app.js +42 -1
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/ui_static/index.html +12 -1
- witbitz_code-1.6.1/src/witbitz_code/uitools.py +108 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/uninstall.py +75 -5
- witbitz_code-1.6.1/src/witbitz_code/winbg.py +97 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/wizard.py +15 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/wslstart.py +62 -9
- {witbitz_code-1.5.1 → witbitz_code-1.6.1/src/witbitz_code.egg-info}/PKG-INFO +1 -1
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code.egg-info/SOURCES.txt +4 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_setup.py +22 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_ui.py +20 -0
- witbitz_code-1.6.1/tests/test_uitools.py +71 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_uninstall.py +41 -3
- witbitz_code-1.6.1/tests/test_winbg.py +71 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_wslstart.py +25 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/LICENSE +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/README.md +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/setup.cfg +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/__main__.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/_js.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/account.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/asks.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/attachments.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/auto.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/auto_runner.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/codekeys.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/codetools.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/compress.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/computers.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/confidential.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/confidential_models.json +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/connector.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/devicelink.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/dirs.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/folders.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/gateway.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/keys.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/link.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/mkdir.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/models.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/net.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/offered_models.json +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/outputs.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/pair.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/pairings.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/payers.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/plugins.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/policy.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/ports.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/qr.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/receipt.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/relay.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/seen.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/tinfoil_read.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/tools_catalog.json +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/tools_probe.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/toolsetup.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/winagent.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code/winconsole.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code.egg-info/entry_points.txt +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code.egg-info/requires.txt +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/src/witbitz_code.egg-info/top_level.txt +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_asks.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_attachments.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_auto.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_cli.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_codekeys.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_codetools.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_confidential.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_connector_e2e.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_devicelink.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_dirs.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_files_and_compress.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_folders.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_outputs.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_pair_flows.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_plugins.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_policy.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_ports.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_ports_win.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_registry.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_relay_codec.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_tools_probe.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_toolsetup.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_winagent.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/tests/test_winconsole.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.1}/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.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"
|
|
@@ -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, uninstall, 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
|
|
|
@@ -123,9 +123,10 @@ class Manager:
|
|
|
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
|
-
label: dict | None = None, log: deque | None = None) -> None:
|
|
126
|
+
tools: uitools.Tools | None = None, label: dict | None = None, log: deque | None = None) -> None:
|
|
127
127
|
self._serve, self._is_listening, self._pair, self._link = serve, is_listening, pair, link_node
|
|
128
128
|
self._load, self._keys_line = load_pairings, keys_line
|
|
129
|
+
self.tools = tools or uitools.Tools()
|
|
129
130
|
self._owners = port_owners or ports.port_owners
|
|
130
131
|
self._stop_pid = stop_pid or uninstall.stop_process
|
|
131
132
|
self._elsewhere_cache: tuple[float, dict | None] | None = None
|
|
@@ -143,6 +144,8 @@ class Manager:
|
|
|
143
144
|
self._keys_cache: dict[str, tuple[float, str]] = {}
|
|
144
145
|
self._login_cache: tuple[float, dict] | None = None
|
|
145
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
|
|
146
149
|
|
|
147
150
|
# ── reading ────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
148
151
|
def pairings(self) -> list:
|
|
@@ -206,7 +209,8 @@ class Manager:
|
|
|
206
209
|
if el:
|
|
207
210
|
serving = {"state": "elsewhere", "port": el["port"], "owners": el["owners"]}
|
|
208
211
|
return {"version": __version__, "computer": self.label, "paired": paired, "serving": serving, "pair": pair,
|
|
209
|
-
"login": self.login(), "busy": self.busy, "
|
|
212
|
+
"login": self.login(), "busy": self.busy, "background": self.background,
|
|
213
|
+
"tools": await asyncio.to_thread(self.tools.status), "log": list(self.log)[-14:]}
|
|
210
214
|
|
|
211
215
|
async def elsewhere(self, fresh: bool = False) -> dict | None:
|
|
212
216
|
"""{port, owners} when THIS person's other process holds OpenCode's port or the model proxy's (port + 100) while this
|
|
@@ -381,6 +385,13 @@ class Manager:
|
|
|
381
385
|
self._login_cache = None
|
|
382
386
|
return {"ok": bool(ok), **({"why": why} if not ok else {})}
|
|
383
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
|
+
|
|
384
395
|
async def shutdown(self) -> None:
|
|
385
396
|
if self.pair_task and not self.pair_task.done():
|
|
386
397
|
self.pair_task.cancel()
|
|
@@ -518,6 +529,8 @@ class Server:
|
|
|
518
529
|
"/api/pair/cancel": self.m.cancel_pairing,
|
|
519
530
|
"/api/unpair": lambda: self.m.unpair(str(data.get("account") or "")),
|
|
520
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,
|
|
521
534
|
}
|
|
522
535
|
act = actions.get(path)
|
|
523
536
|
if not act:
|
|
@@ -567,22 +580,29 @@ async def run(args: list[str], *, serve: Callable[..., Awaitable[int]], is_liste
|
|
|
567
580
|
if not no_browser:
|
|
568
581
|
open_browser(url)
|
|
569
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
|
|
570
587
|
log: deque = deque(maxlen=60)
|
|
571
588
|
tee = _Tee(sys.stderr, log)
|
|
572
589
|
old_err = sys.stderr
|
|
573
590
|
sys.stderr = tee
|
|
574
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
|
|
575
595
|
srv = Server(m)
|
|
576
596
|
try:
|
|
577
597
|
await srv.start()
|
|
578
598
|
pp.write_private(lock_path(), json.dumps({"port": srv.port, "token": srv.token, "pid": os.getpid()}) + "\n")
|
|
579
599
|
print(f"witbitz-code: {m.label['label']} — open {srv.url}", file=sys.stderr, flush=True)
|
|
580
|
-
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)
|
|
581
602
|
if m.pairings():
|
|
582
603
|
await m.start_serving()
|
|
583
604
|
if not no_browser and not open_browser(srv.url):
|
|
584
605
|
print("witbitz-code: open the address above in your browser", file=sys.stderr, flush=True)
|
|
585
|
-
stop = asyncio.Event()
|
|
586
606
|
loop = asyncio.get_running_loop()
|
|
587
607
|
import signal # noqa: PLC0415
|
|
588
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; }
|
|
@@ -43,6 +43,7 @@ function elsewhereText(sv) {
|
|
|
43
43
|
function render(s) {
|
|
44
44
|
state = s
|
|
45
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'
|
|
46
47
|
$('version').textContent = s.version
|
|
47
48
|
const paired = s.paired.length > 0
|
|
48
49
|
const run = s.serving.state
|
|
@@ -91,6 +92,11 @@ function render(s) {
|
|
|
91
92
|
$('login').disabled = busy || !lg.available
|
|
92
93
|
$('login').checked = !!lg.on
|
|
93
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)
|
|
94
100
|
|
|
95
101
|
$('log').textContent = (s.log || []).join('\n')
|
|
96
102
|
}
|
|
@@ -105,7 +111,8 @@ async function refresh() {
|
|
|
105
111
|
} catch (e) {
|
|
106
112
|
$('pill').textContent = 'Not running'
|
|
107
113
|
$('pill').className = 'pill bad'
|
|
108
|
-
$('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.'
|
|
109
116
|
}
|
|
110
117
|
}
|
|
111
118
|
|
|
@@ -131,7 +138,41 @@ function accountOf() {
|
|
|
131
138
|
return pick || null
|
|
132
139
|
}
|
|
133
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
|
+
|
|
134
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.')
|
|
135
176
|
$('pair').onclick = () => act('/api/pair', {})
|
|
136
177
|
$('start').onclick = () => act('/api/serve', { on: true })
|
|
137
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"
|
|
@@ -23,7 +23,7 @@ from dataclasses import dataclass, field
|
|
|
23
23
|
from pathlib import Path
|
|
24
24
|
from typing import Any
|
|
25
25
|
|
|
26
|
-
from . import keys, plugins
|
|
26
|
+
from . import keys, plugins, ports
|
|
27
27
|
from . import pairings as pp
|
|
28
28
|
from .wizard import find_opencode
|
|
29
29
|
|
|
@@ -138,6 +138,8 @@ def opencode_holders(data_dir: str) -> list[dict]:
|
|
|
138
138
|
cmd = ""
|
|
139
139
|
hits.append({"pid": int(p), "cmd": cmd})
|
|
140
140
|
return hits
|
|
141
|
+
if sys.platform == "win32": # no /proc, no lsof: every opencode.exe of this user's (the owner, 2026-10-09: the background
|
|
142
|
+
return win_opencode_processes() # copy's OpenCode held opencode.db and the delete died "WinError 32")
|
|
141
143
|
files = [os.path.join(data_dir, f) for f in ("opencode.db", "opencode.db-wal", "opencode.db-shm") if os.path.exists(os.path.join(data_dir, f))]
|
|
142
144
|
if not files or not shutil.which("lsof"):
|
|
143
145
|
return hits
|
|
@@ -150,6 +152,55 @@ def opencode_holders(data_dir: str) -> list[dict]:
|
|
|
150
152
|
return hits
|
|
151
153
|
|
|
152
154
|
|
|
155
|
+
def win_opencode_processes(*, run: Callable[[list[str]], str] | None = None, user: str | None = None) -> list[dict]:
|
|
156
|
+
"""Windows: this user's opencode.exe processes (tasklist, one CSV row each)."""
|
|
157
|
+
run = run or ports._run_text
|
|
158
|
+
user = os.environ.get("USERNAME", "") if user is None else user
|
|
159
|
+
hits = []
|
|
160
|
+
for line in run(["tasklist", "/V", "/FO", "CSV", "/NH", "/FI", "IMAGENAME eq opencode.exe"]).splitlines():
|
|
161
|
+
row = ports.parse_tasklist_row(line)
|
|
162
|
+
if row and (not user or row["user"].rsplit("\\", 1)[-1].lower() == user.lower()):
|
|
163
|
+
hits.append({"pid": row["pid"], "cmd": row["image"]})
|
|
164
|
+
return hits
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def running_witbitz(*, lock: Path | None = None, pairings: Callable[[], list] | None = None,
|
|
168
|
+
owners: Callable[[int], list] | None = None, alive: Callable[[int], bool] | None = None) -> list[dict]:
|
|
169
|
+
"""What runs witbitz-code here now: the page's manager (its ui.json names it — in the background it has no window to
|
|
170
|
+
close) and whatever holds each paired OpenCode port or its model proxy's (port + 100): a serve, its OpenCode."""
|
|
171
|
+
lock = lock or pp.pairings_path().parent / "ui.json"
|
|
172
|
+
pairings = pairings or (lambda: pp.load_pairings(log=lambda _m: None))
|
|
173
|
+
owners = owners or ports.port_owners
|
|
174
|
+
alive = alive or _alive
|
|
175
|
+
found: list[dict] = []
|
|
176
|
+
try:
|
|
177
|
+
pid = json.loads(lock.read_text(encoding="utf-8")).get("pid")
|
|
178
|
+
if isinstance(pid, int) and pid != os.getpid() and alive(pid):
|
|
179
|
+
found.append({"pid": pid, "cmd": "the Witbitz Code page"})
|
|
180
|
+
except (OSError, ValueError, AttributeError):
|
|
181
|
+
pass
|
|
182
|
+
for p in pairings():
|
|
183
|
+
tail = str(p.get("opencodeUrl") or "").rsplit(":", 1)[-1].rstrip("/")
|
|
184
|
+
port = int(tail) if tail.isdigit() else 0
|
|
185
|
+
for q in ([port, port + 100] if port else []):
|
|
186
|
+
for o in owners(q):
|
|
187
|
+
if o["pid"] != os.getpid() and all(o["pid"] != x["pid"] for x in found):
|
|
188
|
+
found.append({"pid": int(o["pid"]), "cmd": str(o.get("cmd") or "")[:70]})
|
|
189
|
+
return found
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _alive(pid: int) -> bool:
|
|
193
|
+
if sys.platform == "win32":
|
|
194
|
+
return _win_alive(pid)
|
|
195
|
+
try:
|
|
196
|
+
os.kill(pid, 0)
|
|
197
|
+
return True
|
|
198
|
+
except ProcessLookupError:
|
|
199
|
+
return False
|
|
200
|
+
except PermissionError:
|
|
201
|
+
return False # another user's: not ours
|
|
202
|
+
|
|
203
|
+
|
|
153
204
|
def _win_alive(pid: int) -> bool:
|
|
154
205
|
"""Windows: is `pid` still running? (OpenProcess + GetExitCodeProcess; access denied = another user's, still running.)"""
|
|
155
206
|
import ctypes # noqa: PLC0415 — Windows only
|
|
@@ -287,6 +338,7 @@ class Deps:
|
|
|
287
338
|
sessions: Callable[[], dict] = None # type: ignore[assignment]
|
|
288
339
|
delete_sessions: Callable[[], None] = None # type: ignore[assignment]
|
|
289
340
|
opencode_processes: Callable[[], list] = lambda: opencode_holders(str(keys.auth_path().parent))
|
|
341
|
+
running: Callable[[], list] = running_witbitz
|
|
290
342
|
stop_process: Callable[[int], bool] = stop_process
|
|
291
343
|
opencode: Callable[[], dict] = opencode_here
|
|
292
344
|
remove_opencode_program: Callable[[dict], dict] = remove_opencode_program
|
|
@@ -294,6 +346,7 @@ class Deps:
|
|
|
294
346
|
remove_package: Callable[[dict], Any] = remove_package # may be async: the CLI closes its network client first
|
|
295
347
|
login_start: Callable[[], Any] = lambda: __import__("witbitz_code.wslstart", fromlist=["startup_here"]).startup_here() # WSL only
|
|
296
348
|
login_start_disable: Callable[[dict], bool] = lambda spec: __import__("witbitz_code.wslstart", fromlist=["startup_disable"]).startup_disable(spec)
|
|
349
|
+
menu_remove: Callable[[], bool] = lambda: __import__("witbitz_code.wslstart", fromlist=["menu_remove"]).menu_remove() # WSL only
|
|
297
350
|
|
|
298
351
|
|
|
299
352
|
async def run_uninstall(d: Deps) -> dict:
|
|
@@ -331,6 +384,12 @@ async def run_uninstall(d: Deps) -> dict:
|
|
|
331
384
|
how = {"installer": "from OpenCode's installer, in ~/.opencode", "npm": "with npm", "brew": "with Homebrew"}.get(oc["kind"])
|
|
332
385
|
remove_oc = ask(d.remove_opencode, f"Also remove OpenCode itself (installed {how})? [y/N] ") if oc["kind"] not in ("none", "other") else False
|
|
333
386
|
|
|
387
|
+
# The running witbitz-code first — the page's manager in the background, a serve, their OpenCode: it holds the files
|
|
388
|
+
# about to go (Windows refuses to delete an open file) and would keep answering the phone. Continue already said yes.
|
|
389
|
+
for p in d.running():
|
|
390
|
+
d.say(f"✓ Stopped the running witbitz-code: {p['cmd'] or 'a program'} (process {p['pid']})" if d.stop_process(p["pid"])
|
|
391
|
+
else f"✖ Could not stop {p['cmd'] or 'a program'} (process {p['pid']}) — close it, then run uninstall again")
|
|
392
|
+
|
|
334
393
|
# Sessions: an OpenCode that has its database open would write it back — never delete under it.
|
|
335
394
|
while remove_sessions:
|
|
336
395
|
procs = d.opencode_processes()
|
|
@@ -375,8 +434,11 @@ async def run_uninstall(d: Deps) -> dict:
|
|
|
375
434
|
d.remove_path(notes["root"])
|
|
376
435
|
d.say(f"✓ Deleted {notes['root']}")
|
|
377
436
|
if remove_sessions:
|
|
378
|
-
d.delete_sessions()
|
|
379
|
-
|
|
437
|
+
stuck = d.delete_sessions() or []
|
|
438
|
+
if stuck:
|
|
439
|
+
d.say(f"✖ Could not delete {', '.join(stuck[:4])} in {sessions['dir']} — another program still has it open; close OpenCode and delete it yourself")
|
|
440
|
+
else:
|
|
441
|
+
d.say(f"✓ Deleted the OpenCode sessions in {sessions['dir']} (provider logins kept)")
|
|
380
442
|
oc_gone = False
|
|
381
443
|
if remove_oc:
|
|
382
444
|
try:
|
|
@@ -393,6 +455,8 @@ async def run_uninstall(d: Deps) -> dict:
|
|
|
393
455
|
spec = d.login_start() # WSL: the Windows Startup shortcut setup made (wslstart.py)
|
|
394
456
|
if spec and d.login_start_disable(spec):
|
|
395
457
|
d.say(f"✓ Removed the Windows Startup shortcut \"{spec['name'][:-4]}\" — it no longer starts when you log in")
|
|
458
|
+
if spec and d.menu_remove():
|
|
459
|
+
d.say(f"✓ Removed \"{spec['name'][:-4]}\" from the Start menu")
|
|
396
460
|
d.remove_path(d.code_dir)
|
|
397
461
|
d.say(f"✓ Deleted {d.code_dir}")
|
|
398
462
|
pkg_gone = False
|
|
@@ -451,12 +515,18 @@ def sessions_info() -> dict:
|
|
|
451
515
|
return {"dir": str(d), "exists": exists}
|
|
452
516
|
|
|
453
517
|
|
|
454
|
-
def delete_sessions() ->
|
|
518
|
+
def delete_sessions() -> list[str]:
|
|
519
|
+
"""→ the names that could not be deleted (Windows: a file another program still has open), never a crash."""
|
|
455
520
|
d = keys.auth_path().parent
|
|
521
|
+
left = []
|
|
456
522
|
for f in d.iterdir():
|
|
457
523
|
if f.name == "auth.json":
|
|
458
524
|
continue
|
|
459
|
-
|
|
525
|
+
try:
|
|
526
|
+
shutil.rmtree(f, ignore_errors=True) if f.is_dir() and not f.is_symlink() else f.unlink(missing_ok=True)
|
|
527
|
+
except OSError:
|
|
528
|
+
left.append(f.name)
|
|
529
|
+
return left
|
|
460
530
|
|
|
461
531
|
|
|
462
532
|
def saved_keys() -> list[str]:
|
|
@@ -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, ""
|