背景
恋爱中难免会有短时间异地的情况出现,这种时候在做正事的时候,或者是相互协作之类的时候会希望能够尽可能多的看到对方。
于是就有了本程序,原本是打算做全设备的,理论上也是可以做到全设备,但是安卓开发咱也不熟,于是就暂时只做了Windows端。
在此之前,我已经开发并部署了一个Web的直播工具:

但是既然实在做事情,所以肯定不会盯着浏览器看,很多时候也是开一个小窗口,而且开页面然后点击分享屏幕太麻烦了,所以我打算开发这样的软件:
自动小窗弹出对方的直播画面
运行期间自动推送本设备的直播画面
WebRTC方面本文不再赘述,本文主要侧重于桌面客户端开发中的关键点。
技术选型
因为使用了WebRTC、信令交换之类的我不想再写一遍,而且编写的时候界面和直播相关的钩子已经完全解耦了,所以桌面客户端完全使用Web套壳来做。
为了尽可能轻量,采用Tauri而不是Electron来套壳。
在功能划分上,一切与直播相关的东西都不在客户端中,而是随着先前已有的Web,这样已经有的直播钩子不用另外复制到新项目中,桌面客户端项目只负责管理窗口,提供执行环境。
大致实现

其中只有主窗口的代码在客户端中。
而之所以直播管理器没有直接创建直播窗口,是因为主窗口保存了偏好设置,这部分代码不想放进服务器中。
这样就简简单单地完成了,接下来是一些技术细节。
录屏的问题
Tauri用的WebView是微软的EdgeWebView2,在录屏上它表现的很想Edge浏览器,会弹一个不可自定义的窗口,询问要录制的屏幕和窗口,并且开始后还会弹出一个不可以关闭的小条幅,提示正在进行屏幕录制。
对于询问用户这一点,在询问时需要窗口是可见的,用户才能操作,所以在询问之前要先让窗口可见,我的直播管理器页面是空白的,它里面只有JS,显示出来很不美观。所以我把窗口设为透明,页面也透明,窗口置顶无标题栏不可最小化,尺寸比较小,这样看上去就只有录屏的提示了。
对于不可以关闭的小条幅这一点,我选择Hack,找WebView进程,找它的窗口,将这个窗口干掉(隐藏)。
具体RUST代码:
#[tauri::command]
fn hide_recording_prompt(_app: tauri::AppHandle) -> bool {
#[cfg(target_os = "windows")]
{
use std::collections::{HashMap, HashSet};
use windows::Win32::Foundation::CloseHandle;
use windows::Win32::Foundation::{BOOL, HWND, LPARAM};
use windows::Win32::System::Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, Process32First, Process32Next, PROCESSENTRY32,
TH32CS_SNAPPROCESS,
};
use windows::Win32::System::Threading::GetCurrentProcessId;
use windows::Win32::UI::WindowsAndMessaging::{
EnumWindows, GetClassNameW, GetWindowTextW, GetWindowThreadProcessId, ShowWindow,
SW_HIDE,
};
struct Ctx {
target_pids: Vec<u32>,
hidden_any: bool,
}
extern "system" fn enum_proc(hwnd: HWND, lparam: LPARAM) -> BOOL {
let ctx = unsafe { &mut *(lparam.0 as *mut Ctx) };
let mut pid: u32 = 0;
unsafe { GetWindowThreadProcessId(hwnd, Some(&mut pid as *mut u32)) };
if ctx.target_pids.iter().any(|p| *p == pid) {
let mut class_buf = [0u16; 256];
let class_len = unsafe { GetClassNameW(hwnd, &mut class_buf) };
let class = String::from_utf16_lossy(&class_buf[..class_len as usize]);
let mut title_buf = [0u16; 256];
let title_len = unsafe { GetWindowTextW(hwnd, &mut title_buf) };
let title = String::from_utf16_lossy(&title_buf[..title_len as usize]);
// println!(
// "枚举窗口: hwnd={:p}, pid={}, tid={}, class='{}'(len={}), title='{}'(len={})",
// hwnd.0, pid, tid, class, class_len, title, title_len
// );
if class.starts_with("Chrome_WidgetWin") && title.contains("正在共享") {
unsafe {
let _ = ShowWindow(hwnd, SW_HIDE);
}
println!(
"隐藏窗口: hwnd={:p}, class='{}', title='{}'",
hwnd.0, class, title
);
ctx.hidden_any = true;
}
}
BOOL(1)
}
let current_pid = unsafe { GetCurrentProcessId() };
let mut entries: Vec<PROCESSENTRY32> = Vec::new();
match unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) } {
Ok(handle) => {
let mut pe32 = PROCESSENTRY32 {
dwSize: std::mem::size_of::<PROCESSENTRY32>() as u32,
..Default::default()
};
let mut ok = unsafe { Process32First(handle, &mut pe32).is_ok() };
while ok {
entries.push(pe32.clone());
ok = unsafe { Process32Next(handle, &mut pe32).is_ok() };
}
let _ = unsafe { CloseHandle(handle) };
}
Err(err) => {
println!("CreateToolhelp32Snapshot 失败: {:?}", err);
}
}
let mut by_pid: HashMap<u32, (u32, String)> = HashMap::new();
for e in &entries {
let name = unsafe {
std::ffi::CStr::from_ptr(e.szExeFile.as_ptr())
.to_string_lossy()
.into_owned()
};
by_pid.insert(e.th32ProcessID, (e.th32ParentProcessID, name));
}
let is_descendant = |pid: u32| -> bool {
let mut seen: HashSet<u32> = HashSet::new();
let mut p = pid;
while let Some((ppid, _)) = by_pid.get(&p) {
if *ppid == current_pid {
return true;
}
if seen.contains(&p) {
break;
}
seen.insert(p);
p = *ppid;
if p == 0 {
break;
}
}
false
};
let mut target_pids: Vec<u32> = Vec::new();
for (pid, (_ppid, name)) in &by_pid {
if name.eq_ignore_ascii_case("msedgewebview2.exe") && is_descendant(*pid) {
target_pids.push(*pid);
}
}
println!("目标 msedgewebview2 进程: {:?}", target_pids);
let ctx = Box::new(Ctx {
target_pids,
hidden_any: false,
});
let ctx_ptr = Box::into_raw(ctx);
unsafe {
let _ = EnumWindows(Some(enum_proc), LPARAM(ctx_ptr as isize));
}
let ctx = unsafe { Box::from_raw(ctx_ptr) };
ctx.hidden_any
}
#[cfg(not(target_os = "windows"))]
{
false
}
}被优化导致的异常问题
不知道和录屏窗口被隐藏导致程序没有可见窗口是不是有关系,具体表现就是,当进程完全没有窗口时,CPU占用率会特别高、WebRTC连接很容易失败。
对于此问题,通过显示一个置顶的不接收鼠标事件的全透明窗口来解决。