mirror of
https://github.com/tauri-apps/plugins-workspace.git
synced 2026-10-18 23:07:27 +02:00
fix(single-instance): propagate D-Bus setup and handoff errors (#3584)
* fix(single-instance): propagate D-Bus setup and handoff errors * chore: satisfy release metadata and test header checks * fix(single-instance): launch normally without a D-Bus session bus Log a warning instead of failing setup when the session bus is unreachable, and add context to handoff errors. * ci: run the single-instance D-Bus test under dbus-run-session Run it in test-rust on Linux and keep it out of the updater integration job, which has no session bus. --------- Co-authored-by: Lucas Nogueira <lucas@tauri.app>
This commit is contained in:
1 parent
6a263bec1c
commit
f83c5d3b0f
6 files changed
+237
-13
No files matched your search
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"single-instance": patch
|
||||
---
|
||||
|
||||
On Linux, a secondary instance that cannot deliver its arguments to the primary instance now returns a plugin initialization error instead of exiting successfully, and an invalid `dbus_id` returns an error instead of panicking. When the session bus is unavailable the app keeps launching normally, without single-instance support, and logs a warning.
|
||||
@@ -137,4 +137,6 @@ jobs:
|
||||
# about a minute linking each of those builds. None of it matters to the updater.
|
||||
CARGO_PROFILE_RELEASE_LTO: 'false'
|
||||
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: '16'
|
||||
run: cargo test --test '*' -- --ignored
|
||||
# The single-instance D-Bus test is also ignored by default, but needs a session bus and
|
||||
# runs in test-rust.yml instead.
|
||||
run: cargo test --workspace --exclude tauri-plugin-single-instance --test '*' -- --ignored
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
if: contains(matrix.platform.target, 'unknown-linux')
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev
|
||||
sudo apt-get install -y libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev dbus
|
||||
|
||||
- uses: dtolnay/rust-toolchain@1.90
|
||||
with:
|
||||
@@ -174,6 +174,11 @@ jobs:
|
||||
# Only on the platforms we actually test on, since cargo skips doc tests when cross compiling.
|
||||
elif [ "$COMMAND" = test ] && ! $RUNNER test --doc --package "$package" --target "$TARGET" "${features[@]}"; then
|
||||
failed+=("$package (doc tests)")
|
||||
# The D-Bus test is ignored by default because it needs its own session bus, and it
|
||||
# is compiled out with `--all-features` (the `semver` feature changes the bus name).
|
||||
elif [ "$package" = tauri-plugin-single-instance ] && [ "$COMMAND" = test ] && [[ "$TARGET" == *-linux-* ]] \
|
||||
&& ! dbus-run-session -- $RUNNER test --package "$package" --target "$TARGET" --test dbus_errors -- --ignored; then
|
||||
failed+=("$package (D-Bus tests)")
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
done
|
||||
|
||||
@@ -26,6 +26,9 @@ tauri-plugin-deep-link = { path = "../deep-link", version = "2.6.1", optional =
|
||||
semver = { version = "1", optional = true }
|
||||
tokio = { version = "1", features = ["net"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tauri = { workspace = true, features = ["test"] }
|
||||
|
||||
[target."cfg(target_os = \"windows\")".dependencies.windows-sys]
|
||||
version = "0.61"
|
||||
features = [
|
||||
|
||||
@@ -53,22 +53,30 @@ pub fn init<R: Runtime>(
|
||||
app_handle: app.clone(),
|
||||
};
|
||||
|
||||
match zbus::blocking::connection::Builder::session()
|
||||
.unwrap()
|
||||
.name(dbus_name.as_str())
|
||||
.unwrap()
|
||||
let builder = match zbus::blocking::connection::Builder::session() {
|
||||
Ok(builder) => builder,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
"single-instance: invalid D-Bus session address, launching normally: {error}"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
match builder
|
||||
.name(dbus_name.as_str())?
|
||||
.replace_existing_names(false)
|
||||
.allow_name_replacements(false)
|
||||
.serve_at(dbus_path.as_str(), single_instance_dbus)
|
||||
.unwrap()
|
||||
.serve_at(dbus_path.as_str(), single_instance_dbus)?
|
||||
.build()
|
||||
{
|
||||
Ok(connection) => {
|
||||
app.manage(ConnectionHandle(connection));
|
||||
}
|
||||
Err(zbus::Error::NameTaken) => {
|
||||
if let Ok(connection) = Connection::session() {
|
||||
let _ = connection.call_method(
|
||||
let connection = Connection::session()?;
|
||||
connection
|
||||
.call_method(
|
||||
Some(dbus_name.as_str()),
|
||||
dbus_path.as_str(),
|
||||
Some("org.SingleInstance.DBus"),
|
||||
@@ -82,12 +90,21 @@ pub fn init<R: Runtime>(
|
||||
.to_string_lossy()
|
||||
.as_ref(),
|
||||
),
|
||||
);
|
||||
}
|
||||
)
|
||||
.map_err(|error| {
|
||||
format!("failed to forward arguments to the running instance: {error}")
|
||||
})?;
|
||||
// Exit successfully only after the primary accepted the arguments.
|
||||
app.cleanup_before_exit();
|
||||
std::process::exit(0);
|
||||
}
|
||||
_ => {}
|
||||
Err(error) => {
|
||||
// Without a session bus there is no way to find other instances.
|
||||
tracing::warn!(
|
||||
"single-instance: D-Bus session bus unavailable, launching normally: {error}"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
app.manage(DBusName(dbus_name));
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
#![cfg(all(
|
||||
any(target_os = "linux", target_os = "freebsd"),
|
||||
not(feature = "semver")
|
||||
))]
|
||||
|
||||
use std::{
|
||||
env, fs,
|
||||
path::PathBuf,
|
||||
process::{Child, Command, Output, Stdio},
|
||||
sync::mpsc,
|
||||
thread,
|
||||
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
use tauri::test::{mock_builder, mock_context, noop_assets};
|
||||
|
||||
const TEST_NAME: &str = "dbus_setup_errors";
|
||||
const ID_ENV: &str = "TAURI_DBUS_ERROR_ID";
|
||||
const SETUP_ERROR: &str = "SETUP_ERROR:";
|
||||
const STARTED: &str = "STARTED";
|
||||
|
||||
struct RejectCallback(mpsc::Sender<()>);
|
||||
|
||||
#[zbus::interface(name = "org.SingleInstance.DBus")]
|
||||
impl RejectCallback {
|
||||
fn execute_callback(&self, _argv: Vec<String>, _cwd: String) -> zbus::fdo::Result<()> {
|
||||
self.0.send(()).unwrap();
|
||||
Err(zbus::fdo::Error::Failed(
|
||||
"test primary rejected delivery".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
struct Sandbox(PathBuf);
|
||||
impl Drop for Sandbox {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
struct TestChild(Option<Child>);
|
||||
impl TestChild {
|
||||
fn spawn(id: &str, address: &str) -> Self {
|
||||
Self(Some(
|
||||
Command::new(env::current_exe().unwrap())
|
||||
.args(["--ignored", "--exact", TEST_NAME, "--nocapture"])
|
||||
.env(ID_ENV, id)
|
||||
.env("DBUS_SESSION_BUS_ADDRESS", address)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap(),
|
||||
))
|
||||
}
|
||||
|
||||
fn wait(mut self) -> Output {
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
loop {
|
||||
if self.0.as_mut().unwrap().try_wait().unwrap().is_some() {
|
||||
return self.0.take().unwrap().wait_with_output().unwrap();
|
||||
}
|
||||
assert!(Instant::now() < deadline, "D-Bus test child timed out");
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Drop for TestChild {
|
||||
fn drop(&mut self) {
|
||||
if let Some(child) = self.0.as_mut() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_setup_error(output: Output) -> String {
|
||||
assert_eq!(output.status.code(), Some(101));
|
||||
let stdout = String::from_utf8(output.stdout).unwrap();
|
||||
assert!(stdout.contains(SETUP_ERROR), "{stdout}");
|
||||
let stderr = String::from_utf8(output.stderr).unwrap();
|
||||
assert!(
|
||||
stderr.contains("PluginInitialization(\"single-instance\""),
|
||||
"{stderr}"
|
||||
);
|
||||
stdout
|
||||
}
|
||||
|
||||
fn assert_started(output: Output) {
|
||||
assert!(output.status.success(), "{output:?}");
|
||||
let stdout = String::from_utf8(output.stdout).unwrap();
|
||||
assert!(stdout.contains(STARTED), "{stdout}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires an explicit dbus-run-session invocation"]
|
||||
fn dbus_setup_errors() -> Result<(), tauri::Error> {
|
||||
if let Ok(id) = env::var(ID_ENV) {
|
||||
let result = mock_builder()
|
||||
.plugin(
|
||||
tauri_plugin_single_instance::Builder::new()
|
||||
.dbus_id(id)
|
||||
.build(),
|
||||
)
|
||||
.build(mock_context(noop_assets()));
|
||||
return match result {
|
||||
// Without a usable session bus the app must still launch.
|
||||
Ok(app) => {
|
||||
println!("{STARTED}");
|
||||
tauri_plugin_single_instance::destroy(&app);
|
||||
Ok(())
|
||||
}
|
||||
Err(tauri::Error::PluginInitialization(plugin, message)) => {
|
||||
assert_eq!(plugin, "single-instance");
|
||||
println!("{SETUP_ERROR} {message}");
|
||||
Err(tauri::Error::PluginInitialization(plugin, message))
|
||||
}
|
||||
Err(error) => panic!("unexpected error: {error}"),
|
||||
};
|
||||
}
|
||||
|
||||
let address =
|
||||
env::var("DBUS_SESSION_BUS_ADDRESS").expect("run this test inside dbus-run-session");
|
||||
let id = format!("org.tauri.DBusErrorTest.p{}", std::process::id());
|
||||
let root = env::temp_dir().join(format!(
|
||||
"tauri-dbus-error-{}-{}",
|
||||
std::process::id(),
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
fs::create_dir(&root).unwrap();
|
||||
let sandbox = Sandbox(root);
|
||||
let missing_address = format!("unix:path={}", sandbox.0.join("missing.sock").display());
|
||||
assert_started(TestChild::spawn(&id, &missing_address).wait());
|
||||
assert_started(TestChild::spawn(&id, "invalid-address").wait());
|
||||
assert_setup_error(TestChild::spawn("invalid dbus id", &address).wait());
|
||||
|
||||
// A real endpoint rejects delivery immediately, without bus timeouts or startup races.
|
||||
let name = format!("{id}.SingleInstance");
|
||||
let path = format!("/{}", name.replace('.', "/"));
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let owner = zbus::blocking::connection::Builder::session()
|
||||
.unwrap()
|
||||
.name(name.as_str())
|
||||
.unwrap()
|
||||
.serve_at(path.as_str(), RejectCallback(tx))
|
||||
.unwrap()
|
||||
.build()
|
||||
.unwrap();
|
||||
let failure = assert_setup_error(TestChild::spawn(&id, &address).wait());
|
||||
assert!(failure.contains("test primary rejected delivery"));
|
||||
rx.recv_timeout(Duration::from_secs(1)).unwrap();
|
||||
assert!(matches!(rx.try_recv(), Err(mpsc::TryRecvError::Empty)));
|
||||
drop(owner);
|
||||
|
||||
// The successful primary and secondary paths still use the real plugin and bus.
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let app = mock_builder()
|
||||
.plugin(
|
||||
tauri_plugin_single_instance::Builder::new()
|
||||
.dbus_id(format!("{id}.Success"))
|
||||
.callback(move |_, args, cwd| tx.send((args, cwd)).unwrap())
|
||||
.build(),
|
||||
)
|
||||
.build(mock_context(noop_assets()))?;
|
||||
let secondary = TestChild::spawn(&format!("{id}.Success"), &address).wait();
|
||||
assert!(secondary.status.success());
|
||||
assert!(
|
||||
!String::from_utf8(secondary.stdout)
|
||||
.unwrap()
|
||||
.contains(STARTED)
|
||||
);
|
||||
let (args, cwd) = rx.recv_timeout(Duration::from_secs(1)).unwrap();
|
||||
assert_eq!(
|
||||
args,
|
||||
[
|
||||
env::current_exe().unwrap().to_str().unwrap(),
|
||||
"--ignored",
|
||||
"--exact",
|
||||
TEST_NAME,
|
||||
"--nocapture"
|
||||
]
|
||||
);
|
||||
assert_eq!(cwd, env::current_dir().unwrap().to_str().unwrap());
|
||||
assert!(matches!(rx.try_recv(), Err(mpsc::TryRecvError::Empty)));
|
||||
tauri_plugin_single_instance::destroy(&app);
|
||||
Ok(())
|
||||
}
|
||||
Reference in new issue
Block a user