Merge pull request 'feature/web-dynamic-resolution' (#1) from feature/web-dynamic-resolution into main

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-04-25 08:28:27 +00:00
6 changed files with 135 additions and 14 deletions
+3
View File
@@ -12,3 +12,6 @@ venv/
traefik/letsencrypt/acme.json traefik/letsencrypt/acme.json
docs/PROJECT_CONTEXT.md docs/PROJECT_CONTEXT.md
PROJECT_CONTEXT.md PROJECT_CONTEXT.md
*.bak*
*.env.bak*
docs/CONTEXT_TEST.md
+36 -7
View File
@@ -16,7 +16,7 @@ from typing import Optional
import docker import docker
import requests import requests
from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile, status from fastapi import Depends, FastAPI, File, Form, HTTPException, Query, Request, UploadFile, status
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
@@ -58,6 +58,10 @@ UNIVERSAL_POOL_SIZE = int(os.getenv("UNIVERSAL_POOL_SIZE", "0"))
WEB_POOL_SIZE = int(os.getenv("WEB_POOL_SIZE", "5")) WEB_POOL_SIZE = int(os.getenv("WEB_POOL_SIZE", "5"))
WEB_POOL_BUFFER = int(os.getenv("WEB_POOL_BUFFER", "2")) WEB_POOL_BUFFER = int(os.getenv("WEB_POOL_BUFFER", "2"))
MAX_ACTIVE_SERVICES_PER_USER = int(os.getenv("MAX_ACTIVE_SERVICES_PER_USER", "4")) MAX_ACTIVE_SERVICES_PER_USER = int(os.getenv("MAX_ACTIVE_SERVICES_PER_USER", "4"))
WEB_RESOLUTION_MIN_WIDTH = int(os.getenv("WEB_RESOLUTION_MIN_WIDTH", "1024"))
WEB_RESOLUTION_MIN_HEIGHT = int(os.getenv("WEB_RESOLUTION_MIN_HEIGHT", "720"))
WEB_RESOLUTION_MAX_WIDTH = int(os.getenv("WEB_RESOLUTION_MAX_WIDTH", "3840"))
WEB_RESOLUTION_MAX_HEIGHT = int(os.getenv("WEB_RESOLUTION_MAX_HEIGHT", "2160"))
ENABLE_STARTUP_MAINTENANCE = os.getenv("ENABLE_STARTUP_MAINTENANCE", "1") == "1" ENABLE_STARTUP_MAINTENANCE = os.getenv("ENABLE_STARTUP_MAINTENANCE", "1") == "1"
ICON_UPLOAD_MAX_BYTES = 2 * 1024 * 1024 ICON_UPLOAD_MAX_BYTES = 2 * 1024 * 1024
ICON_UPLOAD_TYPES = { ICON_UPLOAD_TYPES = {
@@ -776,13 +780,25 @@ def acquire_web_pool_slot(db: Session) -> int:
return 0 return 0
def dispatch_universal_target(slot: int, service: Service) -> None: def sanitize_client_resolution(width: Optional[int], height: Optional[int]) -> tuple[Optional[int], Optional[int]]:
if width is None or height is None:
return None, None
clamped_width = max(WEB_RESOLUTION_MIN_WIDTH, min(int(width), WEB_RESOLUTION_MAX_WIDTH))
clamped_height = max(WEB_RESOLUTION_MIN_HEIGHT, min(int(height), WEB_RESOLUTION_MAX_HEIGHT))
return clamped_width, clamped_height
def dispatch_universal_target(slot: int, service: Service, width: Optional[int] = None, height: Optional[int] = None) -> None:
name = universal_container_name(slot) name = universal_container_name(slot)
url = "" url = ""
payload = {} payload = {}
if service.type == ServiceType.WEB: if service.type == ServiceType.WEB:
url = f"http://{name}:7000/open" url = f"http://{name}:7000/open"
payload = {"url": normalize_web_target(service.target)} payload = {"url": normalize_web_target(service.target)}
width, height = sanitize_client_resolution(width, height)
if width and height:
payload["width"] = width
payload["height"] = height
elif service.type == ServiceType.RDP: elif service.type == ServiceType.RDP:
cfg = parse_rdp_target(service.target) cfg = parse_rdp_target(service.target)
url = f"http://{name}:7000/rdp" url = f"http://{name}:7000/rdp"
@@ -810,14 +826,19 @@ def dispatch_universal_target(slot: int, service: Service) -> None:
raise last_exc raise last_exc
def dispatch_web_pool_target(slot: int, service: Service) -> None: def dispatch_web_pool_target(slot: int, service: Service, width: Optional[int] = None, height: Optional[int] = None) -> None:
name = web_pool_container_name(slot) name = web_pool_container_name(slot)
target_url = normalize_web_target(service.target) target_url = normalize_web_target(service.target)
url = f"http://{name}:7000/open" url = f"http://{name}:7000/open"
payload = {"url": target_url}
width, height = sanitize_client_resolution(width, height)
if width and height:
payload["width"] = width
payload["height"] = height
last_exc = None last_exc = None
for _ in range(max(1, POOL_DISPATCH_RETRIES)): for _ in range(max(1, POOL_DISPATCH_RETRIES)):
try: try:
resp = requests.post(url, json={"url": target_url}, timeout=POOL_DISPATCH_REQUEST_TIMEOUT_SECONDS) resp = requests.post(url, json=payload, timeout=POOL_DISPATCH_REQUEST_TIMEOUT_SECONDS)
resp.raise_for_status() resp.raise_for_status()
return return
except Exception as exc: except Exception as exc:
@@ -1858,7 +1879,13 @@ def logout(request: Request):
@app.get("/go/{slug}") @app.get("/go/{slug}")
def go_service(slug: str, user: User = Depends(require_user), db: Session = Depends(get_db)): def go_service(
slug: str,
sw: Optional[int] = Query(default=None, ge=320, le=7680),
sh: Optional[int] = Query(default=None, ge=240, le=4320),
user: User = Depends(require_user),
db: Session = Depends(get_db),
):
total_started = time.perf_counter() total_started = time.perf_counter()
phase_ms = {} phase_ms = {}
@@ -1885,6 +1912,8 @@ def go_service(slug: str, user: User = Depends(require_user), db: Session = Depe
if not has_access(db, user.id, service.id): if not has_access(db, user.id, service.id):
raise HTTPException(status_code=403, detail="ACL denied") raise HTTPException(status_code=403, detail="ACL denied")
client_width, client_height = sanitize_client_resolution(sw, sh)
user_lock_started = time.perf_counter() user_lock_started = time.perf_counter()
try: try:
with allocator_lock(db, 92000 + int(user.id), timeout_seconds=GO_USER_LOCK_TIMEOUT_SECONDS): with allocator_lock(db, 92000 + int(user.id), timeout_seconds=GO_USER_LOCK_TIMEOUT_SECONDS):
@@ -1960,7 +1989,7 @@ def go_service(slug: str, user: User = Depends(require_user), db: Session = Depe
t_dispatch = time.perf_counter() t_dispatch = time.perf_counter()
terminate_active_slot_sessions(db, slot_cid) terminate_active_slot_sessions(db, slot_cid)
dispatch_web_pool_target(slot, service) dispatch_web_pool_target(slot, service, width=client_width, height=client_height)
_mark("dispatch_web_target_ms", t_dispatch) _mark("dispatch_web_target_ms", t_dispatch)
t_commit = time.perf_counter() t_commit = time.perf_counter()
@@ -2006,7 +2035,7 @@ def go_service(slug: str, user: User = Depends(require_user), db: Session = Depe
t_dispatch = time.perf_counter() t_dispatch = time.perf_counter()
terminate_active_slot_sessions(db, slot_cid) terminate_active_slot_sessions(db, slot_cid)
dispatch_universal_target(slot, service) dispatch_universal_target(slot, service, width=client_width, height=client_height)
_mark("dispatch_universal_target_ms", t_dispatch) _mark("dispatch_universal_target_ms", t_dispatch)
t_commit = time.perf_counter() t_commit = time.perf_counter()
+26
View File
@@ -100,6 +100,32 @@
banner.style.display = 'none'; banner.style.display = 'none';
}); });
})(); })();
(function () {
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function currentScreenParams() {
const width = clamp(window.innerWidth || document.documentElement.clientWidth || 1280, 320, 7680);
const height = clamp(window.innerHeight || document.documentElement.clientHeight || 720, 240, 4320);
const sp = new URLSearchParams();
sp.set('sw', String(width));
sp.set('sh', String(height));
return sp;
}
document.querySelectorAll('a.tile[href^="/go/"]').forEach(function (link) {
link.addEventListener('click', function () {
try {
const url = new URL(link.getAttribute('href'), window.location.origin);
const params = currentScreenParams();
url.search = params.toString();
link.setAttribute('href', url.pathname + '?' + url.searchParams.toString());
} catch (e) {}
}, { capture: true });
});
})();
</script> </script>
</body> </body>
</html> </html>
+2 -1
View File
@@ -4,6 +4,7 @@ set -euo pipefail
TARGET_URL="${TARGET_URL:-https://example.com}" TARGET_URL="${TARGET_URL:-https://example.com}"
SESSION_ID="${SESSION_ID:-unknown}" SESSION_ID="${SESSION_ID:-unknown}"
IDLE_TIMEOUT="${IDLE_TIMEOUT:-1800}" IDLE_TIMEOUT="${IDLE_TIMEOUT:-1800}"
X11VNC_FLAGS="${X11VNC_FLAGS:--wait 5 -defer 5 -ncache 10 -ncache_cr -threads}"
ENABLE_HEARTBEAT="${ENABLE_HEARTBEAT:-1}" ENABLE_HEARTBEAT="${ENABLE_HEARTBEAT:-1}"
TOUCH_PATH="${TOUCH_PATH:-/api/sessions/${SESSION_ID}/touch}" TOUCH_PATH="${TOUCH_PATH:-/api/sessions/${SESSION_ID}/touch}"
UNIVERSAL_WEB="${UNIVERSAL_WEB:-0}" UNIVERSAL_WEB="${UNIVERSAL_WEB:-0}"
@@ -182,6 +183,6 @@ else
>/tmp/chromium.log 2>&1 & >/tmp/chromium.log 2>&1 &
fi fi
x11vnc -display :1 -rfbport 5900 -forever -shared -nopw -noxdamage >/tmp/x11vnc.log 2>&1 & x11vnc -display :1 -rfbport 5900 -forever -shared -nopw -noxdamage $X11VNC_FLAGS >/tmp/x11vnc.log 2>&1 &
exec websockify --verbose --idle-timeout="$IDLE_TIMEOUT" --web=/opt/portal 6080 localhost:5900 exec websockify --verbose --idle-timeout="$IDLE_TIMEOUT" --web=/opt/portal 6080 localhost:5900
+2 -1
View File
@@ -2,6 +2,7 @@
set -euo pipefail set -euo pipefail
IDLE_TIMEOUT="${IDLE_TIMEOUT:-1800}" IDLE_TIMEOUT="${IDLE_TIMEOUT:-1800}"
X11VNC_FLAGS="${X11VNC_FLAGS:--wait 5 -defer 5 -ncache 10 -ncache_cr -threads}"
SCREEN_GEOMETRY="${SCREEN_GEOMETRY:-1920x1080x24}" SCREEN_GEOMETRY="${SCREEN_GEOMETRY:-1920x1080x24}"
CHROME_WINDOW_SIZE="${CHROME_WINDOW_SIZE:-1920,1080}" CHROME_WINDOW_SIZE="${CHROME_WINDOW_SIZE:-1920,1080}"
ENABLE_HEARTBEAT="${ENABLE_HEARTBEAT:-1}" ENABLE_HEARTBEAT="${ENABLE_HEARTBEAT:-1}"
@@ -263,6 +264,6 @@ export CHROME_WINDOW_SIZE
Xvfb "$DISPLAY_NUM" -screen 0 "$SCREEN_GEOMETRY" >/tmp/xvfb.log 2>&1 & Xvfb "$DISPLAY_NUM" -screen 0 "$SCREEN_GEOMETRY" >/tmp/xvfb.log 2>&1 &
fluxbox >/tmp/fluxbox.log 2>&1 & fluxbox >/tmp/fluxbox.log 2>&1 &
python3 /manager.py >/tmp/manager.log 2>&1 & python3 /manager.py >/tmp/manager.log 2>&1 &
x11vnc -display "$DISPLAY_NUM" -rfbport 5900 -forever -shared -nopw -noxdamage >/tmp/x11vnc.log 2>&1 & x11vnc -display "$DISPLAY_NUM" -rfbport 5900 -forever -shared -nopw -noxdamage $X11VNC_FLAGS >/tmp/x11vnc.log 2>&1 &
exec websockify --verbose --idle-timeout="$IDLE_TIMEOUT" --web=/opt/portal 6080 localhost:5900 exec websockify --verbose --idle-timeout="$IDLE_TIMEOUT" --web=/opt/portal 6080 localhost:5900
+66 -5
View File
@@ -8,11 +8,16 @@ from http.server import BaseHTTPRequestHandler, HTTPServer
DISPLAY = os.environ.get("DISPLAY", ":1") DISPLAY = os.environ.get("DISPLAY", ":1")
CHROME_WINDOW_SIZE = os.environ.get("CHROME_WINDOW_SIZE", "1920,1080") CHROME_WINDOW_SIZE = os.environ.get("CHROME_WINDOW_SIZE", "1920,1080")
RESOLUTION_MIN_WIDTH = int(os.environ.get("WEB_RESOLUTION_MIN_WIDTH", "1024"))
RESOLUTION_MIN_HEIGHT = int(os.environ.get("WEB_RESOLUTION_MIN_HEIGHT", "720"))
RESOLUTION_MAX_WIDTH = int(os.environ.get("WEB_RESOLUTION_MAX_WIDTH", "3840"))
RESOLUTION_MAX_HEIGHT = int(os.environ.get("WEB_RESOLUTION_MAX_HEIGHT", "2160"))
_state = { _state = {
"proc": None, "proc": None,
"mode": "idle", "mode": "idle",
"target": "", "target": "",
"resolution": CHROME_WINDOW_SIZE,
} }
_lock = threading.Lock() _lock = threading.Lock()
@@ -50,7 +55,37 @@ def _start_process(cmd: list[str], mode: str, target: str) -> None:
_state["target"] = target _state["target"] = target
def open_web(url: str) -> None: def _sanitize_resolution(width: int | None, height: int | None) -> tuple[int, int]:
if not width or not height:
try:
default_w, default_h = [int(x) for x in CHROME_WINDOW_SIZE.split(",", 1)]
return default_w, default_h
except Exception:
return 1920, 1080
safe_w = max(RESOLUTION_MIN_WIDTH, min(int(width), RESOLUTION_MAX_WIDTH))
safe_h = max(RESOLUTION_MIN_HEIGHT, min(int(height), RESOLUTION_MAX_HEIGHT))
return safe_w, safe_h
def apply_resolution(width: int | None, height: int | None) -> tuple[int, int]:
safe_w, safe_h = _sanitize_resolution(width, height)
# Best effort: Xvfb usually exposes RandR and accepts xrandr -s.
try:
subprocess.run( # noqa: S603
["xrandr", "-display", DISPLAY, "-s", f"{safe_w}x{safe_h}"],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except Exception:
pass
_state["resolution"] = f"{safe_w},{safe_h}"
return safe_w, safe_h
def open_web(url: str, width: int | None = None, height: int | None = None) -> None:
safe_w, safe_h = apply_resolution(width, height)
cmd = [ cmd = [
"chromium", "chromium",
"--no-sandbox", "--no-sandbox",
@@ -65,7 +100,7 @@ def open_web(url: str) -> None:
"--ignore-certificate-errors", "--ignore-certificate-errors",
"--allow-insecure-localhost", "--allow-insecure-localhost",
"--allow-running-insecure-content", "--allow-running-insecure-content",
f"--window-size={CHROME_WINDOW_SIZE}", f"--window-size={safe_w},{safe_h}",
"--no-first-run", "--no-first-run",
"--no-default-browser-check", "--no-default-browser-check",
url, url,
@@ -125,7 +160,16 @@ class Handler(BaseHTTPRequestHandler):
if self.path == "/health": if self.path == "/health":
proc = _state.get("proc") proc = _state.get("proc")
running = bool(proc and proc.poll() is None) running = bool(proc and proc.poll() is None)
self._json(200, {"ok": True, "mode": _state.get("mode", "idle"), "running": running, "target": _state.get("target", "")}) self._json(
200,
{
"ok": True,
"mode": _state.get("mode", "idle"),
"running": running,
"target": _state.get("target", ""),
"resolution": _state.get("resolution", CHROME_WINDOW_SIZE),
},
)
return return
self._json(404, {"detail": "Not found"}) self._json(404, {"detail": "Not found"})
@@ -137,9 +181,26 @@ class Handler(BaseHTTPRequestHandler):
if not (url.startswith("http://") or url.startswith("https://")): if not (url.startswith("http://") or url.startswith("https://")):
self._json(400, {"detail": "Invalid URL"}) self._json(400, {"detail": "Invalid URL"})
return return
width = data.get("width")
height = data.get("height")
with _lock: with _lock:
open_web(url) open_web(url, width=width, height=height)
self._json(200, {"ok": True, "mode": "web", "target": url}) self._json(
200,
{
"ok": True,
"mode": "web",
"target": url,
"resolution": _state.get("resolution", CHROME_WINDOW_SIZE),
},
)
return
if self.path == "/resolution":
width = data.get("width")
height = data.get("height")
with _lock:
safe_w, safe_h = apply_resolution(width, height)
self._json(200, {"ok": True, "width": safe_w, "height": safe_h})
return return
if self.path == "/rdp": if self.path == "/rdp":
with _lock: with _lock: