diff --git a/install/helpers/setup_potoken_provider.bat b/install/helpers/setup_potoken_provider.bat index fa156055..7ab03cfd 100644 --- a/install/helpers/setup_potoken_provider.bat +++ b/install/helpers/setup_potoken_provider.bat @@ -44,8 +44,18 @@ if !errorlevel! neq 0 ( ) if defined DO_NODE_INSTALL ( winget install --id OpenJS.NodeJS.LTS -e --silent --accept-package-agreements --accept-source-agreements - REM Make the freshly installed node available in THIS session - set "PATH=%ProgramFiles%\nodejs;%PATH%" + REM Make the freshly installed node available in THIS session. The + REM installer updates the registry PATH, but this console keeps its + REM old copy - probe the known install locations (machine-wide and + REM per-user) and refresh PATH from the registry as a fallback. + REM Without this, the check below fails right after a successful + REM install and the provider stays unbuilt until the next run. + if exist "%ProgramFiles%\nodejs\node.exe" set "PATH=%ProgramFiles%\nodejs;!PATH!" + if exist "%LOCALAPPDATA%\Programs\nodejs\node.exe" set "PATH=%LOCALAPPDATA%\Programs\nodejs;!PATH!" + where node >nul 2>&1 + if !errorlevel! neq 0 ( + for /f "delims=" %%P in ('powershell -NoProfile -Command "[Environment]::GetEnvironmentVariable('Path','Machine') + ';' + [Environment]::GetEnvironmentVariable('Path','User')"') do set "PATH=%%P;!PATH!" + ) ) where node >nul 2>&1 if !errorlevel! neq 0 ( @@ -132,6 +142,12 @@ call npx --yes tsc >nul 2>&1 popd if exist "%SERVER_ENTRY%" ( + REM The GUI's first-launch bootstrap starts the server itself right + REM after this script, so the warm-up below would only double the wait. + if defined ULTRASINGER_POTOKEN_SKIP_WARMUP ( + echo Provider built. Warm-up skipped ^(the app manages the server^). + exit /b 0 + ) REM Warm up the provider now so the FIRST app launch is fast. On first REM start, security software scans the freshly built node_modules, which REM on corporate machines can take several minutes - during which the app diff --git a/install/helpers/setup_potoken_provider.sh b/install/helpers/setup_potoken_provider.sh index f41520b0..2c8abd4b 100644 --- a/install/helpers/setup_potoken_provider.sh +++ b/install/helpers/setup_potoken_provider.sh @@ -121,6 +121,12 @@ echo "Building provider (npm install + tsc, this can take a minute)..." ( cd "$PROVIDER_DIR/server" && npm install --no-audit --no-fund >/dev/null 2>&1 && npx --yes tsc >/dev/null 2>&1 ) if [ -f "$SERVER_ENTRY" ]; then + # The GUI's first-launch bootstrap starts the server itself right after + # this script, so the warm-up below would only double the wait. + if [ -n "${ULTRASINGER_POTOKEN_SKIP_WARMUP:-}" ]; then + echo "Provider built. Warm-up skipped (the app manages the server)." + exit 0 + fi # Warm up the provider now so the FIRST app launch is fast. The first time # the freshly built server starts, security software scans its many # node_modules files, which on corporate machines can take several minutes diff --git a/pytest/gui/test_potoken_provider.py b/pytest/gui/test_potoken_provider.py index db9bffa1..76db501f 100644 --- a/pytest/gui/test_potoken_provider.py +++ b/pytest/gui/test_potoken_provider.py @@ -168,6 +168,126 @@ def test_node_timeout_mentions_antivirus_hint(self, tmp_path): assert "antivirus" in status.detail +class TestBootstrapNodeProvider: + """First-launch self-heal: build the provider when the installer could + not (e.g. Node.js only became visible after the install run).""" + + def _completed_proc(self, returncode=0): + proc = MagicMock() + proc.poll.return_value = returncode + proc.returncode = returncode + return proc + + def test_builds_provider_and_reports_success(self, tmp_path): + entry = tmp_path / "main.js" + script = tmp_path / "setup.bat" + script.write_text("rem stub", encoding="utf-8") + entry.write_text("// built", encoding="utf-8") + progress = MagicMock() + with patch.object(pp, "_setup_script", return_value=script), \ + patch.object(pp, "_setup_log_path", + return_value=tmp_path / "setup.log"), \ + patch("shutil.which", return_value="/usr/bin/git"), \ + patch("subprocess.Popen", + return_value=self._completed_proc(0)) as popen: + ok = pp._bootstrap_node_provider(entry, on_progress=progress) + assert ok is True + progress.assert_called_once() + # The GUI manages the server itself - the script must skip warm-up. + env = popen.call_args.kwargs["env"] + assert env["ULTRASINGER_POTOKEN_SKIP_WARMUP"] == "1" + + def test_returns_false_when_script_missing(self, tmp_path): + with patch.object(pp, "_setup_script", + return_value=tmp_path / "missing.bat"), \ + patch("subprocess.Popen") as popen: + ok = pp._bootstrap_node_provider(tmp_path / "main.js") + assert ok is False + popen.assert_not_called() + + def test_returns_false_when_git_missing(self, tmp_path): + script = tmp_path / "setup.bat" + script.write_text("rem stub", encoding="utf-8") + with patch.object(pp, "_setup_script", return_value=script), \ + patch("shutil.which", return_value=None), \ + patch("subprocess.Popen") as popen: + ok = pp._bootstrap_node_provider(tmp_path / "main.js") + assert ok is False + popen.assert_not_called() + + def test_returns_false_when_entry_still_missing(self, tmp_path): + script = tmp_path / "setup.bat" + script.write_text("rem stub", encoding="utf-8") + with patch.object(pp, "_setup_script", return_value=script), \ + patch.object(pp, "_setup_log_path", + return_value=tmp_path / "setup.log"), \ + patch("shutil.which", return_value="/usr/bin/git"), \ + patch("subprocess.Popen", + return_value=self._completed_proc(0)): + ok = pp._bootstrap_node_provider(tmp_path / "never_built.js") + assert ok is False + + def test_cancel_before_launch_skips(self, tmp_path): + import threading + script = tmp_path / "setup.bat" + script.write_text("rem stub", encoding="utf-8") + cancel = threading.Event() + cancel.set() + with patch.object(pp, "_setup_script", return_value=script), \ + patch("shutil.which", return_value="/usr/bin/git"), \ + patch("subprocess.Popen") as popen: + ok = pp._bootstrap_node_provider(tmp_path / "main.js", + cancel=cancel) + assert ok is False + popen.assert_not_called() + + def test_ensure_provider_bootstraps_when_entry_missing(self, tmp_path): + """Node available but no built provider: self-heal instead of hint.""" + entry = tmp_path / "main.js" + + def fake_bootstrap(e, cancel=None, on_progress=None, **kwargs): + e.write_text("// built", encoding="utf-8") + return True + + proc = MagicMock() + proc.poll.return_value = None + with patch.object(pp, "is_provider_running", + side_effect=[False, True]), \ + patch.object(pp, "_node_exe", return_value="/usr/bin/node"), \ + patch.object(pp, "_bootstrap_node_provider", + side_effect=fake_bootstrap) as bootstrap, \ + patch.object(pp, "_start_node_provider", return_value=proc), \ + patch.object(pp, "_docker_exe") as docker_exe: + status = pp.ensure_provider(node_entry=entry) + assert status.running is True + assert status.started_by_us is True + bootstrap.assert_called_once() + docker_exe.assert_not_called() + + def test_ensure_provider_no_bootstrap_when_entry_exists(self, tmp_path): + entry = tmp_path / "main.js" + entry.write_text("// stub", encoding="utf-8") + proc = MagicMock() + proc.poll.return_value = None + with patch.object(pp, "is_provider_running", + side_effect=[False, True]), \ + patch.object(pp, "_node_exe", return_value="/usr/bin/node"), \ + patch.object(pp, "_bootstrap_node_provider") as bootstrap, \ + patch.object(pp, "_start_node_provider", return_value=proc): + status = pp.ensure_provider(node_entry=entry) + assert status.running is True + bootstrap.assert_not_called() + + def test_ensure_provider_no_bootstrap_without_node(self, tmp_path): + with patch.object(pp, "is_provider_running", return_value=False), \ + patch.object(pp, "_node_exe", return_value=None), \ + patch.object(pp, "_bootstrap_node_provider") as bootstrap, \ + patch.object(pp, "_docker_exe", return_value=None): + status = pp.ensure_provider(node_entry=tmp_path / "nope.js") + assert status.running is False + bootstrap.assert_not_called() + + class TestStopProvider: def test_terminates_node_process(self): proc = MagicMock() diff --git a/src/gui/main_window.py b/src/gui/main_window.py index f1ba86c6..3118bae5 100644 --- a/src/gui/main_window.py +++ b/src/gui/main_window.py @@ -36,6 +36,7 @@ class _PotokenWorker(QObject): """ finished = Signal(object) # ProviderStatus | None + progress = Signal(str) # human-readable note on long-running steps def __init__(self, base_url: str, auto_start_node: bool, auto_start_docker: bool, cancel): @@ -55,6 +56,7 @@ def run(self): auto_start_node=self._auto_start_node, auto_start_docker=self._auto_start_docker, cancel=self._cancel, + on_progress=self.progress.emit, ) except Exception as e: # noqa: BLE001 — fail open, never crash startup logger.warning("PO-token provider check failed: %s", e) @@ -254,10 +256,16 @@ def _start_potoken_provider(self): ) self._potoken_worker.moveToThread(self._potoken_thread) self._potoken_thread.started.connect(self._potoken_worker.run) + self._potoken_worker.progress.connect(self._on_potoken_progress) self._potoken_worker.finished.connect(self._on_potoken_ready) self._potoken_worker.finished.connect(self._potoken_thread.quit) self._potoken_thread.start() + def _on_potoken_progress(self, message: str): + """Surface provider-setup progress (e.g. first-launch build).""" + if not self._closing: + self._queue_tab.append_log("[PO-Token] " + message) + def _on_potoken_ready(self, status): """Report PO-token provider status and unlock the Queue button.""" self._potoken_status = status diff --git a/src/gui/potoken_provider.py b/src/gui/potoken_provider.py index bf1fcbec..f17e5f6c 100644 --- a/src/gui/potoken_provider.py +++ b/src/gui/potoken_provider.py @@ -56,6 +56,7 @@ _REPO_ROOT / ".potoken" / "bgutil-ytdlp-pot-provider" / "server" / "build" / "main.js" ) +_BOOTSTRAP_TIMEOUT = 900.0 # seconds; npm install on slow disks/AV takes minutes @dataclass @@ -141,6 +142,100 @@ def _node_exe() -> str | None: return shutil.which("node") +def _setup_script() -> Path: + """The install helper that fetches and builds the Node provider.""" + name = ( + "setup_potoken_provider.bat" if os.name == "nt" + else "setup_potoken_provider.sh" + ) + return _REPO_ROOT / "install" / "helpers" / name + + +def _setup_log_path() -> Path: + return _REPO_ROOT / ".potoken" / "setup.log" + + +def _bootstrap_node_provider( + entry: Path, + cancel: threading.Event | None = None, + timeout: float = _BOOTSTRAP_TIMEOUT, + on_progress=None, +) -> bool: + """Build the Node provider via the repo's setup script (one-time). + + Covers installs where the provider setup could not finish — typically + because Node.js was installed during that very run and was not yet + visible to the same console. Node.js is available *now* (the caller + checked), so build the provider here instead of asking the user to + re-run the installer. The script's output goes to ``.potoken/setup.log``. + Returns True when the server entry exists afterwards. Never raises. + """ + import time + + script = _setup_script() + if not script.is_file() or shutil.which("git") is None: + return False + if cancel is not None and cancel.is_set(): + return False + + logger.info( + "PO-token provider is not built yet - running %s (one-time, output " + "in %s)", script.name, _setup_log_path(), + ) + if on_progress is not None: + on_progress( + "PO-token provider is not set up yet - downloading and building " + "it now (one-time, this can take a few minutes)..." + ) + # The GUI starts (and warms up) the server itself right afterwards. + env = dict(os.environ, ULTRASINGER_POTOKEN_SKIP_WARMUP="1") + if os.name == "nt": + cmd = ["cmd.exe", "/d", "/c", str(script), "install\\update.bat"] + else: + cmd = ["bash", str(script), "./install/update.sh"] + + log_path = _setup_log_path() + try: + log_path.parent.mkdir(parents=True, exist_ok=True) + log_file = open(log_path, "w", encoding="utf-8") # noqa: SIM115 + except OSError: + log_file = subprocess.DEVNULL + try: + proc = subprocess.Popen( + cmd, + stdout=log_file, + stderr=subprocess.STDOUT if log_file is not subprocess.DEVNULL + else subprocess.DEVNULL, + stdin=subprocess.DEVNULL, + cwd=str(_REPO_ROOT), + env=env, + creationflags=_CREATE_NO_WINDOW, + ) + except (OSError, subprocess.SubprocessError) as e: + logger.warning("Failed to run the PO-token provider setup: %s", e) + return False + finally: + if log_file is not subprocess.DEVNULL: + log_file.close() + + deadline = time.monotonic() + timeout + while proc.poll() is None: + if (cancel is not None and cancel.is_set()) \ + or time.monotonic() >= deadline: + _terminate_process(proc) + return False + time.sleep(1.0) + + if proc.returncode == 0 and entry.is_file(): + logger.info("PO-token provider built successfully.") + return True + logger.warning( + "PO-token provider setup did not complete (exit code %s) - see %s", + proc.returncode, log_path, + ) + return False + + def _provider_log_path() -> Path: return _REPO_ROOT / ".potoken" / "provider.log" @@ -276,12 +371,15 @@ def ensure_provider( auto_start_docker: bool = True, node_entry: Path | None = None, cancel: threading.Event | None = None, + on_progress=None, ) -> ProviderStatus: """Ensure a PO-token provider is reachable. Never raises. Order: an already-running server → a local Node server - (preferred, no Docker needed) → Docker → a setup hint. ``cancel`` - lets the GUI abort a pending start on shutdown. + (preferred, no Docker needed; built on the fly when the installer could + not) → Docker → a setup hint. ``cancel`` lets the GUI abort a pending + start on shutdown; ``on_progress`` (a ``str`` callable) receives + human-readable notes about long-running steps. """ try: normalized, port = _normalize_base_url(base_url) @@ -300,6 +398,12 @@ def ensure_provider( # Preferred: a local Node.js provider server (set up by the installer). entry = node_entry or _NODE_SERVER_ENTRY node = _node_exe() if auto_start_node else None + if node and not entry.is_file(): + # The installer could not finish the provider setup (typically: + # Node.js was installed during that very run but was not yet visible + # to the same console). Node.js is available now, so self-heal by + # building the provider here instead of asking for a reinstall. + _bootstrap_node_provider(entry, cancel=cancel, on_progress=on_progress) if node and entry.is_file(): logger.info("Starting bgutil PO-token provider via Node ...") proc = _start_node_provider(node, entry)