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.
Files changed (98) hide show
  1. {witbitz_code-1.5.1/src/witbitz_code.egg-info → witbitz_code-1.6.0}/PKG-INFO +1 -1
  2. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/pyproject.toml +1 -1
  3. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/__init__.py +1 -1
  4. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/cli.py +14 -5
  5. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/ui.py +25 -5
  6. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/ui_static/app.css +2 -0
  7. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/ui_static/app.js +42 -1
  8. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/ui_static/index.html +12 -1
  9. witbitz_code-1.6.0/src/witbitz_code/uitools.py +108 -0
  10. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/uninstall.py +3 -0
  11. witbitz_code-1.6.0/src/witbitz_code/winbg.py +97 -0
  12. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/wizard.py +15 -0
  13. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/wslstart.py +62 -9
  14. {witbitz_code-1.5.1 → witbitz_code-1.6.0/src/witbitz_code.egg-info}/PKG-INFO +1 -1
  15. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code.egg-info/SOURCES.txt +4 -0
  16. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_setup.py +22 -0
  17. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_ui.py +20 -0
  18. witbitz_code-1.6.0/tests/test_uitools.py +71 -0
  19. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_uninstall.py +5 -3
  20. witbitz_code-1.6.0/tests/test_winbg.py +71 -0
  21. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_wslstart.py +25 -0
  22. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/LICENSE +0 -0
  23. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/README.md +0 -0
  24. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/setup.cfg +0 -0
  25. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/__main__.py +0 -0
  26. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/_js.py +0 -0
  27. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/account.py +0 -0
  28. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/asks.py +0 -0
  29. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/attachments.py +0 -0
  30. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/auto.py +0 -0
  31. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/auto_runner.py +0 -0
  32. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/codekeys.py +0 -0
  33. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/codetools.py +0 -0
  34. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/compress.py +0 -0
  35. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/computers.py +0 -0
  36. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/confidential.py +0 -0
  37. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/confidential_models.json +0 -0
  38. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/connector.py +0 -0
  39. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/devicelink.py +0 -0
  40. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/dirs.py +0 -0
  41. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/folders.py +0 -0
  42. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/gateway.py +0 -0
  43. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/keys.py +0 -0
  44. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/link.py +0 -0
  45. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/mkdir.py +0 -0
  46. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/models.py +0 -0
  47. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/net.py +0 -0
  48. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/offered_models.json +0 -0
  49. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/opencode_plugins/notes-init.md +0 -0
  50. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/opencode_plugins/witbitz-media-server.mjs +0 -0
  51. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/opencode_plugins/witbitz-notes.js +0 -0
  52. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/opencode_plugins/witbitz-progress.js +0 -0
  53. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/outputs.py +0 -0
  54. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/pair.py +0 -0
  55. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/pairings.py +0 -0
  56. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/payers.py +0 -0
  57. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/plugins.py +0 -0
  58. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/policy.py +0 -0
  59. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/ports.py +0 -0
  60. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/qr.py +0 -0
  61. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/receipt.py +0 -0
  62. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/relay.py +0 -0
  63. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/seen.py +0 -0
  64. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/tinfoil_read.py +0 -0
  65. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/tools_catalog.json +0 -0
  66. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/tools_probe.py +0 -0
  67. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/toolsetup.py +0 -0
  68. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/winagent.py +0 -0
  69. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code/winconsole.py +0 -0
  70. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code.egg-info/dependency_links.txt +0 -0
  71. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code.egg-info/entry_points.txt +0 -0
  72. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code.egg-info/requires.txt +0 -0
  73. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/src/witbitz_code.egg-info/top_level.txt +0 -0
  74. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_asks.py +0 -0
  75. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_attachments.py +0 -0
  76. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_auto.py +0 -0
  77. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_cli.py +0 -0
  78. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_codekeys.py +0 -0
  79. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_codetools.py +0 -0
  80. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_confidential.py +0 -0
  81. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_connector_e2e.py +0 -0
  82. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_devicelink.py +0 -0
  83. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_dirs.py +0 -0
  84. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_files_and_compress.py +0 -0
  85. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_folders.py +0 -0
  86. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_outputs.py +0 -0
  87. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_pair_flows.py +0 -0
  88. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_plugins.py +0 -0
  89. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_policy.py +0 -0
  90. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_ports.py +0 -0
  91. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_ports_win.py +0 -0
  92. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_registry.py +0 -0
  93. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_relay_codec.py +0 -0
  94. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_tools_probe.py +0 -0
  95. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_toolsetup.py +0 -0
  96. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_winagent.py +0 -0
  97. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_winconsole.py +0 -0
  98. {witbitz_code-1.5.1 → witbitz_code-1.6.0}/tests/test_windows_files.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: witbitz-code
3
- Version: 1.5.1
3
+ Version: 1.6.0
4
4
  Summary: Reach OpenCode on this computer from the Witbitz Spaces Code section, through an end-to-end sealed relay.
5
5
  Author: Witbitz
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "witbitz-code"
7
- version = "1.5.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"
@@ -4,4 +4,4 @@ The Python implementation of tools/witbitz-code.mjs (docs/opencode-relay.md phas
4
4
  vectors and cross-implementation tests.
5
5
  """
6
6
 
7
- __version__ = "1.5.1"
7
+ __version__ = "1.6.0"
@@ -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>] start here: installs OpenCode if needed, pairs this computer — which gives it its own
37
- keys from your Spaces account — then serves
38
- ui [--no-browser] the same in a page in your browser: status, pair / unpair / pair again, start and stop,
39
- start when I log in (Windows and WSL); it serves while its window is open
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, "log": list(self.log)[-14:]}
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: keep this window open (closing it stops witbitz-code). Ctrl-C stops it.", file=sys.stderr, flush=True)
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’s window was closed — open it again from the Start menu.'
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</footer>
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
- in a minimized window: the page's manager, serving, without opening a browser. That window keeps WSL running; closing it
10
- stops witbitz-code. Native Windows: the installer's own Startup shortcut "Witbitz Code.lnk" (code.ps1), pointed at its
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
- UI_QUIET = ["ui", "--no-browser"] # what a login start runs: the page's manager, serving, no browser (ui.py)
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": wsl_args(distro, getpass.getuser(), [exe], UI_QUIET), "target": WSL_EXE}
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", pointed at its launcher (which passes its
103
- arguments on to `witbitz-code ui`), or — installed without it (pipx) — at witbitz-code itself."""
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(UI_QUIET), "target": _q(exe)} if exe else None
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"]))