diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c3b6532..d7563cb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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` diff --git a/Cargo.lock b/Cargo.lock index c9f0070..36f5922 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3700,7 +3700,7 @@ dependencies = [ [[package]] name = "scythe" -version = "0.2.7" +version = "0.2.8" dependencies = [ "ashpd", "cpal", diff --git a/Cargo.toml b/Cargo.toml index 1c78dc5..8162217 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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", ] } diff --git a/packaging/windows/scythe-installer.nsi b/packaging/windows/scythe-installer.nsi index 56b6093..f74291a 100644 --- a/packaging/windows/scythe-installer.nsi +++ b/packaging/windows/scythe-installer.nsi @@ -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" diff --git a/packaging/windows/scythe-standalone.nsi b/packaging/windows/scythe-standalone.nsi index df93487..8bd53a4 100644 --- a/packaging/windows/scythe-standalone.nsi +++ b/packaging/windows/scythe-standalone.nsi @@ -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\*.*" diff --git a/src/bin/scythe-daemon.rs b/src/bin/scythe-daemon.rs index 1a826f1..e054f96 100644 --- a/src/bin/scythe-daemon.rs +++ b/src/bin/scythe-daemon.rs @@ -74,18 +74,31 @@ async fn main() -> Result<(), Box> { #[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> { 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> { }); let _ = recorder_handle.join(); + #[cfg(windows)] + let _ = std::fs::remove_file(scythe::ipc::get_ipc_port_path()); Ok(()) } diff --git a/src/bin/scythe-ui.rs b/src/bin/scythe-ui.rs index ac577ed..aff4a58 100644 --- a/src/bin/scythe-ui.rs +++ b/src/bin/scythe-ui.rs @@ -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::() { + 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> { 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(()); } diff --git a/src/capture/windows.rs b/src/capture/windows.rs index b5533db..c03f73e 100644 --- a/src/capture/windows.rs +++ b/src/capture/windows.rs @@ -18,6 +18,7 @@ pub struct WindowsCapture { staging_texture: ID3D11Texture2D, pub width: u32, pub height: u32, + pub last_stride: u32, last_frame: Vec, } @@ -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::() { 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 = 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 = 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], }) } } diff --git a/src/encoder.rs b/src/encoder.rs index 8b3828d..59b22cd 100644 --- a/src/encoder.rs +++ b/src/encoder.rs @@ -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, String> { let mut packets = Vec::new(); + if !self.has_frame { + return Ok(packets); + } unsafe { if self.sw_frame.is_null() { return Ok(packets); diff --git a/src/hyprland_binds.rs b/src/hyprland_binds.rs index 23744e2..d500749 100644 --- a/src/hyprland_binds.rs +++ b/src/hyprland_binds.rs @@ -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 || { diff --git a/src/ipc.rs b/src/ipc.rs index bf9bb1c..4526cd1 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -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::() { + 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::() { + return port; + } + } + 42069 +} + +pub fn is_daemon_running() -> bool { + query_status().is_ok() +} + pub fn send_command(cmd: Command) -> Result<(), Box> { use std::io::Write; use std::time::Duration; @@ -101,25 +126,52 @@ pub fn send_command(cmd: Command) -> Result<(), Box { - 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::() { + 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); - } } + 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> { + 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::(&resp_payload)?; + Ok(status) +} + pub fn query_status() -> Result> { use std::io::{Read, Write}; use std::time::Duration; @@ -144,8 +196,18 @@ pub fn query_status() -> Result() { + 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 { diff --git a/src/overlay_egui.rs b/src/overlay_egui.rs index f83ba56..161419b 100644 --- a/src/overlay_egui.rs +++ b/src/overlay_egui.rs @@ -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, is_active: Arc 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>, 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::(), + }; + + 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); }); diff --git a/src/updater.rs b/src/updater.rs index 35523cb..843db35 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -46,16 +46,20 @@ pub fn check_for_updates() -> Option { 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;