Skip to content
Merged
133 changes: 80 additions & 53 deletions src/about_window/clipboard.rs
Original file line number Diff line number Diff line change
@@ -1,41 +1,32 @@
use anyhow::{Context, Result};
use anyhow::Result;
use log::warn;
use std::thread::JoinHandle;

pub(super) fn open_url(url: &str) {
let opener = if cfg!(target_os = "macos") {
"open"
} else if cfg!(target_os = "windows") {
"cmd"
} else {
"xdg-open"
};

let mut cmd = std::process::Command::new(opener);
if cfg!(target_os = "windows") {
cmd.args(["/C", "start", ""]).arg(url);
} else {
cmd.arg(url);
}
cmd.stdin(std::process::Stdio::null());
cmd.stdout(std::process::Stdio::null());
cmd.stderr(std::process::Stdio::null());
pub(super) fn open_url(url: &str) -> Result<JoinHandle<()>> {
open_url_with(url, |invocation| {
crate::desktop_open::open_in_background(invocation.clone())
})
}

if let Err(err) = cmd.spawn() {
warn!("Failed to open URL {}: {}", url, err);
}
fn open_url_with<R>(
url: &str,
open: impl FnOnce(&crate::desktop_open::DesktopOpenInvocation) -> Result<R>,
) -> Result<R> {
let invocation = crate::desktop_open::trusted_url(url)?;
open(&invocation)
}

pub(super) fn copy_text_to_clipboard(text: &str) {
pub(super) fn copy_text_to_clipboard(text: &str) -> Option<JoinHandle<()>> {
if text.is_empty() {
return;
return None;
}

let text = text.to_string();
std::thread::spawn(move || {
Some(std::thread::spawn(move || {
if let Err(err) = copy_text_with_command(&text, copy_text_via_command) {
warn!("Failed to copy commit id to clipboard: {}", err);
warn!("Failed to copy About text to clipboard: {err:#}");
}
});
}))
}

fn copy_text_with_command<C>(text: &str, command_copy: C) -> Result<()>
Expand All @@ -49,32 +40,7 @@ where
}

fn copy_text_via_command(text: &str) -> Result<()> {
use std::io::Write;

let mut child = std::process::Command::new("wl-copy")
.arg("--type")
.arg("text/plain")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.spawn()
.context("Failed to spawn wl-copy")?;

if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(text.as_bytes())
.context("Failed to write to wl-copy stdin")?;
}

let output = child
.wait_with_output()
.context("Failed to wait for wl-copy")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow::anyhow!("wl-copy failed: {}", stderr.trim()));
}

Ok(())
crate::clipboard_text::copy_text_via_command(text).map_err(anyhow::Error::msg)
}

#[cfg(test)]
Expand All @@ -83,6 +49,67 @@ mod tests {

use super::*;

#[test]
fn open_url_builds_the_broker_ready_desktop_open_argv() {
let mut observed = None;

open_url_with("https://wayscriber.com/report#d=abc", |invocation| {
observed = Some((
invocation.program().to_owned(),
invocation.arguments().to_vec(),
));
Ok(())
})
.unwrap();

let (program, arguments) = observed.expect("trusted URL reaches the open adapter");
assert!(!matches!(program.to_str(), Some("sh" | "bash" | "cmd")));
assert_eq!(
arguments,
[std::ffi::OsString::from(
"https://wayscriber.com/report#d=abc"
)]
);
}

#[test]
fn open_url_refuses_untrusted_hosts_before_spawning() {
let spawn_calls = AtomicUsize::new(0);

for url in [
"http://wayscriber.com/report",
"https://wayscriber.com.example/report",
"https://example.com/report",
] {
let result = open_url_with(url, |_| {
spawn_calls.fetch_add(1, Ordering::SeqCst);
Ok(())
});
assert!(result.is_err(), "unexpectedly accepted {url}");
}

assert_eq!(spawn_calls.load(Ordering::SeqCst), 0);
}

#[test]
fn open_url_accepts_manifest_trusted_www_host() {
let mut observed = None;
open_url_with(
"https://www.wayscriber.com/docs/getting-started/updating.html",
|invocation| {
observed = Some(invocation.arguments().to_vec());
Ok(())
},
)
.unwrap();
assert_eq!(
observed.unwrap(),
[std::ffi::OsString::from(
"https://www.wayscriber.com/docs/getting-started/updating.html"
)]
);
}

#[test]
fn copy_text_with_command_short_circuits_for_empty_text() {
let command_calls = AtomicUsize::new(0);
Expand Down
16 changes: 15 additions & 1 deletion src/about_window/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,20 @@ pub fn run_about_window() -> Result<()> {
plan,
);

// Join helpers on every return path so ProcessBrokerGuard teardown cannot
// cancel an in-flight Report/open/copy that already showed a success notice.
let result = run_about_event_loop(&conn, &mut event_queue, &mut state);
state.join_helper_workers();
result
}

fn run_about_event_loop(
conn: &Connection,
event_queue: &mut wayland_client::EventQueue<AboutWindowState>,
state: &mut AboutWindowState,
) -> Result<()> {
loop {
event_queue.blocking_dispatch(&mut state)?;
event_queue.blocking_dispatch(state)?;
if state.should_exit {
break;
}
Expand Down Expand Up @@ -134,6 +146,8 @@ struct AboutWindowState {
/// Set when the update card is activated; serviced by the event loop so the
/// blocking fetch never runs inside a protocol handler.
check_requested: bool,
/// Open/copy workers that must finish before the process broker shuts down.
helper_workers: Vec<std::thread::JoinHandle<()>>,
content: AboutContent,
plan: Plan,
update: UpdateState,
Expand Down
57 changes: 46 additions & 11 deletions src/about_window/state.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! About-window state: focus, hover, and the actions the handlers trigger.

use log::debug;
use log::{debug, warn};
use smithay_client_toolkit::seat::pointer::CursorIcon;
use wayland_client::Connection;

Expand All @@ -11,8 +11,10 @@ use super::{AboutWindowState, clipboard, icon, surface_size};

/// How the footer acknowledges an action that has no visible result of its own.
const COPIED_NOTICE: &str = "Copied to clipboard";
const OPENED_NOTICE: &str = "Opened in your browser";
const REPORTED_NOTICE: &str = "Diagnostics copied — paste them if the form asks";
const OPENING_NOTICE: &str = "Opening in your browser";
const OPEN_FAILED_NOTICE: &str = "Could not open your browser — see logs";
const REPORTED_NOTICE: &str = "Diagnostics copied — opening browser";
const REPORT_OPEN_FAILED_NOTICE: &str = "Diagnostics copied — browser open failed";

impl AboutWindowState {
#[allow(clippy::too_many_arguments)]
Expand Down Expand Up @@ -49,6 +51,7 @@ impl AboutWindowState {
should_exit: false,
needs_redraw: true,
check_requested: false,
helper_workers: Vec::new(),
content,
plan,
update,
Expand Down Expand Up @@ -124,27 +127,59 @@ impl AboutWindowState {

fn perform(&mut self, action: AboutAction) {
match action {
AboutAction::OpenUrl(url) => {
clipboard::open_url(&url);
self.set_notice(OPENED_NOTICE);
}
AboutAction::OpenUrl(url) => match clipboard::open_url(&url) {
Ok(worker) => {
self.track_helper_worker(worker);
self.set_notice(OPENING_NOTICE);
}
Err(err) => {
warn!("About dialog refused or failed to open a URL: {err:#}");
self.set_notice(OPEN_FAILED_NOTICE);
}
},
AboutAction::CopyText(text) => {
clipboard::copy_text_to_clipboard(&text);
if let Some(worker) = clipboard::copy_text_to_clipboard(&text) {
self.track_helper_worker(worker);
}
self.set_notice(COPIED_NOTICE);
}
// Copy as well as open: the URL carries the same diagnostics in its
// fragment, but a browser that never launches, or a form that drops
// the prefill, still leaves them one paste away.
AboutAction::ReportBug { url, diagnostics } => {
clipboard::copy_text_to_clipboard(&diagnostics);
clipboard::open_url(&url);
self.set_notice(REPORTED_NOTICE);
// Start the desktop-open worker before the independent clipboard
// publication worker.
let opened = clipboard::open_url(&url);
if let Some(worker) = clipboard::copy_text_to_clipboard(&diagnostics) {
self.track_helper_worker(worker);
}
match opened {
Ok(worker) => {
self.track_helper_worker(worker);
self.set_notice(REPORTED_NOTICE);
}
Err(err) => {
warn!("About dialog failed to open the report URL: {err:#}");
self.set_notice(REPORT_OPEN_FAILED_NOTICE);
}
}
}
AboutAction::CheckForUpdates => self.begin_update_check(),
AboutAction::Close => self.should_exit = true,
}
}

fn track_helper_worker(&mut self, worker: std::thread::JoinHandle<()>) {
self.helper_workers.push(worker);
}

/// Finish in-flight open/copy workers before the process broker tears down.
pub(super) fn join_helper_workers(&mut self) {
for worker in self.helper_workers.drain(..) {
let _ = worker.join();
}
}

fn begin_update_check(&mut self) {
self.set_update(UpdateState::Checking);
self.notice = None;
Expand Down
30 changes: 27 additions & 3 deletions src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,9 +165,7 @@ pub fn run(cli: Cli) -> anyhow::Result<()> {

// Runtime and update-checking modes create their process broker before
// acquiring locks or starting threads. The guard spans the complete run.
let update_mode_needs_broker =
!crate::update_check::compiled_out() && (cli.about || cli.check_update);
let _process_broker = (cli.daemon || cli.active || cli.freeze || update_mode_needs_broker)
let _process_broker = needs_process_broker(&cli)
.then(crate::process_broker::start_for_runtime)
.transpose()?;
crate::daemon::protocol_v2::start_daemon_watchdog_from_environment()?;
Expand Down Expand Up @@ -291,10 +289,36 @@ pub fn run(cli: Cli) -> anyhow::Result<()> {
Ok(())
}

fn needs_process_broker(cli: &Cli) -> bool {
cli.daemon
|| cli.active
|| cli.freeze
// About's URL and clipboard helpers need the broker even when network
// update checks were compiled out of this build.
|| cli.about
|| (cli.check_update && !crate::update_check::compiled_out())
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn about_always_starts_the_process_broker() {
assert!(needs_process_broker(&Cli {
about: true,
..Cli::default()
}));
}

#[test]
fn print_only_mode_does_not_start_the_process_broker() {
assert!(!needs_process_broker(&Cli {
runtime_capabilities: true,
..Cli::default()
}));
}

#[test]
fn daemon_request_session_file_anchors_relative_paths_to_caller_directory() {
let anchored = anchor_session_file_for_daemon_request(
Expand Down
6 changes: 6 additions & 0 deletions src/backend/wayland/backend/event_loop/capture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ pub(super) fn handle_pending_actions(
state.poll_text_paste_completion();
state.poll_ocr_completion();
state.poll_session_file_dialog_completion(qh);
state.poll_desktop_open_completion();
state.drain_clipboard_requests();
state.handle_pending_eyedropper_toggle();
state.handle_pending_ocr_request();
Expand Down Expand Up @@ -137,6 +138,7 @@ pub(super) fn handle_pending_actions(
PendingBackendAction::BoardPdfExport(action) => {
state.handle_board_pdf_export_action(action);
}
PendingBackendAction::DesktopOpen(request) => state.handle_desktop_open(request),
PendingBackendAction::ClearSavedToolState => {
state.handle_clear_saved_tool_state_action();
}
Expand Down Expand Up @@ -411,6 +413,10 @@ fn handle_capture_results(state: &mut WaylandState) {
}
}
if should_exit {
// Exit-after-capture is intentional teardown. Mark it explicit so XDG
// stay-mode cannot clear should_exit while the overlay is unfocused
// (for example after a portal dialog stole focus during capture).
state.mark_xdg_explicit_close_requested();
state.input_state.should_exit = true;
}
}
Expand Down
Loading
Loading