feat: propagate client screen size to web runtime
This commit is contained in:
+36
-7
@@ -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()
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user