diff --git a/Cargo.toml b/Cargo.toml index 6dd3e58..a768033 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,12 +4,8 @@ version = "0.2.18" edition = "2024" [[bin]] -name = "scythe-daemon" -path = "src/bin/scythe-daemon.rs" - -[[bin]] -name = "scythe-ui" -path = "src/bin/scythe-ui.rs" +name = "scythe" +path = "src/main.rs" [dependencies] crossbeam-channel = "0.5.16" diff --git a/TODO_CHECKLIST.md b/TODO_CHECKLIST.md index 4779561..ed7ceda 100644 --- a/TODO_CHECKLIST.md +++ b/TODO_CHECKLIST.md @@ -6,6 +6,14 @@ This checklist tracks known bugs, UI refinements, optimizations, and technical d ## 1. UI Refinements & Imperfections +- [x] **Singular Multicall Binary Architecture (`scythe`)**: + - **Issue**: Distributing separate `scythe-daemon` and `scythe-ui` binaries required cross-process path discovery in `$PATH`, complicated Windows bundling with multiple executables, and prevented atomic single-file updates. + - **Resolution**: + - Consolidated daemon engine and UI overlay/CLI into a single binary (`scythe`) in [src/main.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/main.rs) and [src/daemon.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/daemon.rs). + - Implemented multicall dispatch: inspects `argv[0]` and subcommands (`scythe daemon`, `scythe [options]`, or symlinks `scythe-daemon` / `scythe-ui`). + - Configured [install.sh](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/install.sh) with backward-compatible symlinks. + - Updated release packages: both Linux and Windows distributions now ship a single executable. + - [x] **Screenshot Resolution Presets (Native, 1080p, 1440p, 2160p, 720p) & Lanczos Downscaling**: - **Issue**: Vague compression percentages and presets confused users instead of providing clear resolution controls. - **Resolution**: Replaced vague compression sliders with clean resolution presets (`Native (Source)`, `1080p (FHD)`, `1440p (QHD)`, `2160p (4K UHD)`, `720p (HD)`) in [src/overlay_egui.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/overlay_egui.rs). When a target resolution is selected, [src/bin/scythe-ui.rs](file:///home/oiupoyt/my%20stuff%20yea/projects/scythe/src/bin/scythe-ui.rs) executes pristine Lanczos hardware-accelerated scaling via FFmpeg. diff --git a/install.sh b/install.sh index c9449b4..0188c79 100755 --- a/install.sh +++ b/install.sh @@ -15,15 +15,15 @@ APP_DIR="$HOME/.local/share/applications" mkdir -p "$BIN_DIR" mkdir -p "$APP_DIR" -echo "[2/4] Installing binaries to $BIN_DIR..." -install -m 755 target/release/scythe-daemon "$BIN_DIR/scythe-daemon" -install -m 755 target/release/scythe-ui "$BIN_DIR/scythe-ui" -ln -sf "$BIN_DIR/scythe-ui" "$BIN_DIR/scythe" +echo "[2/4] Installing singular binary to $BIN_DIR..." +install -m 755 target/release/scythe "$BIN_DIR/scythe" -# Backward-compatible symlinks -ln -sf "$BIN_DIR/scythe-daemon" "$BIN_DIR/vrec-daemon" -ln -sf "$BIN_DIR/scythe-ui" "$BIN_DIR/vrec-ui" -ln -sf "$BIN_DIR/scythe-ui" "$BIN_DIR/vrec" +# Backward-compatible multicall symlinks +ln -sf "$BIN_DIR/scythe" "$BIN_DIR/scythe-daemon" +ln -sf "$BIN_DIR/scythe" "$BIN_DIR/scythe-ui" +ln -sf "$BIN_DIR/scythe" "$BIN_DIR/vrec-daemon" +ln -sf "$BIN_DIR/scythe" "$BIN_DIR/vrec-ui" +ln -sf "$BIN_DIR/scythe" "$BIN_DIR/vrec" SYSTEMD_USER_DIR="$HOME/.config/systemd/user" mkdir -p "$SYSTEMD_USER_DIR" @@ -35,7 +35,7 @@ Wants=xdg-desktop-portal.service [Service] Type=simple -ExecStart=$BIN_DIR/scythe-daemon +ExecStart=$BIN_DIR/scythe daemon Restart=always RestartSec=2 Environment="XDG_RUNTIME_DIR=/run/user/1000" diff --git a/src/bin/scythe-daemon.rs b/src/daemon.rs similarity index 94% rename from src/bin/scythe-daemon.rs rename to src/daemon.rs index aad3631..187f36d 100644 --- a/src/bin/scythe-daemon.rs +++ b/src/daemon.rs @@ -1,11 +1,9 @@ -#![cfg_attr(target_os = "windows", windows_subsystem = "windows")] -#![allow(clippy::chunks_exact_to_as_chunks)] -use scythe::capture::{Frame, FrameSource}; -use scythe::encoder::VideoEncoder; -use scythe::ring::Packet; -use scythe::muxer::Muxer; -use scythe::ipc::{Command, DaemonStatus}; +use crate::capture::{Frame, FrameSource}; +use crate::encoder::VideoEncoder; +use crate::ring::Packet; +use crate::muxer::Muxer; +use crate::ipc::{Command, DaemonStatus}; use ringbuf::HeapRb; use ringbuf::traits::{RingBuffer, Consumer, Observer}; use crossbeam_channel::bounded; @@ -79,12 +77,18 @@ fn spawn_stream_worker( } fn ensure_wayland_env() { - scythe::overlay::ensure_wayland_env(); + crate::overlay::ensure_wayland_env(); +} + +pub fn run_daemon() -> Result<(), Box> { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()? + .block_on(run_daemon_async()) } -#[tokio::main] #[allow(unused_assignments)] -async fn main() -> Result<(), Box> { +async fn run_daemon_async() -> Result<(), Box> { #[cfg(target_os = "windows")] unsafe { use windows::Win32::System::Console::FreeConsole; @@ -123,11 +127,11 @@ async fn main() -> Result<(), Box> { Err(_) => None, } }; - let initial_config = scythe::config::ScytheConfig::load(); + let initial_config = crate::config::ScytheConfig::load(); // Automatically register dynamic keybindings on Hyprland if active (no config edits needed) - scythe::hyprland_binds::register_hyprland_binds(&initial_config); - scythe::hyprland_binds::spawn_hyprland_reload_watcher(); + crate::hyprland_binds::register_hyprland_binds(&initial_config); + crate::hyprland_binds::spawn_hyprland_reload_watcher(); let (frame_tx, frame_rx) = bounded::(2); let (cmd_tx, cmd_rx) = bounded::(32); @@ -140,11 +144,11 @@ async fn main() -> Result<(), Box> { let stream_start_state = Arc::new(AtomicU64::new(0)); let replay_enabled_state = Arc::new(AtomicBool::new(initial_config.replay_enabled)); let audio_muted_state = Arc::new(AtomicBool::new(false)); - let audio_levels = Arc::new(scythe::capture::audio::AudioLevels::new()); + let audio_levels = Arc::new(crate::capture::audio::AudioLevels::new()); #[cfg(unix)] let listener = { - let socket_path = scythe::ipc::get_socket_path(); + let socket_path = crate::ipc::get_socket_path(); let _ = std::fs::remove_file(&socket_path); let l = std::os::unix::net::UnixListener::bind(&socket_path)?; println!("Daemon listening on IPC socket: {}", socket_path); @@ -162,7 +166,7 @@ async fn main() -> Result<(), Box> { break; } Err(_) => { - if scythe::ipc::query_status_port(port).is_ok() { + if crate::ipc::query_status_port(port).is_ok() { println!("Another instance of scythe-daemon is already running on port {}. Exiting cleanly.", port); return Ok(()); } @@ -172,7 +176,7 @@ async fn main() -> Result<(), Box> { match bound { Some((l, p)) => { println!("Daemon listening on TCP IPC: 127.0.0.1:{}", p); - let _ = std::fs::write(scythe::ipc::get_ipc_port_path(), p.to_string()); + let _ = std::fs::write(crate::ipc::get_ipc_port_path(), p.to_string()); let _ = std::fs::write(std::env::temp_dir().join("vrec-ipc.port"), p.to_string()); l } @@ -230,7 +234,7 @@ async fn main() -> Result<(), Box> { } else { 0 }; - let cfg = scythe::config::ScytheConfig::load(); + let cfg = crate::config::ScytheConfig::load(); let status = DaemonStatus { is_recording: rec, recording_duration_sec: duration, @@ -252,7 +256,7 @@ async fn main() -> Result<(), Box> { } }, Command::ToggleCursor => { - let mut cfg = scythe::config::ScytheConfig::load(); + let mut cfg = crate::config::ScytheConfig::load(); cfg.show_cursor = !cfg.show_cursor; let _ = cfg.save(); #[cfg(target_os = "linux")] @@ -275,7 +279,7 @@ async fn main() -> Result<(), Box> { println!("Cursor display toggled to: {}", cfg.show_cursor); }, Command::CycleAudioMode => { - let mut cfg = scythe::config::ScytheConfig::load(); + let mut cfg = crate::config::ScytheConfig::load(); cfg.audio_mode = match cfg.audio_mode.as_str() { "system" => "mic", "mic" => "both", @@ -288,7 +292,7 @@ async fn main() -> Result<(), Box> { }, Command::StopDaemon => { println!("StopDaemon requested: Finalizing active recordings..."); - scythe::hyprland_binds::unregister_hyprland_binds(&scythe::config::ScytheConfig::load()); + crate::hyprland_binds::unregister_hyprland_binds(&crate::config::ScytheConfig::load()); let _ = cmd_tx_ipc.send(Command::StopRecording); std::thread::sleep(std::time::Duration::from_millis(350)); std::process::exit(0); @@ -314,7 +318,7 @@ async fn main() -> Result<(), Box> { println!("\nReceived shutdown signal. Finalizing recordings..."); let _ = cmd_tx_sig.send(Command::StopRecording); tokio::time::sleep(std::time::Duration::from_millis(350)).await; - scythe::hyprland_binds::unregister_hyprland_binds(&scythe::config::ScytheConfig::load()); + crate::hyprland_binds::unregister_hyprland_binds(&crate::config::ScytheConfig::load()); std::process::exit(0); } }); @@ -322,7 +326,7 @@ async fn main() -> Result<(), Box> { let session_type = env::var("XDG_SESSION_TYPE").unwrap_or_else(|_| "x11".to_string()); println!("Detected session type: {}", session_type); - let audio_capture_initial = scythe::capture::audio::AudioCapture::new_with_device_mode_volumes_and_levels( + let audio_capture_initial = crate::capture::audio::AudioCapture::new_with_device_mode_volumes_and_levels( audio_tx.clone(), Some(&initial_config.audio_device), &initial_config.audio_mode, @@ -335,12 +339,12 @@ async fn main() -> Result<(), Box> { if std::env::args().any(|a| a == "--mock") { println!("Initializing MOCK capture..."); - source_opt = Some(Box::new(scythe::capture::mock::MockCapture::new())); + source_opt = Some(Box::new(crate::capture::mock::MockCapture::new())); } else if cfg!(target_os = "windows") { #[cfg(target_os = "windows")] { println!("Initializing Windows DXGI capture..."); - if let Ok(c) = scythe::capture::windows::WindowsCapture::new() { + if let Ok(c) = crate::capture::windows::WindowsCapture::new() { source_opt = Some(Box::new(c)); } } @@ -354,7 +358,7 @@ async fn main() -> Result<(), Box> { .args(["--user", "start", "xdg-desktop-portal-hyprland"]) .status(); - match scythe::capture::wayland::WaylandCapture::new_with_cursor(initial_config.show_cursor).await { + match crate::capture::wayland::WaylandCapture::new_with_cursor(initial_config.show_cursor).await { Ok(cap) => { source_opt = Some(Box::new(cap)); break; @@ -375,7 +379,7 @@ async fn main() -> Result<(), Box> { #[cfg(target_os = "linux")] { println!("Initializing X11 capture..."); - if let Ok(c) = scythe::capture::x11::X11Capture::new() { + if let Ok(c) = crate::capture::x11::X11Capture::new() { source_opt = Some(Box::new(c)); } } @@ -387,7 +391,7 @@ async fn main() -> Result<(), Box> { None => { if is_mock_cli { eprintln!("Warning: Mock capture mode active via --mock flag."); - Box::new(scythe::capture::mock::MockCapture::new()) + Box::new(crate::capture::mock::MockCapture::new()) } else { eprintln!("Error: Hardware capture failed to initialize on {} session. Exiting for restart.", session_type); std::process::exit(1); @@ -455,7 +459,7 @@ async fn main() -> Result<(), Box> { let mut audio_capture = audio_capture_initial.map(|(c, _, _)| c); let recorder_handle = thread::spawn(move || { - let mut config = scythe::config::ScytheConfig::load(); + let mut config = crate::config::ScytheConfig::load(); replay_state_clone.store(config.replay_enabled, Ordering::SeqCst); let active_bitrate = if config.replay_enabled { @@ -477,9 +481,9 @@ async fn main() -> Result<(), Box> { println!("Background recorder engine active and ready ({} fps, replay: {}).", config.fps, config.replay_enabled); let mut audio_encoder = if let Some((sr, ch)) = audio_info { - scythe::encoder::AudioEncoder::new_with_codec(sr as i32, ch as i32, &config.audio_codec).ok() + crate::encoder::AudioEncoder::new_with_codec(sr as i32, ch as i32, &config.audio_codec).ok() } else { - scythe::encoder::AudioEncoder::new_with_codec(48000, 2, &config.audio_codec).ok() + crate::encoder::AudioEncoder::new_with_codec(48000, 2, &config.audio_codec).ok() }; let audio_codec_ctx_ptr = audio_encoder.as_ref().map(|e| e.codec_ctx() as usize); @@ -511,8 +515,8 @@ async fn main() -> Result<(), Box> { thread::spawn(move || { while let Ok(drain) = mux_rx.recv() { - let filename = scythe::config::ScytheConfig::format_video_filename("Replay", "mp4"); - let full_path = scythe::config::ScytheConfig::load().resolve_save_path(&filename); + let filename = crate::config::ScytheConfig::format_video_filename("Replay", "mp4"); + let full_path = crate::config::ScytheConfig::load().resolve_save_path(&filename); println!("Saving replay to {}...", full_path); // Find the first video IDR keyframe (ignore audio packets which always have keyframe flag set) @@ -639,7 +643,7 @@ async fn main() -> Result<(), Box> { rec_base_video_pts = pkt.pts(); rec_base_audio_pts = -1; let codec_ctx = codec_ctx_ptr as *mut ffmpeg_next::ffi::AVCodecContext; - let filename = scythe::config::ScytheConfig::format_video_filename("Recording", "mp4"); + let filename = crate::config::ScytheConfig::format_video_filename("Recording", "mp4"); let full_path = config.resolve_save_path(&filename); let audio_codec_ctx = audio_codec_ctx_ptr.map(|p| p as *mut ffmpeg_next::ffi::AVCodecContext); match unsafe { Muxer::new(&full_path, codec_ctx, audio_codec_ctx) } { @@ -696,7 +700,7 @@ async fn main() -> Result<(), Box> { if let Ok(cmd) = cmd_res { match cmd { Command::ReloadConfig => { - let new_config = scythe::config::ScytheConfig::load(); + let new_config = crate::config::ScytheConfig::load(); let video_changed = new_config.show_cursor != config.show_cursor || new_config.fps != config.fps || new_config.video_codec != config.video_codec @@ -754,8 +758,8 @@ async fn main() -> Result<(), Box> { #[cfg(target_os = "linux")] unsafe { libc::malloc_trim(0); } } - scythe::hyprland_binds::register_hyprland_binds(&config); - audio_capture = match scythe::capture::audio::AudioCapture::new_with_device_mode_volumes_and_levels( + crate::hyprland_binds::register_hyprland_binds(&config); + audio_capture = match crate::capture::audio::AudioCapture::new_with_device_mode_volumes_and_levels( audio_tx_clone.clone(), Some(&config.audio_device), &config.audio_mode, @@ -856,7 +860,7 @@ async fn main() -> Result<(), Box> { println!("Audio mute toggled: {}", !cur); }, Command::StartStreaming => { - let cfg = scythe::config::ScytheConfig::load(); + let cfg = crate::config::ScytheConfig::load(); let key = cfg.stream_key.trim(); if key.is_empty() { eprintln!("Cannot start livestream: stream key is empty in config."); @@ -903,7 +907,7 @@ async fn main() -> Result<(), Box> { stream_start_clone.store(0, Ordering::SeqCst); println!("ToggleStreaming: Livestream stopped."); } else { - let cfg = scythe::config::ScytheConfig::load(); + let cfg = crate::config::ScytheConfig::load(); let key = cfg.stream_key.trim(); if key.is_empty() { eprintln!("Cannot start livestream: stream key is empty in config."); @@ -1162,6 +1166,6 @@ async fn main() -> Result<(), Box> { let _ = recorder_handle.join(); #[cfg(windows)] - let _ = std::fs::remove_file(scythe::ipc::get_ipc_port_path()); + let _ = std::fs::remove_file(crate::ipc::get_ipc_port_path()); Ok(()) } diff --git a/src/lib.rs b/src/lib.rs index 213d62c..e834893 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,3 +11,4 @@ pub mod config; pub mod overlay_egui; pub mod hyprland_binds; pub mod updater; +pub mod daemon; diff --git a/src/bin/scythe-ui.rs b/src/main.rs similarity index 97% rename from src/bin/scythe-ui.rs rename to src/main.rs index 4c7dba9..b3fe664 100644 --- a/src/bin/scythe-ui.rs +++ b/src/main.rs @@ -414,7 +414,24 @@ fn handle_toggle_streaming() -> Result<(), Box Result<(), Box> { let args: Vec = env::args().collect(); + let exe_name = env::current_exe() + .ok() + .and_then(|p| p.file_stem().map(|s| s.to_string_lossy().to_string())) + .unwrap_or_default(); + // Multicall dispatch: scythe-daemon executable, symlink, or subcommand + let is_daemon = exe_name == "scythe-daemon" + || exe_name == "vrec-daemon" + || (args.len() > 1 && (args[1] == "daemon" || args[1] == "--daemon" || args[1] == "--mock")); + + if is_daemon { + return scythe::daemon::run_daemon(); + } + + run_client(args) +} + +fn run_client(args: Vec) -> Result<(), Box> { #[cfg(target_os = "windows")] unsafe { let is_cli = args.iter().any(|a| a == "--status" || a == "--help" || a == "-h"); @@ -428,7 +445,7 @@ fn main() -> Result<(), Box> { if args.len() > 1 { match args[1].as_str() { - "--save" | "--save-replay" => { + "--save" | "--save-replay" | "save" => { return handle_save_replay(); } "--notify-save" => { @@ -443,7 +460,7 @@ fn main() -> Result<(), Box> { show_shadowplay_toast("RECORDING", "Recording saved", ToastIcon::Save); return Ok(()); } - "--screenshot" => { + "--screenshot" | "screenshot" => { return handle_screenshot(); } "--mute-mic" => { @@ -462,7 +479,7 @@ fn main() -> Result<(), Box> { scythe::overlay_egui::run_egui_toast(title, subtitle, ToastIcon::from_name(icon_name)); return Ok(()); } - "--menu" => { + "--menu" | "menu" => { ensure_daemon_running_async(); ensure_hotkeys_running(); if check_and_toggle_overlay() { @@ -492,7 +509,7 @@ fn main() -> Result<(), Box> { "--hotkeys" | "--background" => { // fall through to hotkey loop } - "--record" | "--toggle" => { + "--record" | "--toggle" | "record" => { return handle_toggle_recording(); } "--stream" | "--toggle-stream" => { @@ -511,7 +528,7 @@ fn main() -> Result<(), Box> { ensure_daemon_running(); return send_command(Command::ReloadConfig); } - "--status" => { + "--status" | "status" => { ensure_daemon_running(); match query_status() { Ok(st) => { @@ -531,7 +548,7 @@ fn main() -> Result<(), Box> { } } } - "--quit" => { + "--quit" | "quit" => { return send_command(Command::StopDaemon); } "--help" | "-h" => { diff --git a/src/overlay_egui.rs b/src/overlay_egui.rs index 3ed09fa..59459d4 100644 --- a/src/overlay_egui.rs +++ b/src/overlay_egui.rs @@ -570,26 +570,14 @@ fn pick_folder(current_dir: &str, tx: Sender, is_active: Arc } fn spawn_daemon_process() { - let mut cmd = if let Ok(mut path) = std::env::current_exe() { - path.pop(); - #[cfg(target_os = "windows")] - let primary = path.join("scythe-daemon.exe"); - #[cfg(not(target_os = "windows"))] - let primary = path.join("scythe-daemon"); - - if primary.exists() { - std::process::Command::new(primary) - } else { - #[cfg(target_os = "windows")] - { std::process::Command::new("scythe-daemon.exe") } - #[cfg(not(target_os = "windows"))] - { std::process::Command::new("scythe-daemon") } - } + let mut cmd = if let Ok(self_exe) = std::env::current_exe() { + let mut c = std::process::Command::new(self_exe); + c.arg("daemon"); + c } else { - #[cfg(target_os = "windows")] - { std::process::Command::new("scythe-daemon.exe") } - #[cfg(not(target_os = "windows"))] - { std::process::Command::new("scythe-daemon") } + let mut c = std::process::Command::new("scythe"); + c.arg("daemon"); + c }; #[cfg(target_os = "windows")] diff --git a/tests/e2e_test.sh b/tests/e2e_test.sh index 25e3e8d..ac47391 100755 --- a/tests/e2e_test.sh +++ b/tests/e2e_test.sh @@ -23,13 +23,13 @@ if systemctl --user is-active --quiet scythe-daemon.service 2>/dev/null; then sleep 0.5 fi -echo "[2/6] Starting scythe-daemon with mock capture..." -target/release/scythe-daemon --mock > daemon_test.log 2>&1 & +echo "[2/6] Starting scythe daemon with mock capture..." +target/release/scythe daemon --mock > daemon_test.log 2>&1 & DAEMON_PID=$! cleanup() { if kill -0 $DAEMON_PID 2>/dev/null; then - target/release/scythe-ui --quit 2>/dev/null || kill -9 $DAEMON_PID 2>/dev/null || true + target/release/scythe --quit 2>/dev/null || kill -9 $DAEMON_PID 2>/dev/null || true fi rm -f daemon_test.log if [ "$WAS_SERVICE_ACTIVE" -eq 1 ]; then @@ -56,23 +56,23 @@ echo "Daemon is live (PID: $DAEMON_PID, Socket: $SOCKET_PATH)" # Test 1: Start Normal Recording echo "[3/6] Testing: Start Normal Recording..." -target/release/scythe-ui --start +target/release/scythe --start echo "Recording in progress for 3 seconds..." sleep 3 # Test 2: Stop Normal Recording echo "[4/6] Testing: Stop Normal Recording..." -target/release/scythe-ui --stop +target/release/scythe --stop sleep 1 # Test 3: Save Instant Replay echo "[5/6] Testing: Save Instant Replay buffer..." -target/release/scythe-ui --save +target/release/scythe --save sleep 2 # Test 4: Quit Daemon echo "[6/6] Testing: Clean Daemon Shutdown..." -target/release/scythe-ui --quit +target/release/scythe --quit wait $DAEMON_PID 2>/dev/null || true echo "Daemon shutdown cleanly."