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_with, 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)
}
fn merged_meta(base: Value, extra: Option<&Value>) -> Result<Value, String> {
let mut m = match extra {
None | Some(Value::Null) => serde_json::Map::new(),
Some(Value::Object(map)) => map.clone(),
Some(_) => return Err("observer: session meta must be a JSON object".into()),
};
if let Value::Object(base_map) = base {
for (k, v) in base_map {
m.insert(k, v);
}
}
Ok(Value::Object(m))
}
#[tauri::command]
pub fn start_session<R: Runtime>(
app: AppHandle<R>,
state: State<'_, SessionState>,
meta: Option<Value>,
) -> Result<String, String> {
let id = format!("{}", now_ms());
let started_at = now_ms();
let cfg = {
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.clone()
};
let dir = if cfg.mode == Mode::Local {
let payload = merged_meta(
session_meta_payload(&cfg.source, cfg.app_id.as_deref(), &id, started_at, None),
meta.as_ref(),
)?;
let dir = recordings_root_with(&app, &cfg).join(&id);
std::fs::create_dir_all(dir.join("segments")).map_err(|e| e.to_string())?;
std::fs::write(dir.join("session.json"), payload.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, mode) = {
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.config.mode)
};
if mode == Mode::Local {
if let Some(dir) = dir {
let _ = finalize_session(&dir, now_ms());
}
}
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> {
let cfg = state.lock().map_err(|e| e.to_string())?.config.clone();
ensure_local(&cfg)?;
Ok(storage_list_sessions(&recordings_root_with(&app, &cfg)))
}
#[tauri::command(async)]
pub fn export_session<R: Runtime>(
app: AppHandle<R>,
state: State<'_, SessionState>,
session_id: String,
) -> Result<Value, String> {
let cfg = state.lock().map_err(|e| e.to_string())?.config.clone();
ensure_local(&cfg)?;
if !validate_session_id(&session_id) {
return Err(format!("invalid session id: {session_id}"));
}
build_export_bundle(&recordings_root_with(&app, &cfg).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> {
let cfg = state.lock().map_err(|e| e.to_string())?.config.clone();
ensure_local(&cfg)?;
if !validate_session_id(&session_id) {
return Err(format!("invalid session id: {session_id}"));
}
let dir = recordings_root_with(&app, &cfg).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::*;
use crate::config::DirBase;
#[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 merged_meta_merges_user() {
let base = session_meta_payload("tauri", Some("demo"), "123", 1000, None);
let merged = merged_meta(base, Some(&json!({ "user": { "id": "u1", "name": "阿真" } })))
.unwrap();
assert_eq!(merged["user"]["id"], "u1");
assert_eq!(merged["user"]["name"], "阿真");
assert_eq!(merged["id"], "123");
assert_eq!(merged["source"], "tauri");
assert_eq!(merged["appId"], "demo");
assert_eq!(merged["startedAt"], 1000);
}
#[test]
fn merged_meta_base_keys_win() {
let base = session_meta_payload("tauri", Some("demo"), "123", 1000, None);
let merged = merged_meta(
base,
Some(&json!({
"id": "fake",
"startedAt": 0,
"source": "web",
"appId": "evil",
"user": { "id": "u1" }
})),
)
.unwrap();
assert_eq!(merged["id"], "123");
assert_eq!(merged["startedAt"], 1000);
assert_eq!(merged["source"], "tauri");
assert_eq!(merged["appId"], "demo");
assert_eq!(merged["user"]["id"], "u1");
}
#[test]
fn merged_meta_rejects_non_object() {
let base = session_meta_payload("tauri", None, "123", 1000, None);
assert!(merged_meta(base.clone(), Some(&json!("str"))).is_err());
assert!(merged_meta(base.clone(), Some(&json!([1, 2]))).is_err());
assert!(merged_meta(base.clone(), Some(&json!(42))).is_err());
assert_eq!(merged_meta(base.clone(), None).unwrap(), base);
assert_eq!(merged_meta(base.clone(), Some(&Value::Null)).unwrap(), base);
}
#[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());
}
#[test]
fn observer_config_default_uses_app_data() {
let cfg = ObserverConfig::default();
assert_eq!(cfg.dir_base, DirBase::AppData);
assert_eq!(cfg.dir_name, "recordings");
}
#[test]
fn observer_config_serde_backward_compat() {
let cfg: ObserverConfig = serde_json::from_str("{}").unwrap();
assert_eq!(cfg.dir_base, DirBase::AppData);
assert_eq!(cfg.dir_name, "recordings");
}
#[test]
fn dir_base_serde_camel_case() {
let cfg = ObserverConfig {
dir_base: DirBase::ResourceDir,
..Default::default()
};
let json = serde_json::to_string(&cfg).unwrap();
assert!(json.contains("\"dirBase\":\"resourceDir\""), "{json}");
let back: ObserverConfig = serde_json::from_str(&json).unwrap();
assert_eq!(back.dir_base, DirBase::ResourceDir);
assert!(serde_json::from_str::<ObserverConfig>("{\"dirBase\":\"nope\"}").is_err());
}
}