Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -318,11 +318,15 @@ background, and its sound joins the audio scenes that hold it. Every page shares
browser profile, kept in `$XDG_DATA_HOME/moq-studio/browser`
(`~/.local/share/moq-studio/browser` by default) rather than beside the scene
file, so cookies and logins, such as an alert service's, survive a reload and
a restart of the studio. One studio at a time can use it: a second one's
browser sources fail saying so. Each page renders in a process of its own, so
a restart of the studio. They are not encrypted with the desktop's keyring,
so a locked keyring never holds a page back, and anything that can read your
home directory can read them. One studio at a time can use the profile: a
second one's browser sources fail saying so. Each page renders in a process of its own, so
a page that crashes fails only its own source, with the reason, while the
stream carries on. A crash of the browser itself fails every browser source.
Either shows at once, since the browser's processes dump no core.
Either shows at once, since the browser's processes dump no core. On Linux,
pages run without Chromium's sandbox, as in OBS, so a page that exploits a
browser bug can do anything you can: add only pages you trust.
**Reload** in Sources restarts a page, failed or not. A page's dropdown
lists, such as a `<select>`'s, draw over it and take clicks under
**Interact**, but it opens no other windows (`window.open` and new-tab links
Expand Down
1 change: 0 additions & 1 deletion quest/m0/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,6 @@ repository.
- [Virtual camera](/quest/m0/vcam/README.md) - the program or a published scene exposed as a system camera on Linux, Windows, and macOS
- [Browser sources on the GPU under Mesa](/quest/m0/cef-headless.md) - shared-texture pages paint on Intel and AMD under Wayland, and a page that cannot paint fails loudly
- [A browser helper exits with the studio](/quest/m0/browser-helper-exit.md) - no orphaned CEF helper after a crash, kill, or timed-out test
- [Nightly and the browser suite pass](/quest/m0/nightly-green.md) - render-sw, cookies, and the X11 clipboard test fixed at their causes
- [The Apple signing identity is available](/quest/m0/signing.md) - the Apple credentials packaging and the macOS camera need; Windows ships unsigned
- [Packaging](/quest/m0/packaging.md) - a signed macOS app, a Windows installer, 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
Expand Down
26 changes: 0 additions & 26 deletions quest/m0/nightly-green.md

This file was deleted.

19 changes: 12 additions & 7 deletions rs/moq-scene/src/browser/host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,13 +118,7 @@ fn serve() -> Result<(), String> {
writer: Mutex::new(writer),
painted: Mutex::new(painted),
accelerated: matches!(start.raster, Raster::Gpu { .. }),
native_copy: matches!(
start.raster,
Raster::Gpu {
display: Display::X11,
..
}
),
native_copy: start.raster.native_copy(),
});
// The studio shows why, rather than only that the helper exited.
browse(&shared, start, reader, painted_tx).inspect_err(|reason| _ = shared.send(&Event::Fatal(reason.clone()), &[]))
Expand Down Expand Up @@ -489,6 +483,17 @@ wrap_app! {
Some(&"autoplay-policy".into()),
Some(&"no-user-gesture-required".into()),
);
// Chromium otherwise reads the profile's cookies only once the
// desktop's keyring hands over its key, which a locked keyring holds
// behind an unlock prompt: every page that loads over the network
// stays Starting until someone answers it, and forever where no one
// can, as with no monitor attached.
command_line.append_switch_with_value(Some(&"password-store".into()), Some(&"basic".into()));
// Chromium's sandbox needs unprivileged user namespaces, which
// Ubuntu's AppArmor and a Flatpak deny, or a setuid-root helper,
// which a user build lacks. Without either, the browser aborts
// rather than run unsandboxed, so the sandbox is off, as in OBS.
command_line.append_switch(Some(&"no-sandbox".into()));
match &self.raster {
Raster::Gpu { render_node, display } => {
let platform = match display {
Expand Down
7 changes: 7 additions & 0 deletions rs/moq-scene/src/browser/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,13 @@ impl Helper {
None
}

/// Whether a page's Copy and Cut come back through [`Helper::copied`],
/// rather than reaching the X clipboard through CEF itself.
#[cfg(target_os = "linux")]
pub fn relays_copies(&self) -> bool {
!self.raster.native_copy()
}

/// 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.
Expand Down
14 changes: 14 additions & 0 deletions rs/moq-scene/src/browser/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,20 @@ pub(crate) enum Raster {
Cpu,
}

impl Raster {
/// Whether CEF's own copy reaches the desktop's clipboard: only through an
/// X display.
pub(crate) fn native_copy(&self) -> bool {
matches!(
self,
Raster::Gpu {
display: Display::X11,
..
}
)
}
}

/// The display server the GPU path connects to, the one the studio runs on.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) enum Display {
Expand Down
148 changes: 117 additions & 31 deletions rs/moq-studio/tests/browser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -364,23 +364,30 @@ 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.
/// Copy and Cut put the page's selection on the 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 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 = "<body style='margin:0;background:red'>\
<input value='hello world' style='width:100vw;height:100vh;background:transparent' \
onselect=\"document.body.style.background = 'yellow'\" \
oninput=\"document.body.style.background = 'lime'\">\
// A text box over the whole page, then a password field and a second
// text box below it. The page turns yellow once the first is
// selected, blue and white once the password is selected and typed
// in, and aqua and lime for the same on the second. This process's id
// tells its copies from whatever the X clipboard held before.
let id = std::process::id();
let html = format!(
"<body style='margin:0;background:red'>\
<input value='copy {id}' style='width:100vw;height:100vh;background:transparent' \
onselect=\"document.body.style.background = 'yellow'\">\
<input type=password value='hunter2' \
onselect=\"document.body.style.background = 'blue'\" \
oninput=\"document.body.style.background = 'white'\"></body>";
let mut studio = Studio::new(&[("page", page(html))]).await;
oninput=\"document.body.style.background = 'white'\">\
<input value='cut {id}' \
onselect=\"document.body.style.background = 'aqua'\" \
oninput=\"document.body.style.background = 'lime'\"></body>"
);
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();
Expand All @@ -397,21 +404,7 @@ async fn copy_and_cut_reach_the_clipboard() {
ctrl: true,
..Default::default()
};
let shift = Modifiers {
shift: true,
..Default::default()
};
let helper = studio.helper.clone();
let copied = async || {
let deadline = Instant::now() + TIMEOUT;
loop {
if let Some(copied) = helper.copied() {
return copied;
}
assert!(Instant::now() < deadline, "the copy never came back");
kio::clock::sleep(Duration::from_millis(30)).await;
}
};
let mut clipboard = Clipboard::new(studio.helper.clone());

page.send(Input::Focus(true));
for down in [true, false] {
Expand All @@ -429,7 +422,7 @@ async fn copy_and_cut_reach_the_clipboard() {
key(b'A', ctrl);
studio.until_center(YELLOW, &[]).await;
page.send(Input::Copy);
assert_eq!(copied().await, "hello world");
assert_eq!(clipboard.next().await, format!("copy {id}"));

// Text typed after the copy shows only once the copy has come back.
key(b'\t', Modifiers::default());
Expand All @@ -438,18 +431,111 @@ 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!(helper.copied(), None, "a password was copied");
assert_eq!(clipboard.take(), None, "a password was copied");

key(b'\t', shift);
key(b'\t', Modifiers::default());
key(b'A', ctrl);
studio.until_center(YELLOW, &[]).await;
studio.until_center([0, 255, 255, 255], &[]).await;
page.send(Input::Cut);
studio.until_center(GREEN, &[]).await;
assert_eq!(copied().await, "hello world");
assert_eq!(clipboard.next().await, format!("cut {id}"));
})
.await;
}

/// Where a page's copies land: the X clipboard, where CEF copies itself, or
/// else the studio's, through the helper.
enum Clipboard {
Relay(Helper),
/// With the text it held when last read.
X11(Option<String>),
}

impl Clipboard {
fn new(helper: Helper) -> Self {
match helper.relays_copies() {
true => Self::Relay(helper),
false => Self::X11(x11_clipboard()),
}
}

/// What a page copied since this was last asked, if anything.
fn take(&mut self) -> Option<String> {
match self {
Self::Relay(helper) => helper.copied(),
Self::X11(held) => {
let text = x11_clipboard();
if text == *held {
return None;
}
held.clone_from(&text);
text
}
}
}

/// Wait for a page's next copy.
async fn next(&mut self) -> String {
let deadline = Instant::now() + TIMEOUT;
loop {
if let Some(text) = self.take() {
return text;
}
assert!(Instant::now() < deadline, "the copy never landed");
kio::clock::sleep(Duration::from_millis(30)).await;
}
}
}

/// The X clipboard's text, as a paste takes it: none with no owner, or one
/// offering no text.
fn x11_clipboard() -> Option<String> {
use x11rb::connection::Connection;
use x11rb::protocol::Event;
use x11rb::protocol::xproto::{AtomEnum, ConnectionExt, CreateWindowAux, WindowClass};

let (connection, screen) = x11rb::connect(None).expect("the X display");
let root = connection.setup().roots[screen].root;
let window = connection.generate_id().unwrap();
connection
.create_window(
0,
window,
root,
0,
0,
1,
1,
0,
WindowClass::INPUT_ONLY,
0,
&CreateWindowAux::new(),
)
.unwrap();
let atom = |name: &[u8]| connection.intern_atom(false, name).unwrap().reply().unwrap().atom;
let (selection, text, property) = (atom(b"CLIPBOARD"), atom(b"UTF8_STRING"), atom(b"MOQ_STUDIO_PASTE"));
connection
.convert_selection(window, selection, text, property, x11rb::CURRENT_TIME)
.unwrap();
connection.flush().unwrap();
loop {
if let Event::SelectionNotify(notify) = connection.wait_for_event().unwrap()
&& notify.requestor == window
{
if notify.property == x11rb::NONE {
return None;
}
let value = connection
.get_property(true, window, property, AtomEnum::ANY, 0, u32::MAX)
.unwrap()
.reply()
.unwrap()
.value;
return Some(String::from_utf8(value).unwrap());
}
}
}

/// 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")]
Expand Down