Blacksite/crates/editor/src/project/session.rs
Rbanh 0553a85220
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
Build production-ready editor authoring workflows
2026-07-11 12:41:04 -04:00

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));
}
}
}