From e25888c95ab24f2d61bc053a39effa9d75b5006c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 5 Nov 2026 08:31:32 -0800 Subject: [PATCH 1/8] chore(quest): claim quest/m0/cef-headless.md Quest-Claim: e5278f61bf7ea322 From a9b9e1b464f4792bff79cb724c1027ededa01cb7 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 5 Nov 2026 09:08:55 -0800 Subject: [PATCH 2/8] feat(scene): browser sources on the CPU path need no X display The CPU path runs CEF on Chromium's headless Ozone platform, so it opens no display: browser sources work under Wayland without XWayland and with no display at all, and just render-sw drops xvfb-run. Copy and Cut under Interact ask the focused frame's renderer for its selection, never a password field's, and the studio puts it on the desktop's clipboard, which CEF's own reaches only through a display. The GPU path cannot go headless (no DMA-BUF pixmaps there), so the quest is rewritten to what remains for it. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- justfile | 4 +- quest/m0/README.md | 2 +- quest/m0/cef-headless.md | 44 ++++---- rs/moq-scene/src/browser/host.rs | 155 +++++++++++++++++++++++---- rs/moq-scene/src/browser/mod.rs | 11 +- rs/moq-scene/src/browser/protocol.rs | 8 +- rs/moq-scene/src/browser/run.rs | 6 ++ rs/moq-studio/src/interact.rs | 3 +- rs/moq-studio/src/ui.rs | 7 ++ rs/moq-studio/tests/browser.rs | 82 ++++++++++++++ 11 files changed, 278 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 78575890..cd18b133 100644 --- a/README.md +++ b/README.md @@ -756,7 +756,9 @@ paints, given the same Vulkan DMA-BUF import. Every other source, every source on Windows or on a GPU that cannot import, and every still image delivers its frames from the CPU, which the compositor uploads. A browser source on that path renders its page in software, WebGL included, so a WebGL page runs -slower there. +slower there, and needs no X server or `$DISPLAY`: it runs under Wayland +without XWayland, and with no display at all. A page copied on the GPU still +opens the X display. ## Replay diff --git a/justfile b/justfile index ef0a2661..8f69f8fb 100644 --- a/justfile +++ b/justfile @@ -56,9 +56,7 @@ render-sw: export LD_LIBRARY_PATH="$loader/lib" VK_DRIVER_FILES="${icd[0]}" cargo nextest run --locked -p moq-scene --lib --run-ignored only -E 'test(/^compositor::tests::/) | test(/^browser::layers::tests::/)' cargo nextest run --locked -p moq-studio --features openh264 --run-ignored only -E 'test(/^engine::tests::the_program_/) | test(=setup::tests::times_every_canvas)' - # CEF's browser process opens an X display even to paint offscreen. - xvfb=$(nix build --inputs-from . --no-link --print-out-paths nixpkgs#xvfb-run) - "$xvfb/bin/xvfb-run" -a cargo nextest run --locked -p moq-studio --features cef --run-ignored only -E 'binary(browser)' + cargo nextest run --locked -p moq-studio --features cef --run-ignored only -E 'binary(browser)' # Move the moq submodule to moq's latest main (or a given commit) and refresh Cargo.lock and bun.lock. upgrade-moq rev="origin/main": diff --git a/quest/m0/README.md b/quest/m0/README.md index 2f80f7b6..a4874584 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -102,7 +102,7 @@ repository. - [Web studio](/quest/m0/web.md) - the full studio in wasm on WebGPU and WebCodecs, served from this repository - [Virtual camera](/quest/m0/vcam/README.md) - a canvas's program exposed as a system camera on Linux, Windows, and macOS - [Recover crashed recordings](/quest/m0/recording-repair.md) - a recording a crash left unfinished is repaired beside the original at launch -- [Browser sources without an X display](/quest/m0/cef-headless.md) - CEF runs headless, so Wayland sessions and tests need no X server +- [Browser sources on the GPU without an X display](/quest/m0/cef-headless.md) - shared-texture pages run under Wayland without XWayland, and with no display the CPU path runs instead - [Signing identities are available](/quest/m0/signing.md) - the Apple and Windows code-signing credentials packaging and the macOS camera need - [Packaging](/quest/m0/packaging.md) - signed installers for macOS and Windows and a Flatpak for Linux - [The persistable portal restore token is pinned](/quest/m0/pin-portal-token.md) - moq-video's restore token, for Wayland sources diff --git a/quest/m0/cef-headless.md b/quest/m0/cef-headless.md index 01340846..fac245e4 100644 --- a/quest/m0/cef-headless.md +++ b/quest/m0/cef-headless.md @@ -1,28 +1,34 @@ -# [S] Browser sources without an X display +# [S] Browser sources on the GPU without an X display ## Goal -Browser sources run with no X server or `$DISPLAY` on both the CPU path and -the GPU (shared DMA-BUF texture) path, so the studio's CEF helper works under a -pure Wayland session without XWayland and `just render-sw` no longer needs -`xvfb-run`. Copy and paste under Interact still reach the desktop clipboard. +Browser sources copied on the GPU (shared DMA-BUF textures) run under a pure +Wayland session without XWayland, and a studio with no display at all still +runs its browser sources, as the CPU path already does. ## Plan From [moq-dev/studio#49](https://github.com/moq-dev/studio/pull/49), picked -2026-11-05 (maintainer, /quest-plan), m0 by the rule that fixes join m0. With -`--disable-gpu` alone CEF still fails with "Missing X server or $DISPLAY"; a -trial adding `--ozone-platform=headless` on the CPU path passed 10/10 runs -with no display at about 0.4 s, and the browser suite matched Xvfb. +2026-11-05 (maintainer, /quest-plan), m0 by the rule that fixes join m0. +[moq-dev/studio#55](https://github.com/moq-dev/studio/pull/55) moved the CPU +path to Chromium's headless Ozone platform and relayed Copy and Cut through +the studio's clipboard, so only the GPU path still opens an X display. -Check first, and record the result in the PR: -- Whether Copy and Cut under Interact still reach the desktop clipboard with - the headless platform; if not, the studio's own clipboard (egui) relays - them. -- Whether the GPU path can go headless too. If it cannot, land the CPU path - and rewrite this quest to the GPU path's remaining work, with what blocks - it. +The GPU path cannot go headless: Chromium's headless Ozone platform allocates +`TestPixmap`s with no DMA-BUF +(`ui/ozone/platform/headless/headless_surface_factory.cc`), and in a trial its +GPU process failed to start EGL without a display, so pages fell back to +painting into memory (`Paint::Cpu`). -Then drop the `xvfb-run` wrapper from the `render-sw` recipe in the -`justfile` (the nightly job inherits it through the recipe), and say in -`README.md` that browser sources need no X server. +A trial on NixOS (RTX 3070 Ti, NVIDIA 580.178, GNOME on Wayland) on +2026-11-05 found `--ozone-platform=wayland`, with only `WAYLAND_DISPLAY` set, +painted shared textures (`Paint::Gpu`), and `a_page_draws_and_plays` passed. +On the same host the GPU path under XWayland (the default X11 Ozone platform, +`DISPLAY=:0`) never painted: "Can't create buffer -- gbm device is missing". + +Recommended: pick the platform at setup in `Helper::new`, Wayland when +`WAYLAND_DISPLAY` is set, X11 when only `DISPLAY` is, and the CPU path when +neither is, which the Sources panel already shows as CPU. Check popups, +input, and the shared textures on Mesa (Intel or AMD) too, since the trial +covered only NVIDIA. No CI job has a GPU, so the PR records how it was +verified. diff --git a/rs/moq-scene/src/browser/host.rs b/rs/moq-scene/src/browser/host.rs index 45c34697..c37b8510 100644 --- a/rs/moq-scene/src/browser/host.rs +++ b/rs/moq-scene/src/browser/host.rs @@ -7,7 +7,7 @@ use std::collections::HashMap; use std::os::fd::BorrowedFd; use std::os::unix::net::UnixStream; use std::process::ExitCode; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, mpsc}; use cef::*; @@ -17,11 +17,18 @@ use super::outbox::Commands; use super::protocol::{Area, Channel, Command, Event, Format, Id, Layer, Open, Plane, Raster, Start, Texture}; use super::{Button, Input, Modifiers}; +/// What the helper asks of a page's focused frame: its selection, and whether +/// to cut it. +const COPY: &str = "moq-studio-copy"; +/// What the frame's renderer answers: the text it copied. +const COPIED: &str = "moq-studio-copied"; + /// Run one of CEF's subprocesses (renderer, GPU, utility, zygote). pub(super) fn subprocess() -> ExitCode { let _ = api_hash(sys::CEF_API_VERSION_LAST, 0); let args = args::Args::new(); - let code = execute_process(Some(args.as_main_args()), None, std::ptr::null_mut()); + let mut app = RendererApp::new(RendererCopy::new()); + let code = execute_process(Some(args.as_main_args()), Some(&mut app), std::ptr::null_mut()); ExitCode::from(code.clamp(0, 255) as u8) } @@ -66,6 +73,9 @@ struct Paged { shared: Arc, id: Id, open: Open, + /// The copies asked of its renderers and not yet answered, so a renderer + /// sets the studio's clipboard only when asked. + copies: AtomicUsize, } impl Paged { @@ -76,7 +86,7 @@ impl Paged { thread_local! { /// The open pages, on CEF's UI thread, where input is delivered. - static PAGES: RefCell> = RefCell::new(HashMap::new()); + static PAGES: RefCell)>> = RefCell::new(HashMap::new()); /// Whether the studio is gone, so the helper quits once its last page /// closes. static QUITTING: Cell = const { Cell::new(false) }; @@ -257,8 +267,14 @@ fn open(shared: Arc, id: Id, open: Open) { ..Default::default() }; let url = CefString::from(open.url.as_str()); - let paged = Arc::new(Paged { shared, id, open }); + let paged = Arc::new(Paged { + shared, + id, + open, + copies: AtomicUsize::new(0), + }); let mut client = HelperClient::new( + paged.clone(), HelperRender::new(paged.clone()), HelperAudio::new(paged.clone()), HelperLoad::new(paged.clone()), @@ -273,7 +289,7 @@ fn open(shared: Arc, id: Id, open: Open) { None, None, ) { - Some(browser) => _ = PAGES.with_borrow_mut(|pages| pages.insert(id, browser)), + Some(browser) => _ = PAGES.with_borrow_mut(|pages| pages.insert(id, (browser, paged))), None => paged.fail("CEF failed to create the page".into()), } } @@ -286,7 +302,7 @@ fn close(id: Id) { /// Run `f` on page `id`'s host, if the page is open. Runs on the UI thread. fn host(id: Id, f: impl FnOnce(BrowserHost)) { - if let Some(host) = PAGES.with_borrow(|pages| pages.get(&id).and_then(|browser| browser.host())) { + if let Some(host) = PAGES.with_borrow(|pages| pages.get(&id).and_then(|(browser, _)| browser.host())) { f(host); } } @@ -300,7 +316,7 @@ fn retime(id: Id, rate: u32) { /// Hand one input event to page `id`. Runs on the UI thread. fn deliver(id: Id, input: Input) { - let Some(browser) = PAGES.with_borrow(|pages| pages.get(&id).cloned()) else { + let Some((browser, page)) = PAGES.with_borrow(|pages| pages.get(&id).cloned()) else { return; }; let Some(host) = browser.host() else { @@ -366,22 +382,27 @@ fn deliver(id: Id, input: Input) { host.send_key_event(Some(&event)); } } - // The focused frame holds the selection, which may be in an - // iframe. - Input::Copy => { - if let Some(frame) = browser.focused_frame() { - frame.copy(); - } - } - Input::Cut => { - if let Some(frame) = browser.focused_frame() { - frame.cut(); - } - } + Input::Copy => copy(&browser, &page, false), + Input::Cut => copy(&browser, &page, true), Input::Focus(focused) => host.set_focus(focused.into()), } } +/// Ask the page's focused frame, which may be an iframe, for its selection, +/// and to cut it if `cut`. Its renderer answers, and the studio puts what it +/// copied on the desktop's clipboard: CEF's own reaches that only through a +/// display, which the CPU path never opens. +fn copy(browser: &Browser, page: &Paged, cut: bool) { + let (Some(frame), Some(mut message)) = (browser.focused_frame(), process_message_create(Some(©.into()))) else { + return; + }; + if let Some(args) = message.argument_list() { + args.set_bool(0, cut.into()); + } + page.copies.fetch_add(1, Ordering::Relaxed); + frame.send_process_message(ProcessId::RENDERER, Some(&mut message)); +} + fn flags(modifiers: Modifiers) -> u32 { use sys::cef_event_flags_t as F; [ @@ -458,9 +479,14 @@ wrap_app! { // WebGL then needs SwiftShader, which Chromium runs only when // told to, since it JITs page shaders in the GPU process. A // browser source runs the pages its user picked, not the web. + // + // Painting offscreen in software needs no display either, + // so none is opened, which lets the helper run with no X + // server, as under Wayland without XWayland. Raster::Cpu => { command_line.append_switch(Some(&"disable-gpu".into())); command_line.append_switch(Some(&"enable-unsafe-swiftshader".into())); + command_line.append_switch_with_value(Some(&"ozone-platform".into()), Some(&"headless".into())); } } } @@ -483,8 +509,77 @@ wrap_browser_process_handler! { } } +wrap_app! { + struct RendererApp { + render: RenderProcessHandler, + } + + impl App { + fn render_process_handler(&self) -> Option { + Some(self.render.clone()) + } + } +} + +wrap_render_process_handler! { + struct RendererCopy; + + impl RenderProcessHandler { + fn on_process_message_received( + &self, + _browser: Option<&mut Browser>, + frame: Option<&mut Frame>, + source_process: ProcessId, + message: Option<&mut ProcessMessage>, + ) -> ::std::os::raw::c_int { + let (Some(frame), Some(message)) = (frame, message) else { + return 0; + }; + if source_process != ProcessId::BROWSER || CefString::from(&message.name()).to_string() != COPY { + return 0; + } + let cut = message.argument_list().is_some_and(|args| args.bool(0) != 0); + let copied = Arc::new(Mutex::new(String::new())); + frame.visit_dom(Some(&mut SelectionReader::new(copied.clone()))); + let Some(mut answer) = process_message_create(Some(&COPIED.into())) else { + return 1; + }; + if let Some(args) = answer.argument_list() { + args.set_string(0, Some(&copied.lock().unwrap().as_str().into())); + } + frame.send_process_message(ProcessId::BROWSER, Some(&mut answer)); + // After reading the selection, which cutting removes. + if cut { + frame.cut(); + } + 1 + } + } +} + +wrap_domvisitor! { + struct SelectionReader { + text: Arc>, + } + + impl Domvisitor { + fn visit(&self, document: Option<&mut Domdocument>) { + let Some(document) = document else { return }; + // A browser never copies a password field's text. + let password = document.focused_node().is_some_and(|node| { + node.is_form_control_element() != 0 + && node.form_control_element_type() == DomFormControlType::INPUT_PASSWORD + }); + if !password { + *self.text.lock().unwrap() = CefString::from(&document.selection_as_text()).to_string(); + } + } + } +} + wrap_client! { struct HelperClient { + page: Arc, render: RenderHandler, audio: AudioHandler, load: LoadHandler, @@ -512,6 +607,28 @@ wrap_client! { fn life_span_handler(&self) -> Option { Some(self.life.clone()) } + + fn on_process_message_received( + &self, + _browser: Option<&mut Browser>, + _frame: Option<&mut Frame>, + source_process: ProcessId, + message: Option<&mut ProcessMessage>, + ) -> ::std::os::raw::c_int { + let Some(message) = message else { return 0 }; + if source_process != ProcessId::RENDERER || CefString::from(&message.name()).to_string() != COPIED { + return 0; + } + // One the helper never asked for is dropped. + let asked = self.page.copies.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |copies| copies.checked_sub(1)); + let text = message.argument_list().map(|args| CefString::from(&args.string(0)).to_string()); + if let (Ok(_), Some(text)) = (asked, text) + && !text.is_empty() + { + self.page.shared.send(&Event::Copied(self.page.id, text), &[]); + } + 1 + } } } diff --git a/rs/moq-scene/src/browser/mod.rs b/rs/moq-scene/src/browser/mod.rs index 560ab07a..ed79651b 100644 --- a/rs/moq-scene/src/browser/mod.rs +++ b/rs/moq-scene/src/browser/mod.rs @@ -188,6 +188,8 @@ struct PageState { /// Whether the helper has yet to hear `framerate`. #[cfg(target_os = "linux")] retime: bool, + /// What the page last copied or cut, until the studio takes it. + copied: Option, paint: Option, } @@ -237,6 +239,11 @@ impl Page { self.state.lock().unwrap().paint } + /// Take what the page last copied or cut, once, for the clipboard. + pub fn copied(&self) -> Option { + self.state.lock().unwrap().copied.take() + } + /// Paint at `rate`, the canvas's frame rate, from now on, without waiting on /// the helper. One that is behind hears it once it catches up. pub(crate) fn set_framerate(&self, rate: u32) { @@ -301,9 +308,9 @@ pub enum Input { }, /// Text typed, after the keyboard layout and any input method. Text(String), - /// Copy the page's selection to the clipboard. + /// Copy the page's selection, which [`Page::copied`] then returns. Copy, - /// Cut the page's selection to the clipboard. + /// Cut the page's selection, which [`Page::copied`] then returns. Cut, /// The page gained or lost keyboard focus. Focus(bool), diff --git a/rs/moq-scene/src/browser/protocol.rs b/rs/moq-scene/src/browser/protocol.rs index dfacf282..7d089b73 100644 --- a/rs/moq-scene/src/browser/protocol.rs +++ b/rs/moq-scene/src/browser/protocol.rs @@ -62,8 +62,9 @@ pub(crate) enum Raster { /// On the GPU, into shared textures the studio imports: on the DRM render /// node named, the studio's GPU, or else on the one CEF picks. Gpu { render_node: Option }, - /// In software, into memory the studio uploads. Startup never waits on a - /// GPU process CEF cannot start, and WebGL runs on SwiftShader. + /// In software, into memory the studio uploads, with no display opened. + /// Startup never waits on a GPU process CEF cannot start, and WebGL runs + /// on SwiftShader. #[default] Cpu, } @@ -103,6 +104,9 @@ pub(crate) enum Event { Popup(Id, Option), /// Interleaved samples at the [`Open`] rate and channel count. Audio(Id, Vec), + /// The text a [`Input::Copy`] or [`Input::Cut`] took from a page, for the + /// studio's clipboard. + Copied(Id, String), /// A page cannot be shown, and why. Failed(Id, String), /// No page can be shown, and why. The helper exits next. diff --git a/rs/moq-scene/src/browser/run.rs b/rs/moq-scene/src/browser/run.rs index 27e20f78..d56124fd 100644 --- a/rs/moq-scene/src/browser/run.rs +++ b/rs/moq-scene/src/browser/run.rs @@ -454,6 +454,12 @@ fn deliver(gpu: &Gpu, events: &Channel, commands: &Commands, routes: &Mutex { + if let Some(route) = routes.lock().unwrap().pages.get(&id) { + route.page.state.lock().unwrap().copied = Some(text); + } + continue; + } Event::Failed(id, reason) => { routes.lock().unwrap().end(id, reason); continue; diff --git a/rs/moq-studio/src/interact.rs b/rs/moq-studio/src/interact.rs index 55ee8673..02e6aa3a 100644 --- a/rs/moq-studio/src/interact.rs +++ b/rs/moq-studio/src/interact.rs @@ -300,7 +300,8 @@ impl Interact { } } // egui swallows the copy and cut shortcuts too, which go to - // the page as commands: only it knows its selection. + // the page as commands: only it knows its selection, which + // comes back for the clipboard. egui::Event::Copy => { if let Some(page) = &self.focused { send(page, Input::Copy); diff --git a/rs/moq-studio/src/ui.rs b/rs/moq-studio/src/ui.rs index f548f3de..65eaab44 100644 --- a/rs/moq-studio/src/ui.rs +++ b/rs/moq-studio/src/ui.rs @@ -840,6 +840,13 @@ impl Panels { .update(ui, image, state.collection(), self.view.as_deref(), scene); deliver(&state.sources, delivered); self.rebind(&state.sources); + // A page's copy reaches the desktop's clipboard through the + // studio's own. + for page in self.bound.values() { + if let Some(text) = page.copied() { + ui.ctx().copy_text(text); + } + } return; } let response = picture(ui, self.preview.id, canvas, egui::Sense::click_and_drag()); diff --git a/rs/moq-studio/tests/browser.rs b/rs/moq-studio/tests/browser.rs index 124bcd48..27c3eeaf 100644 --- a/rs/moq-studio/tests/browser.rs +++ b/rs/moq-studio/tests/browser.rs @@ -325,6 +325,88 @@ async fn a_dropdown_draws_and_takes_clicks() { .await; } +/// Copy and Cut hand the page's selection to the studio for its clipboard, +/// and Cut deletes it from the page. A password field's text is never taken. +#[tokio::test(flavor = "multi_thread")] +#[ignore = "needs CEF and a Vulkan device; run by just render-sw"] +async fn copy_and_cut_reach_the_clipboard() { + local(async { + // A text box over the whole page, and a password field below it. The + // page turns blue once the password is selected, white once it is + // typed in, and lime once the text box is. + let html = "\ + \ + "; + let mut studio = Studio::new(&[("page", page(html))]).await; + studio.until_center([255, 0, 0, 255], &[]).await; + + let page = studio.sources.get("page").unwrap().page().unwrap().clone(); + let key = |code: u8, modifiers: Modifiers| { + for down in [true, false] { + page.send(Input::Key { + code: code.into(), + down, + modifiers, + }); + } + }; + let ctrl = Modifiers { + ctrl: true, + ..Default::default() + }; + let shift = Modifiers { + shift: true, + ..Default::default() + }; + // The selection reaches the helper after the keys that make it. + let copy = async || { + let deadline = Instant::now() + TIMEOUT; + loop { + page.send(Input::Copy); + kio::clock::sleep(Duration::from_millis(30)).await; + if let Some(copied) = page.copied() { + return copied; + } + assert!(Instant::now() < deadline, "the copy never came back"); + } + }; + + page.send(Input::Focus(true)); + for down in [true, false] { + page.send(Input::Button { + x: 160, + y: 120, + button: Button::Left, + down, + clicks: 1, + modifiers: Modifiers::default(), + }); + } + key(b'A', ctrl); + assert_eq!(copy().await, "hello world"); + + // Text typed after the copy shows only once the copy has come back. + key(b'\t', Modifiers::default()); + key(b'A', ctrl); + studio.until_center(BLUE, &[]).await; + page.send(Input::Copy); + page.send(Input::Text("x".into())); + studio.until_center([255, 255, 255, 255], &[]).await; + assert_eq!(page.copied(), None, "a password was copied"); + + key(b'\t', shift); + key(b'A', ctrl); + assert_eq!(copy().await, "hello world"); + page.send(Input::Cut); + studio.until_center(GREEN, &[]).await; + assert_eq!(page.copied().as_deref(), Some("hello world")); + }) + .await; +} + /// A page that closes itself fails its own source and leaves the canvas, /// while the page beside it in the same helper keeps drawing. #[tokio::test(flavor = "multi_thread")] From bf9f9128e1ac14b535c951dd745bf3ae97692dcb Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 5 Nov 2026 09:22:15 -0800 Subject: [PATCH 3/8] fix(studio): a page's copy reaches the clipboard after the preview lets go Co-Authored-By: Claude Opus 5.5 --- rs/moq-studio/src/ui.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/rs/moq-studio/src/ui.rs b/rs/moq-studio/src/ui.rs index 65eaab44..448ab555 100644 --- a/rs/moq-studio/src/ui.rs +++ b/rs/moq-studio/src/ui.rs @@ -195,6 +195,13 @@ impl eframe::App for Studio { let framerate = state.compositor.canvas().framerate; ui.ctx().request_repaint_after(Duration::from_secs(1) / framerate); self.panels.follow_reloads(&state); + // A page's copy reaches the desktop's clipboard through the studio's + // own, even if the preview let go of the page before it came back. + for (_, feed) in state.sources.iter() { + if let Some(text) = feed.page().and_then(|page| page.copied()) { + ui.ctx().copy_text(text); + } + } drop(state); self.panels.shortcuts(ui.ctx()); @@ -840,13 +847,6 @@ impl Panels { .update(ui, image, state.collection(), self.view.as_deref(), scene); deliver(&state.sources, delivered); self.rebind(&state.sources); - // A page's copy reaches the desktop's clipboard through the - // studio's own. - for page in self.bound.values() { - if let Some(text) = page.copied() { - ui.ctx().copy_text(text); - } - } return; } let response = picture(ui, self.preview.id, canvas, egui::Sense::click_and_drag()); From a52429036196cc45ebed0fcccf1fb8e9120c3e3e Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 5 Nov 2026 09:33:18 -0800 Subject: [PATCH 4/8] test(studio): copy only once the page shows its selection Co-Authored-By: Claude Opus 5.5 --- rs/moq-studio/tests/browser.rs | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/rs/moq-studio/tests/browser.rs b/rs/moq-studio/tests/browser.rs index 27c3eeaf..fc0a8427 100644 --- a/rs/moq-studio/tests/browser.rs +++ b/rs/moq-studio/tests/browser.rs @@ -332,10 +332,11 @@ async fn a_dropdown_draws_and_takes_clicks() { async fn copy_and_cut_reach_the_clipboard() { local(async { // A text box over the whole page, and a password field below it. The - // page turns blue once the password is selected, white once it is - // typed in, and lime once the text box is. + // page turns yellow once the text box is selected, lime once it is + // typed in, and blue and white for the same on the password. let html = "\ \ Date: Thu, 5 Nov 2026 09:34:59 -0800 Subject: [PATCH 5/8] fix(scene): the GPU path keeps CEF's native copy and cut With a display, CEF's clipboard is the desktop's, and the page's copy and cut handlers decide what lands there; only the CPU path relays its selection. Co-Authored-By: Claude Opus 5.5 --- rs/moq-scene/src/browser/host.rs | 20 +++++++++++++++----- rs/moq-scene/src/browser/mod.rs | 10 +++++++--- rs/moq-studio/src/interact.rs | 3 +-- rs/moq-studio/src/ui.rs | 4 ++-- 4 files changed, 25 insertions(+), 12 deletions(-) diff --git a/rs/moq-scene/src/browser/host.rs b/rs/moq-scene/src/browser/host.rs index c37b8510..83e0304c 100644 --- a/rs/moq-scene/src/browser/host.rs +++ b/rs/moq-scene/src/browser/host.rs @@ -388,12 +388,22 @@ fn deliver(id: Id, input: Input) { } } -/// Ask the page's focused frame, which may be an iframe, for its selection, -/// and to cut it if `cut`. Its renderer answers, and the studio puts what it -/// copied on the desktop's clipboard: CEF's own reaches that only through a -/// display, which the CPU path never opens. +/// Copy, or cut if `cut`, the selection in the page's focused frame, which +/// may be an iframe, to the desktop's clipboard. fn copy(browser: &Browser, page: &Paged, cut: bool) { - let (Some(frame), Some(mut message)) = (browser.focused_frame(), process_message_create(Some(©.into()))) else { + let Some(frame) = browser.focused_frame() else { return }; + // The GPU path opens a display, whose clipboard CEF's own is, and the + // page's copy and cut handlers decide what lands there. + if page.shared.accelerated { + match cut { + true => frame.cut(), + false => frame.copy(), + } + return; + } + // The CPU path opens none, so the frame's renderer answers with its + // selection, and the studio puts that on the desktop's clipboard. + let Some(mut message) = process_message_create(Some(©.into())) else { return; }; if let Some(args) = message.argument_list() { diff --git a/rs/moq-scene/src/browser/mod.rs b/rs/moq-scene/src/browser/mod.rs index ed79651b..723576bf 100644 --- a/rs/moq-scene/src/browser/mod.rs +++ b/rs/moq-scene/src/browser/mod.rs @@ -239,7 +239,9 @@ impl Page { self.state.lock().unwrap().paint } - /// Take what the page last copied or cut, once, for the clipboard. + /// Take what the page last copied or cut, once, for the desktop's + /// clipboard: a page that paints in memory has no display to put it there + /// itself. pub fn copied(&self) -> Option { self.state.lock().unwrap().copied.take() } @@ -308,9 +310,11 @@ pub enum Input { }, /// Text typed, after the keyboard layout and any input method. Text(String), - /// Copy the page's selection, which [`Page::copied`] then returns. + /// Copy the page's selection to the clipboard, through [`Page::copied`] + /// when it paints in memory. Copy, - /// Cut the page's selection, which [`Page::copied`] then returns. + /// Cut the page's selection to the clipboard, through [`Page::copied`] + /// when it paints in memory. Cut, /// The page gained or lost keyboard focus. Focus(bool), diff --git a/rs/moq-studio/src/interact.rs b/rs/moq-studio/src/interact.rs index 02e6aa3a..55ee8673 100644 --- a/rs/moq-studio/src/interact.rs +++ b/rs/moq-studio/src/interact.rs @@ -300,8 +300,7 @@ impl Interact { } } // egui swallows the copy and cut shortcuts too, which go to - // the page as commands: only it knows its selection, which - // comes back for the clipboard. + // the page as commands: only it knows its selection. egui::Event::Copy => { if let Some(page) = &self.focused { send(page, Input::Copy); diff --git a/rs/moq-studio/src/ui.rs b/rs/moq-studio/src/ui.rs index ae8becff..5ddd6ddd 100644 --- a/rs/moq-studio/src/ui.rs +++ b/rs/moq-studio/src/ui.rs @@ -195,8 +195,8 @@ impl eframe::App for Studio { let framerate = state.compositor.canvas().framerate; ui.ctx().request_repaint_after(Duration::from_secs(1) / framerate); self.panels.follow_reloads(&state); - // A page's copy reaches the desktop's clipboard through the studio's - // own, even if the preview let go of the page before it came back. + // A page painting in memory copies through the studio's clipboard, + // even if the preview let go of the page before the copy came back. for (_, feed) in state.sources.iter() { if let Some(text) = feed.page().and_then(|page| page.copied()) { ui.ctx().copy_text(text); From 7d72e6e5ee85e0459ea6fb91bbc3ce1282b91e8f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 5 Nov 2026 10:12:47 -0800 Subject: [PATCH 6/8] fix(scene): a cut that deletes nothing copies nothing on the CPU path Co-Authored-By: Claude Opus 5.5 --- rs/moq-scene/src/browser/host.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/rs/moq-scene/src/browser/host.rs b/rs/moq-scene/src/browser/host.rs index 83e0304c..38163041 100644 --- a/rs/moq-scene/src/browser/host.rs +++ b/rs/moq-scene/src/browser/host.rs @@ -550,7 +550,7 @@ wrap_render_process_handler! { } let cut = message.argument_list().is_some_and(|args| args.bool(0) != 0); let copied = Arc::new(Mutex::new(String::new())); - frame.visit_dom(Some(&mut SelectionReader::new(copied.clone()))); + frame.visit_dom(Some(&mut SelectionReader::new(cut, copied.clone()))); let Some(mut answer) = process_message_create(Some(&COPIED.into())) else { return 1; }; @@ -569,18 +569,22 @@ wrap_render_process_handler! { wrap_domvisitor! { struct SelectionReader { + cut: bool, text: Arc>, } impl Domvisitor { fn visit(&self, document: Option<&mut Domdocument>) { let Some(document) = document else { return }; - // A browser never copies a password field's text. - let password = document.focused_node().is_some_and(|node| { + let focused = document.focused_node(); + // A browser never copies a password field's text, and cuts only + // what it can delete. + let password = focused.as_ref().is_some_and(|node| { node.is_form_control_element() != 0 && node.form_control_element_type() == DomFormControlType::INPUT_PASSWORD }); - if !password { + let editable = focused.is_some_and(|node| node.is_editable() != 0); + if !password && (editable || !self.cut) { *self.text.lock().unwrap() = CefString::from(&document.selection_as_text()).to_string(); } } From b1727ae1de410fbb11104521831708cbaeb20761 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 5 Nov 2026 10:22:29 -0800 Subject: [PATCH 7/8] fix(scene): only the newest copy reaches the clipboard on the CPU path A renderer slow to answer a copy no longer overwrites a newer copy from another page. Co-Authored-By: Claude Opus 5.5 --- rs/moq-scene/src/browser/host.rs | 40 ++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/rs/moq-scene/src/browser/host.rs b/rs/moq-scene/src/browser/host.rs index 38163041..bdbd46a6 100644 --- a/rs/moq-scene/src/browser/host.rs +++ b/rs/moq-scene/src/browser/host.rs @@ -7,7 +7,7 @@ use std::collections::HashMap; use std::os::fd::BorrowedFd; use std::os::unix::net::UnixStream; use std::process::ExitCode; -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, mpsc}; use cef::*; @@ -73,9 +73,6 @@ struct Paged { shared: Arc, id: Id, open: Open, - /// The copies asked of its renderers and not yet answered, so a renderer - /// sets the studio's clipboard only when asked. - copies: AtomicUsize, } impl Paged { @@ -90,6 +87,11 @@ thread_local! { /// Whether the studio is gone, so the helper quits once its last page /// closes. static QUITTING: Cell = const { Cell::new(false) }; + /// The number of the last copy asked of a renderer. + static COPIES: Cell = const { Cell::new(0) }; + /// The copy whose answer the studio still wants. Only the newest is, so + /// a slow renderer never overwrites a newer copy, nor answers unasked. + static COPYING: Cell> = const { Cell::new(None) }; } fn serve() -> Result<(), String> { @@ -267,12 +269,7 @@ fn open(shared: Arc, id: Id, open: Open) { ..Default::default() }; let url = CefString::from(open.url.as_str()); - let paged = Arc::new(Paged { - shared, - id, - open, - copies: AtomicUsize::new(0), - }); + let paged = Arc::new(Paged { shared, id, open }); let mut client = HelperClient::new( paged.clone(), HelperRender::new(paged.clone()), @@ -406,10 +403,13 @@ fn copy(browser: &Browser, page: &Paged, cut: bool) { let Some(mut message) = process_message_create(Some(©.into())) else { return; }; + let number = COPIES.get().wrapping_add(1); + COPIES.set(number); + COPYING.set(Some(number)); if let Some(args) = message.argument_list() { args.set_bool(0, cut.into()); + args.set_int(1, number); } - page.copies.fetch_add(1, Ordering::Relaxed); frame.send_process_message(ProcessId::RENDERER, Some(&mut message)); } @@ -548,7 +548,8 @@ wrap_render_process_handler! { if source_process != ProcessId::BROWSER || CefString::from(&message.name()).to_string() != COPY { return 0; } - let cut = message.argument_list().is_some_and(|args| args.bool(0) != 0); + let Some(args) = message.argument_list() else { return 1 }; + let (cut, number) = (args.bool(0) != 0, args.int(1)); let copied = Arc::new(Mutex::new(String::new())); frame.visit_dom(Some(&mut SelectionReader::new(cut, copied.clone()))); let Some(mut answer) = process_message_create(Some(&COPIED.into())) else { @@ -556,6 +557,7 @@ wrap_render_process_handler! { }; if let Some(args) = answer.argument_list() { args.set_string(0, Some(&copied.lock().unwrap().as_str().into())); + args.set_int(1, number); } frame.send_process_message(ProcessId::BROWSER, Some(&mut answer)); // After reading the selection, which cutting removes. @@ -633,12 +635,14 @@ wrap_client! { if source_process != ProcessId::RENDERER || CefString::from(&message.name()).to_string() != COPIED { return 0; } - // One the helper never asked for is dropped. - let asked = self.page.copies.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |copies| copies.checked_sub(1)); - let text = message.argument_list().map(|args| CefString::from(&args.string(0)).to_string()); - if let (Ok(_), Some(text)) = (asked, text) - && !text.is_empty() - { + let Some(args) = message.argument_list() else { return 1 }; + // Only the newest copy's answer, once. + if COPYING.get() != Some(args.int(1)) { + return 1; + } + COPYING.set(None); + let text = CefString::from(&args.string(0)).to_string(); + if !text.is_empty() { self.page.shared.send(&Event::Copied(self.page.id, text), &[]); } 1 From ab3dc81f5d687afe8766caf27d3e384baaf96710 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 5 Nov 2026 10:25:25 -0800 Subject: [PATCH 8/8] refactor(scene): one copied slot for every page, on the helper The studio takes the newest copy from Helper::copied rather than draining a slot per page in name order, so an older copy can never overwrite a newer one. Co-Authored-By: Claude Opus 5.5 --- rs/moq-scene/src/browser/host.rs | 2 +- rs/moq-scene/src/browser/mod.rs | 32 +++++++++++++++++----------- rs/moq-scene/src/browser/protocol.rs | 2 +- rs/moq-scene/src/browser/run.rs | 14 ++++++++---- rs/moq-studio/src/ui.rs | 12 +++++++---- rs/moq-studio/tests/browser.rs | 5 +++-- 6 files changed, 42 insertions(+), 25 deletions(-) diff --git a/rs/moq-scene/src/browser/host.rs b/rs/moq-scene/src/browser/host.rs index bdbd46a6..5625ba64 100644 --- a/rs/moq-scene/src/browser/host.rs +++ b/rs/moq-scene/src/browser/host.rs @@ -643,7 +643,7 @@ wrap_client! { COPYING.set(None); let text = CefString::from(&args.string(0)).to_string(); if !text.is_empty() { - self.page.shared.send(&Event::Copied(self.page.id, text), &[]); + self.page.shared.send(&Event::Copied(text), &[]); } 1 } diff --git a/rs/moq-scene/src/browser/mod.rs b/rs/moq-scene/src/browser/mod.rs index 723576bf..97b514f4 100644 --- a/rs/moq-scene/src/browser/mod.rs +++ b/rs/moq-scene/src/browser/mod.rs @@ -139,6 +139,21 @@ impl Helper { } } + /// Take what a page last copied or cut, once, for the desktop's + /// clipboard: a page that paints in memory has no display to put it there + /// itself. + #[cfg(target_os = "linux")] + pub fn copied(&self) -> Option { + let running = self.running.lock().unwrap(); + running.as_ref()?.copied() + } + + /// Take what a page last copied or cut: nothing, without browser sources. + #[cfg(not(target_os = "linux"))] + pub fn copied(&self) -> Option { + None + } + /// Stop the helper, failing every page, and wait for it to exit, so the /// profile is saved and free for the next studio. The next page to start /// starts a new helper. @@ -188,8 +203,6 @@ struct PageState { /// Whether the helper has yet to hear `framerate`. #[cfg(target_os = "linux")] retime: bool, - /// What the page last copied or cut, until the studio takes it. - copied: Option, paint: Option, } @@ -239,13 +252,6 @@ impl Page { self.state.lock().unwrap().paint } - /// Take what the page last copied or cut, once, for the desktop's - /// clipboard: a page that paints in memory has no display to put it there - /// itself. - pub fn copied(&self) -> Option { - self.state.lock().unwrap().copied.take() - } - /// Paint at `rate`, the canvas's frame rate, from now on, without waiting on /// the helper. One that is behind hears it once it catches up. pub(crate) fn set_framerate(&self, rate: u32) { @@ -310,11 +316,11 @@ pub enum Input { }, /// Text typed, after the keyboard layout and any input method. Text(String), - /// Copy the page's selection to the clipboard, through [`Page::copied`] - /// when it paints in memory. + /// Copy the page's selection to the clipboard, through + /// [`Helper::copied`] when it paints in memory. Copy, - /// Cut the page's selection to the clipboard, through [`Page::copied`] - /// when it paints in memory. + /// Cut the page's selection to the clipboard, through + /// [`Helper::copied`] when it paints in memory. Cut, /// The page gained or lost keyboard focus. Focus(bool), diff --git a/rs/moq-scene/src/browser/protocol.rs b/rs/moq-scene/src/browser/protocol.rs index 7d089b73..dbc01953 100644 --- a/rs/moq-scene/src/browser/protocol.rs +++ b/rs/moq-scene/src/browser/protocol.rs @@ -106,7 +106,7 @@ pub(crate) enum Event { Audio(Id, Vec), /// The text a [`Input::Copy`] or [`Input::Cut`] took from a page, for the /// studio's clipboard. - Copied(Id, String), + Copied(String), /// A page cannot be shown, and why. Failed(Id, String), /// No page can be shown, and why. The helper exits next. diff --git a/rs/moq-scene/src/browser/run.rs b/rs/moq-scene/src/browser/run.rs index d56124fd..173ec333 100644 --- a/rs/moq-scene/src/browser/run.rs +++ b/rs/moq-scene/src/browser/run.rs @@ -107,6 +107,9 @@ struct Routes { pages: HashMap, /// Why the helper stopped, once it has: no page opens in it after. ended: Option, + /// What a page last copied or cut, until the studio takes it. One slot + /// for every page, so a newer copy always replaces an older one. + copied: Option, } /// One open page's source. @@ -276,6 +279,11 @@ impl Instance { Ok(opened) } + /// Take what a page last copied or cut, once. + pub(super) fn copied(&self) -> Option { + self.routes.lock().unwrap().copied.take() + } + /// Ask the helper to exit, and wait until it has. pub(super) fn stop(&self) { self.end(); @@ -454,10 +462,8 @@ fn deliver(gpu: &Gpu, events: &Channel, commands: &Commands, routes: &Mutex { - if let Some(route) = routes.lock().unwrap().pages.get(&id) { - route.page.state.lock().unwrap().copied = Some(text); - } + Event::Copied(text) => { + routes.lock().unwrap().copied = Some(text); continue; } Event::Failed(id, reason) => { diff --git a/rs/moq-studio/src/ui.rs b/rs/moq-studio/src/ui.rs index 852282f5..19152c8c 100644 --- a/rs/moq-studio/src/ui.rs +++ b/rs/moq-studio/src/ui.rs @@ -199,10 +199,14 @@ impl eframe::App for Studio { self.panels.follow_reloads(&state); // A page painting in memory copies through the studio's clipboard, // even if the preview let go of the page before the copy came back. - for (_, feed) in state.sources.iter() { - if let Some(text) = feed.page().and_then(|page| page.copied()) { - ui.ctx().copy_text(text); - } + if let Some(text) = state + .sources + .context() + .browser + .as_ref() + .and_then(|helper| helper.copied()) + { + ui.ctx().copy_text(text); } drop(state); diff --git a/rs/moq-studio/tests/browser.rs b/rs/moq-studio/tests/browser.rs index e1de21dd..bfe3baa2 100644 --- a/rs/moq-studio/tests/browser.rs +++ b/rs/moq-studio/tests/browser.rs @@ -362,10 +362,11 @@ async fn copy_and_cut_reach_the_clipboard() { shift: true, ..Default::default() }; + let helper = studio.helper.clone(); let copied = async || { let deadline = Instant::now() + TIMEOUT; loop { - if let Some(copied) = page.copied() { + if let Some(copied) = helper.copied() { return copied; } assert!(Instant::now() < deadline, "the copy never came back"); @@ -398,7 +399,7 @@ async fn copy_and_cut_reach_the_clipboard() { page.send(Input::Copy); page.send(Input::Text("x".into())); studio.until_center([255, 255, 255, 255], &[]).await; - assert_eq!(page.copied(), None, "a password was copied"); + assert_eq!(helper.copied(), None, "a password was copied"); key(b'\t', shift); key(b'A', ctrl);