use std::sync::Mutex;
use serde_json::{json, Value};
use tauri::{AppHandle, Emitter, Manager, Runtime, State, Window};
use crate::config::{Mode, ObserverConfig};
use crate::session::Session;
use crate::storage::{
append_events_file, append_lifecycle, build_export_bundle, finalize_session, now_ms,
recordings_root, validate_session_id,
};
use crate::storage::list_sessions as storage_list_sessions;
type SessionState = Mutex<Session>;
fn session_meta_payload(
source: &str,
app_id: Option<&str>,
id: &str,
started_at: i64,
ended_at: Option<i64>,
) -> Value {
let mut m = serde_json::Map::new();
m.insert("id".into(), json!(id));
m.insert("source".into(), json!(source));
if let Some(app) = app_id {
m.insert("appId".into(), json!(app));
}
m.insert("startedAt".into(), json!(started_at));
if let Some(ended) = ended_at {
m.insert("endedAt".into(), json!(ended));
}
Value::Object(m)
}
#[tauri::command]
pub fn start_session<R: Runtime>(app: AppHandle<R>, state: State<'_, SessionState>) -> Result<String, String> {
let id = format!("{}", now_ms());
let started_at = now_ms();
let (mode, source, app_id) = {
let mut s = state.lock().map_err(|e| e.to_string())?;
s.id = Some(id.clone());
s.started_at = started_at;
s.active = true;
s.segment_seq.clear();
s.current.clear();
s.remote_session_id = None;
s.dir = None;
(
s.config.mode,
s.config.source.clone(),
s.config.app_id.clone(),
)
};
let dir = if mode == Mode::Local {
let dir = recordings_root(&app).join(&id);
std::fs::create_dir_all(dir.join("segments")).map_err(|e| e.to_string())?;
std::fs::write(
dir.join("session.json"),
session_meta_payload(&source, app_id.as_deref(), &id, started_at, None).to_string(),
)
.map_err(|e| e.to_string())?;
state.lock().map_err(|e| e.to_string())?.dir = Some(dir.clone());
Some(dir)
} else {
None
};
if let Some(dir) = dir {
let skip_prefix = state
.lock()
.map_err(|e| e.to_string())?
.config
.skip_focus_prefix
.clone();
for (label, w) in app.webview_windows() {
if !skip_prefix.is_empty() && label.starts_with(&skip_prefix) {
continue;
}
if w.is_focused().unwrap_or(false) {
let _ = append_lifecycle(
&dir,
json!({ "type": "focus", "label": label, "t": now_ms() }),
);
break;
}
}
}
app.emit("recording-session", json!({ "active": true, "id": id }))
.map_err(|e| e.to_string())?;
Ok(id)
}
#[tauri::command]
pub fn bind_session<R: Runtime>(
app: AppHandle<R>,
state: State<'_, SessionState>,
session_id: String,
) -> Result<(), String> {
{
let mut s = state.lock().map_err(|e| e.to_string())?;
s.active = true;
s.remote_session_id = Some(session_id.clone());
}
app.emit(
"recording-session",
json!({ "active": true, "sessionId": session_id }),
)
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub fn session_id(state: State<'_, SessionState>) -> Result<Option<String>, String> {
Ok(state
.lock()
.map_err(|e| e.to_string())?
.remote_session_id
.clone())
}
#[tauri::command]
pub fn stop_session<R: Runtime>(app: AppHandle<R>, state: State<'_, SessionState>) -> Result<(), String> {
let (dir, id, started_at, mode, source, app_id) = {
let mut s = state.lock().map_err(|e| e.to_string())?;
s.active = false;
s.remote_session_id = None;
let open_segs: Vec<(String, String)> = s.current.drain().collect();
if let Some(dir) = &s.dir {
let now = now_ms();
for (label, seg) in open_segs {
let _ = append_lifecycle(
dir,
json!({ "type": "hidden", "label": label, "segmentId": seg, "t": now }),
);
}
}
(
s.dir.clone(),
s.id.clone(),
s.started_at,
s.config.mode,
s.config.source.clone(),
s.config.app_id.clone(),
)
};
if mode == Mode::Local {
if let Some(dir) = dir {
let ended_at = now_ms();
let _ = finalize_session(&dir, ended_at);
let meta = session_meta_payload(
&source,
app_id.as_deref(),
id.as_deref().unwrap_or(""),
started_at,
Some(ended_at),
);
let _ = std::fs::write(dir.join("session.json"), meta.to_string());
}
}
app.emit("recording-session", json!({ "active": false }))
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub fn is_recording_active(state: State<'_, SessionState>) -> bool {
state.lock().map(|s| s.active).unwrap_or(false)
}
#[tauri::command]
pub fn begin_segment<R: Runtime>(
app: AppHandle<R>,
state: State<'_, SessionState>,
window: Window<R>,
) -> Result<String, String> {
let label = window.label().to_string();
let (seg, dir, mode) = {
let mut s = state.lock().map_err(|e| e.to_string())?;
if !s.active {
return Err("session not active".into());
}
if s.config.mode == Mode::Local {
let n = s.segment_seq.entry(label.clone()).or_insert(0);
let seg = format!("{}#{}", label, n);
*n += 1;
s.current.insert(label.clone(), seg.clone());
(seg, s.dir.clone(), Mode::Local)
} else {
(String::new(), None, Mode::Remote)
}
};
if mode == Mode::Local {
if let Some(dir) = dir {
append_lifecycle(
&dir,
json!({ "type": "shown", "label": label, "segmentId": seg, "t": now_ms() }),
)?;
}
}
let _ = app; Ok(seg)
}
#[tauri::command(async)]
pub fn append_events(
state: State<'_, SessionState>,
segment_id: String,
events: Vec<Value>,
) -> Result<(), String> {
let (dir, mode) = {
let s = state.lock().map_err(|e| e.to_string())?;
(s.dir.clone(), s.config.mode)
};
if mode == Mode::Local {
if let Some(dir) = dir {
append_events_file(&dir, &segment_id, &events)?;
}
}
Ok(())
}
fn ensure_local(config: &ObserverConfig) -> Result<(), String> {
if config.mode == Mode::Local {
Ok(())
} else {
Err("observer: list/export commands are Local-mode only".into())
}
}
#[tauri::command]
pub fn list_sessions<R: Runtime>(
app: AppHandle<R>,
state: State<'_, SessionState>,
) -> Result<Vec<Value>, String> {
ensure_local(&state.lock().map_err(|e| e.to_string())?.config)?;
Ok(storage_list_sessions(&recordings_root(&app)))
}
#[tauri::command(async)]
pub fn export_session<R: Runtime>(
app: AppHandle<R>,
state: State<'_, SessionState>,
session_id: String,
) -> Result<Value, String> {
ensure_local(&state.lock().map_err(|e| e.to_string())?.config)?;
if !validate_session_id(&session_id) {
return Err(format!("invalid session id: {session_id}"));
}
build_export_bundle(&recordings_root(&app).join(&session_id))
}
fn write_bundle_to_file(dir: &std::path::Path, path: &str) -> Result<u64, String> {
let bundle = build_export_bundle(dir)?;
let json = serde_json::to_string(&bundle).map_err(|e| e.to_string())?;
let tmp = format!("{path}.tmp");
std::fs::write(&tmp, json.as_bytes()).map_err(|e| e.to_string())?;
std::fs::rename(&tmp, path).map_err(|e| e.to_string())?;
Ok(json.len() as u64)
}
#[tauri::command(async)]
pub async fn export_session_to_file<R: Runtime>(
app: AppHandle<R>,
state: State<'_, SessionState>,
session_id: String,
path: String,
) -> Result<u64, String> {
ensure_local(&state.lock().map_err(|e| e.to_string())?.config)?;
if !validate_session_id(&session_id) {
return Err(format!("invalid session id: {session_id}"));
}
let dir = recordings_root(&app).join(&session_id);
tauri::async_runtime::spawn_blocking(move || write_bundle_to_file(&dir, &path))
.await
.map_err(|e| e.to_string())?
}
#[tauri::command]
pub fn notify_segment_start<R: Runtime>(app: AppHandle<R>, label: String) -> Result<bool, String> {
Ok(crate::emit_segment_start_if_active(&app, &label))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn session_meta_omits_app_id_and_ended_when_none() {
let v = session_meta_payload("tauri", None, "123", 1000, None);
assert_eq!(v["id"], "123");
assert_eq!(v["source"], "tauri");
assert!(v.get("appId").is_none(), "appId None 时省键(D9)");
assert!(v.get("endedAt").is_none());
}
#[test]
fn session_meta_includes_app_id_and_ended() {
let v = session_meta_payload("self", Some("demo"), "123", 1000, Some(2000));
assert_eq!(v["appId"], "demo");
assert_eq!(v["endedAt"], 2000);
}
#[test]
fn stop_overwrite_keeps_app_id() {
let start = session_meta_payload("tauri", Some("demo"), "123", 1000, None);
let end = session_meta_payload(
"tauri",
Some("demo"),
"123",
start["startedAt"].as_i64().unwrap(),
Some(2000),
);
assert_eq!(end["appId"], start["appId"]);
assert_eq!(end["source"], start["source"]);
}
#[test]
fn ensure_local_gates_by_mode() {
assert!(ensure_local(&ObserverConfig::default()).is_ok()); let remote = ObserverConfig {
mode: Mode::Remote,
..Default::default()
};
assert!(ensure_local(&remote).is_err());
}
#[test]
fn export_session_id_guard_rejects_traversal() {
assert!(!validate_session_id("../etc"));
assert!(!validate_session_id("1/../../x"));
assert!(!validate_session_id(""));
assert!(validate_session_id("1730000000000"));
}
fn fixture_session(dir: &std::path::Path) {
std::fs::create_dir_all(dir.join("segments")).unwrap();
std::fs::write(
dir.join("session.json"),
r#"{"id":"1730000000000","source":"tauri","startedAt":1000}"#,
)
.unwrap();
std::fs::write(
dir.join("segments").join("main#0.jsonl"),
"{\"type\":2,\"t\":1}\n{\"type\":3,\"t\":2}\n",
)
.unwrap();
}
#[test]
fn to_file_matches_export_ignoring_exported_at() {
let root = tempfile::tempdir().unwrap();
let dir = root.path().join("1730000000000");
fixture_session(&dir);
let target = root.path().join("out.bundle.json");
let mut expect = build_export_bundle(&dir).unwrap();
let n = write_bundle_to_file(&dir, target.to_str().unwrap()).unwrap();
let written: Value =
serde_json::from_str(&std::fs::read_to_string(&target).unwrap()).unwrap();
expect["exportedAt"] = written["exportedAt"].clone(); assert_eq!(
serde_json::to_string(&expect).unwrap(),
serde_json::to_string(&written).unwrap()
);
assert_eq!(n, std::fs::metadata(&target).unwrap().len());
assert!(!root.path().join("out.bundle.json.tmp").exists());
}
#[test]
fn to_file_overwrites_existing_target() {
let root = tempfile::tempdir().unwrap();
let dir = root.path().join("1730000000000");
fixture_session(&dir);
let target = root.path().join("out.bundle.json");
std::fs::write(&target, "stale").unwrap();
write_bundle_to_file(&dir, target.to_str().unwrap()).unwrap();
let written: Value =
serde_json::from_str(&std::fs::read_to_string(&target).unwrap()).unwrap();
assert_eq!(written["format"], "prism-session");
}
#[test]
fn to_file_errors_when_session_dir_missing() {
let root = tempfile::tempdir().unwrap();
let target = root.path().join("out.bundle.json");
let err = write_bundle_to_file(&root.path().join("nope"), target.to_str().unwrap());
assert!(err.is_err());
assert!(!target.exists());
assert!(!root.path().join("out.bundle.json.tmp").exists());
}
}