|
# retoor <retoor@molodetz.nl>
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import shlex
|
|
from dataclasses import asdict, dataclass
|
|
from pathlib import Path
|
|
|
|
from devplacepy import config
|
|
from devplacepy.database import get_int_setting, get_setting, get_table
|
|
from devplacepy.services.containers import store
|
|
from devplacepy.services.containers.backend.base import (
|
|
WORKSPACE_MOUNT,
|
|
WORKSPACE_STATE_MOUNT,
|
|
)
|
|
|
|
from . import quota
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
PREFS_TABLE = "workspace_editor_prefs"
|
|
|
|
APP_NAME = "DevPlace"
|
|
WELCOME_TEXT = "Sign in to your DevPlace workspace"
|
|
EDITOR_DEFAULT_PORT = 8443
|
|
PROFILE_FILE = "devplace-editor.json"
|
|
MANAGED_FILE = ".devplace-managed.json"
|
|
|
|
INHERIT_TEXT = ""
|
|
INHERIT_INT = 0
|
|
INHERIT_ZOOM = -99
|
|
INHERIT_FLAG = -1
|
|
|
|
THEME_CHOICES = ("devplace-dark", "devplace-light", "system")
|
|
LAYOUT_CHOICES = ("standard", "terminal-focus", "zen")
|
|
PANEL_CHOICES = ("short", "normal", "tall", "maximized")
|
|
WINDOW_CHOICES = ("tab", "window", "fullscreen")
|
|
AGENT_CHOICES = ("dpc", "none")
|
|
|
|
CHOICES = {
|
|
"theme": THEME_CHOICES,
|
|
"layout": LAYOUT_CHOICES,
|
|
"panel_preset": PANEL_CHOICES,
|
|
"window_mode": WINDOW_CHOICES,
|
|
"boot_agent": AGENT_CHOICES,
|
|
}
|
|
|
|
SENTINELS = {
|
|
"zoom_level": INHERIT_ZOOM,
|
|
"boot_shell": INHERIT_FLAG,
|
|
}
|
|
|
|
THEME_LABELS = {
|
|
"devplace-dark": "DevPlace Dark",
|
|
"devplace-light": "DevPlace Light",
|
|
}
|
|
|
|
LAYOUTS = {
|
|
"standard": {
|
|
"workbench.activityBar.location": "default",
|
|
"workbench.sideBar.location": "left",
|
|
"workbench.panel.defaultLocation": "bottom",
|
|
"editor.minimap.enabled": True,
|
|
"breadcrumbs.enabled": True,
|
|
},
|
|
"terminal-focus": {
|
|
"workbench.activityBar.location": "top",
|
|
"workbench.sideBar.location": "left",
|
|
"workbench.panel.defaultLocation": "bottom",
|
|
"editor.minimap.enabled": False,
|
|
"breadcrumbs.enabled": True,
|
|
},
|
|
"zen": {
|
|
"workbench.activityBar.location": "hidden",
|
|
"workbench.sideBar.location": "left",
|
|
"workbench.panel.defaultLocation": "bottom",
|
|
"editor.minimap.enabled": False,
|
|
"breadcrumbs.enabled": False,
|
|
},
|
|
}
|
|
|
|
FOREIGN_AI_SETTINGS = {
|
|
"chat.disableAIFeatures": True,
|
|
"chat.commandCenter.enabled": False,
|
|
"workbench.secondarySideBar.defaultVisibility": "hidden",
|
|
}
|
|
|
|
TRUST_SETTINGS = {
|
|
"security.workspace.trust.enabled": False,
|
|
"security.workspace.trust.startupPrompt": "never",
|
|
"security.workspace.trust.banner": "never",
|
|
"security.workspace.trust.emptyWindow": True,
|
|
"security.workspace.trust.untrustedFiles": "open",
|
|
"task.allowAutomaticTasks": "on",
|
|
}
|
|
|
|
BOUNDS = {
|
|
"font_size": (8, 48),
|
|
"terminal_font_size": (8, 48),
|
|
"zoom_level": (-5, 5),
|
|
"window_width": (640, 7680),
|
|
"window_height": (480, 4320),
|
|
}
|
|
|
|
SETTING_KEYS = {
|
|
"trust_all": "workspace_editor_trust_all",
|
|
"theme": "workspace_editor_theme",
|
|
"font_size": "workspace_editor_font_size",
|
|
"terminal_font_size": "workspace_editor_terminal_font_size",
|
|
"zoom_level": "workspace_editor_zoom_level",
|
|
"layout": "workspace_editor_layout",
|
|
"panel_preset": "workspace_editor_panel_preset",
|
|
"boot_agent": "workspace_editor_boot_agent",
|
|
"boot_shell": "workspace_editor_boot_shell",
|
|
"window_mode": "workspace_editor_window_mode",
|
|
"window_width": "workspace_editor_window_width",
|
|
"window_height": "workspace_editor_window_height",
|
|
}
|
|
|
|
DEFAULTS = {
|
|
"trust_all": True,
|
|
"theme": "devplace-dark",
|
|
"font_size": 14,
|
|
"terminal_font_size": 13,
|
|
"zoom_level": 0,
|
|
"layout": "standard",
|
|
"panel_preset": "tall",
|
|
"boot_agent": "dpc",
|
|
"boot_shell": True,
|
|
"window_mode": "tab",
|
|
"window_width": 1600,
|
|
"window_height": 1000,
|
|
}
|
|
|
|
PREF_COLUMNS = (
|
|
"font_size",
|
|
"terminal_font_size",
|
|
"zoom_level",
|
|
"theme",
|
|
"layout",
|
|
"panel_preset",
|
|
"window_mode",
|
|
"window_width",
|
|
"window_height",
|
|
"boot_agent",
|
|
"boot_shell",
|
|
)
|
|
|
|
OPTIONAL_FLAGS = (
|
|
"--app-name",
|
|
"--welcome-text",
|
|
"--disable-getting-started-override",
|
|
"--disable-workspace-trust",
|
|
)
|
|
|
|
SOURCE_SITE = "site"
|
|
SOURCE_USER = "user"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class EditorProfile:
|
|
trust_all: bool
|
|
theme: str
|
|
font_size: int
|
|
terminal_font_size: int
|
|
zoom_level: int
|
|
layout: str
|
|
panel_preset: str
|
|
boot_agent: str
|
|
boot_shell: bool
|
|
window_mode: str
|
|
window_width: int
|
|
window_height: int
|
|
cpu_millicores: int
|
|
memory_mb: int
|
|
disk_quota_mb: int
|
|
|
|
def as_dict(self) -> dict:
|
|
return asdict(self)
|
|
|
|
def cpu_cores(self) -> float:
|
|
return round(self.cpu_millicores / 1000, 3)
|
|
|
|
def cpu_limit(self) -> str:
|
|
return quota.format_cpu(self.cpu_millicores)
|
|
|
|
def mem_limit(self) -> str:
|
|
return quota.format_memory(self.memory_mb)
|
|
|
|
|
|
def _clamp(key: str, value: int) -> int:
|
|
bounds = BOUNDS.get(key)
|
|
if not bounds:
|
|
return value
|
|
low, high = bounds
|
|
return max(low, min(high, value))
|
|
|
|
|
|
def _choice(value: str, choices: tuple[str, ...], fallback: str) -> str:
|
|
cleaned = (value or "").strip().lower()
|
|
return cleaned if cleaned in choices else fallback
|
|
|
|
|
|
def prefs_for(owner_uid: str) -> dict | None:
|
|
if not owner_uid:
|
|
return None
|
|
return get_table(PREFS_TABLE).find_one(
|
|
owner_kind="user", owner_id=owner_uid, deleted_at=None
|
|
)
|
|
|
|
|
|
def _inherits(key: str, row: dict | None) -> bool:
|
|
if not row:
|
|
return True
|
|
value = row.get(key)
|
|
if value is None:
|
|
return True
|
|
if key in SENTINELS:
|
|
return int(value) == SENTINELS[key]
|
|
if isinstance(DEFAULTS[key], str):
|
|
return not str(value).strip()
|
|
return int(value) == INHERIT_INT
|
|
|
|
|
|
def _site_value(key: str):
|
|
default = DEFAULTS[key]
|
|
setting = SETTING_KEYS[key]
|
|
if isinstance(default, bool):
|
|
return get_setting(setting, "1" if default else "0") == "1"
|
|
if isinstance(default, int):
|
|
return get_int_setting(setting, default)
|
|
return get_setting(setting, default)
|
|
|
|
|
|
def _normalize(key: str, value):
|
|
default = DEFAULTS[key]
|
|
if key in CHOICES:
|
|
return _choice(value, CHOICES[key], default)
|
|
if isinstance(default, bool):
|
|
if isinstance(value, bool):
|
|
return value
|
|
try:
|
|
return bool(int(value))
|
|
except (TypeError, ValueError):
|
|
return bool(value)
|
|
if isinstance(default, int):
|
|
try:
|
|
return _clamp(key, int(value))
|
|
except (TypeError, ValueError):
|
|
return default
|
|
return str(value)
|
|
|
|
|
|
def source_map(owner_uid: str = "") -> dict[str, str]:
|
|
row = prefs_for(owner_uid)
|
|
sources = {key: SOURCE_SITE for key in SETTING_KEYS}
|
|
for key in PREF_COLUMNS:
|
|
if not _inherits(key, row):
|
|
sources[key] = SOURCE_USER
|
|
return sources
|
|
|
|
|
|
def resolve(owner_uid: str = "", instance: dict | None = None) -> EditorProfile:
|
|
row = prefs_for(owner_uid)
|
|
values = {}
|
|
for key in SETTING_KEYS:
|
|
value = _site_value(key)
|
|
if key in PREF_COLUMNS and not _inherits(key, row):
|
|
value = row.get(key)
|
|
values[key] = _normalize(key, value)
|
|
limits = quota.resolve(owner_uid, instance)
|
|
return EditorProfile(
|
|
cpu_millicores=limits.cpu_millicores,
|
|
memory_mb=limits.memory_mb,
|
|
disk_quota_mb=limits.disk_quota_mb,
|
|
**values,
|
|
)
|
|
|
|
|
|
def settings_for(profile: EditorProfile) -> dict:
|
|
settings = {
|
|
"editor.fontSize": profile.font_size,
|
|
"terminal.integrated.fontSize": profile.terminal_font_size,
|
|
"window.zoomLevel": profile.zoom_level,
|
|
"telemetry.telemetryLevel": "off",
|
|
"update.mode": "none",
|
|
"workbench.tips.enabled": False,
|
|
"workbench.startupEditor": "none",
|
|
"extensions.autoCheckUpdates": False,
|
|
**FOREIGN_AI_SETTINGS,
|
|
"terminal.integrated.defaultProfile.linux": "bash",
|
|
"terminal.integrated.profiles.linux": {
|
|
"bash": {"path": "/bin/bash", "args": ["-l"], "icon": "terminal-bash"},
|
|
"DevPlace Code": {"path": "/usr/bin/dpc", "icon": "rocket"},
|
|
},
|
|
}
|
|
settings.update(LAYOUTS[profile.layout])
|
|
if profile.theme in THEME_LABELS:
|
|
settings["workbench.colorTheme"] = THEME_LABELS[profile.theme]
|
|
if profile.trust_all:
|
|
settings.update(TRUST_SETTINGS)
|
|
return settings
|
|
|
|
|
|
def merge_managed(current: dict, managed: dict, desired: dict) -> tuple[dict, dict]:
|
|
merged = dict(current)
|
|
for key, value in desired.items():
|
|
if key not in merged or merged[key] == managed.get(key):
|
|
merged[key] = value
|
|
return merged, dict(desired)
|
|
|
|
|
|
def _read_json(path: Path) -> dict:
|
|
try:
|
|
data = json.loads(path.read_text())
|
|
except (OSError, ValueError):
|
|
return {}
|
|
return data if isinstance(data, dict) else {}
|
|
|
|
|
|
def state_dir(instance: dict) -> Path:
|
|
return config.WORKSPACE_STATE_DIR / instance["uid"]
|
|
|
|
|
|
def profile_payload(instance: dict, profile: EditorProfile) -> dict:
|
|
return {
|
|
"app_name": APP_NAME,
|
|
"workspace_uid": instance.get("uid", ""),
|
|
"boot_marker": instance.get("boot_marker", ""),
|
|
"editor": profile.as_dict(),
|
|
}
|
|
|
|
|
|
def seed_state(instance: dict, profile: EditorProfile) -> bool:
|
|
root = state_dir(instance)
|
|
user_dir = root / "data" / "User"
|
|
try:
|
|
user_dir.mkdir(parents=True, exist_ok=True)
|
|
settings_path = user_dir / "settings.json"
|
|
managed_path = user_dir / MANAGED_FILE
|
|
settings, managed = merge_managed(
|
|
_read_json(settings_path),
|
|
_read_json(managed_path),
|
|
settings_for(profile),
|
|
)
|
|
settings_path.write_text(json.dumps(settings, indent=2, sort_keys=True))
|
|
managed_path.write_text(json.dumps(managed, indent=2, sort_keys=True))
|
|
(root / PROFILE_FILE).write_text(
|
|
json.dumps(profile_payload(instance, profile), indent=2, sort_keys=True)
|
|
)
|
|
except OSError as error:
|
|
logger.warning("workspace editor seed failed for %s: %s", instance.get("uid"), error)
|
|
return False
|
|
return True
|
|
|
|
|
|
def argv(instance: dict, profile: EditorProfile) -> list[str]:
|
|
port = int(instance.get("editor_port") or EDITOR_DEFAULT_PORT)
|
|
command = [
|
|
"code-server",
|
|
"--bind-addr",
|
|
f"0.0.0.0:{port}",
|
|
"--auth",
|
|
"password",
|
|
"--app-name",
|
|
APP_NAME,
|
|
"--welcome-text",
|
|
WELCOME_TEXT,
|
|
"--disable-telemetry",
|
|
"--disable-update-check",
|
|
"--disable-getting-started-override",
|
|
]
|
|
if profile.trust_all:
|
|
command.append("--disable-workspace-trust")
|
|
command += [
|
|
"--user-data-dir",
|
|
f"{WORKSPACE_STATE_MOUNT}/data",
|
|
"--extensions-dir",
|
|
f"{WORKSPACE_STATE_MOUNT}/extensions",
|
|
WORKSPACE_MOUNT,
|
|
]
|
|
return command
|
|
|
|
|
|
ENV_EXPORT_FILE = "/etc/profile.d/devplace-env.sh"
|
|
|
|
_ENV_EXPORT_SCRIPT = (
|
|
"import os, pathlib, shlex\n"
|
|
f"path = pathlib.Path({ENV_EXPORT_FILE!r})\n"
|
|
"lines = ['export ' + k + '=' + shlex.quote(v) for k, v in sorted(os.environ.items()) if k.startswith('DEVPLACE_')]\n"
|
|
"path.write_text('\\n'.join(lines) + '\\n' if lines else '')\n"
|
|
)
|
|
|
|
|
|
def wrap_with_env_export(command: list[str]) -> list[str]:
|
|
export_step = f"umask 022; python3 -c {shlex.quote(_ENV_EXPORT_SCRIPT)} 2>/dev/null || true"
|
|
script = f"{export_step}; exec {shlex.join(command)}"
|
|
return ["/bin/sh", "-c", script]
|
|
|
|
|
|
def env_for(profile: EditorProfile) -> dict:
|
|
return {
|
|
"DEVPLACE_EDITOR_APP_NAME": APP_NAME,
|
|
"DEVPLACE_EDITOR_PROFILE": f"{WORKSPACE_STATE_MOUNT}/{PROFILE_FILE}",
|
|
"DEVPLACE_EDITOR_THEME": profile.theme,
|
|
"DEVPLACE_EDITOR_FONT_SIZE": str(profile.font_size),
|
|
"DEVPLACE_EDITOR_TERMINAL_FONT_SIZE": str(profile.terminal_font_size),
|
|
"DEVPLACE_EDITOR_ZOOM_LEVEL": str(profile.zoom_level),
|
|
"DEVPLACE_EDITOR_LAYOUT": profile.layout,
|
|
"DEVPLACE_EDITOR_PANEL_PRESET": profile.panel_preset,
|
|
"DEVPLACE_EDITOR_BOOT_AGENT": profile.boot_agent,
|
|
"DEVPLACE_EDITOR_BOOT_SHELL": "1" if profile.boot_shell else "0",
|
|
"DEVPLACE_EDITOR_TRUST_ALL": "1" if profile.trust_all else "0",
|
|
}
|
|
|
|
|
|
def booted_profile(instance: dict) -> dict:
|
|
return _read_json(state_dir(instance) / PROFILE_FILE).get("editor") or {}
|
|
|
|
|
|
def restart_required(instance: dict, profile: EditorProfile) -> bool:
|
|
booted = booted_profile(instance)
|
|
if not booted:
|
|
return False
|
|
return booted != profile.as_dict()
|
|
|
|
|
|
def save_prefs(owner_uid: str, payload: dict) -> dict:
|
|
from devplacepy.utils import generate_uid
|
|
|
|
table = get_table(PREFS_TABLE)
|
|
row = prefs_for(owner_uid)
|
|
values = {key: payload[key] for key in PREF_COLUMNS if key in payload}
|
|
stamp = store.now()
|
|
if row:
|
|
table.update({"uid": row["uid"], "updated_at": stamp, **values}, ["uid"])
|
|
return table.find_one(uid=row["uid"])
|
|
uid = generate_uid()
|
|
table.insert(
|
|
{
|
|
"uid": uid,
|
|
"owner_kind": "user",
|
|
"owner_id": owner_uid,
|
|
"created_at": stamp,
|
|
"updated_at": stamp,
|
|
"deleted_at": None,
|
|
"deleted_by": None,
|
|
**{key: _blank(key) for key in PREF_COLUMNS},
|
|
**values,
|
|
}
|
|
)
|
|
return table.find_one(uid=uid)
|
|
|
|
|
|
def reset_prefs(owner_uid: str, actor_uid: str) -> bool:
|
|
row = prefs_for(owner_uid)
|
|
if not row:
|
|
return False
|
|
get_table(PREFS_TABLE).update(
|
|
{"uid": row["uid"], "deleted_at": store.now(), "deleted_by": actor_uid}, ["uid"]
|
|
)
|
|
return True
|
|
|
|
|
|
def _blank(key: str):
|
|
if key in SENTINELS:
|
|
return SENTINELS[key]
|
|
if isinstance(DEFAULTS[key], str):
|
|
return INHERIT_TEXT
|
|
return INHERIT_INT
|
|
|
|
|
|
def view(owner_uid: str = "", instance: dict | None = None) -> dict:
|
|
profile = resolve(owner_uid, instance)
|
|
payload = profile.as_dict()
|
|
payload["cpu_cores"] = profile.cpu_cores()
|
|
payload["sources"] = source_map(owner_uid)
|
|
return payload
|