diff --git a/backend/main.py b/backend/main.py index dd4236d..f7eb2fb 100644 --- a/backend/main.py +++ b/backend/main.py @@ -11,7 +11,7 @@ from pydantic import BaseModel from backend.auth import authenticate_user, create_token, logout from backend.config import FRONTEND_DIR, HOST, PORT -from backend.routers import stats, terminal, files, services, processes, settings, network +from backend.routers import stats, terminal, files, services, processes, settings, network, packages, updates from backend.routers.plugins import asi_bridge logging.basicConfig( @@ -87,6 +87,8 @@ app.include_router(services.router) app.include_router(processes.router) app.include_router(settings.router) app.include_router(network.router) +app.include_router(packages.router) +app.include_router(updates.router) app.include_router(asi_bridge.router) diff --git a/backend/routers/packages.py b/backend/routers/packages.py new file mode 100644 index 0000000..8a6fc72 --- /dev/null +++ b/backend/routers/packages.py @@ -0,0 +1,199 @@ +"""Package management — apt-get / dpkg wrapper for Debian-based systems.""" + +import asyncio +import os +import re +import shutil + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel + +from backend.auth import get_current_user + +router = APIRouter(prefix="/api/packages", tags=["packages"]) + +# --------------------------------------------------------------------------- +# Guard +# --------------------------------------------------------------------------- + +_HAS_APT = bool(shutil.which("apt-cache")) + + +def _require_apt(): + if not _HAS_APT: + raise HTTPException(503, "apt package manager not available on this system") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_PKG_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9.+\-:]+$") + + +def _validate_name(name: str) -> str: + name = name.strip() + if not _PKG_NAME_RE.match(name): + raise HTTPException(400, f"Invalid package name: {name}") + return name + + +async def _apt(*args: str, timeout: float = 30) -> str: + """Run an apt/dpkg command and return stdout.""" + _require_apt() + cmd = list(args) + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env={**os.environ, "LC_ALL": "C", "DEBIAN_FRONTEND": "noninteractive"}, + ) + try: + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + raise HTTPException(504, "Package operation timed out") + if proc.returncode != 0: + msg = stderr.decode().strip() or stdout.decode().strip() + raise HTTPException(500, f"Package error: {msg}") + return stdout.decode() + + +async def _apt_nofail(*args: str, timeout: float = 30) -> tuple[int, str, str]: + """Run an apt/dpkg command, return (returncode, stdout, stderr) without raising.""" + _require_apt() + cmd = list(args) + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env={**os.environ, "LC_ALL": "C", "DEBIAN_FRONTEND": "noninteractive"}, + ) + try: + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + return 1, "", "Timed out" + return proc.returncode, stdout.decode(), stderr.decode() + + +# --------------------------------------------------------------------------- +# Models +# --------------------------------------------------------------------------- + +class PackageAction(BaseModel): + name: str + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + +@router.get("/search") +async def search_packages( + q: str = Query(..., min_length=2, description="Search query"), + _user: str = Depends(get_current_user), +): + """Search for packages matching a query.""" + output = await _apt("apt-cache", "search", q) + results = [] + for line in output.strip().splitlines(): + if not line.strip(): + continue + parts = line.split(" - ", 1) + if len(parts) == 2: + results.append({"name": parts[0].strip(), "summary": parts[1].strip()}) + if len(results) >= 100: + break + return results + + +@router.get("/info/{name}") +async def package_info( + name: str, + _user: str = Depends(get_current_user), +): + """Get detailed information about a package.""" + name = _validate_name(name) + + # Get package info from apt-cache + output = await _apt("apt-cache", "show", name) + + # Parse key-value fields + info = {} + current_key = None + current_val = "" + for line in output.splitlines(): + if line.startswith(" "): + # Continuation of previous field + current_val += "\n" + line.strip() + elif ": " in line: + if current_key: + info[current_key] = current_val + current_key, current_val = line.split(": ", 1) + elif line == "": + if current_key: + info[current_key] = current_val + current_key = None + current_val = "" + break # Only parse first stanza + if current_key: + info[current_key] = current_val + + # Check installed status + rc, dpkg_out, _ = await _apt_nofail( + "dpkg-query", "-W", "-f=${Status}\t${Version}\n", name + ) + installed_version = None + is_installed = False + if rc == 0 and dpkg_out.strip(): + parts = dpkg_out.strip().split("\t") + if len(parts) >= 2 and "install ok installed" in parts[0]: + is_installed = True + installed_version = parts[1] + + return { + "name": info.get("Package", name), + "version": info.get("Version", ""), + "installed": is_installed, + "installed_version": installed_version, + "description": info.get("Description", info.get("Description-en", "")), + "size": info.get("Size", ""), + "installed_size": info.get("Installed-Size", ""), + "depends": info.get("Depends", ""), + "section": info.get("Section", ""), + "maintainer": info.get("Maintainer", ""), + "homepage": info.get("Homepage", ""), + "architecture": info.get("Architecture", ""), + } + + +@router.post("/install") +async def install_package( + req: PackageAction, + _user: str = Depends(get_current_user), +): + """Install a package via apt-get.""" + name = _validate_name(req.name) + output = await _apt("apt-get", "install", "-y", name, timeout=300) + return {"success": True, "package": name, "output": output[-500:]} + + +@router.post("/remove") +async def remove_package( + req: PackageAction, + _user: str = Depends(get_current_user), +): + """Remove a package via apt-get.""" + name = _validate_name(req.name) + output = await _apt("apt-get", "remove", "-y", name, timeout=120) + return {"success": True, "package": name, "output": output[-500:]} + + +@router.post("/update-cache") +async def update_cache(_user: str = Depends(get_current_user)): + """Run apt-get update to refresh package lists.""" + output = await _apt("apt-get", "update", timeout=120) + return {"success": True, "output": output[-500:]} diff --git a/backend/routers/updates.py b/backend/routers/updates.py new file mode 100644 index 0000000..f1e6946 --- /dev/null +++ b/backend/routers/updates.py @@ -0,0 +1,166 @@ +"""Self-update — check Gitea repo for new commits and apply updates.""" + +import asyncio +import os +import shutil + +from fastapi import APIRouter, Depends, HTTPException + +from backend.auth import get_current_user +from backend.config import BASE_DIR + +router = APIRouter(prefix="/api/updates", tags=["updates"]) + +# --------------------------------------------------------------------------- +# Guard +# --------------------------------------------------------------------------- + +_IS_GIT = (BASE_DIR / ".git").is_dir() +_HAS_GIT = bool(shutil.which("git")) + + +def _require_git(): + if not _HAS_GIT: + raise HTTPException(503, "git is not installed") + if not _IS_GIT: + raise HTTPException(503, "Atlus was not installed via git — updates unavailable") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +async def _git(*args: str, timeout: float = 30) -> str: + """Run a git command in the Atlus install directory.""" + _require_git() + cmd = ["git", "-C", str(BASE_DIR)] + list(args) + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env={**os.environ, "LC_ALL": "C"}, + ) + try: + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + raise HTTPException(504, "git operation timed out") + if proc.returncode != 0: + msg = stderr.decode().strip() or stdout.decode().strip() + raise HTTPException(500, f"git error: {msg}") + return stdout.decode().strip() + + +async def _git_nofail(*args: str, timeout: float = 30) -> tuple[int, str]: + """Run a git command, return (returncode, stdout) without raising.""" + _require_git() + cmd = ["git", "-C", str(BASE_DIR)] + list(args) + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env={**os.environ, "LC_ALL": "C"}, + ) + try: + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + return 1, "" + return proc.returncode, stdout.decode().strip() + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + +@router.get("/check") +async def check_for_updates(_user: str = Depends(get_current_user)): + """Check if there are new commits on the remote.""" + # Get current local HEAD + local_hash = await _git("rev-parse", "HEAD") + + # Fetch latest from remote (may take a few seconds) + try: + await _git("fetch", "origin", timeout=30) + except HTTPException: + # Fetch failed (no network, etc.) — report no update + return { + "available": False, + "local_hash": local_hash[:8], + "remote_hash": local_hash[:8], + "behind_count": 0, + "error": "Could not reach remote repository", + } + + # Get remote HEAD + rc, remote_hash = await _git_nofail("rev-parse", "origin/main") + if rc != 0: + # Try origin/master as fallback + rc, remote_hash = await _git_nofail("rev-parse", "origin/master") + if rc != 0: + return { + "available": False, + "local_hash": local_hash[:8], + "remote_hash": "", + "behind_count": 0, + "error": "Could not determine remote branch", + } + + # Count commits behind + rc, count_str = await _git_nofail("rev-list", "--count", f"HEAD..{remote_hash}") + behind_count = int(count_str) if rc == 0 and count_str.isdigit() else 0 + + return { + "available": behind_count > 0, + "local_hash": local_hash[:8], + "remote_hash": remote_hash[:8], + "behind_count": behind_count, + } + + +@router.post("/apply") +async def apply_update(_user: str = Depends(get_current_user)): + """Pull latest changes and schedule a service restart.""" + # Pull latest + pull_output = await _git("pull", "--ff-only", timeout=60) + + # Reinstall Python dependencies + pip_bin = str(BASE_DIR / "venv" / "bin" / "pip") + if not os.path.exists(pip_bin): + # Fallback: try system pip + pip_bin = shutil.which("pip3") or shutil.which("pip") or "pip3" + + req_file = str(BASE_DIR / "backend" / "requirements.txt") + if os.path.exists(req_file): + proc = await asyncio.create_subprocess_exec( + pip_bin, "install", "-r", req_file, "-q", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + try: + await asyncio.wait_for(proc.communicate(), timeout=120) + except asyncio.TimeoutError: + pass # Non-fatal — deps might already be satisfied + + # Schedule restart after response is sent + if shutil.which("systemctl"): + asyncio.create_task(_delayed_restart()) + + return { + "success": True, + "message": "Update applied. Restarting service...", + "pull_output": pull_output[-500:], + } + + +async def _delayed_restart(delay: float = 2.0): + """Wait briefly then restart the atlus systemd service.""" + await asyncio.sleep(delay) + proc = await asyncio.create_subprocess_exec( + "systemctl", "restart", "atlus", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + await proc.wait() diff --git a/frontend/css/apps/editor.css b/frontend/css/apps/editor.css new file mode 100644 index 0000000..cf0bdba --- /dev/null +++ b/frontend/css/apps/editor.css @@ -0,0 +1,195 @@ +/* Editor / File Viewer app styles */ +.app-editor { + display: flex; + flex-direction: column; + height: 100%; +} + +.editor-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 6px 12px; + background: var(--bg-titlebar); + border-bottom: 1px solid var(--border-structural); + min-height: 44px; +} + +.editor-title-wrap { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + flex: 1; +} + +.editor-title { + font-family: var(--font-mono); + font-size: 13px; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.editor-dirty { + color: var(--status-amber); + font-size: 12px; + flex-shrink: 0; +} + +.editor-save-btn { + height: 32px; + padding: 0 16px; + background: var(--accent); + border: none; + border-radius: var(--radius-sm); + color: #fff; + font-family: var(--font-ui); + font-size: 13px; + font-weight: 500; + cursor: pointer; + flex-shrink: 0; +} + +.editor-save-btn:hover { + opacity: 0.9; +} + +.editor-save-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Main content area */ +.editor-content { + flex: 1; + display: flex; + overflow: hidden; +} + +/* Text editor body */ +.editor-body { + display: flex; + flex: 1; + overflow: hidden; +} + +.editor-lines { + width: 48px; + padding: 12px 8px 12px 4px; + background: var(--bg-dock); + border-right: 1px solid var(--border-structural); + font-family: var(--font-mono); + font-size: 13px; + line-height: 1.5; + color: var(--text-muted); + text-align: right; + overflow: hidden; + white-space: pre; + user-select: none; + -webkit-user-select: none; +} + +.editor-textarea { + flex: 1; + padding: 12px; + background: var(--bg-stage); + border: none; + outline: none; + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 13px; + line-height: 1.5; + resize: none; + tab-size: 4; + -moz-tab-size: 4; + white-space: pre; + overflow: auto; + -webkit-overflow-scrolling: touch; +} + +.editor-textarea::selection { + background: var(--accent-dim); +} + +/* Image preview */ +.editor-preview { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + overflow: auto; + -webkit-overflow-scrolling: touch; + background: var(--bg-stage); +} + +.editor-preview-img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + border-radius: var(--radius-md); + box-shadow: 0 4px 24px rgba(0,0,0,0.3); +} + +/* Binary file info */ +.editor-binary-info { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 48px; + background: var(--bg-stage); +} + +.binary-icon { + font-size: 48px; +} + +.binary-name { + font-family: var(--font-mono); + font-size: 16px; + font-weight: 500; + color: var(--text-primary); +} + +.binary-meta { + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-secondary); +} + +.editor-download-btn { + margin-top: 12px; + height: 40px; + padding: 0 24px; + background: var(--accent); + border: none; + border-radius: var(--radius-md); + color: #fff; + font-family: var(--font-ui); + font-size: 14px; + font-weight: 500; + cursor: pointer; +} + +.editor-download-btn:hover { + opacity: 0.9; +} + +/* Status bar */ +.editor-status { + padding: 4px 12px; + background: var(--bg-titlebar); + border-top: 1px solid var(--border-structural); + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-muted); + min-height: 24px; + display: flex; + align-items: center; +} diff --git a/frontend/css/apps/files.css b/frontend/css/apps/files.css index 239bddb..a90a452 100644 --- a/frontend/css/apps/files.css +++ b/frontend/css/apps/files.css @@ -50,6 +50,13 @@ font-size: 12px; } +.files-btn-group { + display: flex; + align-items: center; + gap: 2px; + flex-shrink: 0; +} + .files-action-btn { width: 36px; height: 36px; diff --git a/frontend/css/apps/packages.css b/frontend/css/apps/packages.css new file mode 100644 index 0000000..fdb1b93 --- /dev/null +++ b/frontend/css/apps/packages.css @@ -0,0 +1,262 @@ +/* Package Manager app styles */ +.app-packages { + display: flex; + flex-direction: column; + height: 100%; +} + +.pkg-toolbar { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: var(--bg-titlebar); + border-bottom: 1px solid var(--border-structural); + min-height: 48px; +} + +.pkg-search { + flex: 1; + height: 36px; + padding: 0 12px; + background: var(--bg-input); + border: 1px solid var(--border-structural); + border-radius: var(--radius-md); + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 13px; + outline: none; +} + +.pkg-search:focus { + border-color: var(--accent); +} + +.pkg-search::placeholder { + color: var(--text-muted); +} + +.pkg-cache-btn { + height: 36px; + padding: 0 14px; + background: var(--bg-titlebar); + border: 1px solid var(--border-structural); + border-radius: var(--radius-md); + color: var(--text-primary); + font-family: var(--font-ui); + font-size: 13px; + cursor: pointer; + white-space: nowrap; + flex-shrink: 0; +} + +.pkg-cache-btn:hover { + background: var(--accent-hover); +} + +.pkg-cache-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Results list */ +.pkg-list { + flex: 1; + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} + +.pkg-empty, +.pkg-loading, +.pkg-error { + padding: 24px; + text-align: center; + font-family: var(--font-mono); + font-size: 13px; + color: var(--text-muted); +} + +.pkg-error { + color: var(--status-red); +} + +/* Package row */ +.pkg-row { + border-bottom: 1px solid var(--border-structural); +} + +.pkg-row-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + cursor: pointer; + transition: background var(--transition-fast); + min-height: 48px; + gap: 12px; +} + +.pkg-row-header:hover { + background: var(--accent-hover); +} + +.pkg-row.expanded .pkg-row-header { + background: var(--accent-dim); +} + +.pkg-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.pkg-name { + font-family: var(--font-mono); + font-size: 14px; + font-weight: 500; + color: var(--text-primary); +} + +.pkg-summary { + font-family: var(--font-ui); + font-size: 12px; + color: var(--text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pkg-expand-icon { + color: var(--text-muted); + font-size: 12px; + flex-shrink: 0; + width: 16px; + text-align: center; +} + +/* Package detail */ +.pkg-detail { + padding: 0 16px 16px; + background: var(--bg-dock); + border-top: 1px solid var(--border-structural); +} + +.pkg-detail-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 0; + flex-wrap: wrap; +} + +.pkg-detail-version { + font-family: var(--font-mono); + font-size: 13px; + color: var(--text-secondary); +} + +.pkg-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 10px; + font-family: var(--font-mono); + font-size: 11px; + font-weight: 500; + margin-left: 8px; +} + +.pkg-badge.installed { + background: rgba(58, 184, 106, 0.15); + color: var(--status-green); +} + +.pkg-badge.not-installed { + background: var(--border-structural); + color: var(--text-muted); +} + +.pkg-detail-actions { + flex-shrink: 0; +} + +.pkg-action-btn { + height: 32px; + padding: 0 16px; + border: none; + border-radius: var(--radius-sm); + font-family: var(--font-ui); + font-size: 13px; + font-weight: 500; + cursor: pointer; +} + +.pkg-action-btn.install { + background: var(--accent); + color: #fff; +} + +.pkg-action-btn.install:hover { + opacity: 0.9; +} + +.pkg-action-btn.remove { + background: var(--status-red); + color: #fff; +} + +.pkg-action-btn.remove:hover { + opacity: 0.9; +} + +.pkg-action-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.pkg-detail-desc { + font-family: var(--font-ui); + font-size: 13px; + color: var(--text-primary); + line-height: 1.5; + padding: 8px 0; + white-space: pre-wrap; +} + +.pkg-detail-meta { + display: flex; + flex-wrap: wrap; + gap: 16px; + padding: 4px 0; + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-secondary); +} + +.pkg-detail-deps { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-secondary); + padding: 4px 0; + word-break: break-word; +} + +.pkg-deps-label { + color: var(--text-muted); + font-weight: 500; +} + +/* Status bar */ +.pkg-status { + padding: 6px 16px; + background: var(--bg-titlebar); + border-top: 1px solid var(--border-structural); + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-muted); + min-height: 28px; + display: flex; + align-items: center; +} diff --git a/frontend/css/panel.css b/frontend/css/panel.css index db6374e..def26b3 100644 --- a/frontend/css/panel.css +++ b/frontend/css/panel.css @@ -212,3 +212,75 @@ background: var(--accent-hover); color: var(--accent); } + +/* Update toast */ +.panel-updates { + transition: all var(--transition-base); +} + +.update-toast { + position: relative; + padding: 12px; + background: var(--accent-dim); + border-left: 3px solid var(--accent); + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; +} + +.update-dismiss { + position: absolute; + top: 4px; + right: 4px; + width: 24px; + height: 24px; + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm); +} + +.update-dismiss:hover { + background: var(--accent-hover); + color: var(--text-primary); +} + +.update-toast-title { + font-family: var(--font-ui); + font-size: 13px; + font-weight: 500; + color: var(--accent); + margin-bottom: 4px; +} + +.update-toast-info { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-secondary); + margin-bottom: 10px; +} + +.update-toast-btn { + width: 100%; + height: 30px; + background: var(--accent); + border: none; + border-radius: var(--radius-sm); + color: #fff; + font-family: var(--font-ui); + font-size: 12px; + font-weight: 500; + cursor: pointer; +} + +.update-toast-btn:hover { + opacity: 0.9; +} + +.update-toast-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} diff --git a/frontend/desktop.html b/frontend/desktop.html index 595afa2..c7fadeb 100644 --- a/frontend/desktop.html +++ b/frontend/desktop.html @@ -21,6 +21,8 @@ + +
@@ -54,6 +56,10 @@ Network +