Move desktop and e2e into examples/ directory

- desktop/ → examples/django-react-desktop-app/
- e2e/ → examples/django-react-site/
- example/ → examples/django-react-site/backend/
- Update Dockerfile.test, Makefile, playwright config, and
  django.config.mjs path references

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-31 20:41:20 -04:00
parent c866142770
commit eee352d908
51 changed files with 5983 additions and 10 deletions

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class DesktopBackendConfig(AppConfig):
name = "backend"
default_auto_field = "django.db.models.BigAutoField"

View File

@@ -0,0 +1,13 @@
import os
import django
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "backend.settings")
django.setup()
from django.core.asgi import get_asgi_application
from mizan import wrap_asgi
import backend.mizan_clients # noqa: F401
application = wrap_asgi(get_asgi_application())

View File

@@ -0,0 +1,419 @@
"""
Desktop RPC server functions.
Tests mizan's appropriateness for desktop apps:
- Local file system access
- SQLite CRUD
- System introspection
- Real-time channels (file watcher, app status)
- No auth required (single-user desktop)
"""
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 ReactChannel
from mizan.setup.registry import register
from mizan.channels import register as register_channel
# =============================================================================
# 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()
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, OSError):
continue
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()
# Safety: limit to 1MB text files
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()
# Safety: only allow writing within home directory
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)
note.delete()
return DeleteNoteOutput(id=id, deleted=True)
except Note.DoesNotExist:
return DeleteNoteOutput(id=id, deleted=False)
register(delete_note, "delete_note")
# =============================================================================
# Channels — Real-time Desktop Events
# =============================================================================
class AppStatusChannel(ReactChannel):
"""Push app status updates to the UI (uptime, memory, etc.)."""
class DjangoMessage(BaseModel):
uptime_seconds: float
memory_mb: float
note_count: int
timestamp: str
def authorize(self, params=None):
return True # Desktop app, no auth needed
def group(self, params=None):
return "app_status"
register_channel(AppStatusChannel, "app_status")
class NotesChannel(ReactChannel):
"""Push notifications when notes are modified."""
class DjangoMessage(BaseModel):
action: str # "created", "updated", "deleted"
note_id: int
title: str
def authorize(self, params=None):
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")

View File

@@ -0,0 +1,15 @@
from django.db import models
class Note(models.Model):
title = models.CharField(max_length=200)
content = models.TextField(blank=True, default="")
pinned = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["-pinned", "-updated_at"]
def __str__(self):
return self.title

View File

@@ -0,0 +1,49 @@
"""
Django settings for the mizan desktop integration test app.
Runs entirely local: SQLite database, in-memory channel layer,
no external services required.
"""
import os
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SECRET_KEY = "desktop-app-local-only-secret-key"
DEBUG = True
ALLOWED_HOSTS = ["127.0.0.1", "localhost"]
INSTALLED_APPS = [
"django.contrib.contenttypes",
"backend",
]
MIDDLEWARE = []
ROOT_URLCONF = "backend.urls"
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": os.path.join(BASE_DIR, "app.db"),
}
}
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
ASGI_APPLICATION = "backend.asgi.application"
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels.layers.InMemoryChannelLayer",
},
}
# Serve the built frontend
STATIC_URL = "/static/"
STATICFILES_DIRS = [os.path.join(BASE_DIR, "frontend", "dist")]
# No auth, no CSRF — local desktop app
CSRF_COOKIE_HTTPONLY = False

View File

@@ -0,0 +1,34 @@
from django.urls import include, path, re_path
from django.http import HttpResponse, HttpResponseNotFound
from pathlib import Path
DIST_DIR = Path(__file__).resolve().parent.parent / "frontend" / "dist"
CONTENT_TYPES = {
".html": "text/html",
".js": "application/javascript",
".css": "text/css",
".svg": "image/svg+xml",
".png": "image/png",
".ico": "image/x-icon",
".woff2": "font/woff2",
".json": "application/json",
}
def serve_dist(request, path="index.html"):
file_path = (DIST_DIR / path).resolve()
if not str(file_path).startswith(str(DIST_DIR)) or not file_path.is_file():
return HttpResponseNotFound()
ct = CONTENT_TYPES.get(file_path.suffix, "application/octet-stream")
return HttpResponse(file_path.read_bytes(), content_type=ct)
urlpatterns = [
path("api/mizan/", include("mizan.urls")),
re_path(r"^(?P<path>assets/.+)$", serve_dist),
path("favicon.ico", serve_dist, {"path": "favicon.ico"}),
path("", serve_dist),
]