import logging import os import platform import shutil import sys import time from datetime import datetime from pathlib import Path from django.http import HttpRequest from pydantic import BaseModel from mizan.client import client from mizan.channels import Channel from mizan.setup import register from mizan.channels import register as register_channel logger = logging.getLogger(__name__) # ============================================================================= # System Info # ============================================================================= class SystemInfoOutput(BaseModel): os_name: str os_version: str python_version: str hostname: str username: str home_dir: str cwd: str cpu_count: int mizan_version: str @client(websocket=True) def system_info(request: HttpRequest) -> SystemInfoOutput: import mizan return SystemInfoOutput( os_name=platform.system(), os_version=platform.version(), python_version=sys.version.split()[0], hostname=platform.node(), username=os.getenv("USER", os.getenv("USERNAME", "unknown")), home_dir=str(Path.home()), cwd=os.getcwd(), cpu_count=os.cpu_count() or 1, mizan_version=getattr(mizan, "__version__", "dev"), ) register(system_info, "system_info") class DiskUsageOutput(BaseModel): path: str total_gb: float used_gb: float free_gb: float percent_used: float @client(websocket=True) def disk_usage(request: HttpRequest, path: str = "/") -> DiskUsageOutput: usage = shutil.disk_usage(path) return DiskUsageOutput( path=path, total_gb=round(usage.total / (1024**3), 2), used_gb=round(usage.used / (1024**3), 2), free_gb=round(usage.free / (1024**3), 2), percent_used=round(usage.used / usage.total * 100, 1), ) register(disk_usage, "disk_usage") # ============================================================================= # File System # ============================================================================= class FileEntry(BaseModel): name: str path: str is_dir: bool size: int modified: str class ListFilesOutput(BaseModel): directory: str entries: list[FileEntry] parent: str | None @client(websocket=True) def list_files(request: HttpRequest, directory: str = "~") -> ListFilesOutput: dir_path = Path(directory).expanduser().resolve() if not dir_path.is_dir(): raise ValueError(f"Not a directory: {dir_path}") entries = [] try: for entry in sorted( dir_path.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower()) ): try: stat = entry.stat() except (PermissionError, OSError) as e: # A broken symlink or an unreadable entry drops out of the # listing rather than failing the whole directory. logger.warning("Skipping %s: %s", entry, e) continue entries.append( FileEntry( name=entry.name, path=str(entry), is_dir=entry.is_dir(), size=stat.st_size if not entry.is_dir() else 0, modified=datetime.fromtimestamp(stat.st_mtime).isoformat(), ) ) except PermissionError: raise PermissionError(f"Cannot read directory: {dir_path}") parent = str(dir_path.parent) if dir_path.parent != dir_path else None return ListFilesOutput( directory=str(dir_path), entries=entries, parent=parent, ) register(list_files, "list_files") class FileContentOutput(BaseModel): path: str content: str size: int modified: str @client(websocket=True) def read_file(request: HttpRequest, path: str) -> FileContentOutput: file_path = Path(path).expanduser().resolve() if not file_path.is_file(): raise FileNotFoundError(f"File not found: {file_path}") stat = file_path.stat() # Reads are capped at 1MB so a large binary cannot be pulled into memory. if stat.st_size > 1_048_576: raise ValueError(f"File too large: {stat.st_size} bytes (max 1MB)") try: content = file_path.read_text(encoding="utf-8") except UnicodeDecodeError: raise ValueError(f"Not a text file: {file_path}") return FileContentOutput( path=str(file_path), content=content, size=stat.st_size, modified=datetime.fromtimestamp(stat.st_mtime).isoformat(), ) register(read_file, "read_file") class WriteFileOutput(BaseModel): path: str size: int @client(websocket=True) def write_file(request: HttpRequest, path: str, content: str) -> WriteFileOutput: file_path = Path(path).expanduser().resolve() home = Path.home() if not str(file_path).startswith(str(home)): raise PermissionError(f"Can only write files within home directory: {home}") file_path.parent.mkdir(parents=True, exist_ok=True) file_path.write_text(content, encoding="utf-8") return WriteFileOutput(path=str(file_path), size=len(content.encode("utf-8"))) register(write_file, "write_file") class DeleteFileOutput(BaseModel): path: str deleted: bool @client(websocket=True) def delete_file(request: HttpRequest, path: str) -> DeleteFileOutput: file_path = Path(path).expanduser().resolve() home = Path.home() if not str(file_path).startswith(str(home)): raise PermissionError(f"Can only delete files within home directory: {home}") if file_path.exists(): file_path.unlink() return DeleteFileOutput(path=str(file_path), deleted=True) return DeleteFileOutput(path=str(file_path), deleted=False) register(delete_file, "delete_file") # ============================================================================= # Notes CRUD (SQLite) # ============================================================================= class NoteOutput(BaseModel): id: int title: str content: str pinned: bool created_at: str updated_at: str class NoteListOutput(BaseModel): notes: list[NoteOutput] count: int def _note_to_output(note) -> NoteOutput: return NoteOutput( id=note.id, title=note.title, content=note.content, pinned=note.pinned, created_at=note.created_at.isoformat(), updated_at=note.updated_at.isoformat(), ) @client(websocket=True) def list_notes(request: HttpRequest) -> NoteListOutput: from backend.models import Note notes = Note.objects.all() return NoteListOutput( notes=[_note_to_output(n) for n in notes], count=notes.count(), ) register(list_notes, "list_notes") @client(websocket=True) def create_note( request: HttpRequest, title: str, content: str = "", pinned: bool = False ) -> NoteOutput: from backend.models import Note note = Note.objects.create(title=title, content=content, pinned=pinned) return _note_to_output(note) register(create_note, "create_note") @client(websocket=True) def get_note(request: HttpRequest, id: int) -> NoteOutput: from backend.models import Note try: note = Note.objects.get(pk=id) except Note.DoesNotExist: raise ValueError(f"Note {id} not found") return _note_to_output(note) register(get_note, "get_note") @client(websocket=True) def update_note( request: HttpRequest, id: int, title: str | None = None, content: str | None = None, pinned: bool | None = None, ) -> NoteOutput: from backend.models import Note try: note = Note.objects.get(pk=id) except Note.DoesNotExist: raise ValueError(f"Note {id} not found") if title is not None: note.title = title if content is not None: note.content = content if pinned is not None: note.pinned = pinned note.save() return _note_to_output(note) register(update_note, "update_note") class DeleteNoteOutput(BaseModel): id: int deleted: bool @client(websocket=True) def delete_note(request: HttpRequest, id: int) -> DeleteNoteOutput: from backend.models import Note try: note = Note.objects.get(pk=id) except Note.DoesNotExist: # Deleting an absent note is reported, not raised. logger.info("delete_note: note %s is already absent", id) return DeleteNoteOutput(id=id, deleted=False) note.delete() return DeleteNoteOutput(id=id, deleted=True) register(delete_note, "delete_note") # ============================================================================= # Channels — Real-time Desktop Events # ============================================================================= class AppStatusChannel(Channel): """Push app status updates to the UI (uptime, memory, etc.).""" class ServerMessage(BaseModel): uptime_seconds: float memory_mb: float note_count: int timestamp: str def authorize(self, params=None): # One local user owns the whole process; there is no identity to check. return True def group(self, params=None): return "app_status" register_channel(AppStatusChannel, "app_status") class NotesChannel(Channel): """Push notifications when notes are modified.""" class ServerMessage(BaseModel): action: str # "created", "updated", "deleted" note_id: int title: str def authorize(self, params=None): # One local user owns the whole process; there is no identity to check. return True def group(self, params=None): return "notes_updates" register_channel(NotesChannel, "notes_updates") # ============================================================================= # App Lifecycle # ============================================================================= _start_time = time.time() class AppInfoOutput(BaseModel): app_name: str uptime_seconds: float db_path: str pid: int @client(websocket=True) def app_info(request: HttpRequest) -> AppInfoOutput: from django.conf import settings return AppInfoOutput( app_name="mizan Desktop", uptime_seconds=round(time.time() - _start_time, 2), db_path=str(settings.DATABASES["default"]["NAME"]), pid=os.getpid(), ) register(app_info, "app_info")