scythe/src/config.rs

568 lines
22 KiB
Rust

use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
fn default_true() -> bool { true }
fn default_replay_duration() -> u32 { 60 }
fn default_replay_bitrate() -> u32 { 18000 }
fn default_record_bitrate() -> u32 { 20000 }
fn default_fps() -> u32 { 60 }
fn default_video_codec() -> String { "h264".to_string() }
fn default_audio_device() -> String { "default".to_string() }
fn default_audio_mode() -> String { "system".to_string() }
fn default_lang() -> String { "en".to_string() }
fn default_theme() -> String { "dark".to_string() }
fn default_save_hotkey() -> String { "Ctrl+Shift+R".to_string() }
fn default_menu_hotkey() -> String { "Alt+Z".to_string() }
fn default_record_hotkey() -> String { "Ctrl+Shift+F9".to_string() }
fn default_cursor_hotkey() -> String { "Ctrl+Shift+F10".to_string() }
fn default_mic_volume() -> f32 { 0.60 }
fn default_system_volume() -> f32 { 1.00 }
fn default_accent_color() -> String { "blue".to_string() }
fn default_stream_service() -> String { "twitch".to_string() }
fn default_stream_url() -> String { "rtmp://live.twitch.tv/app/".to_string() }
fn default_screenshot_hotkey() -> String { "Ctrl+Shift+S".to_string() }
fn default_screenshot_format() -> String { "png".to_string() }
fn default_capture_source() -> String { "focused".to_string() }
fn default_video_quality_mode() -> String { "cbr".to_string() }
fn default_color_range() -> String { "limited".to_string() }
fn default_audio_codec() -> String { "opus".to_string() }
fn default_replay_storage() -> String { "ram".to_string() }
fn default_container_format() -> String { "mp4".to_string() }
fn default_res_w() -> u32 { 1920 }
fn default_res_h() -> u32 { 1080 }
fn default_screenshot_quality() -> u32 { 90 }
fn default_screenshot_preset() -> String { "balanced".to_string() }
fn default_pause_hotkey() -> String { "Ctrl+Shift+F8".to_string() }
fn default_mute_mic_hotkey() -> String { "Ctrl+Shift+M".to_string() }
fn default_mute_sys_hotkey() -> String { "Ctrl+Shift+N".to_string() }
fn default_screenshot_resolution() -> String { "native".to_string() }
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ScytheConfig {
#[serde(default = "default_accent_color")]
pub accent_color: String,
#[serde(default = "default_true")]
pub replay_enabled: bool,
#[serde(default = "default_replay_duration")]
pub replay_duration_sec: u32,
#[serde(default = "default_replay_bitrate")]
pub replay_bitrate_kbps: u32,
#[serde(default)]
pub record_enabled: bool,
#[serde(default = "default_record_bitrate")]
pub record_bitrate_kbps: u32,
#[serde(default = "default_fps")]
pub fps: u32,
#[serde(default = "default_video_codec")]
pub video_codec: String,
#[serde(default = "ScytheConfig::default_output_directory")]
pub output_directory: String,
#[serde(default = "default_true")]
pub show_cursor: bool,
#[serde(default = "default_audio_device")]
pub audio_device: String,
#[serde(default = "default_audio_mode")]
pub audio_mode: String,
#[serde(default = "default_mic_volume")]
pub mic_volume: f32,
#[serde(default = "default_system_volume")]
pub system_volume: f32,
#[serde(default)]
pub autostart: bool,
#[serde(default)]
pub autostart_replay: bool,
#[serde(default)]
pub autostart_overlay: bool,
#[serde(default = "default_lang")]
pub language: String,
#[serde(default = "default_theme")]
pub ui_color_theme: String,
#[serde(default = "default_save_hotkey")]
pub save_hotkey: String,
#[serde(default = "default_menu_hotkey")]
pub menu_hotkey: String,
#[serde(default = "default_record_hotkey")]
pub record_hotkey: String,
#[serde(default = "default_cursor_hotkey")]
pub cursor_hotkey: String,
#[serde(default = "default_true")]
pub auto_check_updates: bool,
#[serde(default)]
pub do_not_ask_updates: bool,
#[serde(default = "default_stream_service")]
pub stream_service: String,
#[serde(default = "default_stream_url")]
pub stream_url: String,
#[serde(default)]
pub stream_key: String,
#[serde(default = "default_screenshot_hotkey")]
pub screenshot_hotkey: String,
#[serde(default = "ScytheConfig::default_screenshot_directory")]
pub screenshot_directory: String,
#[serde(default = "default_screenshot_format")]
pub screenshot_format: String,
#[serde(default)]
pub screenshot_cursor: bool,
#[serde(default = "default_capture_source")]
pub capture_source: String,
#[serde(default = "default_video_quality_mode")]
pub video_quality_mode: String,
#[serde(default = "default_color_range")]
pub color_range: String,
#[serde(default = "default_audio_codec")]
pub audio_codec: String,
#[serde(default = "default_replay_storage")]
pub replay_storage: String,
#[serde(default)]
pub record_low_power: bool,
#[serde(default = "default_true")]
pub show_replay_notifications: bool,
#[serde(default = "default_container_format")]
pub container_format: String,
#[serde(default)]
pub change_resolution: bool,
#[serde(default = "default_res_w")]
pub resolution_w: u32,
#[serde(default = "default_res_h")]
pub resolution_h: u32,
#[serde(default)]
pub save_folder_by_game: bool,
#[serde(default)]
pub name_by_game: bool,
#[serde(default = "default_true")]
pub restart_replay_on_save: bool,
#[serde(default)]
pub show_scroll_lock_led: bool,
#[serde(default)]
pub autostart_on_game: bool,
#[serde(default)]
pub autostart_power_only: bool,
#[serde(default = "default_screenshot_quality")]
pub screenshot_quality: u32,
#[serde(default = "default_screenshot_preset")]
pub screenshot_preset: String,
#[serde(default = "default_pause_hotkey")]
pub pause_hotkey: String,
#[serde(default = "default_mute_mic_hotkey")]
pub mute_mic_hotkey: String,
#[serde(default = "default_mute_sys_hotkey")]
pub mute_sys_hotkey: String,
#[serde(default = "default_screenshot_resolution")]
pub screenshot_resolution: String,
#[serde(default)]
pub use_monospace_font: bool,
}
pub type VrecConfig = ScytheConfig;
impl Default for ScytheConfig {
fn default() -> Self {
Self {
accent_color: default_accent_color(),
replay_enabled: true,
replay_duration_sec: 60,
replay_bitrate_kbps: 18000,
record_enabled: false,
record_bitrate_kbps: 20000,
fps: 60,
video_codec: "h264".to_string(),
output_directory: Self::default_output_directory(),
show_cursor: true,
audio_device: "default".to_string(),
audio_mode: "system".to_string(),
mic_volume: default_mic_volume(),
system_volume: default_system_volume(),
autostart: false,
autostart_replay: false,
autostart_overlay: false,
language: "en".to_string(),
ui_color_theme: "dark".to_string(),
save_hotkey: "Ctrl+Shift+R".to_string(),
menu_hotkey: "Alt+Z".to_string(),
record_hotkey: "Ctrl+Shift+F9".to_string(),
cursor_hotkey: "Ctrl+Shift+F10".to_string(),
auto_check_updates: true,
do_not_ask_updates: false,
stream_service: "twitch".to_string(),
stream_url: "rtmp://live.twitch.tv/app/".to_string(),
stream_key: String::new(),
screenshot_hotkey: default_screenshot_hotkey(),
screenshot_directory: Self::default_screenshot_directory(),
screenshot_format: default_screenshot_format(),
screenshot_cursor: false,
capture_source: default_capture_source(),
video_quality_mode: default_video_quality_mode(),
color_range: default_color_range(),
audio_codec: default_audio_codec(),
replay_storage: default_replay_storage(),
record_low_power: false,
show_replay_notifications: true,
container_format: default_container_format(),
change_resolution: false,
resolution_w: default_res_w(),
resolution_h: default_res_h(),
save_folder_by_game: false,
name_by_game: false,
restart_replay_on_save: true,
show_scroll_lock_led: false,
autostart_on_game: false,
autostart_power_only: false,
screenshot_quality: default_screenshot_quality(),
screenshot_preset: default_screenshot_preset(),
pause_hotkey: default_pause_hotkey(),
mute_mic_hotkey: default_mute_mic_hotkey(),
mute_sys_hotkey: default_mute_sys_hotkey(),
screenshot_resolution: default_screenshot_resolution(),
use_monospace_font: false,
}
}
}
impl ScytheConfig {
pub fn default_output_directory() -> String {
if let Some(video) = dirs::video_dir() {
return video.to_string_lossy().to_string();
}
if let Some(home) = dirs::home_dir() {
return home.join("Videos").to_string_lossy().to_string();
}
"~/Videos".to_string()
}
pub fn default_screenshot_directory() -> String {
if let Some(pic) = dirs::picture_dir() {
return pic.join("Screenshots").to_string_lossy().to_string();
}
if let Some(home) = dirs::home_dir() {
return home.join("Pictures").join("Screenshots").to_string_lossy().to_string();
}
Self::default_output_directory()
}
pub fn config_path() -> PathBuf {
let base = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
base.join("scythe").join("config.json")
}
pub fn legacy_config_path() -> PathBuf {
let base = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
base.join("vrec").join("config.json")
}
pub fn expand_tilde(path: &str) -> PathBuf {
let trimmed = path.trim();
if let Some(sub) = trimmed.strip_prefix("~/").or_else(|| trimmed.strip_prefix("~\\")) {
if let Some(home) = dirs::home_dir() {
return home.join(sub);
}
} else if trimmed == "~"
&& let Some(home) = dirs::home_dir() {
return home;
}
PathBuf::from(trimmed)
}
pub fn resolve_save_path(&self, filename: &str) -> String {
let mut p = Self::expand_tilde(&self.output_directory);
let _ = fs::create_dir_all(&p);
p.push(filename);
if !p.exists() {
return p.to_string_lossy().to_string();
}
let stem = p.file_stem().and_then(|s| s.to_str()).unwrap_or("recording");
let ext = p.extension().and_then(|e| e.to_str()).unwrap_or("mp4");
let parent = p.parent().unwrap_or_else(|| std::path::Path::new("."));
for counter in 1..10000 {
let candidate = parent.join(format!("{}_{}.{}", stem, counter, ext));
if !candidate.exists() {
return candidate.to_string_lossy().to_string();
}
}
p.to_string_lossy().to_string()
}
pub fn load() -> Self {
let path = Self::config_path();
if let Ok(content) = fs::read_to_string(&path)
&& let Ok(mut cfg) = serde_json::from_str::<Self>(&content) {
if cfg.autostart && !cfg.autostart_replay {
cfg.autostart_replay = true;
}
return cfg;
}
let legacy = Self::legacy_config_path();
if let Ok(content) = fs::read_to_string(&legacy)
&& let Ok(mut cfg) = serde_json::from_str::<Self>(&content) {
// If the legacy config used the old default directory, update it to the new Scythe directory
if cfg.output_directory.ends_with("/Videos/vrec") || cfg.output_directory.ends_with("\\Videos\\vrec") {
cfg.output_directory = Self::default_output_directory();
}
if cfg.autostart && !cfg.autostart_replay {
cfg.autostart_replay = true;
}
let _ = cfg.save();
return cfg;
}
let default_cfg = Self::default();
let _ = default_cfg.save();
default_cfg
}
pub fn save(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let path = Self::config_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(self)?;
fs::write(&path, &json)?;
// Also update legacy config if its directory exists, ensuring backward compatibility
let legacy = Self::legacy_config_path();
if let Some(parent) = legacy.parent()
&& parent.exists() {
let _ = fs::write(legacy, &json);
}
self.sync_autostart();
Ok(())
}
pub fn sync_autostart(&self) {
#[cfg(unix)]
{
let home = match std::env::var("HOME") {
Ok(h) => PathBuf::from(h),
Err(_) => return,
};
let autostart_dir = home.join(".config").join("autostart");
let _ = fs::create_dir_all(&autostart_dir);
// 1. Replay daemon autostart entry
let daemon_desktop = autostart_dir.join("scythe-daemon.desktop");
if self.autostart_replay {
let content = "[Desktop Entry]\n\
Type=Application\n\
Name=Scythe Replay Engine\n\
Comment=Scythe Background Screen Recorder & Instant Replay Daemon\n\
Icon=media-record\n\
Exec=scythe-daemon\n\
Terminal=false\n\
Hidden=false\n\
X-GNOME-Autostart-enabled=true\n";
let _ = fs::write(&daemon_desktop, content);
} else {
let _ = fs::remove_file(&daemon_desktop);
}
// 2. Overlay autostart entry
let overlay_desktop = autostart_dir.join("scythe-overlay.desktop");
if self.autostart_overlay {
let content = "[Desktop Entry]\n\
Type=Application\n\
Name=Scythe Overlay\n\
Comment=Scythe Screen Recorder HUD Overlay\n\
Icon=media-record\n\
Exec=scythe-ui --menu\n\
Terminal=false\n\
Hidden=false\n\
X-GNOME-Autostart-enabled=true\n";
let _ = fs::write(&overlay_desktop, content);
} else {
let _ = fs::remove_file(&overlay_desktop);
}
// 3. Hyprland execs.lua integration if present
let hypr_execs_lua = home.join(".config").join("hypr").join("hyprland").join("execs.lua");
if hypr_execs_lua.exists()
&& let Ok(mut text) = fs::read_to_string(&hypr_execs_lua) {
let mut modified = false;
let daemon_cmd_pattern = "scythe-daemon";
let daemon_cmd_line = " hl.exec_cmd(\"scythe-daemon\")\n";
if self.autostart_replay {
if !text.contains(daemon_cmd_pattern)
&& let Some(pos) = text.rfind("end)") {
text.insert_str(pos, daemon_cmd_line);
modified = true;
}
} else if text.contains(daemon_cmd_pattern) {
text = text.lines()
.filter(|l| !l.contains(daemon_cmd_pattern))
.collect::<Vec<_>>()
.join("\n") + "\n";
modified = true;
}
let overlay_cmd_pattern = "scythe-ui --menu";
let overlay_cmd_line = " hl.exec_cmd(\"scythe-ui --menu\")\n";
if self.autostart_overlay {
if !text.contains(overlay_cmd_pattern)
&& let Some(pos) = text.rfind("end)") {
text.insert_str(pos, overlay_cmd_line);
modified = true;
}
} else if text.contains(overlay_cmd_pattern) {
text = text.lines()
.filter(|l| !l.contains(overlay_cmd_pattern))
.collect::<Vec<_>>()
.join("\n") + "\n";
modified = true;
}
if modified {
let _ = fs::write(&hypr_execs_lua, text);
}
}
}
}
pub fn notify_daemon_reload() {
std::thread::spawn(|| {
let _ = crate::ipc::send_command(crate::ipc::Command::ReloadConfig);
});
}
/// Formats video filenames with informative local timestamps, e.g. "Replay-18-30-00_05-09-2026.mp4"
pub fn format_video_filename(prefix: &str, ext: &str) -> String {
unsafe {
let t = libc::time(std::ptr::null_mut());
let mut tm = std::mem::zeroed::<libc::tm>();
#[cfg(unix)]
libc::localtime_r(&t, &mut tm);
#[cfg(windows)]
libc::localtime_s(&mut tm, &t);
let hour = tm.tm_hour;
let min = tm.tm_min;
let sec = tm.tm_sec;
let day = tm.tm_mday;
let month = tm.tm_mon + 1;
let year = tm.tm_year + 1900;
format!("{}-{:02}-{:02}-{:02}_{:02}-{:02}-{:04}.{}", prefix, hour, min, sec, day, month, year, ext)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_config_defaults_and_json() {
let cfg = ScytheConfig::default();
assert_eq!(cfg.accent_color, "blue");
assert_eq!(cfg.replay_duration_sec, 60);
assert_eq!(cfg.replay_bitrate_kbps, 18000);
assert_eq!(cfg.fps, 60);
assert_eq!(cfg.video_codec, "h264");
assert_eq!(cfg.audio_mode, "system");
assert!(cfg.show_cursor);
let json = serde_json::to_string(&cfg).unwrap();
let parsed: ScytheConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.accent_color, "blue");
assert_eq!(parsed.replay_duration_sec, 60);
assert_eq!(parsed.audio_mode, "system");
assert!(parsed.show_cursor);
assert!((parsed.mic_volume - 0.60).abs() < 1e-4);
assert!((parsed.system_volume - 1.00).abs() < 1e-4);
assert!(parsed.auto_check_updates);
assert!(!parsed.do_not_ask_updates);
assert_eq!(parsed.stream_service, "twitch");
assert_eq!(parsed.stream_url, "rtmp://live.twitch.tv/app/");
assert_eq!(parsed.stream_key, "");
assert_eq!(parsed.container_format, "mp4");
assert!(!parsed.change_resolution);
assert_eq!(parsed.resolution_w, 1920);
assert_eq!(parsed.resolution_h, 1080);
assert!(!parsed.save_folder_by_game);
assert!(!parsed.name_by_game);
assert!(parsed.restart_replay_on_save);
assert!(!parsed.show_scroll_lock_led);
assert!(!parsed.autostart_on_game);
assert!(!parsed.autostart_power_only);
assert_eq!(parsed.capture_source, "focused");
assert_eq!(parsed.video_quality_mode, "cbr");
assert_eq!(parsed.color_range, "limited");
assert_eq!(parsed.audio_codec, "opus");
assert_eq!(parsed.replay_storage, "ram");
assert!(!parsed.record_low_power);
assert!(parsed.show_replay_notifications);
assert_eq!(parsed.screenshot_hotkey, "Ctrl+Shift+S");
assert_eq!(parsed.screenshot_format, "png");
assert!(!parsed.screenshot_cursor);
assert_eq!(parsed.screenshot_quality, 90);
assert_eq!(parsed.screenshot_preset, "balanced");
assert_eq!(parsed.pause_hotkey, "Ctrl+Shift+F8");
assert_eq!(parsed.mute_mic_hotkey, "Ctrl+Shift+M");
assert_eq!(parsed.mute_sys_hotkey, "Ctrl+Shift+N");
assert_eq!(parsed.screenshot_resolution, "native");
assert!(!parsed.use_monospace_font);
}
#[test]
fn test_format_video_filename() {
let name = ScytheConfig::format_video_filename("Replay", "mp4");
assert!(name.starts_with("Replay-"));
assert!(name.ends_with(".mp4"));
// Check pattern Replay-HH-MM-SS_DD-MM-YYYY.mp4
let without_ext = name.strip_suffix(".mp4").unwrap();
let top_parts: Vec<&str> = without_ext.split('_').collect();
assert_eq!(top_parts.len(), 2, "Filename underscore separation mismatch: {}", name);
let time_parts: Vec<&str> = top_parts[0].split('-').collect();
assert_eq!(time_parts.len(), 4, "Time parts mismatch: {}", top_parts[0]);
assert_eq!(time_parts[0], "Replay");
for part in &time_parts[1..] {
assert!(part.chars().all(|c| c.is_ascii_digit()));
}
let date_parts: Vec<&str> = top_parts[1].split('-').collect();
assert_eq!(date_parts.len(), 3, "Date parts mismatch: {}", top_parts[1]);
for part in &date_parts {
assert!(part.chars().all(|c| c.is_ascii_digit()));
}
}
#[test]
fn test_resolve_save_path_collision() {
let temp_dir = std::env::temp_dir().join(format!("scythe_test_collision_{}", std::process::id()));
let cfg = ScytheConfig {
output_directory: temp_dir.to_string_lossy().to_string(),
..Default::default()
};
let path1 = cfg.resolve_save_path("test_clip.mp4");
assert!(path1.ends_with("test_clip.mp4"));
fs::write(&path1, b"dummy").unwrap();
let path2 = cfg.resolve_save_path("test_clip.mp4");
assert!(path2.ends_with("test_clip_1.mp4"), "Expected collision to append _1: {}", path2);
fs::write(&path2, b"dummy2").unwrap();
let path3 = cfg.resolve_save_path("test_clip.mp4");
assert!(path3.ends_with("test_clip_2.mp4"), "Expected collision to append _2: {}", path3);
let _ = fs::remove_dir_all(temp_dir);
}
}