feature/web-dynamic-resolution #1

Merged
ruslan merged 2 commits from feature/web-dynamic-resolution into main 2026-04-25 08:28:27 +00:00
3 changed files with 128 additions and 12 deletions
Showing only changes of commit 8863943d79 - Show all commits
+36 -7
View File
@@ -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()
+26
View File
@@ -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>
+66 -5
View File
@@ -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: