fix(windows): overhaul Windows capture, encoding fallback, overlay translucency, installer DLL locking, and CI packaging
Some checks are pending
Release & Packaging / Build Linux Packages (.tar.gz, .deb, .rpm, .AppImage) (push) Waiting to run
Release & Packaging / Build Windows Binaries (.exe) (push) Waiting to run
Release & Packaging / Publish GitHub Release (push) Blocked by required conditions

This commit is contained in:
katR 2026-09-08 17:26:39 +05:30
parent c04bf36e05
commit 96dcfc1a17
13 changed files with 440 additions and 142 deletions

View file

@ -158,12 +158,17 @@ jobs:
}
}
makensis packaging/windows/scythe-installer.nsi
makensis packaging/windows/scythe-standalone.nsi
Compress-Archive -Path dist/bundle/* -DestinationPath dist/scythe-windows-x86_64.zip -Force
- name: Upload Windows Artifacts
uses: actions/upload-artifact@v4
with:
name: windows-packages
path: dist/scythe-setup.exe
path: |
dist/scythe-setup.exe
dist/scythe.exe
dist/scythe-windows-x86_64.zip
publish-release:
name: Publish GitHub Release
@ -190,6 +195,8 @@ jobs:
### Packages
- **Windows**: `scythe-setup.exe` (Official installer with Start Menu & Desktop shortcuts)
- **Windows Portable**: `scythe.exe` (Standalone portable launcher)
- **Windows Archive**: `scythe-windows-x86_64.zip` (Portable zip archive)
- **Universal Linux**: `scythe-x86_64.AppImage`
- **Debian / Ubuntu / Mint**: `scythe_*_amd64.deb`
- **Fedora / RHEL / openSUSE**: `*.rpm`

2
Cargo.lock generated
View file

@ -3700,7 +3700,7 @@ dependencies = [
[[package]]
name = "scythe"
version = "0.2.7"
version = "0.2.8"
dependencies = [
"ashpd",
"cpal",

View file

@ -1,6 +1,6 @@
[package]
name = "scythe"
version = "0.2.7"
version = "0.2.8"
edition = "2024"
[[bin]]
@ -52,4 +52,5 @@ windows = { version = "0.58.0", features = [
"Win32_UI_WindowsAndMessaging",
"Win32_UI_Controls",
"Win32_System_Threading",
"Win32_System_LibraryLoader",
] }

View file

@ -31,9 +31,10 @@ Section "Scythe" SecMain
nsExec::Exec 'cmd.exe /C taskkill /F /IM scythe-daemon.exe /T >nul 2>&1'
nsExec::Exec 'cmd.exe /C taskkill /F /IM vrec-ui.exe /T >nul 2>&1'
nsExec::Exec 'cmd.exe /C taskkill /F /IM vrec-daemon.exe /T >nul 2>&1'
Sleep 800
nsExec::Exec 'powershell -NoProfile -NonInteractive -Command "Get-Process -Name scythe-ui, scythe-daemon, vrec-ui, vrec-daemon -ErrorAction SilentlyContinue | Stop-Process -Force"'
Sleep 1500
SetOverwrite on
SetOverwrite try
SetOutPath "$INSTDIR"
File /r "..\..\dist\bundle\*.*"
@ -55,7 +56,8 @@ Section "Uninstall"
nsExec::Exec 'cmd.exe /C taskkill /F /IM scythe-daemon.exe /T >nul 2>&1'
nsExec::Exec 'cmd.exe /C taskkill /F /IM vrec-ui.exe /T >nul 2>&1'
nsExec::Exec 'cmd.exe /C taskkill /F /IM vrec-daemon.exe /T >nul 2>&1'
Sleep 800
nsExec::Exec 'powershell -NoProfile -NonInteractive -Command "Get-Process -Name scythe-ui, scythe-daemon, vrec-ui, vrec-daemon -ErrorAction SilentlyContinue | Stop-Process -Force"'
Sleep 1500
Delete "$DESKTOP\scythe.lnk"
RMDir /r "$SMPROGRAMS\scythe"

View file

@ -11,9 +11,12 @@ Section
; Terminate any running instances so locked executables and DLLs can be updated cleanly
nsExec::Exec 'cmd.exe /C taskkill /F /IM scythe-ui.exe /T >nul 2>&1'
nsExec::Exec 'cmd.exe /C taskkill /F /IM scythe-daemon.exe /T >nul 2>&1'
Sleep 500
nsExec::Exec 'cmd.exe /C taskkill /F /IM vrec-ui.exe /T >nul 2>&1'
nsExec::Exec 'cmd.exe /C taskkill /F /IM vrec-daemon.exe /T >nul 2>&1'
nsExec::Exec 'powershell -NoProfile -NonInteractive -Command "Get-Process -Name scythe-ui, scythe-daemon, vrec-ui, vrec-daemon -ErrorAction SilentlyContinue | Stop-Process -Force"'
Sleep 1500
SetOverwrite on
SetOverwrite try
; Extract self-contained binaries and all runtime DLLs directly to user app data
SetOutPath "$LOCALAPPDATA\scythe"
File /r "..\..\dist\bundle\*.*"

View file

@ -74,18 +74,31 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
#[cfg(windows)]
let listener = {
match std::net::TcpListener::bind("127.0.0.1:42069") {
Ok(l) => {
println!("Daemon listening on TCP IPC: 127.0.0.1:42069");
let ports = [42069, 42070, 42071, 42072];
let mut bound = None;
for &port in &ports {
match std::net::TcpListener::bind(format!("127.0.0.1:{}", port)) {
Ok(l) => {
bound = Some((l, port));
break;
}
Err(_) => {
if scythe::ipc::query_status_port(port).is_ok() {
println!("Another instance of scythe-daemon is already running on port {}. Exiting cleanly.", port);
return Ok(());
}
}
}
}
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(std::env::temp_dir().join("vrec-ipc.port"), p.to_string());
l
}
Err(_e) => {
if scythe::ipc::query_status().is_ok() {
println!("Another instance of scythe-daemon is already running. Exiting cleanly.");
return Ok(());
}
std::thread::sleep(std::time::Duration::from_millis(500));
std::net::TcpListener::bind("127.0.0.1:42069")?
None => {
return Err("Failed to bind any TCP IPC port (all ports 42069-42072 in use)".into());
}
}
};
@ -307,14 +320,19 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let capture_tx = frame_tx.clone();
thread::spawn(move || {
let mut err_count = 0;
loop {
match source.next_frame() {
Ok(frame) => {
err_count = 0;
let _ = capture_tx.try_send(frame);
}
Err(e) => {
eprintln!("Capture error: {}", e);
break;
err_count += 1;
if err_count % 60 == 1 {
eprintln!("Capture transient error: {}", e);
}
std::thread::sleep(std::time::Duration::from_millis(16));
}
}
}
@ -707,5 +725,7 @@ 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());
Ok(())
}

View file

@ -23,9 +23,13 @@ fn get_daemon_cmd() -> std::process::Command {
let fallback = path.join("vrec-daemon");
if primary.exists() {
std::process::Command::new(primary)
let mut c = std::process::Command::new(primary);
c.current_dir(&path);
c
} else if fallback.exists() {
std::process::Command::new(fallback)
let mut c = std::process::Command::new(fallback);
c.current_dir(&path);
c
} else {
#[cfg(target_os = "windows")]
{
@ -59,7 +63,11 @@ fn get_daemon_cmd() -> std::process::Command {
fn get_ui_cmd() -> std::process::Command {
#[allow(unused_mut)]
let mut cmd = if let Ok(path) = std::env::current_exe() {
std::process::Command::new(path)
let mut c = std::process::Command::new(&path);
if let Some(parent) = path.parent() {
c.current_dir(parent);
}
c
} else {
#[cfg(target_os = "windows")]
{
@ -112,6 +120,21 @@ fn check_and_toggle_overlay() -> bool {
}
}
}
#[cfg(windows)]
if let Ok(pid) = content.trim().parse::<u32>() {
if pid != std::process::id() {
unsafe {
use windows::Win32::System::Threading::{OpenProcess, TerminateProcess, PROCESS_TERMINATE};
use windows::Win32::Foundation::CloseHandle;
if let Ok(h) = OpenProcess(PROCESS_TERMINATE, false, pid) {
let _ = TerminateProcess(h, 0);
let _ = CloseHandle(h);
let _ = std::fs::remove_file(&pid_path);
return true;
}
}
}
}
}
let _ = std::fs::remove_file(&pid_path);
}
@ -135,6 +158,12 @@ fn ensure_daemon_running_async() {
.status();
}
}
#[cfg(target_os = "windows")]
{
if scythe::ipc::is_daemon_running() {
return;
}
}
let _ = get_daemon_cmd()
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
@ -173,7 +202,7 @@ fn ensure_hotkeys_running() {
use windows::Win32::Foundation::CloseHandle;
use windows::Win32::System::Threading::{OpenMutexW, SYNCHRONIZATION_ACCESS_RIGHTS};
if let Ok(handle) = OpenMutexW(SYNCHRONIZATION_ACCESS_RIGHTS(0x00100000), false, windows::core::w!("Global\\scythe_hotkeys_single_instance")) {
if let Ok(handle) = OpenMutexW(SYNCHRONIZATION_ACCESS_RIGHTS(0x00100000), false, windows::core::w!("Local\\scythe_hotkeys_single_instance")) {
let _ = CloseHandle(handle);
return;
}
@ -387,7 +416,7 @@ fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use windows::Win32::Foundation::{GetLastError, WIN32_ERROR};
use windows::Win32::System::Threading::CreateMutexW;
let handle = CreateMutexW(None, true, windows::core::w!("Global\\scythe_hotkeys_single_instance"));
let handle = CreateMutexW(None, true, windows::core::w!("Local\\scythe_hotkeys_single_instance"));
if GetLastError() == WIN32_ERROR(183) {
return Ok(());
}

View file

@ -18,6 +18,7 @@ pub struct WindowsCapture {
staging_texture: ID3D11Texture2D,
pub width: u32,
pub height: u32,
pub last_stride: u32,
last_frame: Vec<u8>,
}
@ -69,7 +70,12 @@ impl WindowsCapture {
D3D_DRIVER_TYPE_UNKNOWN,
HMODULE(std::ptr::null_mut()),
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
Some(&[D3D_FEATURE_LEVEL_11_1, D3D_FEATURE_LEVEL_11_0]),
Some(&[
D3D_FEATURE_LEVEL_11_1,
D3D_FEATURE_LEVEL_11_0,
D3D_FEATURE_LEVEL_10_1,
D3D_FEATURE_LEVEL_10_0,
]),
D3D11_SDK_VERSION,
Some(&mut device),
Some(&mut feature_level),
@ -113,6 +119,7 @@ impl WindowsCapture {
staging_texture,
width,
height,
last_stride: width * 4,
last_frame: Vec::new(),
})
}
@ -127,6 +134,32 @@ impl WindowsCapture {
if let Ok(output1) = output.cast::<IDXGIOutput1>() {
if let Ok(dup) = output1.DuplicateOutput(&self.device) {
self.duplication = dup;
if let Ok(desc) = output1.GetDesc() {
let new_w = (desc.DesktopCoordinates.right - desc.DesktopCoordinates.left).unsigned_abs();
let new_h = (desc.DesktopCoordinates.bottom - desc.DesktopCoordinates.top).unsigned_abs();
if new_w != self.width || new_h != self.height {
self.width = new_w;
self.height = new_h;
self.last_stride = new_w * 4;
let staging_desc = D3D11_TEXTURE2D_DESC {
Width: new_w,
Height: new_h,
MipLevels: 1,
ArraySize: 1,
Format: DXGI_FORMAT_B8G8R8A8_UNORM,
SampleDesc: DXGI_SAMPLE_DESC { Count: 1, Quality: 0 },
Usage: D3D11_USAGE_STAGING,
BindFlags: 0,
CPUAccessFlags: D3D11_CPU_ACCESS_READ.0 as u32,
MiscFlags: 0,
};
let mut new_staging: Option<ID3D11Texture2D> = None;
if self.device.CreateTexture2D(&staging_desc, None, Some(&mut new_staging)).is_ok()
&& let Some(st) = new_staging {
self.staging_texture = st;
}
}
}
return Ok(());
}
}
@ -145,7 +178,7 @@ impl FrameSource for WindowsCapture {
let mut desktop_resource: Option<IDXGIResource> = None;
for _ in 0..5 {
match self.duplication.AcquireNextFrame(100, &mut frame_info, &mut desktop_resource) {
match self.duplication.AcquireNextFrame(16, &mut frame_info, &mut desktop_resource) {
Ok(()) => {
if let Some(resource) = desktop_resource {
let texture: ID3D11Texture2D = resource.cast()?;
@ -160,6 +193,7 @@ impl FrameSource for WindowsCapture {
self.context.Map(&self.staging_texture, 0, D3D11_MAP_READ, 0, Some(&mut mapped))?;
let stride = mapped.RowPitch as usize;
self.last_stride = mapped.RowPitch;
let total_bytes = stride * self.height as usize;
if self.last_frame.len() != total_bytes {
self.last_frame.resize(total_bytes, 0);
@ -185,19 +219,40 @@ impl FrameSource for WindowsCapture {
return Ok(Frame::Raw {
width: self.width,
height: self.height,
stride: self.width * 4,
stride: self.last_stride,
data: self.last_frame.clone(),
});
}
std::thread::sleep(std::time::Duration::from_millis(8));
}
Err(e) if e.code() == DXGI_ERROR_ACCESS_LOST => {
println!("DXGI access lost (display mode or fullscreen switch), reacquiring...");
Err(e) if e.code() == DXGI_ERROR_ACCESS_LOST
|| e.code() == DXGI_ERROR_ACCESS_DENIED
|| e.code() == DXGI_ERROR_INVALID_CALL => {
let _ = self.duplication.ReleaseFrame();
let _ = self.reinit_duplication();
std::thread::sleep(std::time::Duration::from_millis(50));
if !self.last_frame.is_empty() {
return Ok(Frame::Raw {
width: self.width,
height: self.height,
stride: self.last_stride,
data: self.last_frame.clone(),
});
}
std::thread::sleep(std::time::Duration::from_millis(30));
continue;
}
Err(e) => {
let _ = self.duplication.ReleaseFrame();
let _ = self.reinit_duplication();
if !self.last_frame.is_empty() {
return Ok(Frame::Raw {
width: self.width,
height: self.height,
stride: self.last_stride,
data: self.last_frame.clone(),
});
}
std::thread::sleep(std::time::Duration::from_millis(16));
return Err(Box::new(e));
}
}
@ -207,15 +262,15 @@ impl FrameSource for WindowsCapture {
Ok(Frame::Raw {
width: self.width,
height: self.height,
stride: self.width * 4,
stride: self.last_stride,
data: self.last_frame.clone(),
})
} else {
Ok(Frame::Raw {
width: self.width,
height: self.height,
stride: self.width * 4,
data: vec![0u8; (self.width * self.height * 4) as usize],
stride: self.last_stride,
data: vec![0u8; (self.last_stride * self.height) as usize],
})
}
}

View file

@ -211,6 +211,7 @@ impl VaapiEncoder {
self.send_staged_frame(&mut packets, pts)?;
}
Frame::DmaBuf { width: dma_width, height: dma_height, format, modifier, fd, stride, offset } => {
#[allow(unused_variables)]
let mmap_size = (*offset as usize) + (*stride as usize) * (*dma_height as usize);
let mut mapped_direct = false;
@ -382,6 +383,7 @@ pub struct WindowsHwEncoder {
encoder_name: String,
sws_ctx: *mut SwsContext,
sw_frame: *mut AVFrame,
pub has_frame: bool,
}
#[cfg(target_os = "windows")]
@ -418,75 +420,90 @@ impl WindowsHwEncoder {
],
};
let mut selected_codec: *const AVCodec = ptr::null();
let mut opened_codec_ctx: *mut AVCodecContext = ptr::null_mut();
let mut selected_desc = String::new();
for (name, desc) in candidates {
let c = avcodec_find_encoder_by_name(name.as_ptr());
if !c.is_null() {
selected_codec = c;
if c.is_null() {
continue;
}
let ctx = avcodec_alloc_context3(c);
if ctx.is_null() {
continue;
}
(*ctx).width = width as i32;
(*ctx).height = height as i32;
(*ctx).time_base = AVRational { num: 1, den: fps };
(*ctx).framerate = AVRational { num: fps, den: 1 };
(*ctx).gop_size = fps;
(*ctx).max_b_frames = 0;
let rate = (bitrate_kbps as i64) * 1000;
(*ctx).bit_rate = rate;
(*ctx).rc_max_rate = rate * 3 / 2;
(*ctx).rc_buffer_size = (rate / 2) as i32;
(*ctx).qmin = 16;
(*ctx).qmax = 28;
let codec_id = (*c).id;
(*ctx).profile = if codec_id == AVCodecID::AV_CODEC_ID_HEVC {
FF_PROFILE_HEVC_MAIN
} else if codec_id == AVCodecID::AV_CODEC_ID_AV1 {
FF_PROFILE_UNKNOWN
} else {
FF_PROFILE_H264_HIGH
};
(*ctx).flags |= AV_CODEC_FLAG_GLOBAL_HEADER as libc::c_int;
if desc.contains("NVENC") {
(*ctx).pix_fmt = AVPixelFormat::AV_PIX_FMT_NV12;
let _ = av_opt_set((*ctx).priv_data, c"preset".as_ptr(), c"p1".as_ptr(), 0);
let _ = av_opt_set((*ctx).priv_data, c"tune".as_ptr(), c"ull".as_ptr(), 0);
let _ = av_opt_set((*ctx).priv_data, c"forced-idr".as_ptr(), c"1".as_ptr(), 0);
} else if desc.contains("AMF") {
(*ctx).pix_fmt = AVPixelFormat::AV_PIX_FMT_NV12;
let _ = av_opt_set((*ctx).priv_data, c"usage".as_ptr(), c"ultralowlatency".as_ptr(), 0);
} else if desc.contains("QuickSync") {
(*ctx).pix_fmt = AVPixelFormat::AV_PIX_FMT_NV12;
let _ = av_opt_set((*ctx).priv_data, c"preset".as_ptr(), c"veryfast".as_ptr(), 0);
} else {
(*ctx).pix_fmt = AVPixelFormat::AV_PIX_FMT_YUV420P;
let _ = av_opt_set((*ctx).priv_data, c"preset".as_ptr(), c"ultrafast".as_ptr(), 0);
let _ = av_opt_set((*ctx).priv_data, c"tune".as_ptr(), c"zerolatency".as_ptr(), 0);
}
let ret = avcodec_open2(ctx, c, ptr::null_mut());
if ret >= 0 {
println!("Successfully initialized Windows video encoder: {}", desc);
opened_codec_ctx = ctx;
selected_desc = desc.to_string();
println!("Auto-detected Windows encoder: {}", desc);
break;
} else {
let mut tmp = ctx;
avcodec_free_context(&mut tmp);
}
}
if selected_codec.is_null() {
return Err("No compatible H.264 video encoder found on this system".into());
if opened_codec_ctx.is_null() {
return Err("Failed to open any compatible video encoder on this system".into());
}
let codec_ctx = avcodec_alloc_context3(selected_codec);
if codec_ctx.is_null() {
return Err("Failed to allocate codec context".into());
let codec_ctx = opened_codec_ctx;
let mut sw_frame = av_frame_alloc();
if sw_frame.is_null() {
avcodec_free_context(&mut opened_codec_ctx);
return Err("Failed to allocate frame for encoder".into());
}
(*codec_ctx).width = width as i32;
(*codec_ctx).height = height as i32;
(*codec_ctx).time_base = AVRational { num: 1, den: fps };
(*codec_ctx).framerate = AVRational { num: fps, den: 1 };
(*codec_ctx).gop_size = fps;
(*codec_ctx).max_b_frames = 0;
let rate = (bitrate_kbps as i64) * 1000;
(*codec_ctx).bit_rate = rate;
(*codec_ctx).rc_max_rate = rate * 3 / 2;
(*codec_ctx).rc_buffer_size = (rate / 2) as i32;
(*codec_ctx).qmin = 16;
(*codec_ctx).qmax = 28;
(*codec_ctx).profile = match codec_pref.to_lowercase().as_str() {
"hevc" | "h265" => FF_PROFILE_HEVC_MAIN,
"av1" => FF_PROFILE_UNKNOWN,
_ => FF_PROFILE_H264_HIGH,
};
(*codec_ctx).flags |= AV_CODEC_FLAG_GLOBAL_HEADER as libc::c_int;
if selected_desc.contains("NVENC") {
(*codec_ctx).pix_fmt = AVPixelFormat::AV_PIX_FMT_NV12;
let _ = av_opt_set((*codec_ctx).priv_data, c"preset".as_ptr(), c"p1".as_ptr(), 0);
let _ = av_opt_set((*codec_ctx).priv_data, c"tune".as_ptr(), c"ull".as_ptr(), 0);
} else if selected_desc.contains("AMF") {
(*codec_ctx).pix_fmt = AVPixelFormat::AV_PIX_FMT_NV12;
let _ = av_opt_set((*codec_ctx).priv_data, c"usage".as_ptr(), c"ultralowlatency".as_ptr(), 0);
} else if selected_desc.contains("QuickSync") {
(*codec_ctx).pix_fmt = AVPixelFormat::AV_PIX_FMT_NV12;
let _ = av_opt_set((*codec_ctx).priv_data, c"preset".as_ptr(), c"veryfast".as_ptr(), 0);
} else {
(*codec_ctx).pix_fmt = AVPixelFormat::AV_PIX_FMT_YUV420P;
let _ = av_opt_set((*codec_ctx).priv_data, c"preset".as_ptr(), c"ultrafast".as_ptr(), 0);
let _ = av_opt_set((*codec_ctx).priv_data, c"tune".as_ptr(), c"zerolatency".as_ptr(), 0);
}
let ret = avcodec_open2(codec_ctx, selected_codec, ptr::null_mut());
if ret < 0 {
avcodec_free_context(&mut (codec_ctx as *mut _));
return Err(format!("Failed to open Windows encoder: {}", ret));
}
let sw_frame = av_frame_alloc();
(*sw_frame).format = (*codec_ctx).pix_fmt as i32;
(*sw_frame).width = width as i32;
(*sw_frame).height = height as i32;
av_frame_get_buffer(sw_frame, 32);
if av_frame_get_buffer(sw_frame, 32) < 0 {
av_frame_free(&mut sw_frame);
avcodec_free_context(&mut opened_codec_ctx);
return Err("Failed to allocate frame buffer for encoder".into());
}
let sws_ctx = sws_getContext(
width as i32,
@ -506,6 +523,7 @@ impl WindowsHwEncoder {
encoder_name: selected_desc,
sws_ctx,
sw_frame,
has_frame: false,
})
}
}
@ -522,6 +540,10 @@ impl WindowsHwEncoder {
let src_data = [data.as_ptr(), ptr::null(), ptr::null(), ptr::null()];
let src_linesize = [*stride as i32, 0, 0, 0];
if av_frame_make_writable(self.sw_frame) < 0 {
let _ = av_frame_get_buffer(self.sw_frame, 32);
}
sws_scale(
self.sws_ctx,
src_data.as_ptr(),
@ -542,6 +564,7 @@ impl WindowsHwEncoder {
packets.push(crate::ring::Packet::new(new_pkt));
}
av_packet_free(&mut pkt);
self.has_frame = true;
}
}
#[cfg(target_os = "windows")]
@ -557,6 +580,7 @@ impl WindowsHwEncoder {
packets.push(crate::ring::Packet::new(new_pkt));
}
av_packet_free(&mut pkt);
self.has_frame = true;
}
}
_ => {}
@ -568,6 +592,9 @@ impl WindowsHwEncoder {
#[cfg(target_os = "windows")]
pub fn encode_cached_frame(&mut self, pts: i64) -> Result<Vec<crate::ring::Packet>, String> {
let mut packets = Vec::new();
if !self.has_frame {
return Ok(packets);
}
unsafe {
if self.sw_frame.is_null() {
return Ok(packets);

View file

@ -208,6 +208,7 @@ pub fn register_hyprland_binds(config: &ScytheConfig) {
}
}
#[cfg(unix)]
pub fn register_hyprland_binds_async(config: &ScytheConfig) {
let cfg = config.clone();
std::thread::spawn(move || {
@ -215,6 +216,7 @@ pub fn register_hyprland_binds_async(config: &ScytheConfig) {
});
}
#[cfg(unix)]
pub fn unbind_hotkey_async(hotkey: &str) {
let hk = hotkey.to_string();
std::thread::spawn(move || {

View file

@ -65,6 +65,31 @@ pub fn get_legacy_socket_path() -> String {
format!("{}/vrec.sock", runtime_dir)
}
#[cfg(windows)]
pub fn get_ipc_port_path() -> std::path::PathBuf {
std::env::temp_dir().join("scythe-ipc.port")
}
#[cfg(windows)]
pub fn read_active_ipc_port() -> u16 {
if let Ok(content) = std::fs::read_to_string(get_ipc_port_path()) {
if let Ok(port) = content.trim().parse::<u16>() {
return port;
}
}
let legacy = std::env::temp_dir().join("vrec-ipc.port");
if let Ok(content) = std::fs::read_to_string(legacy) {
if let Ok(port) = content.trim().parse::<u16>() {
return port;
}
}
42069
}
pub fn is_daemon_running() -> bool {
query_status().is_ok()
}
pub fn send_command(cmd: Command) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use std::io::Write;
use std::time::Duration;
@ -101,25 +126,52 @@ pub fn send_command(cmd: Command) -> Result<(), Box<dyn std::error::Error + Send
#[cfg(windows)]
{
use std::net::{SocketAddr, TcpStream};
let addr: SocketAddr = "127.0.0.1:42069".parse().unwrap();
match TcpStream::connect_timeout(&addr, Duration::from_millis(300)) {
Ok(mut stream) => {
let _ = stream.set_write_timeout(Some(Duration::from_millis(600)));
let _ = stream.set_read_timeout(Some(Duration::from_millis(600)));
if stream.write_all(&len_buf).is_ok() && stream.write_all(&payload).is_ok() {
return Ok(());
let active_port = read_active_ipc_port();
let candidate_ports = [active_port, 42069, 42070, 42071, 42072];
for &port in &candidate_ports {
if let Ok(addr) = format!("127.0.0.1:{}", port).parse::<SocketAddr>() {
if let Ok(mut stream) = TcpStream::connect_timeout(&addr, Duration::from_millis(150)) {
let _ = stream.set_write_timeout(Some(Duration::from_millis(600)));
let _ = stream.set_read_timeout(Some(Duration::from_millis(600)));
if stream.write_all(&len_buf).is_ok() && stream.write_all(&payload).is_ok() {
return Ok(());
}
}
}
Err(e) => {
last_err = Some(Box::new(e) as Box<dyn std::error::Error + Send + Sync>);
}
}
last_err = Some("Could not connect to daemon on any TCP IPC port".into());
}
}
Err(last_err.unwrap_or_else(|| "Failed to communicate with scythe-daemon".into()))
}
#[cfg(windows)]
pub fn query_status_port(port: u16) -> Result<DaemonStatus, Box<dyn std::error::Error + Send + Sync>> {
use std::io::{Read, Write};
use std::time::Duration;
use std::net::{SocketAddr, TcpStream};
let payload = serde_json::to_vec(&Command::GetStatus)?;
let len_buf = (payload.len() as u32).to_le_bytes();
let addr: SocketAddr = format!("127.0.0.1:{}", port).parse()?;
let mut stream = TcpStream::connect_timeout(&addr, Duration::from_millis(200))?;
let _ = stream.set_write_timeout(Some(Duration::from_millis(500)));
let _ = stream.set_read_timeout(Some(Duration::from_millis(500)));
stream.write_all(&len_buf)?;
stream.write_all(&payload)?;
let mut resp_len_buf = [0u8; 4];
stream.read_exact(&mut resp_len_buf)?;
let resp_len = u32::from_le_bytes(resp_len_buf) as usize;
let mut resp_payload = vec![0u8; resp_len];
stream.read_exact(&mut resp_payload)?;
let status = serde_json::from_slice::<DaemonStatus>(&resp_payload)?;
Ok(status)
}
pub fn query_status() -> Result<DaemonStatus, Box<dyn std::error::Error + Send + Sync>> {
use std::io::{Read, Write};
use std::time::Duration;
@ -144,8 +196,18 @@ pub fn query_status() -> Result<DaemonStatus, Box<dyn std::error::Error + Send +
#[cfg(windows)]
let stream_res = {
use std::net::{SocketAddr, TcpStream};
let addr: SocketAddr = "127.0.0.1:42069".parse().unwrap();
TcpStream::connect_timeout(&addr, Duration::from_millis(300))
let active_port = read_active_ipc_port();
let candidate_ports = [active_port, 42069, 42070, 42071, 42072];
let mut conn = None;
for &port in &candidate_ports {
if let Ok(addr) = format!("127.0.0.1:{}", port).parse::<SocketAddr>() {
if let Ok(stream) = TcpStream::connect_timeout(&addr, Duration::from_millis(150)) {
conn = Some(stream);
break;
}
}
}
conn.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotConnected, "No active daemon port reachable"))
};
match stream_res {

View file

@ -221,7 +221,7 @@ fn open_folder(path: &std::path::Path) {
if is_file {
let p_str = p.to_string_lossy().replace('/', "\\");
let _ = std::process::Command::new("explorer.exe")
.arg(format!("/select,\"{}\"", p_str))
.arg(format!("/select,{}", p_str))
.creation_flags(0x08000000)
.spawn();
} else {
@ -254,9 +254,13 @@ fn pick_folder(current_dir: &str, tx: Sender<String>, is_active: Arc<AtomicBool>
use std::os::windows::process::CommandExt;
let clean_cur = cur.replace('/', "\\").replace('\'', "''");
let script = format!(
"$s = (New-Object -ComObject Shell.Application).BrowseForFolder(0, 'Select Recordings Directory', 0, '{}'); \
if ($s) {{ Write-Output $s.Self.Path }}",
clean_cur
"[System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms') | Out-Null; \
$f = New-Object System.Windows.Forms.FolderBrowserDialog; \
$f.Description = 'Select Recordings Directory'; \
$f.UseDescriptionForTitle = $true; \
if (Test-Path '{}') {{ $f.SelectedPath = '{}' }}; \
if ($f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {{ Write-Output $f.SelectedPath }}",
clean_cur, clean_cur
);
let mut cmd = std::process::Command::new("powershell.exe");
cmd.args(["-NoProfile", "-STA", "-Command", &script]);
@ -587,7 +591,11 @@ fn render_action_card(
sub_text: &str,
accent: Color32,
) -> bool {
let (rect, response) = ui.allocate_exact_size(Vec2::new(width, height), egui::Sense::click());
let (raw_rect, response) = ui.allocate_exact_size(Vec2::new(width, height), egui::Sense::click());
let rect = egui::Rect::from_min_size(
egui::pos2(raw_rect.left().round(), raw_rect.top().round()),
Vec2::new(width.round(), height.round()),
);
let hovered = response.hovered();
let bg = if dropdown_open {
@ -652,23 +660,27 @@ fn render_dropdown_menu(
add_contents: impl FnOnce(&mut egui::Ui),
) {
ui.add_space(6.0);
let inner_w = card_width - 2.0;
egui::Frame::NONE
.fill(Color32::from_rgba_unmultiplied(12, 13, 17, 225))
.fill(Color32::from_rgba_unmultiplied(12, 13, 17, 235))
.stroke(Stroke::new(1.0_f32, Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), 180)))
.corner_radius(CornerRadius::ZERO)
.inner_margin(Margin::ZERO)
.show(ui, |ui| {
ui.spacing_mut().item_spacing = Vec2::ZERO;
ui.set_min_width(inner_w);
ui.set_max_width(inner_w);
ui.set_width(card_width);
ui.set_min_width(card_width);
ui.set_max_width(card_width);
add_contents(ui);
});
}
// Sleek Squared Dropdown Action Menu Item (Completely fills container, zero bottom dead space)
fn render_menu_item(ui: &mut egui::Ui, label: &str, accent: Color32, is_last: bool) -> bool {
let (rect, response) = ui.allocate_exact_size(Vec2::new(ui.available_width(), 35.0), egui::Sense::click());
let (raw_rect, response) = ui.allocate_exact_size(Vec2::new(ui.available_width(), 35.0), egui::Sense::click());
let rect = egui::Rect::from_min_size(
egui::pos2(raw_rect.left().round(), raw_rect.top().round()),
Vec2::new(raw_rect.width().round(), 35.0),
);
let hovered = response.hovered();
let bg = if hovered {
@ -759,6 +771,7 @@ pub struct ScytheOverlayApp {
bitrate_input_str: String,
replay_sec_input_str: String,
panel_rect: egui::Rect,
frame_count: u32,
update_status: Arc<std::sync::Mutex<crate::updater::UpdateStatus>>,
update_dismissed: bool,
auto_check_updates: bool,
@ -862,6 +875,7 @@ impl ScytheOverlayApp {
bitrate_input_str,
replay_sec_input_str,
panel_rect: egui::Rect::NOTHING,
frame_count: 0,
update_status,
update_dismissed: false,
auto_check_updates,
@ -997,6 +1011,8 @@ impl ScytheOverlayApp {
}
ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing = Vec2::new(card_gap, 0.0);
// =========================================================================
// CARD 1: INSTANT REPLAY
// =========================================================================
@ -1037,7 +1053,7 @@ impl ScytheOverlayApp {
self.status.is_replay_active = cfg.replay_enabled;
self.replay_dropdown_open = false;
}
if render_menu_item(ui, "Save Replay", accent, is_replay_active) {
if render_menu_item(ui, "Save Replay", accent, true) {
if is_replay_active {
async_send_command(Command::SaveReplay);
self.show_hud_notification("INSTANT REPLAY", "Saved to Videos", crate::overlay::ToastIcon::Replay);
@ -1050,8 +1066,6 @@ impl ScytheOverlayApp {
}
});
ui.add_space(card_gap);
// =========================================================================
// CARD 2: RECORD
// =========================================================================
@ -1104,8 +1118,6 @@ impl ScytheOverlayApp {
}
});
ui.add_space(card_gap);
// =========================================================================
// CARD 3: SETTINGS
// =========================================================================
@ -2148,8 +2160,9 @@ impl eframe::App for ScytheOverlayApp {
ctx.request_repaint_after(Duration::from_millis(16));
}
self.frame_count += 1;
// Auto-position, DWM transparency, and size to monitor on launch
if !self.initial_pos_set {
if !self.initial_pos_set && self.frame_count >= 2 {
if let Some(monitor_size) = ctx.input(|i| i.viewport().monitor_size) {
if monitor_size.x > 100.0 && monitor_size.y > 100.0 {
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(egui::pos2(0.0, 0.0)));
@ -2282,31 +2295,97 @@ impl eframe::App for ScytheOverlayApp {
}
#[cfg(target_os = "windows")]
pub fn apply_windows_transparency(_title: &str) {
pub fn apply_windows_transparency(title: &str) {
unsafe {
use windows::Win32::UI::WindowsAndMessaging::{EnumWindows, GetWindowThreadProcessId};
use windows::Win32::UI::WindowsAndMessaging::{
EnumWindows, GetWindowThreadProcessId, SetClassLongPtrW, GCLP_HBRBACKGROUND,
};
use windows::Win32::System::Threading::GetCurrentProcessId;
use windows::Win32::Foundation::{BOOL, HWND, LPARAM};
use windows::Win32::Graphics::Dwm::DwmExtendFrameIntoClientArea;
use windows::Win32::UI::Controls::MARGINS;
use windows::Win32::System::LibraryLoader::{GetModuleHandleA, GetProcAddress};
let my_pid = GetCurrentProcessId();
let is_overlay = title == "Scythe";
unsafe extern "system" fn enum_proc(hwnd: HWND, lparam: LPARAM) -> BOOL {
let my_pid = lparam.0 as u32;
let mut proc_id = 0u32;
GetWindowThreadProcessId(hwnd, Some(&mut proc_id));
if proc_id == my_pid {
let margins = MARGINS {
cxLeftWidth: -1,
cxRightWidth: -1,
cyTopHeight: -1,
cyBottomHeight: -1,
unsafe {
let (my_pid, is_overlay) = {
let ptr = lparam.0 as *const (u32, bool);
*ptr
};
let _ = DwmExtendFrameIntoClientArea(hwnd, &margins);
let mut proc_id = 0u32;
GetWindowThreadProcessId(hwnd, Some(&mut proc_id));
if proc_id == my_pid {
// 1. Set window class background brush to 0 (NULL) to prevent GDI white flash
#[cfg(target_pointer_width = "64")]
let _ = SetClassLongPtrW(hwnd, GCLP_HBRBACKGROUND, 0);
// 2. Extend DWM frame margins into the client area
let margins = MARGINS {
cxLeftWidth: -1,
cxRightWidth: -1,
cyTopHeight: -1,
cyBottomHeight: -1,
};
let _ = DwmExtendFrameIntoClientArea(hwnd, &margins);
// 3. Set AccentPolicy via SetWindowCompositionAttribute for true transparency/blur
#[repr(C)]
struct AccentPolicy {
accent_state: u32,
accent_flags: u32,
gradient_color: u32,
animation_id: u32,
}
#[repr(C)]
struct WindowCompositionAttributeData {
attribute: u32, // WCA_ACCENT_POLICY = 19
data: *mut AccentPolicy,
size_of_data: usize,
}
type SetWindowCompositionAttributeFn =
unsafe extern "system" fn(HWND, *mut WindowCompositionAttributeData) -> BOOL;
if let Ok(user32) = GetModuleHandleA(windows::core::s!("user32.dll")) {
if let Some(proc) = GetProcAddress(user32, windows::core::s!("SetWindowCompositionAttribute")) {
let set_wca: SetWindowCompositionAttributeFn = std::mem::transmute(proc);
let mut policy = if is_overlay {
AccentPolicy {
accent_state: 3, // ACCENT_ENABLE_BLURBEHIND
accent_flags: 2,
gradient_color: 0x99101014, // Translucent dark obsidian tint
animation_id: 0,
}
} else {
AccentPolicy {
accent_state: 2, // ACCENT_ENABLE_TRANSPARENTGRADIENT
accent_flags: 2,
gradient_color: 0x00000000, // Fully transparent
animation_id: 0,
}
};
let mut data = WindowCompositionAttributeData {
attribute: 19,
data: &mut policy,
size_of_data: std::mem::size_of::<AccentPolicy>(),
};
let _ = set_wca(hwnd, &mut data);
}
}
}
BOOL(1)
}
BOOL(1)
}
let _ = EnumWindows(Some(enum_proc), LPARAM(my_pid as isize));
let ctx_data = (my_pid, is_overlay);
let _ = EnumWindows(Some(enum_proc), LPARAM(&ctx_data as *const _ as isize));
}
}
@ -2419,6 +2498,13 @@ impl eframe::App for ShadowPlayToastApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
if !self.initial_setup {
if let Some(mon_size) = ctx.input(|i| i.viewport().monitor_size) {
let toast_w = 340.0;
let target_x = (mon_size.x - toast_w - 28.0).max(10.0);
let target_y = 28.0;
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(Vec2::new(toast_w, 64.0)));
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(egui::pos2(target_x, target_y)));
}
#[cfg(target_os = "windows")]
apply_windows_transparency("Scythe Notification");
ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true));
@ -2430,7 +2516,7 @@ impl eframe::App for ShadowPlayToastApp {
if elapsed >= total_dur {
crate::ipc::clean_toast_pid();
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
std::process::exit(0);
return;
}
ctx.request_repaint_after(Duration::from_millis(16));
@ -2605,7 +2691,7 @@ pub fn run_egui_toast(title: &str, subtitle: &str, icon: crate::overlay::ToastIc
// Watchdog thread to guarantee exit after duration
let watchdog_pid_path = toast_pid_path.clone();
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(3200));
std::thread::sleep(Duration::from_millis(5000));
let _ = std::fs::remove_file(watchdog_pid_path);
std::process::exit(0);
});

View file

@ -46,16 +46,20 @@ pub fn check_for_updates() -> Option<ReleaseInfo> {
let url = format!("https://api.github.com/repos/{}/releases/latest", GITHUB_REPO);
// Run curl with a 4-second timeout to avoid blocking
let output = std::process::Command::new("curl")
.args([
"-s",
"-H", "User-Agent: scythe-updater",
"-H", "Accept: application/vnd.github.v3+json",
"--max-time", "4",
&url,
])
.output()
.ok()?;
let mut cmd = std::process::Command::new("curl");
cmd.args([
"-s",
"-H", "User-Agent: scythe-updater",
"-H", "Accept: application/vnd.github.v3+json",
"--max-time", "4",
&url,
]);
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(0x08000000);
}
let output = cmd.output().ok()?;
if !output.status.success() {
return None;