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
21 changes: 21 additions & 0 deletions .github/workflows/linux-installer.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,27 @@ jobs:
fi
echo "version=${version}" >> "$GITHUB_OUTPUT"

- name: Set up Node.js (web build)
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # pinned from v6.4.0
with:
node-version: "24"
cache: "npm"
cache-dependency-path: web/package-lock.json

# The web/vis frontends are gitignored build artifacts; without these
# steps PyInstaller froze a binary whose web UI 404'd on "/". The
# bundles are arch-independent, so they are built once on the host —
# the QEMU container build picks them up from the mounted checkout.
# The spec file now also refuses to freeze when the bundles are missing.
- name: Build web UI bundle
env:
PYTHINKER_WEB_STRICT_VERSION: "1"
PYTHINKER_WEB_EXPECT_VERSION: ${{ steps.ver.outputs.version }}
run: python3 scripts/build_web.py

- name: Build vis UI bundle
run: python3 scripts/build_vis.py

- name: Set up QEMU (aarch64 only)
if: matrix.qemu
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # pinned from v4.1.0
Expand Down
19 changes: 19 additions & 0 deletions .github/workflows/windows-installer.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,25 @@ jobs:
- name: Sync project dependencies
run: uv sync --frozen --no-dev

- name: Set up Node.js (web build)
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # pinned from v6.4.0
with:
node-version: "24"
cache: "npm"
cache-dependency-path: web/package-lock.json

# The web/vis frontends are gitignored build artifacts; without these
# steps PyInstaller froze a binary whose web UI 404'd on "/". The spec
# file now also refuses to freeze when the bundles are missing.
- name: Build web UI bundle
env:
PYTHINKER_WEB_STRICT_VERSION: "1"
PYTHINKER_WEB_EXPECT_VERSION: ${{ steps.ver.outputs.version }}
run: python scripts/build_web.py

- name: Build vis UI bundle
run: python scripts/build_vis.py

- name: Install PyInstaller
run: uv pip install pyinstaller

Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ GitHub Releases page; `0.8.0` is the new starting line.

## Unreleased

- **Windows/Linux native installers: web UI no longer 404s on `/`.** The installer CI froze the app without building the gitignored web/vis frontend bundles, so `pythinker web` opened a browser onto `GET /?token=… → 404 Not Found`. Both installer workflows now build the bundles before PyInstaller (matching the PyPI release flow — pip/wheel installs were never affected), every PyInstaller spec refuses to freeze when the bundles are missing, and a build that still lacks them serves an explanatory page on `/` (with the REST API still reachable under `/api`) instead of a bare 404.
- **Startup banner renders on legacy Windows consoles.** The `pythinker web` / `pythinker vis` PYTHINKER banner raw-printed Unicode block art, which garbled on legacy code pages (e.g. PowerShell with cp1252) and raised `UnicodeEncodeError` when output was redirected. The banner now honors the existing ASCII-glyph detection (`PYTHINKER_ASCII_UI` / `PYTHINKER_TUI_GLYPHS=ascii` opt-ins included) with width-preserving ASCII fallbacks, and degrades per line instead of crashing when a stream rejects Unicode.

## 0.40.0 (2026-06-10)

- **Web: same-origin WebSockets accepted, version banner synced to the backend, and token bootstrap race fixed.** The local-mode web server now auto-populates the allowed-origin list (an empty allowlist rejects every `Origin`-bearing request, which previously broke all WebSocket handshakes with a 403). The UI version banner prefers the version the running backend reports (via the config API) over the stale build-time constant, and a transient version-fetch failure no longer permanently disables the backend banner for the session. The initial auth-token bootstrap race that could fail the first request is resolved. `ESC` now reliably terminates only the background tasks spawned by the interrupted turn, and recall context is re-framed so prior-session snippets can't be misread as new instructions.
Expand Down
22 changes: 22 additions & 0 deletions packages/linux-installer/pythinker.spec
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,30 @@
# / tarball). Mode: --onedir — fpm wraps the directory into the package and
# install-native.sh tar-gzips it for the curl-bash flow.

import importlib.util
from pathlib import Path

from PyInstaller.utils.hooks import collect_data_files, collect_submodules, copy_metadata

# The web/vis frontends are gitignored build artifacts. collect_data_files()
# silently collects nothing when they are missing, which shipped installers
# whose web UI answered "/" with a 404. Fail the freeze loudly instead.
_pkg_spec = importlib.util.find_spec("pythinker_code")
if _pkg_spec is None or _pkg_spec.origin is None:
raise SystemExit("pythinker.spec: pythinker_code is not installed in the build environment")
_pkg_root = Path(_pkg_spec.origin).resolve().parent
_missing_ui = [
rel
for rel in ("web/static/index.html", "vis/static/index.html")
if not (_pkg_root / rel).is_file()
]
if _missing_ui:
raise SystemExit(
f"pythinker.spec: UI bundles missing from pythinker_code ({', '.join(_missing_ui)}). "
"Run `make build-web build-vis` (or scripts/build_web.py and scripts/build_vis.py) "
"before freezing, or the packaged web UI will 404 on '/'."
)

block_cipher = None

hiddenimports = []
Expand Down
22 changes: 22 additions & 0 deletions packages/windows-installer/pythinker.spec
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,30 @@
# PyInstaller spec for the Pythinker Code Windows native build.
# Mode: --onedir (faster startup, fewer AV false-positives than --onefile).

import importlib.util
from pathlib import Path

from PyInstaller.utils.hooks import collect_data_files, collect_submodules, copy_metadata

# The web/vis frontends are gitignored build artifacts. collect_data_files()
# silently collects nothing when they are missing, which shipped installers
# whose web UI answered "/" with a 404. Fail the freeze loudly instead.
_pkg_spec = importlib.util.find_spec("pythinker_code")
if _pkg_spec is None or _pkg_spec.origin is None:
raise SystemExit("pythinker.spec: pythinker_code is not installed in the build environment")
_pkg_root = Path(_pkg_spec.origin).resolve().parent
_missing_ui = [
rel
for rel in ("web/static/index.html", "vis/static/index.html")
if not (_pkg_root / rel).is_file()
]
if _missing_ui:
raise SystemExit(
f"pythinker.spec: UI bundles missing from pythinker_code ({', '.join(_missing_ui)}). "
"Run `make build-web build-vis` (or scripts/build_web.py and scripts/build_vis.py) "
"before freezing, or the packaged web UI will 404 on '/'."
)

block_cipher = None

hiddenimports = []
Expand Down
6 changes: 5 additions & 1 deletion pythinker.spec
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
# -*- mode: python ; coding: utf-8 -*-

import os
from pythinker_code.utils.pyinstaller import datas, hiddenimports
from pythinker_code.utils.pyinstaller import datas, hiddenimports, require_ui_assets

# Fail loud when the gitignored web/vis bundles haven't been built; a freeze
# without them ships a binary whose web UI 404s on "/".
require_ui_assets()

# Read codesign identity from environment variable (for macOS signing in CI)
codesign_identity = os.environ.get("APPLE_SIGNING_IDENTITY", None)
Expand Down
23 changes: 23 additions & 0 deletions src/pythinker_code/utils/pyinstaller.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,32 @@
from __future__ import annotations

from pathlib import Path

from PyInstaller.utils.hooks import collect_data_files, collect_submodules, copy_metadata

from pythinker_code.cli._lazy_group import LazySubcommandGroup

# The web/vis frontends are gitignored build artifacts synced into the package
# by scripts/build_web.py and scripts/build_vis.py. collect_data_files()
# silently collects nothing for missing globs, which froze builds whose web UI
# answered "/" with a 404 (Windows/Linux native installers).
_REQUIRED_UI_ASSETS = ("web/static/index.html", "vis/static/index.html")


def require_ui_assets(package_root: Path | None = None) -> None:
"""Abort the freeze when the web/vis UI bundles haven't been built."""
if package_root is None:
package_root = Path(__file__).resolve().parents[1]
missing = [rel for rel in _REQUIRED_UI_ASSETS if not (package_root / rel).is_file()]
if missing:
raise SystemExit(
"PyInstaller build aborted: UI bundles missing from pythinker_code "
f"({', '.join(missing)}). They are gitignored build artifacts; run "
"`make build-web build-vis` before freezing, or the packaged web UI "
"will 404 on '/'."
)


lazy_cli_hiddenimports = [
module_name
for module_name, _attribute_name, _help_text in (LazySubcommandGroup.lazy_subcommands.values())
Expand Down
81 changes: 74 additions & 7 deletions src/pythinker_code/utils/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@

import importlib
import socket
import sys
import textwrap

from pythinker_code.ui.terminal_capabilities import ascii_glyphs_enabled

# Shared "PYTHINKER" wordmark used by the web and vis startup banners.
PYTHINKER_BANNER_ART = [
"<center>██████╗ ██╗ ██╗████████╗██╗ ██╗██╗███╗ ██╗██╗ ██╗███████╗██████╗ ",
Expand All @@ -16,6 +19,37 @@
"<center>╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝",
]

# Single-cell ASCII stand-ins for every non-ASCII character the banners emit.
# All replacements are 1:1 so box alignment is preserved. Used when the
# terminal can't render Unicode (legacy Windows code pages such as cp1252,
# TERM=dumb, or an explicit PYTHINKER_ASCII_UI/PYTHINKER_TUI_GLYPHS opt-in);
# raw print() of the block art would otherwise garble or raise
# UnicodeEncodeError on those streams.
_BANNER_ASCII_FALLBACKS = str.maketrans(
{
"█": "#",
"╔": " ",
"╗": " ",
"╚": " ",
"╝": " ",
"║": " ",
"═": " ",
"➜": ">",
"•": "*",
"⚠": "!",
}
)


def _print_banner_line(text: str) -> None:
"""Print one banner line, degrading to ASCII if the stream rejects it."""
try:
print(text)
except UnicodeEncodeError:
encoding = getattr(sys.stdout, "encoding", None) or "ascii"
fallback = text.translate(_BANNER_ASCII_FALLBACKS)
print(fallback.encode(encoding, errors="replace").decode(encoding, errors="replace"))


def get_address_family(host: str) -> socket.AddressFamily:
"""Return AF_INET6 for IPv6 addresses, AF_INET for IPv4 and hostnames."""
Expand Down Expand Up @@ -96,8 +130,39 @@ def get_network_addresses() -> list[str]:
return addresses


def missing_ui_page(asset_path: str, build_command: str) -> str:
"""HTML served on ``/`` when the bundled frontend assets are absent.

Packaged builds that skipped the frontend build would otherwise answer
``/`` with a bare 404, which reads like a routing bug instead of a
packaging one.
"""
return f"""<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Pythinker UI unavailable</title>
</head>
<body style="font-family: system-ui, sans-serif; max-width: 40rem;
margin: 4rem auto; line-height: 1.5;">
<h1>UI assets are missing</h1>
<p>This Pythinker build was packaged without its frontend bundle
(<code>{asset_path}</code> was not found).</p>
<p>If you installed a packaged build (Windows installer, winget, scoop,
.deb/.rpm), update to the latest release or report this as a packaging
bug. When running from source, build the frontend first:</p>
<pre><code>{build_command}</code></pre>
<p>The REST API is unaffected and remains available under <code>/api</code>.</p>
</body>
</html>
"""


def print_banner(lines: list[str]) -> None:
"""Print a boxed banner with tag conventions (<center>, <nowrap>, <hr>)."""
if ascii_glyphs_enabled():
lines = [line.translate(_BANNER_ASCII_FALLBACKS) for line in lines]

processed: list[str] = []
for line in lines:
if line == "<hr>":
Expand All @@ -113,19 +178,21 @@ def strip_tags(s: str) -> str:
return s.removeprefix("<center>").removeprefix("<nowrap>")

content_lines = [strip_tags(line) for line in processed if line != "<hr>"]
width = max(60, *(len(line) for line in content_lines))
# The leading 60 lives inside the list: `max(60, *[])` is a TypeError when
# every line is an <hr>.
width = max([60, *(len(line) for line in content_lines)])
top = "+" + "=" * (width + 2) + "+"

print(top)
_print_banner_line(top)
for line in processed:
if line == "<hr>":
print("|" + "-" * (width + 2) + "|")
_print_banner_line("|" + "-" * (width + 2) + "|")
elif line.startswith("<center>"):
content = line.removeprefix("<center>")
print(f"| {content.center(width)} |")
_print_banner_line(f"| {content.center(width)} |")
elif line.startswith("<nowrap>"):
content = line.removeprefix("<nowrap>")
print(f"| {content.ljust(width)} |")
_print_banner_line(f"| {content.ljust(width)} |")
else:
print(f"| {line.ljust(width)} |")
print(top)
_print_banner_line(f"| {line.ljust(width)} |")
_print_banner_line(top)
14 changes: 13 additions & 1 deletion src/pythinker_code/vis/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles

from pythinker_code.utils.server import (
Expand All @@ -18,6 +19,7 @@
format_url,
get_network_addresses,
is_local_host,
missing_ui_page,
print_banner,
)
from pythinker_code.vis.api import sessions_router, statistics_router, system_router
Expand Down Expand Up @@ -82,8 +84,18 @@ def create_app() -> FastAPI:
async def health_probe() -> dict[str, Any]: # pyright: ignore[reportUnusedFunction]
return {"status": "ok"}

if STATIC_DIR.exists():
# Gate on index.html, not just the directory: a build that skipped the vis
# bundle would otherwise answer "/" with a bare 404 instead of a hint.
if (STATIC_DIR / "index.html").is_file():
application.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")
else:

@application.get("/", include_in_schema=False)
async def missing_vis_ui() -> HTMLResponse: # pyright: ignore[reportUnusedFunction]
return HTMLResponse(
status_code=503,
content=missing_ui_page("pythinker_code/vis/static/index.html", "make build-vis"),
)

return application

Expand Down
20 changes: 18 additions & 2 deletions src/pythinker_code/web/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
format_url,
get_network_addresses,
is_local_host,
missing_ui_page,
)
from pythinker_code.web.api import (
config_router,
Expand Down Expand Up @@ -218,9 +219,24 @@ async def health_probe() -> dict[str, Any]: # pyright: ignore[reportUnusedFunct
"""Health check endpoint."""
return {"status": "ok"}

# Mount static files as fallback (must be last)
if STATIC_DIR.exists():
# Mount static files as fallback (must be last). Gate on index.html, not
# just the directory: a build that skipped the web bundle can still ship
# the git-tracked brand/ files, and StaticFiles(html=True) would then 404
# on "/" with no hint of why.
if (STATIC_DIR / "index.html").is_file():
application.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")
else:
logger.warning(
"Web UI assets missing at {static_dir}; serving setup instructions on /",
static_dir=STATIC_DIR,
)

@application.get("/", include_in_schema=False)
async def missing_web_ui() -> HTMLResponse: # pyright: ignore[reportUnusedFunction]
return HTMLResponse(
status_code=503,
content=missing_ui_page("pythinker_code/web/static/index.html", "make build-web"),
)

return application

Expand Down
Loading
Loading