vvland 0.1.0

Run one Wayland app or compositor inside Vivido over Vivid
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
//! Single-app mode: the profile table and everything needed to build one launch.
//!
//! "Run one Wayland app alone" means starting a headless compositor and auto-launching exactly one
//! application inside it — not embedding a compositor (plan decision 2). A profile carries the
//! discovery order, the Wayland-mode environment, the default arguments, and the compositor the
//! app prefers; `--` arguments append to the profile's own.
//!
//! The pipeline resolves the profile before the compositor probe (its preference steers the
//! probe), bakes the window rule into the generated compositor configuration, and launches the
//! application once the session is ready.

use std::ffi::{OsStr, OsString};
use std::io;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};

use crate::cli::CompositorChoice;
use crate::linux::launcher::command_in_path;

/// How a named application is started inside the nested compositor.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AppProfile {
    /// The `--app` value.
    pub name: &'static str,
    /// Binary names to look for, in order of preference.
    pub binary_names: &'static [&'static str],
    /// Environment forced on the child so it takes its Wayland path.
    pub env: &'static [(&'static str, &'static str)],
    /// Arguments prepended to any `--` passthrough.
    pub args: &'static [&'static str],
    /// The compositor this app prefers; `Auto` defers to the normal probe order.
    pub compositor: CompositorChoice,
    /// Whether the single window should be made fullscreen (Sway: a `for_window` rule).
    pub fullscreen: bool,
    /// Whether snap confinement needs the raw host `PULSE_SERVER` withheld.
    pub snap_aware: bool,
    /// Whether the application needs its own D-Bus session bus.
    ///
    /// D-Bus single-instance applications hand a launch request to whichever instance already
    /// owns their name on the bus. With the host's session bus inherited, that instance is on the
    /// host's desktop: the nested compositor gets no window at all. A private bus also keeps the
    /// nested session from talking to the host's services, which is the point of running it
    /// isolated.
    pub private_dbus: bool,
    /// The Wayland `app_id` used to target the window from the compositor config.
    pub app_id: &'static str,
}

/// The built-in profile table.
///
/// Sway is preferred for app mode: it can force a single fullscreen window over IPC and draws no
/// panel, whereas Weston's `[shell]` panel cannot be reliably hidden across the 13..16 range
/// (plan D4/D5). Weston still runs the app when it is the only compositor available or when a
/// DRM flag names it.
pub const PROFILES: &[AppProfile] = &[
    AppProfile {
        name: "google-chrome",
        binary_names: &[
            "google-chrome",
            "google-chrome-stable",
            "chromium",
            "chromium-browser",
        ],
        // The proven invocation from the manual reference run (`veston.txt`).
        env: &[],
        args: &[
            "--ozone-platform=wayland",
            "--start-maximized",
            "--no-first-run",
            "--no-default-browser-check",
            "--disable-breakpad",
            "--disable-crash-reporter",
            "--disable-metrics",
            "--disable-metrics-repo-reporting",
            "--disable-component-update",
            "--disable-background-networking",
            "--disable-sync",
            "--disable-features=PassageEmbeddings,HistoryEmbeddings,OptimizationGuideModelDownloading",
        ],
        compositor: CompositorChoice::Sway,
        fullscreen: true,
        snap_aware: true,
        // Chrome's single-instance check is keyed on its user-data directory rather than the
        // session bus, and it reaches the nested compositor with the host bus inherited.
        private_dbus: false,
        app_id: "google-chrome",
    },
    AppProfile {
        name: "thunar",
        binary_names: &["thunar", "Thunar"],
        env: &[("GDK_BACKEND", "wayland")],
        args: &[],
        compositor: CompositorChoice::Sway,
        fullscreen: true,
        snap_aware: false,
        private_dbus: true,
        app_id: "thunar",
    },
];

/// Look up a profile by its `--app` name.
pub fn profile(name: &str) -> io::Result<&'static AppProfile> {
    PROFILES
        .iter()
        .find(|profile| profile.name == name)
        .ok_or_else(|| {
            io::Error::new(
                io::ErrorKind::InvalidInput,
                format!(
                    "unknown --app profile {name:?}; known profiles: {}",
                    known()
                ),
            )
        })
}

/// The known profile names, for help and error text.
pub fn known() -> String {
    PROFILES
        .iter()
        .map(|profile| profile.name)
        .collect::<Vec<_>>()
        .join(", ")
}

/// A resolved launch: the argument vector and the environment the child needs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AppLaunch {
    /// The full argv, including any private-bus wrapper.
    pub program: Vec<OsString>,
    /// The application binary itself, for diagnostics.
    pub binary: PathBuf,
    pub env: Vec<(OsString, OsString)>,
    /// Set when the resolved binary is snap-confined; snap rewrites `XDG_RUNTIME_DIR` and
    /// mediates Pulse itself, so a raw host `PULSE_SERVER` must be withheld (kitweb's finding).
    pub snap_confined: bool,
    pub fullscreen: bool,
    pub app_id: &'static str,
}

/// The wrapper that gives a launched application its own D-Bus session bus.
const PRIVATE_BUS_LAUNCHER: &str = "dbus-run-session";

impl AppProfile {
    /// Build the launch for this profile, appending any `--` passthrough arguments.
    pub fn launch(&self, passthrough: &[OsString]) -> io::Result<AppLaunch> {
        let binary = self.discover().ok_or_else(|| {
            io::Error::new(
                io::ErrorKind::NotFound,
                format!(
                    "--app {} found none of: {}",
                    self.name,
                    self.binary_names.join(", ")
                ),
            )
        })?;
        Ok(self.launch_with(&binary, passthrough))
    }

    /// The argv/env half of [`launch`], separated so it is testable without a real binary.
    pub fn launch_with(&self, binary: &Path, passthrough: &[OsString]) -> AppLaunch {
        self.launch_parts(
            binary,
            passthrough,
            self.private_dbus && private_bus_available(),
        )
    }

    /// [`launch_with`] with the private-bus decision supplied, so it can be tested both ways.
    fn launch_parts(
        &self,
        binary: &Path,
        passthrough: &[OsString],
        private_bus: bool,
    ) -> AppLaunch {
        let mut program = Vec::with_capacity(3 + self.args.len() + passthrough.len());
        if private_bus {
            // dbus-run-session starts a fresh bus and overrides DBUS_SESSION_BUS_ADDRESS for the
            // command it runs, so nothing else has to unset the inherited one.
            program.push(OsString::from(PRIVATE_BUS_LAUNCHER));
            program.push(OsString::from("--"));
        }
        program.push(binary.as_os_str().to_owned());
        program.extend(self.args.iter().map(OsString::from));
        program.extend(passthrough.iter().cloned());
        AppLaunch {
            program,
            binary: binary.to_owned(),
            env: self
                .env
                .iter()
                .map(|(name, value)| (OsString::from(name), OsString::from(value)))
                .collect(),
            snap_confined: self.snap_aware && is_snap_binary(binary),
            fullscreen: self.fullscreen,
            app_id: self.app_id,
        }
    }

    /// The best of this profile's binaries present on `PATH`.
    ///
    /// A non-snap binary wins over a snap one at the same preference level: snap confinement
    /// cannot reach a nested compositor's private Wayland socket (see [`SNAP_WAYLAND_GAP`]).
    pub fn discover(&self) -> Option<PathBuf> {
        prefer_non_snap(
            self.binary_names
                .iter()
                .filter(|name| command_in_path(name))
                .filter_map(|name| which_path(name))
                .collect(),
        )
    }
}

fn private_bus_available() -> bool {
    command_in_path(PRIVATE_BUS_LAUNCHER)
}

/// Pick the first non-snap candidate, falling back to the first candidate of any kind.
///
/// Snap confinement cannot reach the nested compositor's private Wayland socket
/// ([`SNAP_WAYLAND_GAP`]), so a deb or flatpak build of the same application always wins.
fn prefer_non_snap(candidates: Vec<PathBuf>) -> Option<PathBuf> {
    candidates
        .iter()
        .find(|path| !is_snap_binary(path))
        .or_else(|| candidates.first())
        .cloned()
}

/// Whether a resolved binary path is snap-confined.
///
/// Pure so it can be unit-tested with fixture paths; the `snap list` fallback below is the only
/// part that touches the host (kitweb `browser.rs:277-307`).
pub fn is_snap_path(path: &Path) -> bool {
    let path = path.to_string_lossy();
    path.starts_with("/snap/") || path.contains("/snap/bin/")
}

fn is_snap_binary(path: &Path) -> bool {
    if is_snap_path(path) {
        return true;
    }
    // Ubuntu ships firefox and chromium as snaps behind a /usr/bin shim, so the path alone is not
    // conclusive; ask snapd about the package that owns the file name.
    path.file_name()
        .and_then(OsStr::to_str)
        .is_some_and(snap_package_installed)
}

fn snap_package_installed(package: &str) -> bool {
    Command::new("snap")
        .args(["list", package])
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status()
        .is_ok_and(|status| status.success())
}

/// Why a snap-confined application cannot reach the nested compositor.
///
/// Confirmed on Ubuntu with the Firefox snap: snapd rewrites `XDG_RUNTIME_DIR` to
/// `/run/user/<uid>/snap.<name>/`, and its AppArmor profile only permits Wayland sockets at the
/// standard `/run/user/<uid>/wayland-N` path. vvland's socket lives in a private 0700 directory
/// by design, so the snap's connect is denied. Putting the socket in the shared runtime directory
/// would expose the nested session to every process on the host, so vvland does not do it: the
/// fix is a non-snap build of the application (plan risk R6).
pub const SNAP_WAYLAND_GAP: &str = "is snap-confined; snap's sandbox only allows Wayland sockets at $XDG_RUNTIME_DIR/wayland-N, \
     so it cannot connect to vvland's private compositor socket. Install the non-snap (deb or \
     flatpak) build, or run it under --xwayland";

/// Whether a Pulse server address is a raw host unix socket.
///
/// Snap-confined browsers run behind snap's own Pulse mediation; handing them a raw host socket
/// can drop them to ALSA, while `PULSE_SINK` alone still selects the private sink.
pub fn is_unix_pulse_server(server: &OsStr) -> bool {
    server.to_string_lossy().starts_with("unix:")
}

fn which_path(name: &str) -> Option<PathBuf> {
    std::env::var_os("PATH").and_then(|paths| {
        std::env::split_paths(&paths)
            .map(|directory| directory.join(name))
            .find(|candidate| candidate.is_file())
    })
}

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

    #[test]
    fn unknown_profiles_list_the_known_ones() {
        let error = profile("emacs").unwrap_err();
        assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
        let message = error.to_string();
        assert!(message.contains("emacs"));
        for name in ["google-chrome", "thunar"] {
            assert!(message.contains(name), "{message}");
            assert_eq!(profile(name).unwrap().name, name);
        }
    }

    #[test]
    fn profile_arguments_come_before_the_passthrough() {
        let chrome = profile("google-chrome").unwrap();
        let launch = chrome.launch_with(
            Path::new("/usr/bin/google-chrome"),
            &[OsString::from("--user-data-dir=/tmp/x")],
        );
        assert_eq!(
            launch.program,
            [
                "/usr/bin/google-chrome",
                "--ozone-platform=wayland",
                "--start-maximized",
                "--no-first-run",
                "--no-default-browser-check",
                "--disable-breakpad",
                "--disable-crash-reporter",
                "--disable-metrics",
                "--disable-metrics-repo-reporting",
                "--disable-component-update",
                "--disable-background-networking",
                "--disable-sync",
                "--disable-features=PassageEmbeddings,HistoryEmbeddings,OptimizationGuideModelDownloading",
                "--user-data-dir=/tmp/x",
            ]
            .map(OsString::from)
        );
    }

    #[test]
    fn profiles_force_the_wayland_backend() {
        let thunar = profile("thunar")
            .unwrap()
            .launch_with(Path::new("/usr/bin/thunar"), &[]);
        assert!(
            thunar
                .env
                .contains(&(OsString::from("GDK_BACKEND"), OsString::from("wayland")))
        );
        assert_eq!(thunar.app_id, "thunar");
        // Chrome takes its Wayland path from an argument, not the environment.
        assert!(profile("google-chrome").unwrap().env.is_empty());
    }

    #[test]
    fn a_dbus_single_instance_application_gets_its_own_bus() {
        // Without this, Thunar hands the launch to whichever instance already owns its name on
        // the inherited host bus and the nested compositor never sees a window.
        let thunar = profile("thunar").unwrap();
        assert!(thunar.private_dbus);
        let wrapped = thunar.launch_parts(Path::new("/usr/bin/thunar"), &[], true);
        assert_eq!(
            wrapped.program,
            ["dbus-run-session", "--", "/usr/bin/thunar"].map(OsString::from)
        );
        // The application binary stays reportable for diagnostics.
        assert_eq!(wrapped.binary, Path::new("/usr/bin/thunar"));

        // Without the wrapper available the application is still launched, just on the host bus.
        let bare = thunar.launch_parts(Path::new("/usr/bin/thunar"), &[], false);
        assert_eq!(bare.program, ["/usr/bin/thunar"].map(OsString::from));

        // Chrome does not need one, so it is never wrapped.
        let chrome = profile("google-chrome").unwrap();
        assert!(!chrome.private_dbus);
        assert_eq!(
            chrome
                .launch_with(Path::new("/usr/bin/google-chrome"), &[])
                .program
                .first()
                .unwrap(),
            &OsString::from("/usr/bin/google-chrome")
        );
    }

    #[test]
    fn the_private_bus_wrapper_precedes_profile_and_passthrough_arguments() {
        let thunar = profile("thunar").unwrap();
        let launch = thunar.launch_parts(
            Path::new("/usr/bin/thunar"),
            &[OsString::from("/tmp")],
            true,
        );
        assert_eq!(
            launch.program,
            ["dbus-run-session", "--", "/usr/bin/thunar", "/tmp"].map(OsString::from)
        );
    }

    #[test]
    fn discovery_prefers_a_non_snap_binary() {
        let snap = PathBuf::from("/snap/bin/chromium");
        let plain = PathBuf::from("/usr/bin/google-chrome");
        assert_eq!(
            prefer_non_snap(vec![snap.clone(), plain.clone()]),
            Some(plain.clone())
        );
        // Order within the profile does not rescue a snap when a plain build exists.
        assert_eq!(
            prefer_non_snap(vec![plain.clone(), snap.clone()]),
            Some(plain)
        );
        // A snap is still better than nothing; the launch warns rather than refusing.
        assert_eq!(prefer_non_snap(vec![snap.clone()]), Some(snap));
        assert_eq!(prefer_non_snap(Vec::new()), None);
    }

    #[test]
    fn snap_paths_are_detected_without_touching_snapd() {
        assert!(is_snap_path(Path::new("/snap/bin/chromium")));
        assert!(is_snap_path(Path::new("/snap/chromium/current/chromium")));
        assert!(is_snap_path(Path::new("/usr/local/snap/bin/chromium")));
        assert!(!is_snap_path(Path::new("/usr/bin/google-chrome")));
        assert!(!is_snap_path(Path::new("/opt/google/chrome/chrome")));

        // A snap path is confinement only for a profile that opts into snap handling.
        let launch = profile("thunar")
            .unwrap()
            .launch_with(Path::new("/snap/bin/thunar"), &[]);
        assert!(!launch.snap_confined, "thunar is not snap-aware");
        let launch = profile("google-chrome")
            .unwrap()
            .launch_with(Path::new("/snap/bin/chromium"), &[]);
        assert!(launch.snap_confined);
    }

    #[test]
    fn raw_unix_pulse_servers_are_recognized() {
        assert!(is_unix_pulse_server(OsStr::new(
            "unix:/run/user/1000/pulse/native"
        )));
        assert!(!is_unix_pulse_server(OsStr::new("tcp:127.0.0.1:4713")));
    }

    #[test]
    fn help_text_lists_every_built_in_profile() {
        // The `--app` help line names the profiles; keep it honest as the table grows.
        use clap::CommandFactory;
        let help = crate::cli::Config::command().render_long_help().to_string();
        for profile in PROFILES {
            assert!(help.contains(profile.name), "help omits {}", profile.name);
        }
    }

    #[test]
    fn every_profile_prefers_a_compositor_that_can_isolate_one_window() {
        // Weston's shell panel cannot be reliably hidden across 13..16, so app mode prefers Sway
        // wherever the profile expresses a preference (plan D4).
        for profile in PROFILES {
            assert_ne!(
                profile.compositor,
                CompositorChoice::Weston,
                "{}",
                profile.name
            );
            assert!(!profile.binary_names.is_empty(), "{}", profile.name);
            assert!(!profile.app_id.is_empty(), "{}", profile.name);
        }
    }
}