witbitz-code 1.5.1__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.1/src/witbitz_code.egg-info → witbitz_code-1.6.0}/PKG-INFO +1 -1
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/pyproject.toml +1 -1
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/__init__.py +1 -1
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/cli.py +14 -5
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/ui.py +25 -5
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/ui_static/app.css +2 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/ui_static/app.js +42 -1
- {witbitz_code-1.5.1 → 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.1 → 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.1 → witbitz_code-1.6.0}/src/witbitz_code/wizard.py +15 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/wslstart.py +62 -9
- {witbitz_code-1.5.1 → witbitz_code-1.6.0/src/witbitz_code.egg-info}/PKG-INFO +1 -1
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code.egg-info/SOURCES.txt +4 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_setup.py +22 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_ui.py +20 -0
- witbitz_code-1.6.0/tests/test_uitools.py +71 -0
- {witbitz_code-1.5.1 → 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.1 → witbitz_code-1.6.0}/tests/test_wslstart.py +25 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/LICENSE +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/README.md +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/setup.cfg +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/__main__.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/_js.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/account.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/asks.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/attachments.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/auto.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/auto_runner.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/codekeys.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/codetools.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/compress.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/computers.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/confidential.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/confidential_models.json +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/connector.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/devicelink.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/dirs.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/folders.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/gateway.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/keys.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/link.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/mkdir.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/models.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/net.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/offered_models.json +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/outputs.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/pair.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/pairings.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/payers.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/plugins.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/policy.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/ports.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/qr.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/receipt.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/relay.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/seen.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/tinfoil_read.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/tools_catalog.json +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/tools_probe.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/toolsetup.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/winagent.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/winconsole.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code.egg-info/entry_points.txt +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code.egg-info/requires.txt +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code.egg-info/top_level.txt +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_asks.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_attachments.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_auto.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_cli.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_codekeys.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_codetools.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_confidential.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_connector_e2e.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_devicelink.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_dirs.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_files_and_compress.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_folders.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_outputs.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_pair_flows.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_plugins.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_policy.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_ports.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_ports_win.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_registry.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_relay_codec.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_tools_probe.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_toolsetup.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_winagent.py +0 -0
- {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_winconsole.py +0 -0
- {witbitz_code-1.5.1 → 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, 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"
|
|
@@ -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, ""
|
|
@@ -90,6 +90,8 @@ class Deps:
|
|
|
90
90
|
run_tools: Callable[[list], dict] = None # type: ignore[assignment]
|
|
91
91
|
refresh_path: Callable[[], None] = lambda: None
|
|
92
92
|
serve_here: Callable[[int], Awaitable[int]] = None # type: ignore[assignment]
|
|
93
|
+
# Windows: start the page's manager in the background (winbg.launch_ui) instead of serving in this window → (ok, why)
|
|
94
|
+
background_ui: Callable[[], tuple] | None = None
|
|
93
95
|
result: dict = field(default_factory=lambda: {"opencode": False, "paired": False, "keys": False, "running": ""})
|
|
94
96
|
|
|
95
97
|
|
|
@@ -120,6 +122,19 @@ async def run_setup(d: Deps) -> dict:
|
|
|
120
122
|
d.say(" ✓ OpenCode is installed")
|
|
121
123
|
result["opencode"] = True
|
|
122
124
|
|
|
125
|
+
# Windows and WSL: the rest — pairing, the tools, start when I log in — is on a page in the browser, and witbitz-code
|
|
126
|
+
# runs in the background (no window to keep open). The owner, 2026-10-09: "the installer still asks its questions in
|
|
127
|
+
# the terminal - why the terminal?" → "in wsl, will we get the same page install by default when we setup?". --here:
|
|
128
|
+
# the steps below, in this terminal, as before.
|
|
129
|
+
if d.background_ui:
|
|
130
|
+
ok, why = d.background_ui()
|
|
131
|
+
if ok:
|
|
132
|
+
d.say("\n2. The rest is on a page in your browser — it is opening now: pair this computer there, and install the tools.")
|
|
133
|
+
d.say(" witbitz-code runs in the background; you can close this window. The page's Quit stops it.")
|
|
134
|
+
d.say(" To open the page again: witbitz-code ui" + ("" if d.wsl else " (or Start menu → Witbitz Code)") + "\n")
|
|
135
|
+
return {**result, "running": "background"}
|
|
136
|
+
d.say(f" (Could not start it in the background: {why} — carrying on here)")
|
|
137
|
+
|
|
123
138
|
# 2 — pairing (a pairing belongs to one OpenCode port; already paired for another port → use it, or move it on --port)
|
|
124
139
|
d.say("\n2. This computer and your Witbitz account")
|
|
125
140
|
on = lambda lst, p: [x for x in lst if d.port_of(x) == p] # noqa: E731
|
|
@@ -4,10 +4,13 @@ have the option to start on login?" → "can it be automatic in the install?"),
|
|
|
4
4
|
WSL runs only once something on Windows starts it, so the start lives on the WINDOWS side: a shortcut in the Windows Startup
|
|
5
5
|
folder that runs
|
|
6
6
|
|
|
7
|
-
wsl.exe -d <distro> -u <user> --cd ~ -- <this witbitz-code> ui --no-browser
|
|
7
|
+
conhost.exe --headless wsl.exe -d <distro> -u <user> --cd ~ -- <this witbitz-code> ui --no-browser
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
with NO window (1.6.0; the owner, 2026-10-09: "can this be run in the background?"): the page's manager, serving, without
|
|
10
|
+
opening a browser. The wsl.exe it holds keeps WSL running (WSL shuts down soon after its last Windows-side process ends,
|
|
11
|
+
background programs included); the page's Quit ends it. setup hands off the same way, opening the page (launch_background).
|
|
12
|
+
Measured on the owner's laptop: conhost --headless runs wsl.exe with MainWindowHandle 0, and the command runs in the distro.
|
|
13
|
+
(1.4.0–1.5.x wrote a minimized wsl.exe window instead.) Native Windows: the installer's own Startup shortcut "Witbitz Code.lnk" (code.ps1), pointed at its
|
|
11
14
|
launcher. Written and removed by Windows PowerShell (WSL's interop reaches it). = tools/code-wsl-start.mjs (held equal by test).
|
|
12
15
|
"""
|
|
13
16
|
|
|
@@ -25,8 +28,11 @@ from . import ports
|
|
|
25
28
|
|
|
26
29
|
POWERSHELL = "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe"
|
|
27
30
|
STARTUP = "[Environment]::GetFolderPath('Startup')"
|
|
31
|
+
PROGRAMS = "[Environment]::GetFolderPath('Programs')" # the Start menu
|
|
28
32
|
WSL_EXE = "(Join-Path $env:WINDIR 'System32\\wsl.exe')"
|
|
29
|
-
|
|
33
|
+
CONHOST = "(Join-Path $env:WINDIR 'System32\\conhost.exe')"
|
|
34
|
+
UI_QUIET = ["ui", "--no-browser", "--hidden"] # what a WSL login start runs: the page's manager, serving, no browser, no window (ui.py)
|
|
35
|
+
WIN_QUIET = ["ui", "--no-browser"] # native Windows: pythonw knows it has no window by itself (winbg.py)
|
|
30
36
|
|
|
31
37
|
|
|
32
38
|
def shortcut_name(distro: str) -> str:
|
|
@@ -44,6 +50,11 @@ def wsl_args(distro: str, user: str, cmd: list[str], verb: list[str] | None = No
|
|
|
44
50
|
return _join(parts)
|
|
45
51
|
|
|
46
52
|
|
|
53
|
+
def headless_args(distro: str, user: str, cmd: list[str], verb: list[str]) -> str:
|
|
54
|
+
"""conhost.exe's arguments: no window, then wsl.exe with wsl_args."""
|
|
55
|
+
return "--headless wsl.exe " + wsl_args(distro, user, cmd, verb)
|
|
56
|
+
|
|
57
|
+
|
|
47
58
|
def _q(s: str) -> str: # a PowerShell single-quoted string
|
|
48
59
|
return "'" + s.replace("'", "''") + "'"
|
|
49
60
|
|
|
@@ -94,19 +105,55 @@ def startup_here() -> dict | None:
|
|
|
94
105
|
if not exe:
|
|
95
106
|
return None
|
|
96
107
|
distro = os.environ.get("WSL_DISTRO_NAME", "")
|
|
97
|
-
return {"name": shortcut_name(distro), "args":
|
|
108
|
+
return {"name": shortcut_name(distro), "args": headless_args(distro, getpass.getuser(), [exe], UI_QUIET), "target": CONHOST}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def menu_name(distro: str) -> str:
|
|
112
|
+
"""The WSL copy's own Start-menu entry — Windows lists it apart from the Windows copy's "Witbitz Code" (the owner,
|
|
113
|
+
2026-10-09: "the windows witbitz code program for wsl should be called differently")."""
|
|
114
|
+
return shortcut_name(distro)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def launch_script(args: str) -> str:
|
|
118
|
+
return (f"Start-Process -FilePath {CONHOST} -ArgumentList {_q(args)} -WorkingDirectory $env:USERPROFILE; 'ok'")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def launch_background(*, ps: Callable[[str], tuple[int, str]] = run_ps, which: Callable[[str], str | None] = shutil.which,
|
|
122
|
+
on_wsl: Callable[[], bool] = ports.on_wsl) -> tuple[bool, str]:
|
|
123
|
+
"""WSL: start the page's manager with no window, held by a hidden wsl.exe on the Windows side, and let it open the
|
|
124
|
+
page in the Windows browser — setup's hand-off (this terminal may then close). → (ok, why)."""
|
|
125
|
+
if not on_wsl():
|
|
126
|
+
return False, "not in WSL"
|
|
127
|
+
exe = which("witbitz-code")
|
|
128
|
+
if not exe:
|
|
129
|
+
return False, "witbitz-code is not on PATH"
|
|
130
|
+
distro = os.environ.get("WSL_DISTRO_NAME", "")
|
|
131
|
+
args = headless_args(distro, getpass.getuser(), [exe], ["ui", "--hidden"])
|
|
132
|
+
# The Start-menu entry first (it opens this page again, starting it if it is not running), then the start itself.
|
|
133
|
+
menu = enable_script(menu_name(distro), args, folder=PROGRAMS, target=CONHOST)
|
|
134
|
+
# A login start written by 1.4.0–1.5.x opens a minimized window: an existing one is rewritten to start hidden too.
|
|
135
|
+
quiet = headless_args(distro, getpass.getuser(), [exe], UI_QUIET)
|
|
136
|
+
login = (f"if (Test-Path (Join-Path ({STARTUP}) {_q(shortcut_name(distro))})) {{ "
|
|
137
|
+
f"{enable_script(shortcut_name(distro), quiet, target=CONHOST)} | Out-Null }}")
|
|
138
|
+
code, out = ps(f"try {{ {menu} | Out-Null; {login} }} catch {{ }}; {launch_script(args)}")
|
|
139
|
+
ok = code == 0 and bool(out) and out.splitlines()[-1].strip() == "ok"
|
|
140
|
+
return ok, "" if ok else ("Windows PowerShell could not be started" if code == -1 else f"Windows PowerShell answered {code}")
|
|
98
141
|
|
|
99
142
|
|
|
100
143
|
def windows_startup_spec(env: dict | None = None, which: Callable[[str], str | None] = shutil.which,
|
|
101
|
-
exists: Callable[[str], bool] = os.path.exists) -> dict | None:
|
|
102
|
-
"""Native Windows: the installer's own Startup shortcut "Witbitz Code.lnk"
|
|
103
|
-
|
|
144
|
+
exists: Callable[[str], bool] = os.path.exists, executable: str | None = None) -> dict | None:
|
|
145
|
+
"""Native Windows: the installer's own Startup shortcut "Witbitz Code.lnk". It runs the page's manager in the
|
|
146
|
+
background — pythonw.exe beside this Python, no window (winbg.py) — or, without pythonw, the installer's launcher
|
|
147
|
+
(which passes its arguments on to `witbitz-code ui`), or — installed without it (pipx) — witbitz-code itself."""
|
|
104
148
|
env = os.environ if env is None else env
|
|
149
|
+
pyw = os.path.join(os.path.dirname(executable or sys.executable), "pythonw.exe")
|
|
150
|
+
if exists(pyw):
|
|
151
|
+
return {"name": "Witbitz Code.lnk", "args": _join(["-m", "witbitz_code", *WIN_QUIET]), "target": _q(pyw)}
|
|
105
152
|
launcher = os.path.join(env.get("LOCALAPPDATA", ""), "Witbitz", "Witbitz Code.cmd")
|
|
106
153
|
if env.get("LOCALAPPDATA") and exists(launcher):
|
|
107
154
|
return {"name": "Witbitz Code.lnk", "args": "--no-browser", "target": _q(launcher)}
|
|
108
155
|
exe = which("witbitz-code")
|
|
109
|
-
return {"name": "Witbitz Code.lnk", "args": _join(
|
|
156
|
+
return {"name": "Witbitz Code.lnk", "args": _join(WIN_QUIET), "target": _q(exe)} if exe else None
|
|
110
157
|
|
|
111
158
|
|
|
112
159
|
def login_spec() -> dict | None:
|
|
@@ -129,6 +176,12 @@ def startup_enable(spec: dict, *, ps: Callable[[str], tuple[int, str]] = run_ps)
|
|
|
129
176
|
return ok, "" if ok else (f"Windows PowerShell answered {code}" if code != -1 else "Windows PowerShell could not be started")
|
|
130
177
|
|
|
131
178
|
|
|
179
|
+
def menu_remove(*, ps: Callable[[str], tuple[int, str]] = run_ps) -> bool:
|
|
180
|
+
"""WSL: remove this distro's Start-menu entry (uninstall). True when one was there and is gone."""
|
|
181
|
+
code, out = ps(disable_script(menu_name(os.environ.get("WSL_DISTRO_NAME", "")), folder=PROGRAMS))
|
|
182
|
+
return code == 0 and bool(out) and out.splitlines()[-1].strip() == "removed"
|
|
183
|
+
|
|
184
|
+
|
|
132
185
|
def startup_disable(spec: dict, *, ps: Callable[[str], tuple[int, str]] = run_ps) -> bool:
|
|
133
186
|
"""True when a shortcut was there and is gone."""
|
|
134
187
|
code, out = ps(disable_script(spec["name"]))
|