"""Editor autosave + crash recovery.
The editor's autosave is conceptually distinct from :class:`~simvx.core.SaveManager`:
* ``SaveManager`` snapshots only ``Property`` descriptors flagged
``persist=True`` -- a deliberately narrow contract for in-game state.
* The editor's autosave needs the **full scene structure** so that, after a
crash, no newly-added nodes are lost. We therefore reuse the engine's
in-memory scene snapshot helper (:func:`simvx.core._scene_internal._serialize_node`)
and pickle that dict. Restoring goes through :func:`_deserialize_node`.
Both paths share :func:`simvx.core.save_manager.pickle_atomic` for the
write-tmp + fsync + rename + two-deep ``.bak``/``.bak2`` rotation, so the
crash-safety guarantees are identical.
The autosave file lives at ``$XDG_DATA_HOME/simvx/editor/autosave.sav``
(falling back to ``~/.local/share/simvx/editor/autosave.sav``). One file --
the most-recent project's working tree -- because the editor only ever has
one foreground workspace.
"""
from __future__ import annotations
import logging
import os
import pickle
import time
from pathlib import Path
from simvx.core import Button, Label, Panel, Signal, Vec2
from simvx.core._scene_internal import _deserialize_node, _serialize_node
from simvx.core.save_manager import pickle_atomic
log = logging.getLogger(__name__)
def _default_autosave_dir() -> Path:
"""Return ``$XDG_DATA_HOME/simvx/editor`` or the standard fallback."""
xdg = os.environ.get("XDG_DATA_HOME")
base = Path(xdg) if xdg else Path.home() / ".local" / "share"
return base / "simvx" / "editor"
[docs]
class Autosave:
"""Periodic full-tree autosave with crash-recovery on launch.
Polled each frame via :meth:`poll`. When the autosave interval has
elapsed and the editor's scene is marked modified, a pickle of
:func:`_serialize_node(scene_root)` is written atomically to
``<autosave_dir>/autosave.sav``. On startup, :meth:`check_recovery`
returns the autosave envelope when it is newer than the most-recently
opened project file (if any).
"""
INTERVAL = 60.0 # seconds
def __init__(self, state, autosave_dir: Path | None = None):
self._state = state
self._last_save = 0.0
self.autosave_dir: Path = autosave_dir if autosave_dir is not None else _default_autosave_dir()
# ------------------------------------------------------------------
# Paths
# ------------------------------------------------------------------
[docs]
@property
def autosave_path(self) -> Path:
"""Single canonical autosave file path."""
return self.autosave_dir / "autosave.sav"
# ------------------------------------------------------------------
# Frame polling
# ------------------------------------------------------------------
[docs]
def poll(self, now: float = 0.0) -> None:
"""Save the working tree if the interval has elapsed and it's dirty."""
if not now:
now = time.monotonic()
if now - self._last_save < self.INTERVAL:
return
if not self._state.modified:
return
self._save_snapshot()
self._last_save = now
# ------------------------------------------------------------------
# Snapshot persistence
# ------------------------------------------------------------------
def _save_snapshot(self) -> None:
scene = getattr(self._state, "edited_scene", None)
root = scene.root if scene is not None else None
if root is None:
return
scene_path = self._state.current_scene_path
envelope = {
"format_version": 1,
"timestamp": time.time(),
"scene_path": str(scene_path) if scene_path else None,
"scene": _serialize_node(root),
}
try:
pickle_atomic(self.autosave_path, envelope)
log.debug("Autosaved editor scene to %s", self.autosave_path)
except Exception:
# Reported via the standard log path -- the Console panel surfaces
# these. Strict-raise is wrong here: a failed autosave must never
# crash the editor or interrupt the user.
log.exception("Editor autosave failed for %s", self.autosave_path)
# ------------------------------------------------------------------
# Recovery
# ------------------------------------------------------------------
[docs]
def check_recovery(self, project_path: Path | None = None) -> dict | None:
"""Return the autosave envelope when recovery should be offered.
Returns ``None`` when:
* no autosave file exists, or
* a ``project_path`` was supplied and the autosave is *older than* the
project file (i.e. the project's on-disk state is already fresher).
Otherwise returns the decoded envelope dict (caller decides whether
to show a dialog and/or restore).
"""
path = self.autosave_path
if not path.exists():
return None
try:
autosave_mtime = path.stat().st_mtime
except OSError:
log.warning("Could not stat autosave file %s", path, exc_info=True)
return None
if project_path is not None:
try:
project_mtime = Path(project_path).stat().st_mtime
if autosave_mtime <= project_mtime:
return None
except OSError:
# Project path missing -- treat the autosave as recoverable.
pass
try:
envelope = pickle.loads(path.read_bytes())
except Exception as exc:
# Surface to the Console panel; do not throw. The recovery prompt
# will inspect ``envelope is None`` and show the error message.
log.exception("Autosave file %s is corrupt: %s", path, exc)
return {"_error": f"{type(exc).__name__}: {exc}", "_path": str(path)}
if not isinstance(envelope, dict):
log.warning("Autosave file %s has unexpected payload type %s", path, type(envelope).__name__)
return None
return envelope
[docs]
def restore(self, envelope: dict):
"""Reconstruct a Node tree from a recovery envelope.
The caller is responsible for swapping the returned root into the
editor's active scene tab. Raises ``RuntimeError`` if the envelope is
malformed (missing ``scene`` key); strict per the engine's error
philosophy.
"""
if "_error" in envelope:
raise RuntimeError(f"Cannot restore corrupt autosave: {envelope['_error']}")
scene = envelope.get("scene")
if not isinstance(scene, dict):
raise RuntimeError("Autosave envelope missing valid 'scene' dict")
scene_path = envelope.get("scene_path")
scene_dir = str(Path(scene_path).parent) if scene_path else ""
return _deserialize_node(scene, scene_dir=scene_dir)
[docs]
def discard(self) -> None:
"""Delete the live autosave file (keeps ``.bak`` / ``.bak2``)."""
try:
self.autosave_path.unlink()
except FileNotFoundError:
pass
except OSError:
log.warning("Could not delete autosave file %s", self.autosave_path, exc_info=True)
# ---------------------------------------------------------------------------
# Recovery dialog
# ---------------------------------------------------------------------------
[docs]
class AutosaveRecoveryDialog(Panel):
"""Modal prompt shown on startup when a fresh autosave is available.
Three outcomes, exposed as signals:
* :attr:`restore_requested` -- user wants the autosaved tree swapped in.
* :attr:`discard_requested` -- delete the autosave and continue with the
project file as-is.
* :attr:`cancelled` -- close the dialog without acting (e.g. user wants
to inspect things first; autosave is left intact for later).
"""
DIALOG_WIDTH = 380.0
DIALOG_HEIGHT = 180.0
BUTTON_HEIGHT = 32.0
BUTTON_GAP = 8.0
def __init__(self, **kwargs):
kwargs.setdefault("name", "AutosaveRecoveryDialog")
super().__init__(**kwargs)
self.restore_requested = Signal()
self.discard_requested = Signal()
self.cancelled = Signal()
self.bg_colour = (0.0, 0.0, 0.0, 0.6)
self.border_width = 0
self.visible = False
self._dialog_panel: Panel | None = None
self._message_label: Label | None = None
self._build()
def _build(self) -> None:
inner = Panel(name="DialogInner")
inner.bg_colour = (0.18, 0.18, 0.20, 1.0)
inner.border_colour = (0.35, 0.35, 0.4, 1.0)
inner.border_width = 1.0
inner.size = Vec2(self.DIALOG_WIDTH, self.DIALOG_HEIGHT)
self._dialog_panel = inner
title = Label("Recover Autosave?", name="Title")
title.font_size = 14.0
title.text_colour = (0.9, 0.9, 0.9, 1.0)
title.position = Vec2(16, 12)
inner.add_child(title)
msg = Label("", name="Message")
msg.font_size = 12.0
msg.text_colour = (0.7, 0.7, 0.7, 1.0)
msg.position = Vec2(16, 44)
self._message_label = msg
inner.add_child(msg)
btn_y = self.DIALOG_HEIGHT - self.BUTTON_HEIGHT - 16
btn_w = (self.DIALOG_WIDTH - 16 * 2 - self.BUTTON_GAP * 2) / 3
restore_btn = Button("Restore", name="RestoreBtn")
restore_btn.size = Vec2(btn_w, self.BUTTON_HEIGHT)
restore_btn.position = Vec2(16, btn_y)
restore_btn.bg_colour = (0.20, 0.57, 0.92, 1.0)
restore_btn.pressed.connect(self._on_restore)
inner.add_child(restore_btn)
discard_btn = Button("Discard", name="DiscardBtn")
discard_btn.size = Vec2(btn_w, self.BUTTON_HEIGHT)
discard_btn.position = Vec2(16 + btn_w + self.BUTTON_GAP, btn_y)
discard_btn.bg_colour = (0.30, 0.30, 0.33, 1.0)
discard_btn.pressed.connect(self._on_discard)
inner.add_child(discard_btn)
cancel_btn = Button("Open Anyway", name="CancelBtn")
cancel_btn.size = Vec2(btn_w, self.BUTTON_HEIGHT)
cancel_btn.position = Vec2(16 + (btn_w + self.BUTTON_GAP) * 2, btn_y)
cancel_btn.bg_colour = (0.30, 0.30, 0.33, 1.0)
cancel_btn.pressed.connect(self._on_cancel)
inner.add_child(cancel_btn)
self.add_child(inner)
[docs]
def show_dialog(self, message: str, parent_size: Vec2 | None = None) -> None:
"""Reveal the dialog with the given ``message`` body."""
self.visible = True
if self._message_label:
self._message_label.text = message
if parent_size:
self.size = parent_size
if self._dialog_panel:
pw = self.size.x if self.size.x > 0 else 400
ph = self.size.y if self.size.y > 0 else 300
dw, dh = self._dialog_panel.size.x, self._dialog_panel.size.y
self._dialog_panel.position = Vec2((pw - dw) / 2, (ph - dh) / 2)
def _on_restore(self) -> None:
self.visible = False
self.restore_requested.emit()
def _on_discard(self) -> None:
self.visible = False
self.discard_requested.emit()
def _on_cancel(self) -> None:
self.visible = False
self.cancelled.emit()