Commit 1eba89fc by PLN (Algolia)

feat(bridge): fleet launchers + MIDI monitor + unified tray

The hub was dead links (clicking Foundry 404'd — its server wasn't running).
Now it's a real control center, with one shared registry driving both faces.

- launchers.py: allow-listed registry. Web tools (Foundry/Armada) = start-or-open
  via port-check (spawn the server if down, then open); native apps
  (Pulsar/Ardour/qjackctl) launch detached with running + availability state.
  Wayland has no portable focus → running apps report 'already-running'.
- midimon.py: 'aseqdump | tr' done right — parses each event, renders aligned &
  colourised with note names (C4) and velocity bars. parse_line is pure/tested.
- Bridge UI: hub shows live running dots; start-or-open opens a blank tab within
  the click gesture then navigates once the server binds (no popup block).
- perf-tray: 'Open Bridge' + 'Launch ▸' submenu off the SAME launchers.py — the
  tray is now the unified ParVagues tray (DRY with the web hub).
- 17 tests (launcher registry/state + MIDI parser). Verified: Foundry card boots
  the server → :8765 200.
parent ba1cb149
...@@ -34,9 +34,11 @@ from PyQt5.QtCore import QTimer, Qt, QProcess, QRect ...@@ -34,9 +34,11 @@ from PyQt5.QtCore import QTimer, Qt, QProcess, QRect
# perf.py lives under tools/bridge. # perf.py lives under tools/bridge.
sys.path.insert(0, str(Path(__file__).resolve().parent / "tools" / "bridge")) sys.path.insert(0, str(Path(__file__).resolve().parent / "tools" / "bridge"))
from perf import Thermals, detect_mode, MODES, SCRIPT # noqa: E402 from perf import Thermals, detect_mode, MODES, SCRIPT # noqa: E402
import launchers as LA # noqa: E402
SERVICE = "perf-tray" # systemd --user unit controlling autostart SERVICE = "perf-tray" # systemd --user unit controlling autostart
REFRESH_MS = 2000 REFRESH_MS = 2000
BRIDGE_URL = "http://127.0.0.1:8773/"
def autostart_enabled(): def autostart_enabled():
...@@ -142,6 +144,19 @@ class PerfTray: ...@@ -142,6 +144,19 @@ class PerfTray:
self.mode_actions[key] = act self.mode_actions[key] = act
self.menu.addSeparator() self.menu.addSeparator()
bridge_act = QAction("⚓ Open Bridge", self.menu)
bridge_act.triggered.connect(lambda: self._open_url(BRIDGE_URL))
self.menu.addAction(bridge_act)
launch_menu = self.menu.addMenu("Launch ▸")
snap = LA.snapshot()
for item in (*snap["web"], *snap["apps"]):
act = QAction(item["name"], launch_menu)
act.setEnabled(item["available"])
act.triggered.connect(lambda _c, k=item["key"]: self._launch(k))
launch_menu.addAction(act)
self.menu.addSeparator()
self.autostart_action = QAction("Start at login", self.menu, checkable=True) self.autostart_action = QAction("Start at login", self.menu, checkable=True)
self.autostart_action.toggled.connect(self.on_autostart_toggled) self.autostart_action.toggled.connect(self.on_autostart_toggled)
self.menu.addAction(self.autostart_action) self.menu.addAction(self.autostart_action)
...@@ -189,6 +204,17 @@ class PerfTray: ...@@ -189,6 +204,17 @@ class PerfTray:
proc.finished.connect(done) proc.finished.connect(done)
proc.start() proc.start()
def _open_url(self, url):
QProcess.startDetached("xdg-open", [url])
def _launch(self, key):
r = LA.launch(key)
if r["ok"] and r.get("url"):
QTimer.singleShot(1300 if r["status"] == "launched" else 0,
lambda: self._open_url(r["url"]))
if not r["ok"]:
self.tray.showMessage("perf-tray", r["msg"], QSystemTrayIcon.Warning, 5000)
def on_autostart_toggled(self, on): def on_autostart_toggled(self, on):
set_autostart(on) set_autostart(on)
QTimer.singleShot(200, self.refresh) QTimer.singleShot(200, self.refresh)
......
"""launchers — the fleet's app launchers, shared by the web Bridge and the tray.
An allow-listed registry (never arbitrary commands): each entry knows how to be
found, whether it's running, and how to spawn detached. Both faces — the web hub
(`/api/launch`) and the perf-tray menu — drive the same `launch(key)`.
"Open if running, else launch": on Wayland there's no portable window-focus, so
a running app reports `already-running` (the user can click again knowingly)
rather than spawning a duplicate.
"""
from __future__ import annotations
import os
import shutil
import socket
import subprocess
from pathlib import Path
HERE = Path(__file__).resolve().parent
TIDAL = Path.home() / "Work" / "Sound" / "Tidal"
ARDOUR_SESSION = Path.home() / "Work" / "Sound" / "Ardour" / "Tidal Multi" / "Tidal Multi.ardour"
MIDIMON = HERE / "midimon.py"
# Terminal emulators we know how to wrap, best first.
TERMINALS = ("konsole", "kitty", "alacritty", "gnome-terminal", "x-terminal-emulator")
def which_first(candidates):
for c in candidates:
p = shutil.which(c)
if p:
return p
return None
def _term_argv(inner):
"""Wrap an argv to run inside the first available terminal emulator."""
term = which_first(TERMINALS)
if not term:
return None
base = os.path.basename(term)
if base == "gnome-terminal":
return [term, "--", *inner]
if base == "kitty":
return [term, *inner]
return [term, "-e", *inner] # konsole, alacritty, x-terminal-emulator
# key -> spec. `candidates` = binaries (first found wins); `args` appended;
# `pgrep` = running-check pattern; `terminal` = run inside a terminal emulator.
LAUNCHERS = [
{"key": "pulsar", "name": "Pulsar", "blurb": "editor — open the Tidal folder",
"candidates": ["pulsar"], "args": [str(TIDAL)], "pgrep": "pulsar"},
{"key": "ardour", "name": "Ardour", "blurb": "DAW — the Tidal Multi session",
"candidates": ["ardour8", "ardour7", "ardour6", "ardour"],
"args": ([str(ARDOUR_SESSION)] if ARDOUR_SESSION.exists() else []), "pgrep": "ardour"},
{"key": "qjackctl", "name": "QjackCtl", "blurb": "JACK / audio graph control",
"candidates": ["qjackctl"], "args": [], "pgrep": "qjackctl"},
{"key": "midimon", "name": "MIDI Monitor", "blurb": "live, parsed MIDI seq (aseqdump done right)",
"candidates": ["python3"], "args": [str(MIDIMON)], "pgrep": "midimon.py", "terminal": True},
]
# Web tools = start-or-open. `port` open ⇒ running; else `cmd` is spawned, then
# the UI opens `url`. (Fixes dead hub links to not-yet-started servers.)
WEB = [
{"key": "foundry", "name": "The Foundry", "blurb": "URL → audio → stems → loops",
"url": "http://127.0.0.1:8765/", "port": 8765,
"cmd": ["python3", str(TIDAL / "tools/foundry/server.py"), "--port", "8765"]},
{"key": "armada", "name": "L'Armada", "blurb": "catalog · distribution · gigs",
"url": "http://127.0.0.1:8731/", "port": 8731,
"cmd": ["python3", str(TIDAL / "armada/serve.py"),
"--dir", str(TIDAL / "armada"), "--port", "8731"]},
]
BY_KEY = {l["key"]: l for l in (*LAUNCHERS, *WEB)}
def _port_open(port, host="127.0.0.1"):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(0.25)
return s.connect_ex((host, port)) == 0
def binary(spec):
return which_first(spec["candidates"])
def available(spec):
if not binary(spec):
return False
if spec.get("terminal") and not which_first(TERMINALS):
return False
return True
def is_running(spec):
try:
return subprocess.run(["pgrep", "-f", spec["pgrep"]],
capture_output=True).returncode == 0
except OSError:
return False
def _argv(spec):
inner = [binary(spec), *spec.get("args", [])]
return _term_argv(inner) if spec.get("terminal") else inner
def launch(key):
"""Spawn the launcher detached. Returns {ok, status, msg, url?}."""
spec = BY_KEY.get(key)
if not spec:
return {"ok": False, "status": "unknown", "msg": f"unknown launcher {key!r}"}
if "port" in spec: # web service: start-or-open
if _port_open(spec["port"]):
return {"ok": True, "status": "already-running", "url": spec["url"],
"msg": f"{spec['name']} is up"}
try:
subprocess.Popen(spec["cmd"], cwd=str(TIDAL), start_new_session=True,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except OSError as e:
return {"ok": False, "status": "error", "msg": str(e)}
return {"ok": True, "status": "launched", "url": spec["url"],
"msg": f"starting {spec['name']}…"}
if not available(spec):
return {"ok": False, "status": "unavailable",
"msg": f"{spec['name']} not installed ({'/'.join(spec['candidates'])})"}
if not spec.get("terminal") and is_running(spec):
# Wayland: no portable focus — report rather than duplicate.
return {"ok": True, "status": "already-running",
"msg": f"{spec['name']} is already running"}
argv = _argv(spec)
if not argv:
return {"ok": False, "status": "no-terminal", "msg": "no terminal emulator found"}
try:
subprocess.Popen(argv, cwd=str(TIDAL), start_new_session=True,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except OSError as e:
return {"ok": False, "status": "error", "msg": str(e)}
return {"ok": True, "status": "launched", "msg": f"launched {spec['name']}"}
def snapshot():
"""Apps + web tools with live state for the UI / tray."""
apps = [{"key": s["key"], "name": s["name"], "blurb": s["blurb"], "kind": "app",
"available": available(s), "running": is_running(s),
"terminal": bool(s.get("terminal"))} for s in LAUNCHERS]
web = [{"key": s["key"], "name": s["name"], "blurb": s["blurb"], "kind": "web",
"url": s["url"], "available": True, "running": _port_open(s["port"])}
for s in WEB]
return {"web": web, "apps": apps}
#!/usr/bin/env python3
"""midimon — a clean live MIDI monitor (aseqdump, parsed & rendered right).
`aseqdump | tr -d …` is a hack. This wraps aseqdump, parses each event, and
renders an aligned, colourised stream: note numbers → names (C4…), velocity as a
little bar, events tinted by type. Ctrl-C to stop.
python3 midimon.py # monitor aseqdump's default port
python3 midimon.py -p 28:0 # subscribe to a specific source
python3 midimon.py -l # list ports and exit
python3 midimon.py --raw # passthrough (debug)
Parsing is a pure function (`parse_line`) so it's unit-tested without ALSA.
"""
from __future__ import annotations
import argparse
import re
import subprocess
import sys
NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
# event keyword → ANSI colour
C = {"note on": "92", "note off": "90", "control": "93", "program": "96",
"pitch": "95", "channel": "94", "aftertouch": "94",
"clock": "90", "start": "92", "stop": "91", "continue": "92",
"song": "90", "sysex": "95", "active": "90", "reset": "91"}
RESET, DIM, BOLD = "\033[0m", "\033[2m", "\033[1m"
# " 28:0 Note on 0, note 60, velocity 100"
_ROW = re.compile(r"^\s*(\d+:\d+)\s+(\S.*?\S|\S)\s{2,}(.*?)\s*$")
def note_name(n: int) -> str:
return f"{NOTE_NAMES[n % 12]}{n // 12 - 1}"
def parse_line(line: str) -> dict | None:
"""aseqdump event row → {source, event, data, ch?}. None if not an event."""
m = _ROW.match(line.rstrip("\n"))
if not m:
return None
source, event, data = m.group(1), m.group(2), m.group(3)
ch = None
mc = re.match(r"^(\d+)\b", data) # first int of data is the channel
if mc:
ch = int(mc.group(1))
return {"source": source, "event": event, "data": data, "ch": ch}
def _color(event: str) -> str:
e = event.lower()
for kw, code in C.items():
if e.startswith(kw):
return code
return "97"
def _render_data(event: str, data: str) -> str:
"""Friendlier data: note names + a velocity bar where it applies."""
note = re.search(r"note (\d+)", data)
vel = re.search(r"velocity (\d+)", data)
out = data
if note:
n = int(note.group(1))
out = out.replace(f"note {n}", f"note {n} {DIM}({note_name(n)}){RESET}")
if vel:
v = int(vel.group(1))
bars = "▁▂▃▄▅▆▇█"
out += f" {bars[min(v, 127) * (len(bars) - 1) // 127]}"
return out
def render(ev: dict) -> str:
code = _color(ev["event"])
ch = f"ch{ev['ch']:<2}" if ev["ch"] is not None else " "
return (f"{DIM}{ev['source']:>6}{RESET} "
f"\033[{code}m{BOLD}{ev['event']:<18}{RESET} "
f"{DIM}{ch}{RESET} {_render_data(ev['event'], ev['data'])}")
def list_ports():
subprocess.run(["aseqdump", "-l"])
def monitor(port: str | None, raw: bool):
cmd = ["aseqdump"] + (["-p", port] if port else [])
print(f"{DIM}⚓ midimon — {' '.join(cmd)} · Ctrl-C to stop{RESET}", file=sys.stderr)
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, text=True, bufsize=1)
try:
for line in proc.stdout or []:
if raw:
sys.stdout.write(line); continue
ev = parse_line(line)
if ev:
print(render(ev), flush=True)
elif line.strip() and not line.startswith(("Source", "Waiting")):
print(f"{DIM}{line.rstrip()}{RESET}")
except KeyboardInterrupt:
pass
finally:
proc.terminate()
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("-p", "--port", help="source port (CLIENT:PORT), e.g. 28:0")
ap.add_argument("-l", "--list", action="store_true", help="list ports and exit")
ap.add_argument("--raw", action="store_true", help="passthrough, no parsing")
a = ap.parse_args(argv)
if a.list:
return list_ports()
monitor(a.port, a.raw)
if __name__ == "__main__":
main()
...@@ -15,7 +15,8 @@ mode-switching must not be LAN-triggerable unless you say so. ...@@ -15,7 +15,8 @@ mode-switching must not be LAN-triggerable unless you say so.
API: API:
GET /api/perf → perf.snapshot() (mode, temps, freq, fans, throttle) GET /api/perf → perf.snapshot() (mode, temps, freq, fans, throttle)
POST /api/perf {mode} → switch perf mode → {ok, msg, snapshot} POST /api/perf {mode} → switch perf mode → {ok, msg, snapshot}
GET /api/hub → [{name, url, blurb}] (the tool launcher) GET /api/launchers → {web:[…], apps:[…]} (live running/available state)
POST /api/launch {key} → start-or-open a tool → {ok, status, msg, url?}
GET /api/health → {ok:true} GET /api/health → {ok:true}
""" """
from __future__ import annotations from __future__ import annotations
...@@ -32,18 +33,10 @@ from urllib.parse import urlparse ...@@ -32,18 +33,10 @@ from urllib.parse import urlparse
HERE = Path(__file__).resolve().parent HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE)) sys.path.insert(0, str(HERE))
import perf as P # noqa: E402 import perf as P # noqa: E402
import launchers as L # noqa: E402
UI = HERE / "ui" UI = HERE / "ui"
# Hub entries — the fleet's other GUIs. Extensible: add a dict, it shows up as a
# card. url is opened in a new tab; ports match each tool's default serve port.
HUB = [
{"name": "The Foundry", "url": "http://127.0.0.1:8765/",
"blurb": "URL → audio → stems → loops (sampling)"},
{"name": "L'Armada", "url": "http://127.0.0.1:8731/",
"blurb": "catalog · distribution · gigs (serve.py)"},
]
# One Thermals instance per process → throttle_delta is relative to Bridge start. # One Thermals instance per process → throttle_delta is relative to Bridge start.
_THERM = P.Thermals() _THERM = P.Thermals()
...@@ -59,8 +52,8 @@ class Handler(BaseHTTPRequestHandler): ...@@ -59,8 +52,8 @@ class Handler(BaseHTTPRequestHandler):
return self._json({"ok": True}) return self._json({"ok": True})
if p == "/api/perf": if p == "/api/perf":
return self._json(P.snapshot(_THERM)) return self._json(P.snapshot(_THERM))
if p == "/api/hub": if p == "/api/launchers":
return self._json(HUB) return self._json(L.snapshot())
# static assets under ui/ # static assets under ui/
asset = (UI / p.lstrip("/")).resolve() asset = (UI / p.lstrip("/")).resolve()
if UI in asset.parents and asset.is_file(): if UI in asset.parents and asset.is_file():
...@@ -75,6 +68,10 @@ class Handler(BaseHTTPRequestHandler): ...@@ -75,6 +68,10 @@ class Handler(BaseHTTPRequestHandler):
ok, msg = P.set_mode(mode) ok, msg = P.set_mode(mode)
return self._json({"ok": ok, "msg": msg, "snapshot": P.snapshot(_THERM)}, return self._json({"ok": ok, "msg": msg, "snapshot": P.snapshot(_THERM)},
200 if ok else 400) 200 if ok else 400)
if p == "/api/launch":
key = (self._body().get("key") or "").strip()
r = L.launch(key)
return self._json(r, 200 if r["ok"] else 400)
return self.send_error(404) return self.send_error(404)
# ── helpers ───────────────────────────────────────────────────────── # ── helpers ─────────────────────────────────────────────────────────
......
"""Pure-logic tests for the launcher registry (no processes spawned)."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import launchers as L
def test_registry_keys():
assert {l["key"] for l in L.LAUNCHERS} == {"pulsar", "ardour", "qjackctl", "midimon"}
assert {w["key"] for w in L.WEB} == {"foundry", "armada"}
assert set(L.BY_KEY) >= {"pulsar", "ardour", "qjackctl", "midimon", "foundry", "armada"}
def test_which_first():
assert L.which_first(["definitely-not-a-binary-xyz"]) is None
assert L.which_first(["definitely-not-xyz", "python3"]).endswith("python3")
def test_snapshot_shape():
s = L.snapshot()
assert set(s) == {"web", "apps"}
for it in s["apps"]:
assert {"key", "name", "blurb", "kind", "available", "running", "terminal"} <= set(it)
assert it["kind"] == "app"
for it in s["web"]:
assert it["kind"] == "web" and it["url"].startswith("http")
def test_launch_unknown():
r = L.launch("nope")
assert r["ok"] is False and r["status"] == "unknown"
def test_port_open_false_on_dead_port():
assert L._port_open(59999) is False # nothing should be listening here
def test_web_launch_already_running_opens(monkeypatch):
# pretend the port is up → must NOT spawn, just report the url to open
monkeypatch.setattr(L, "_port_open", lambda *a, **k: True)
r = L.launch("foundry")
assert r["ok"] is True and r["status"] == "already-running"
assert r["url"].endswith(":8765/")
"""Pure-parser tests for midimon (no ALSA needed)."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import midimon as M
def test_note_name():
assert M.note_name(60) == "C4"
assert M.note_name(69) == "A4"
assert M.note_name(72) == "C5"
assert M.note_name(61) == "C#4"
def test_parse_note_on():
ev = M.parse_line(" 28:0 Note on 0, note 60, velocity 100")
assert ev["source"] == "28:0"
assert ev["event"] == "Note on"
assert ev["ch"] == 0
assert "note 60" in ev["data"]
def test_parse_control_change():
ev = M.parse_line(" 28:0 Control change 5, controller 7, value 99")
assert ev["event"] == "Control change" and ev["ch"] == 5
def test_parse_non_event_returns_none():
assert M.parse_line("Source Event Ch Data") is None
assert M.parse_line("Waiting for data at port 128:0.") is None
assert M.parse_line("") is None
def test_render_contains_event_and_notename():
ev = M.parse_line(" 28:0 Note on 0, note 60, velocity 100")
out = M.render(ev)
assert "Note on" in out and "C4" in out
...@@ -37,12 +37,16 @@ ...@@ -37,12 +37,16 @@
/* ── hub ─────────────────────────────────────────────────── */ /* ── hub ─────────────────────────────────────────────────── */
main{max-width:880px;margin:0 auto;padding:28px 20px} main{max-width:880px;margin:0 auto;padding:28px 20px}
h2{font-size:12px;text-transform:uppercase;letter-spacing:.1em;color:var(--faint);margin:0 0 14px;font-family:var(--mono)} h2{font-size:12px;text-transform:uppercase;letter-spacing:.1em;color:var(--faint);margin:0 0 14px;font-family:var(--mono)}
.hub{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:14px} .hub{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:14px;margin-bottom:28px}
.card{display:block;text-decoration:none;color:inherit;background:var(--raised); .card{display:block;text-align:left;width:100%;cursor:pointer;color:inherit;background:var(--raised);
border:1px solid var(--hairline);border-radius:12px;padding:18px;transition:.15s} border:1px solid var(--hairline);border-radius:12px;padding:18px;transition:.15s;font-family:inherit}
.card:hover{border-color:var(--brand);transform:translateY(-2px)} .card:hover{border-color:var(--brand);transform:translateY(-2px)}
.card:disabled{opacity:.45;cursor:not-allowed;transform:none}
.card h3{margin:0 0 4px;font-size:15px}.card p{margin:0;color:var(--mute);font-size:12px} .card h3{margin:0 0 4px;font-size:15px}.card p{margin:0;color:var(--mute);font-size:12px}
.card .arr{float:right;color:var(--faint)} .card .arr{float:right;color:var(--faint)}
.dot{display:inline-block;width:8px;height:8px;border-radius:99px;margin-right:7px;
background:var(--faint);vertical-align:middle}
.dot.on{background:var(--cool);box-shadow:0 0 6px var(--cool)}
.toast{font-family:var(--mono);font-size:12px;color:var(--mute);padding:0 20px 8px;min-height:16px} .toast{font-family:var(--mono);font-size:12px;color:var(--mute);padding:0 20px 8px;min-height:16px}
.toast.err{color:var(--critical)} .toast.err{color:var(--critical)}
</style> </style>
...@@ -60,7 +64,9 @@ ...@@ -60,7 +64,9 @@
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<main> <main>
<h2>Fleet</h2> <h2>Fleet</h2>
<div class="hub" id="hub"></div> <div class="hub" id="web"></div>
<h2>Launch</h2>
<div class="hub" id="apps"></div>
</main> </main>
<script> <script>
const $=s=>document.querySelector(s), api=(u,o)=>fetch(u,o).then(r=>r.json()); const $=s=>document.querySelector(s), api=(u,o)=>fetch(u,o).then(r=>r.json());
...@@ -99,12 +105,38 @@ async function setMode(mode,btn){ ...@@ -99,12 +105,38 @@ async function setMode(mode,btn){
buildModes(); buildModes();
} }
async function tick(){try{paint(await api("/api/perf"));}catch(e){}} async function tick(){try{paint(await api("/api/perf"));}catch(e){}}
async function loadHub(){ function card(it){
const hub=await api("/api/hub"), box=$("#hub"); const dot=`<span class="dot ${it.running?"on":""}"></span>`;
box.innerHTML=hub.map(h=>`<a class="card" href="${h.url}" target="_blank"> const arr=it.kind==="web"?'<span class="arr">↗</span>':"";
<span class="arr">↗</span><h3>${h.name}</h3><p>${h.blurb}</p></a>`).join(""); return `<button class="card" data-key="${it.key}" data-kind="${it.kind}" data-url="${it.url||""}"
${it.available?"":"disabled"}>${arr}<h3>${dot}${it.name}</h3>
<p>${it.blurb}${it.available?"":" · not installed"}</p></button>`;
} }
loadHub(); tick(); setInterval(tick,2000); async function loadLaunchers(){
const L=await api("/api/launchers");
$("#web").innerHTML=L.web.map(card).join("");
$("#apps").innerHTML=L.apps.map(card).join("");
document.querySelectorAll(".card").forEach(b=>b.onclick=()=>doLaunch(b));
}
async function doLaunch(btn){
const {key,kind,url}=btn.dataset;
// Open a blank tab NOW (within the click gesture → no popup block), navigate
// once the server is confirmed up.
const win = kind==="web" ? window.open("about:blank","_blank") : null;
toast(`launching ${key}…`);
try{
const r=await api("/api/launch",{method:"POST",headers:{"Content-Type":"application/json"},
body:JSON.stringify({key})});
toast((r.ok?"✓ ":"✗ ")+r.msg,!r.ok);
if(win){
if(r.ok){ const t=r.url||url;
if(r.status==="launched") setTimeout(()=>win.location=t,1300); else win.location=t; }
else win.close();
}
setTimeout(loadLaunchers,900);
}catch(e){ toast("✗ "+e,true); if(win)win.close(); }
}
loadLaunchers(); setInterval(loadLaunchers,5000); tick(); setInterval(tick,2000);
</script> </script>
</body> </body>
</html> </html>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment