420 lines
14 KiB
Rust
420 lines
14 KiB
Rust
//! Versioned machine-local editor session persistence and crash boundary.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use bevy::app::AppExit;
|
|
use bevy::prelude::*;
|
|
use bevy_egui::egui;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::project_io::{ProjectWorkspace, UserPreferences};
|
|
use crate::scene::recovery::{atomic_write, default_state_root};
|
|
use crate::scene_io::{SceneIo, SceneIoRequest};
|
|
use crate::settings_ui::ProjectSettingsPanel;
|
|
use crate::ui::{BrushDiagnosticsPanel, DiagnosticsPanel};
|
|
use crate::viewport::rendering_diagnostics::RenderingDiagnosticsPanel;
|
|
use crate::viewport::CameraBookmarks;
|
|
|
|
const SESSION_FILE: &str = "session.ron";
|
|
const SESSION_SCHEMA_VERSION: u32 = 1;
|
|
const SESSION_WRITE_INTERVAL_SECS: f32 = 2.0;
|
|
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
|
pub struct SessionPanelState {
|
|
#[serde(default)]
|
|
pub diagnostics: bool,
|
|
#[serde(default)]
|
|
pub brush_diagnostics: bool,
|
|
#[serde(default)]
|
|
pub rendering: bool,
|
|
#[serde(default)]
|
|
pub project_settings: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
pub struct CameraBookmarkRecord {
|
|
pub scene: String,
|
|
pub translation: [f32; 3],
|
|
pub rotation: [f32; 4],
|
|
pub scale: [f32; 3],
|
|
}
|
|
|
|
impl CameraBookmarkRecord {
|
|
fn from_transform(scene: String, transform: Transform) -> Self {
|
|
Self {
|
|
scene,
|
|
translation: transform.translation.to_array(),
|
|
rotation: transform.rotation.to_array(),
|
|
scale: transform.scale.to_array(),
|
|
}
|
|
}
|
|
|
|
fn to_transform(&self) -> Transform {
|
|
Transform {
|
|
translation: Vec3::from_array(self.translation),
|
|
rotation: Quat::from_array(self.rotation).normalize(),
|
|
scale: Vec3::from_array(self.scale),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
pub struct EditorSessionDocument {
|
|
#[serde(default)]
|
|
pub schema_version: u32,
|
|
#[serde(default)]
|
|
pub clean_shutdown: bool,
|
|
#[serde(default)]
|
|
pub project_root: String,
|
|
#[serde(default)]
|
|
pub active_scene: Option<String>,
|
|
#[serde(default)]
|
|
pub dock_layout: Option<String>,
|
|
#[serde(default)]
|
|
pub hierarchy_expanded_paths: Vec<String>,
|
|
#[serde(default)]
|
|
pub panels: SessionPanelState,
|
|
#[serde(default)]
|
|
pub camera_bookmarks: Vec<CameraBookmarkRecord>,
|
|
}
|
|
|
|
impl Default for EditorSessionDocument {
|
|
fn default() -> Self {
|
|
Self {
|
|
schema_version: SESSION_SCHEMA_VERSION,
|
|
clean_shutdown: true,
|
|
project_root: String::new(),
|
|
active_scene: None,
|
|
dock_layout: None,
|
|
hierarchy_expanded_paths: Vec::new(),
|
|
panels: SessionPanelState::default(),
|
|
camera_bookmarks: Vec::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Resource, Debug, Default)]
|
|
pub struct EditorSessionRuntime {
|
|
pub prior_abnormal_shutdown: bool,
|
|
pub resume_scene: Option<PathBuf>,
|
|
pub resume_prompt_open: bool,
|
|
save_elapsed_secs: f32,
|
|
}
|
|
|
|
pub struct EditorSessionPlugin;
|
|
|
|
impl Plugin for EditorSessionPlugin {
|
|
fn build(&self, app: &mut App) {
|
|
app.init_resource::<EditorSessionRuntime>()
|
|
.add_systems(Startup, initialize_session)
|
|
.add_systems(Update, persist_running_session)
|
|
.add_systems(
|
|
Last,
|
|
persist_clean_session_on_exit.after(bevy::window::ExitSystems),
|
|
);
|
|
}
|
|
}
|
|
|
|
pub fn session_resume_window(world: &mut World, ctx: &egui::Context) {
|
|
let (open, scene) = {
|
|
let runtime = world.resource::<EditorSessionRuntime>();
|
|
(runtime.resume_prompt_open, runtime.resume_scene.clone())
|
|
};
|
|
if !open {
|
|
return;
|
|
}
|
|
let Some(scene) = scene else {
|
|
world
|
|
.resource_mut::<EditorSessionRuntime>()
|
|
.resume_prompt_open = false;
|
|
return;
|
|
};
|
|
|
|
egui::Window::new("Recover Editor Session")
|
|
.collapsible(false)
|
|
.resizable(false)
|
|
.anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
|
|
.show(ctx, |ui| {
|
|
ui.label("The previous editor session did not close cleanly.");
|
|
ui.small("The safe startup scene is active. Resume only the last authored scene; modal tools and dirty preview state are never restored.");
|
|
ui.monospace(scene.display().to_string());
|
|
ui.separator();
|
|
ui.horizontal(|ui| {
|
|
if ui.button("Resume Last Scene").clicked() {
|
|
world.resource_mut::<SceneIo>().request =
|
|
Some(SceneIoRequest::OpenPath(scene.clone()));
|
|
world
|
|
.resource_mut::<EditorSessionRuntime>()
|
|
.resume_prompt_open = false;
|
|
}
|
|
if ui.button("Continue Safe").clicked() {
|
|
world
|
|
.resource_mut::<EditorSessionRuntime>()
|
|
.resume_prompt_open = false;
|
|
world
|
|
.resource_mut::<SceneIo>()
|
|
.set_status("Continued with safe startup after abnormal shutdown");
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
fn session_path() -> Option<PathBuf> {
|
|
default_state_root().map(|root| root.join(SESSION_FILE))
|
|
}
|
|
|
|
fn read_session(path: &Path) -> Result<EditorSessionDocument, String> {
|
|
let text = std::fs::read_to_string(path)
|
|
.map_err(|error| format!("could not read {}: {error}", path.display()))?;
|
|
let document: EditorSessionDocument =
|
|
ron::from_str(&text).map_err(|error| format!("invalid session document: {error}"))?;
|
|
normalize_session(document)
|
|
}
|
|
|
|
fn normalize_session(mut document: EditorSessionDocument) -> Result<EditorSessionDocument, String> {
|
|
match document.schema_version {
|
|
0 => document.schema_version = SESSION_SCHEMA_VERSION,
|
|
SESSION_SCHEMA_VERSION => {}
|
|
version => {
|
|
return Err(format!(
|
|
"session schema {version} is newer than supported {SESSION_SCHEMA_VERSION}"
|
|
));
|
|
}
|
|
}
|
|
document.camera_bookmarks.retain(valid_bookmark);
|
|
Ok(document)
|
|
}
|
|
|
|
fn valid_bookmark(bookmark: &CameraBookmarkRecord) -> bool {
|
|
bookmark
|
|
.translation
|
|
.iter()
|
|
.chain(bookmark.rotation.iter())
|
|
.chain(bookmark.scale.iter())
|
|
.all(|value| value.is_finite())
|
|
}
|
|
|
|
fn write_session(path: &Path, document: &EditorSessionDocument) -> Result<(), String> {
|
|
let text = ron::ser::to_string_pretty(document, ron::ser::PrettyConfig::default())
|
|
.map_err(|error| format!("could not serialize session: {error}"))?;
|
|
atomic_write(path, text.as_bytes())
|
|
}
|
|
|
|
fn initialize_session(world: &mut World) {
|
|
let previous = session_path().and_then(|path| read_session(&path).ok());
|
|
let prior_abnormal = previous
|
|
.as_ref()
|
|
.is_some_and(|document| !document.clean_shutdown);
|
|
|
|
if let Some(document) = previous.as_ref() {
|
|
restore_non_destructive_session_state(world, document);
|
|
if !prior_abnormal {
|
|
restore_active_scene(world, document);
|
|
}
|
|
}
|
|
|
|
let resume_scene = previous
|
|
.as_ref()
|
|
.and_then(|document| document.active_scene.as_deref())
|
|
.map(PathBuf::from)
|
|
.filter(|path| path.exists());
|
|
world.insert_resource(EditorSessionRuntime {
|
|
prior_abnormal_shutdown: prior_abnormal,
|
|
resume_prompt_open: prior_abnormal && resume_scene.is_some(),
|
|
resume_scene,
|
|
save_elapsed_secs: 0.0,
|
|
});
|
|
|
|
if let Some(path) = session_path() {
|
|
let document = capture_session(world, false);
|
|
if let Err(error) = write_session(&path, &document) {
|
|
warn!("Failed to write running session marker: {error}");
|
|
}
|
|
}
|
|
}
|
|
|
|
fn restore_active_scene(world: &mut World, document: &EditorSessionDocument) {
|
|
let Some(path) = document.active_scene.as_deref().map(PathBuf::from) else {
|
|
return;
|
|
};
|
|
if path.exists() {
|
|
world.resource_mut::<SceneIo>().active_path = Some(path);
|
|
}
|
|
}
|
|
|
|
fn restore_non_destructive_session_state(world: &mut World, document: &EditorSessionDocument) {
|
|
let poses = document
|
|
.camera_bookmarks
|
|
.iter()
|
|
.map(|bookmark| (bookmark.scene.clone(), bookmark.to_transform()))
|
|
.collect();
|
|
world.resource_mut::<CameraBookmarks>().poses = poses;
|
|
world.resource_mut::<DiagnosticsPanel>().open = document.panels.diagnostics;
|
|
world.resource_mut::<BrushDiagnosticsPanel>().open = document.panels.brush_diagnostics;
|
|
world.resource_mut::<RenderingDiagnosticsPanel>().open = document.panels.rendering;
|
|
world.resource_mut::<ProjectSettingsPanel>().open = document.panels.project_settings;
|
|
}
|
|
|
|
fn capture_session(world: &World, clean_shutdown: bool) -> EditorSessionDocument {
|
|
let prefs = world.resource::<UserPreferences>();
|
|
EditorSessionDocument {
|
|
schema_version: SESSION_SCHEMA_VERSION,
|
|
clean_shutdown,
|
|
project_root: world.resource::<ProjectWorkspace>().root.clone(),
|
|
active_scene: world
|
|
.resource::<SceneIo>()
|
|
.active_path
|
|
.as_ref()
|
|
.map(|path| path.display().to_string()),
|
|
dock_layout: prefs.dock_layout.clone(),
|
|
hierarchy_expanded_paths: prefs.hierarchy_expanded_paths.clone(),
|
|
panels: SessionPanelState {
|
|
diagnostics: world.resource::<DiagnosticsPanel>().open,
|
|
brush_diagnostics: world.resource::<BrushDiagnosticsPanel>().open,
|
|
rendering: world.resource::<RenderingDiagnosticsPanel>().open,
|
|
project_settings: world.resource::<ProjectSettingsPanel>().open,
|
|
},
|
|
camera_bookmarks: world
|
|
.resource::<CameraBookmarks>()
|
|
.poses
|
|
.iter()
|
|
.map(|(scene, transform)| {
|
|
CameraBookmarkRecord::from_transform(scene.clone(), *transform)
|
|
})
|
|
.collect(),
|
|
}
|
|
}
|
|
|
|
fn persist_running_session(world: &mut World) {
|
|
let delta = world.resource::<Time>().delta_secs();
|
|
let should_write = {
|
|
let mut runtime = world.resource_mut::<EditorSessionRuntime>();
|
|
runtime.save_elapsed_secs += delta;
|
|
if runtime.save_elapsed_secs < SESSION_WRITE_INTERVAL_SECS {
|
|
false
|
|
} else {
|
|
runtime.save_elapsed_secs = 0.0;
|
|
true
|
|
}
|
|
};
|
|
if !should_write {
|
|
return;
|
|
}
|
|
let Some(path) = session_path() else {
|
|
return;
|
|
};
|
|
if let Err(error) = write_session(&path, &capture_session(world, false)) {
|
|
warn!("Failed to persist running editor session: {error}");
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn persist_clean_session_on_exit(
|
|
mut exits: MessageReader<AppExit>,
|
|
scene_io: Res<SceneIo>,
|
|
workspace: Res<ProjectWorkspace>,
|
|
prefs: Res<UserPreferences>,
|
|
bookmarks: Res<CameraBookmarks>,
|
|
diagnostics: Res<DiagnosticsPanel>,
|
|
brush_diagnostics: Res<BrushDiagnosticsPanel>,
|
|
rendering: Res<RenderingDiagnosticsPanel>,
|
|
project_settings: Res<ProjectSettingsPanel>,
|
|
) {
|
|
if exits.read().next().is_none() {
|
|
return;
|
|
}
|
|
let Some(path) = session_path() else {
|
|
return;
|
|
};
|
|
let document = EditorSessionDocument {
|
|
schema_version: SESSION_SCHEMA_VERSION,
|
|
clean_shutdown: true,
|
|
project_root: workspace.root.clone(),
|
|
active_scene: scene_io
|
|
.active_path
|
|
.as_ref()
|
|
.map(|path| path.display().to_string()),
|
|
dock_layout: prefs.dock_layout.clone(),
|
|
hierarchy_expanded_paths: prefs.hierarchy_expanded_paths.clone(),
|
|
panels: SessionPanelState {
|
|
diagnostics: diagnostics.open,
|
|
brush_diagnostics: brush_diagnostics.open,
|
|
rendering: rendering.open,
|
|
project_settings: project_settings.open,
|
|
},
|
|
camera_bookmarks: bookmarks
|
|
.poses
|
|
.iter()
|
|
.map(|(scene, transform)| {
|
|
CameraBookmarkRecord::from_transform(scene.clone(), *transform)
|
|
})
|
|
.collect(),
|
|
};
|
|
if let Err(error) = write_session(&path, &document) {
|
|
warn!("Failed to persist clean editor shutdown: {error}");
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn legacy_unversioned_session_migrates_to_v1() {
|
|
let legacy = r#"(
|
|
clean_shutdown: true,
|
|
project_root: "/project",
|
|
active_scene: Some("assets/levels/test.scn.ron"),
|
|
)"#;
|
|
let document: EditorSessionDocument = ron::from_str(legacy).unwrap();
|
|
|
|
let migrated = normalize_session(document).unwrap();
|
|
|
|
assert_eq!(migrated.schema_version, SESSION_SCHEMA_VERSION);
|
|
assert_eq!(migrated.project_root, "/project");
|
|
}
|
|
|
|
#[test]
|
|
fn newer_session_schema_is_rejected() {
|
|
let document = EditorSessionDocument {
|
|
schema_version: SESSION_SCHEMA_VERSION + 1,
|
|
..default()
|
|
};
|
|
|
|
assert!(normalize_session(document)
|
|
.unwrap_err()
|
|
.contains("newer than supported"));
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_camera_pose_is_removed_during_migration() {
|
|
let mut document = EditorSessionDocument::default();
|
|
document.camera_bookmarks.push(CameraBookmarkRecord {
|
|
scene: "scene".to_string(),
|
|
translation: [f32::NAN, 0.0, 0.0],
|
|
rotation: [0.0, 0.0, 0.0, 1.0],
|
|
scale: [1.0; 3],
|
|
});
|
|
|
|
assert!(normalize_session(document)
|
|
.unwrap()
|
|
.camera_bookmarks
|
|
.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn serialized_session_contains_only_allowlisted_metadata() {
|
|
let document = EditorSessionDocument {
|
|
project_root: "/project".to_string(),
|
|
active_scene: Some("assets/levels/test.scn.ron".to_string()),
|
|
..default()
|
|
};
|
|
let text = ron::ser::to_string(&document).unwrap();
|
|
|
|
assert!(text.contains("active_scene"));
|
|
for forbidden in ["scene_contents", "credential", "token", "environment"] {
|
|
assert!(!text.to_ascii_lowercase().contains(forbidden));
|
|
}
|
|
}
|
|
}
|