feat(arch): consolidate daemon and UI into singular multicall binary scythe

This commit is contained in:
katR 2026-10-11 14:28:14 +05:30
parent 425a9b1003
commit 3cc718fa69
8 changed files with 101 additions and 87 deletions

View file

@ -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"

View file

@ -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.

View file

@ -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"

View file

@ -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<dyn std::error::Error + Send + Sync>> {
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?
.block_on(run_daemon_async())
}
#[tokio::main]
#[allow(unused_assignments)]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
async fn run_daemon_async() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
#[cfg(target_os = "windows")]
unsafe {
use windows::Win32::System::Console::FreeConsole;
@ -123,11 +127,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
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::<Frame>(2);
let (cmd_tx, cmd_rx) = bounded::<Command>(32);
@ -140,11 +144,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
} 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<dyn std::error::Error + Send + Sync>> {
}
},
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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
},
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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
.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<dyn std::error::Error + Send + Sync>> {
#[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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
#[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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
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(())
}

View file

@ -11,3 +11,4 @@ pub mod config;
pub mod overlay_egui;
pub mod hyprland_binds;
pub mod updater;
pub mod daemon;

View file

@ -414,7 +414,24 @@ fn handle_toggle_streaming() -> Result<(), Box<dyn std::error::Error + Send + Sy
fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let args: Vec<String> = 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<String>) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
#[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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
"--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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
}
}
}
"--quit" => {
"--quit" | "quit" => {
return send_command(Command::StopDaemon);
}
"--help" | "-h" => {

View file

@ -570,26 +570,14 @@ fn pick_folder(current_dir: &str, tx: Sender<String>, is_active: Arc<AtomicBool>
}
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")]

View file

@ -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."