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:
@@ -12,3 +12,6 @@ venv/
|
||||
traefik/letsencrypt/acme.json
|
||||
docs/PROJECT_CONTEXT.md
|
||||
PROJECT_CONTEXT.md
|
||||
*.bak*
|
||||
*.env.bak*
|
||||
docs/CONTEXT_TEST.md
|
||||
|
||||
+36
-7
@@ -16,7 +16,7 @@ from typing import Optional
|
||||
|
||||
import docker
|
||||
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.staticfiles import StaticFiles
|
||||
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_BUFFER = int(os.getenv("WEB_POOL_BUFFER", "2"))
|
||||
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"
|
||||
ICON_UPLOAD_MAX_BYTES = 2 * 1024 * 1024
|
||||
ICON_UPLOAD_TYPES = {
|
||||
@@ -776,13 +780,25 @@ def acquire_web_pool_slot(db: Session) -> int:
|
||||
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)
|
||||
url = ""
|
||||
payload = {}
|
||||
if service.type == ServiceType.WEB:
|
||||
url = f"http://{name}:7000/open"
|
||||
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:
|
||||
cfg = parse_rdp_target(service.target)
|
||||
url = f"http://{name}:7000/rdp"
|
||||
@@ -810,14 +826,19 @@ def dispatch_universal_target(slot: int, service: Service) -> None:
|
||||
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)
|
||||
target_url = normalize_web_target(service.target)
|
||||
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
|
||||
for _ in range(max(1, POOL_DISPATCH_RETRIES)):
|
||||
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()
|
||||
return
|
||||
except Exception as exc:
|
||||
@@ -1858,7 +1879,13 @@ def logout(request: Request):
|
||||
|
||||
|
||||
@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()
|
||||
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):
|
||||
raise HTTPException(status_code=403, detail="ACL denied")
|
||||
|
||||
client_width, client_height = sanitize_client_resolution(sw, sh)
|
||||
|
||||
user_lock_started = time.perf_counter()
|
||||
try:
|
||||
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()
|
||||
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)
|
||||
|
||||
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()
|
||||
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)
|
||||
|
||||
t_commit = time.perf_counter()
|
||||
|
||||
@@ -100,6 +100,32 @@
|
||||
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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+2
-1
@@ -4,6 +4,7 @@ set -euo pipefail
|
||||
TARGET_URL="${TARGET_URL:-https://example.com}"
|
||||
SESSION_ID="${SESSION_ID:-unknown}"
|
||||
IDLE_TIMEOUT="${IDLE_TIMEOUT:-1800}"
|
||||
X11VNC_FLAGS="${X11VNC_FLAGS:--wait 5 -defer 5 -ncache 10 -ncache_cr -threads}"
|
||||
ENABLE_HEARTBEAT="${ENABLE_HEARTBEAT:-1}"
|
||||
TOUCH_PATH="${TOUCH_PATH:-/api/sessions/${SESSION_ID}/touch}"
|
||||
UNIVERSAL_WEB="${UNIVERSAL_WEB:-0}"
|
||||
@@ -182,6 +183,6 @@ else
|
||||
>/tmp/chromium.log 2>&1 &
|
||||
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
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
set -euo pipefail
|
||||
|
||||
IDLE_TIMEOUT="${IDLE_TIMEOUT:-1800}"
|
||||
X11VNC_FLAGS="${X11VNC_FLAGS:--wait 5 -defer 5 -ncache 10 -ncache_cr -threads}"
|
||||
SCREEN_GEOMETRY="${SCREEN_GEOMETRY:-1920x1080x24}"
|
||||
CHROME_WINDOW_SIZE="${CHROME_WINDOW_SIZE:-1920,1080}"
|
||||
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 &
|
||||
fluxbox >/tmp/fluxbox.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
|
||||
|
||||
@@ -8,11 +8,16 @@ from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
DISPLAY = os.environ.get("DISPLAY", ":1")
|
||||
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 = {
|
||||
"proc": None,
|
||||
"mode": "idle",
|
||||
"target": "",
|
||||
"resolution": CHROME_WINDOW_SIZE,
|
||||
}
|
||||
_lock = threading.Lock()
|
||||
|
||||
@@ -50,7 +55,37 @@ def _start_process(cmd: list[str], mode: str, target: str) -> None:
|
||||
_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 = [
|
||||
"chromium",
|
||||
"--no-sandbox",
|
||||
@@ -65,7 +100,7 @@ def open_web(url: str) -> None:
|
||||
"--ignore-certificate-errors",
|
||||
"--allow-insecure-localhost",
|
||||
"--allow-running-insecure-content",
|
||||
f"--window-size={CHROME_WINDOW_SIZE}",
|
||||
f"--window-size={safe_w},{safe_h}",
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
url,
|
||||
@@ -125,7 +160,16 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if self.path == "/health":
|
||||
proc = _state.get("proc")
|
||||
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
|
||||
self._json(404, {"detail": "Not found"})
|
||||
|
||||
@@ -137,9 +181,26 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if not (url.startswith("http://") or url.startswith("https://")):
|
||||
self._json(400, {"detail": "Invalid URL"})
|
||||
return
|
||||
width = data.get("width")
|
||||
height = data.get("height")
|
||||
with _lock:
|
||||
open_web(url)
|
||||
self._json(200, {"ok": True, "mode": "web", "target": url})
|
||||
open_web(url, width=width, height=height)
|
||||
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
|
||||
if self.path == "/rdp":
|
||||
with _lock:
|
||||
|
||||
Reference in New Issue
Block a user