ashpd/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![deny(rustdoc::broken_intra_doc_links)]
3#![deny(missing_docs)]
4#![doc(
5    html_logo_url = "https://raw.githubusercontent.com/bilelmoussaoui/ashpd/master/ashpd-demo/data/icons/com.belmoussaoui.ashpd.demo.svg",
6    html_favicon_url = "https://raw.githubusercontent.com/bilelmoussaoui/ashpd/master/ashpd-demo/data/icons/com.belmoussaoui.ashpd.demo-symbolic.svg"
7)]
8#![doc = include_str!("../README.md")]
9#[cfg(all(all(feature = "tokio", feature = "async-std"), not(doc)))]
10compile_error!("You can't enable both async-std & tokio features at once");
11#[cfg(all(not(feature = "tokio"), not(feature = "async-std"), not(doc)))]
12compile_error!("Either the `async-std` or the `tokio` feature has to be enabled");
13
14/// Alias for a [`Result`] with the error type `ashpd::Error`.
15pub type Result<T> = std::result::Result<T, Error>;
16
17static IS_SANDBOXED: OnceLock<bool> = OnceLock::new();
18
19mod activation_token;
20/// Interact with the user's desktop such as taking a screenshot, setting a
21/// background or querying the user's location.
22pub mod desktop;
23/// Interact with the documents store or transfer files across apps.
24pub mod documents;
25mod error;
26mod window_identifier;
27
28pub use self::{activation_token::ActivationToken, window_identifier::WindowIdentifier};
29mod app_id;
30mod registry;
31pub use self::{app_id::AppID, registry::register_host_app};
32mod file_path;
33pub use self::file_path::FilePath;
34
35mod proxy;
36
37#[cfg(feature = "backend")]
38#[cfg_attr(docsrs, doc(cfg(feature = "backend")))]
39pub use self::window_identifier::WindowIdentifierType;
40#[cfg(feature = "backend")]
41#[cfg_attr(docsrs, doc(cfg(feature = "backend")))]
42#[allow(missing_docs)]
43/// Build your custom portals backend.
44pub mod backend;
45/// Spawn commands outside the sandbox or monitor if the running application has
46/// received an update & install it.
47pub mod flatpak;
48mod helpers;
49use std::sync::OnceLock;
50
51#[cfg(feature = "backend")]
52#[cfg_attr(docsrs, doc(cfg(feature = "backend")))]
53pub use async_trait;
54pub use enumflags2;
55pub use url;
56pub use zbus::{self, zvariant};
57
58/// Check whether the application is running inside a sandbox.
59///
60/// The function checks whether the file `/.flatpak-info` exists, or if the app
61/// is running as a snap, or if the environment variable `GTK_USE_PORTAL` is set
62/// to `1`. As the return value of this function will not change during the
63/// runtime of a program; it is cached for future calls.
64pub async fn is_sandboxed() -> bool {
65    if let Some(cached_value) = IS_SANDBOXED.get() {
66        return *cached_value;
67    }
68    let new_value = crate::helpers::is_flatpak().await
69        || crate::helpers::is_snap().await
70        || std::env::var("GTK_USE_PORTAL")
71            .map(|v| v == "1")
72            .unwrap_or(false);
73
74    *IS_SANDBOXED.get_or_init(|| new_value)
75}
76
77pub use self::error::{Error, PortalError};
78
79mod sealed {
80    /// Use as a supertrait for public traits that users should not be able to
81    /// implement
82    pub trait Sealed {}
83}
84
85pub(crate) use sealed::Sealed;
86
87/// Process ID.
88///
89/// Matches the type used in std.
90pub type Pid = u32;
91
92#[cfg(test)]
93mod tests {
94    use std::{collections::HashMap, fs, path::PathBuf};
95
96    use quick_xml::{events::Event, Reader};
97
98    // Helper to convert PascalCase to snake_case
99    fn pascal_to_snake_case(s: &str) -> String {
100        let mut result = String::new();
101        for (i, c) in s.chars().enumerate() {
102            if c.is_ascii_uppercase() {
103                if i > 0 {
104                    result.push('_');
105                }
106                result.push(c.to_ascii_lowercase());
107            } else {
108                result.push(c);
109            }
110        }
111        result
112    }
113
114    fn extract_names_from_xml(xml_content: &str) -> HashMap<String, Vec<String>> {
115        let mut interfaces = HashMap::new();
116        let mut reader = Reader::from_str(xml_content);
117        reader.config_mut().trim_text(true);
118        let mut buf = Vec::new();
119        let mut current_interface_name = String::new();
120        let mut current_names = Vec::new();
121
122        loop {
123            match reader.read_event_into(&mut buf) {
124                Err(e) => panic!("Error at position {}: {:?}", reader.buffer_position(), e),
125                Ok(Event::Eof) => break,
126                Ok(Event::Start(e)) | Ok(Event::Empty(e)) => {
127                    // Handle both Start and Empty events
128                    if let Some(Ok(attr)) = e
129                        .attributes()
130                        .find(|a| a.as_ref().map_or(false, |a| a.key.as_ref() == b"name"))
131                    {
132                        if let Ok(value) = attr.decode_and_unescape_value(reader.decoder()) {
133                            match e.name().as_ref() {
134                                b"interface" => {
135                                    current_interface_name = value.to_string();
136                                    current_names.clear();
137                                }
138                                b"method" | b"property" | b"signal" => {
139                                    if value != "version" {
140                                        current_names.push(value.to_string());
141                                    }
142                                }
143                                _ => (),
144                            }
145                        }
146                    }
147                }
148                Ok(Event::End(e)) => {
149                    if e.name().as_ref() == b"interface" {
150                        interfaces.insert(current_interface_name.clone(), current_names.clone());
151                    }
152                }
153                _ => (),
154            }
155            buf.clear();
156        }
157        interfaces
158    }
159
160    struct TestConfig {
161        interfaces_dir: PathBuf,
162        rust_src_prefix: &'static str,
163        rust_file_mappings: HashMap<&'static str, &'static str>,
164        ignored_interfaces: &'static [&'static str],
165        interface_prefix: &'static str,
166    }
167
168    fn check_doc_aliases_for_config(config: TestConfig) {
169        assert!(
170            config.interfaces_dir.exists(),
171            "Interfaces directory not found at {}",
172            config.interfaces_dir.display()
173        );
174
175        let entries =
176            fs::read_dir(&config.interfaces_dir).expect("Failed to read interfaces directory");
177
178        for entry in entries.filter_map(|e| e.ok()) {
179            let path = entry.path();
180            if path.is_file() && path.extension().map_or(false, |ext| ext == "xml") {
181                println!("Checking XML file: {}", path.display());
182
183                let xml_content = fs::read_to_string(&path).unwrap();
184                let interfaces = extract_names_from_xml(&xml_content);
185
186                for (interface_name, names_to_check) in interfaces {
187                    // Map the D-Bus interface name to the corresponding Rust file path
188                    let interface_name_suffix = interface_name
189                        .strip_prefix(config.interface_prefix)
190                        .expect("Interface name does not have the expected prefix.");
191
192                    if config.ignored_interfaces.contains(&interface_name_suffix) {
193                        continue;
194                    }
195
196                    let rust_path = if let Some(mapped_path) =
197                        config.rust_file_mappings.get(interface_name.as_str())
198                    {
199                        PathBuf::from(mapped_path)
200                    } else {
201                        let rust_file_name_snake = pascal_to_snake_case(interface_name_suffix);
202                        PathBuf::from(format!(
203                            "{}{}.rs",
204                            config.rust_src_prefix, rust_file_name_snake
205                        ))
206                    };
207
208                    // Check if the Rust file exists
209                    assert!(
210                        rust_path.exists(),
211                        "Corresponding Rust file not found for interface '{}' at {}",
212                        interface_name,
213                        rust_path.display()
214                    );
215
216                    // Read the Rust file content
217                    let rust_content = fs::read_to_string(&rust_path).unwrap();
218
219                    // Assert that each name has a corresponding doc alias
220                    for name in &names_to_check {
221                        let alias_str = format!("#[doc(alias = \"{}\")]", name);
222                        assert!(
223                            rust_content.contains(&alias_str),
224                            "Missing doc alias '{}' for interface '{}' in file {}",
225                            alias_str,
226                            interface_name,
227                            rust_path.display()
228                        );
229                    }
230                }
231            }
232        }
233    }
234
235    #[cfg(feature = "backend")]
236    #[test]
237    fn all_interfaces_have_backend_implementations() {
238        let rust_file_mappings: HashMap<&str, &str> = HashMap::from([(
239            "org.freedesktop.impl.portal.ScreenCast",
240            "src/backend/screencast.rs",
241        )]);
242
243        const IGNORED_BACKEND_PORTALS: &[&str; 7] = &[
244            "Clipboard",
245            "DynamicLauncher",
246            "GlobalShortcuts",
247            "Inhibit",
248            "InputCapture",
249            "Notification",
250            "RemoteDesktop",
251        ];
252
253        let config = TestConfig {
254            interfaces_dir: PathBuf::from("./interfaces/backend"),
255            rust_src_prefix: "src/backend/",
256            rust_file_mappings,
257            ignored_interfaces: IGNORED_BACKEND_PORTALS,
258            interface_prefix: "org.freedesktop.impl.portal.",
259        };
260
261        check_doc_aliases_for_config(config);
262    }
263
264    #[test]
265    fn all_interfaces_have_implementations() {
266        let rust_file_mappings: HashMap<&str, &str> = HashMap::from([
267            (
268                "org.freedesktop.portal.ScreenCast",
269                "src/desktop/screencast.rs",
270            ),
271            ("org.freedesktop.portal.OpenURI", "src/desktop/open_uri.rs"),
272            (
273                "org.freedesktop.portal.FileTransfer",
274                "src/documents/file_transfer.rs",
275            ),
276            ("org.freedesktop.portal.Documents", "src/documents/mod.rs"),
277            ("org.freedesktop.portal.Flatpak", "src/flatpak/mod.rs"),
278            (
279                "org.freedesktop.portal.Flatpak.UpdateMonitor",
280                "src/flatpak/update_monitor.rs",
281            ),
282        ]);
283
284        const NO_IGNORED_INTERFACES: &[&str; 0] = &[];
285
286        let config = TestConfig {
287            interfaces_dir: PathBuf::from("./interfaces"),
288            rust_src_prefix: "src/desktop/",
289            rust_file_mappings,
290            ignored_interfaces: NO_IGNORED_INTERFACES,
291            interface_prefix: "org.freedesktop.portal.",
292        };
293
294        check_doc_aliases_for_config(config);
295    }
296}