use std::ffi::OsStr;
use std::fs;
use std::io;
use std::path::{ Path, PathBuf };
use serde::{ Deserialize, Serialize };
const SESSION_FILE: &str = "session.json";
const STATE_FILE: &str = "state.bin";
const FORMAT_VERSION: u32 = 1;
#[ derive( Clone, Copy, Debug, PartialEq, Eq ) ]
pub( crate ) enum RestoreReason
{
Launch,
Recover,
SessionRestore,
}
#[ derive( Clone, Copy, Debug, PartialEq, Eq ) ]
pub( crate ) enum Startup
{
Reason( RestoreReason ),
Concurrent,
}
#[ derive( Serialize, Deserialize, Default, Debug, Clone ) ]
pub( crate ) struct SessionFile
{
pub version: u32,
pub session_id: Option<String>,
pub clean_exit: bool,
pub pid: u32,
}
pub( crate ) struct StateStore
{
dir: PathBuf,
session_id: Option<String>,
last_saved: Option<Vec<u8>>,
}
pub( crate ) fn state_dir(
app_id: &str,
xdg_state_home: Option<&OsStr>,
home: Option<&OsStr>,
) -> Option<PathBuf>
{
if app_id.is_empty() || app_id.contains( '/' ) || app_id == "." || app_id == ".."
{
return None;
}
let base = match xdg_state_home.filter( |v| !v.is_empty() )
{
Some( v ) => PathBuf::from( v ),
None => PathBuf::from( home.filter( |v| !v.is_empty() )? ).join( ".local" ).join( "state" ),
};
Some( base.join( app_id ) )
}
impl StateStore
{
pub fn open( app_id: &str ) -> Option<Self>
{
let xdg = std::env::var_os( "XDG_STATE_HOME" );
let home = std::env::var_os( "HOME" );
let dir = state_dir( app_id, xdg.as_deref(), home.as_deref() );
if dir.is_none()
{
eprintln!( "ltk: session state disabled: cannot derive a state directory for app_id {app_id:?}" );
}
Self::open_at( dir? )
}
pub fn open_at( dir: PathBuf ) -> Option<Self>
{
let mut builder = fs::DirBuilder::new();
builder.recursive( true );
{
use std::os::unix::fs::DirBuilderExt;
builder.mode( 0o700 );
}
if let Err( e ) = builder.create( &dir )
{
eprintln!( "ltk: session state disabled: cannot create {}: {e}", dir.display() );
return None;
}
let mut store = Self { dir, session_id: None, last_saved: None };
store.session_id = store.read_session_file().and_then( |f| f.session_id );
Some( store )
}
pub fn read_session_file( &self ) -> Option<SessionFile>
{
let bytes = fs::read( self.dir.join( SESSION_FILE ) ).ok()?;
serde_json::from_slice::<SessionFile>( &bytes ).ok().filter( |f| f.version == FORMAT_VERSION )
}
pub fn decide( &self, env_restore: bool ) -> Startup
{
if env_restore
{
return Startup::Reason( RestoreReason::SessionRestore );
}
match self.read_session_file()
{
Some( f ) if !f.clean_exit && f.pid != 0 && Self::pid_alive( f.pid ) => Startup::Concurrent,
Some( f ) if !f.clean_exit => Startup::Reason( RestoreReason::Recover ),
_ => Startup::Reason( RestoreReason::Launch ),
}
}
pub fn session_id( &self ) -> Option<String>
{
self.session_id.clone()
}
pub fn load_state( &self ) -> Option<Vec<u8>>
{
fs::read( self.dir.join( STATE_FILE ) ).ok().filter( |b| !b.is_empty() )
}
pub fn mark_running( &mut self )
{
self.write_session_file( false );
}
pub fn set_session_id( &mut self, id: String )
{
self.session_id = Some( id );
self.write_session_file( false );
}
pub fn save_state_if_changed( &mut self, state: Option<Vec<u8>> ) -> bool
{
match state
{
Some( bytes ) =>
{
if self.last_saved.as_deref() == Some( bytes.as_slice() )
{
return false;
}
match Self::write_atomic( &self.dir.join( STATE_FILE ), &bytes )
{
Ok( () ) =>
{
self.last_saved = Some( bytes );
true
}
Err( e ) =>
{
eprintln!( "ltk: session state: cannot write {STATE_FILE}: {e}" );
false
}
}
}
None =>
{
let path = self.dir.join( STATE_FILE );
let existed = path.exists();
if existed
{
if let Err( e ) = fs::remove_file( &path )
{
eprintln!( "ltk: session state: cannot remove {STATE_FILE}: {e}" );
}
}
self.last_saved = None;
existed
}
}
}
pub fn mark_clean_exit( &mut self, state: Option<Vec<u8>> )
{
self.save_state_if_changed( state );
self.write_session_file( true );
}
fn write_session_file( &self, clean_exit: bool )
{
let file = SessionFile {
version: FORMAT_VERSION,
session_id: self.session_id.clone(),
clean_exit,
pid: std::process::id(),
};
match serde_json::to_vec( &file )
{
Ok( bytes ) =>
{
if let Err( e ) = Self::write_atomic( &self.dir.join( SESSION_FILE ), &bytes )
{
eprintln!( "ltk: session state: cannot write {SESSION_FILE}: {e}" );
}
}
Err( e ) => eprintln!( "ltk: session state: cannot encode {SESSION_FILE}: {e}" ),
}
}
fn write_atomic( path: &Path, bytes: &[u8] ) -> io::Result<()>
{
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut tmp = path.as_os_str().to_owned();
tmp.push( ".tmp" );
let tmp = PathBuf::from( tmp );
{
let mut f = fs::OpenOptions::new()
.write( true )
.create( true )
.truncate( true )
.mode( 0o600 )
.open( &tmp )?;
f.write_all( bytes )?;
f.sync_all()?;
}
fs::rename( &tmp, path )
}
fn pid_alive( pid: u32 ) -> bool
{
Path::new( "/proc" ).join( pid.to_string() ).exists()
}
}
#[ cfg( test ) ]
mod tests
{
use super::*;
use std::sync::atomic::{ AtomicU32, Ordering };
static COUNTER: AtomicU32 = AtomicU32::new( 0 );
fn temp_dir() -> PathBuf
{
let n = COUNTER.fetch_add( 1, Ordering::Relaxed );
let dir = std::env::temp_dir().join( format!( "ltk-session-state-{}-{n}", std::process::id() ) );
let _ = fs::remove_dir_all( &dir );
dir
}
#[ test ]
fn state_dir_prefers_xdg_then_home()
{
let xdg = OsStr::new( "/tmp/xdg" );
let home = OsStr::new( "/home/u" );
assert_eq!( state_dir( "net.example.App", Some( xdg ), Some( home ) ), Some( PathBuf::from( "/tmp/xdg/net.example.App" ) ) );
assert_eq!( state_dir( "net.example.App", None, Some( home ) ), Some( PathBuf::from( "/home/u/.local/state/net.example.App" ) ) );
assert_eq!( state_dir( "net.example.App", Some( OsStr::new( "" ) ), Some( home ) ), Some( PathBuf::from( "/home/u/.local/state/net.example.App" ) ) );
assert_eq!( state_dir( "net.example.App", None, None ), None );
}
#[ test ]
fn state_dir_rejects_bad_ids()
{
let home = OsStr::new( "/home/u" );
assert_eq!( state_dir( "", None, Some( home ) ), None );
assert_eq!( state_dir( "a/b", None, Some( home ) ), None );
assert_eq!( state_dir( "..", None, Some( home ) ), None );
}
#[ test ]
fn fresh_dir_is_a_plain_launch()
{
let store = StateStore::open_at( temp_dir() ).unwrap();
assert_eq!( store.decide( false ), Startup::Reason( RestoreReason::Launch ) );
assert_eq!( store.load_state(), None );
assert_eq!( store.session_id(), None );
}
#[ test ]
fn mark_running_then_reopen_is_concurrent()
{
let dir = temp_dir();
let mut store = StateStore::open_at( dir.clone() ).unwrap();
store.mark_running();
let file = store.read_session_file().unwrap();
assert!( !file.clean_exit );
assert_eq!( file.pid, std::process::id() );
let second = StateStore::open_at( dir ).unwrap();
assert_eq!( second.decide( false ), Startup::Concurrent );
}
#[ test ]
fn dead_pid_means_recover_and_env_wins()
{
let dir = temp_dir();
let store = StateStore::open_at( dir.clone() ).unwrap();
let file = SessionFile { version: FORMAT_VERSION, session_id: Some( "abc".into() ), clean_exit: false, pid: 4_000_000_000 };
fs::write( dir.join( SESSION_FILE ), serde_json::to_vec( &file ).unwrap() ).unwrap();
assert_eq!( store.decide( false ), Startup::Reason( RestoreReason::Recover ) );
assert_eq!( store.decide( true ), Startup::Reason( RestoreReason::SessionRestore ) );
}
#[ test ]
fn save_state_if_changed_dedupes_and_removes()
{
let dir = temp_dir();
let mut store = StateStore::open_at( dir.clone() ).unwrap();
assert!( store.save_state_if_changed( Some( b"one".to_vec() ) ) );
assert_eq!( fs::read( dir.join( STATE_FILE ) ).unwrap(), b"one" );
assert!( !store.save_state_if_changed( Some( b"one".to_vec() ) ) );
assert!( store.save_state_if_changed( Some( b"two".to_vec() ) ) );
assert!( !dir.join( "state.bin.tmp" ).exists() );
assert!( store.save_state_if_changed( None ) );
assert!( !dir.join( STATE_FILE ).exists() );
assert!( !store.save_state_if_changed( None ) );
}
#[ test ]
fn file_modes_are_private()
{
use std::os::unix::fs::PermissionsExt;
let dir = temp_dir();
let mut store = StateStore::open_at( dir.clone() ).unwrap();
store.save_state_if_changed( Some( b"x".to_vec() ) );
assert_eq!( fs::metadata( &dir ).unwrap().permissions().mode() & 0o777, 0o700 );
assert_eq!( fs::metadata( dir.join( STATE_FILE ) ).unwrap().permissions().mode() & 0o777, 0o600 );
}
#[ test ]
fn session_id_round_trips_and_clean_exit_flips()
{
let dir = temp_dir();
let mut store = StateStore::open_at( dir.clone() ).unwrap();
store.mark_running();
store.set_session_id( "session-1".into() );
let reopened = StateStore::open_at( dir ).unwrap();
assert_eq!( reopened.session_id(), Some( "session-1".to_string() ) );
store.mark_clean_exit( Some( b"final".to_vec() ) );
let file = store.read_session_file().unwrap();
assert!( file.clean_exit );
assert_eq!( file.session_id.as_deref(), Some( "session-1" ) );
assert_eq!( store.load_state().unwrap(), b"final" );
}
}