背景

恋爱中难免会有短时间异地的情况出现,这种时候在做正事的时候,或者是相互协作之类的时候会希望能够尽可能多的看到对方。

于是就有了本程序,原本是打算做全设备的,理论上也是可以做到全设备,但是安卓开发咱也不熟,于是就暂时只做了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连接很容易失败。

对于此问题,通过显示一个置顶的不接收鼠标事件的全透明窗口来解决。

我能想到的,最大的成功就是无愧于自己的心。