Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions install/helpers/setup_potoken_provider.bat
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions install/helpers/setup_potoken_provider.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
120 changes: 120 additions & 0 deletions pytest/gui/test_potoken_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
8 changes: 8 additions & 0 deletions src/gui/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
108 changes: 106 additions & 2 deletions src/gui/potoken_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
Loading