1
// SPDX-License-Identifier: MIT
2
// SPDX-FileCopyrightText: 2025 Harald Sitter <sitter@kde.org>
3

            
4
use std::os::fd::AsFd;
5

            
6
use ashpd::WindowIdentifierType;
7
use gettextrs::gettext;
8
use oo7::{Secret, dbus::ServiceError};
9
use serde::Serialize;
10
use tokio::io::AsyncReadExt;
11
use zbus::{
12
    object_server::SignalEmitter,
13
    zvariant::{self, ObjectPath, OwnedFd, OwnedObjectPath, Type},
14
};
15

            
16
use crate::{
17
    prompt::{Prompt, PromptRole},
18
    service::Service,
19
};
20

            
21
#[repr(i32)]
22
#[derive(Type, Serialize)]
23
pub enum CallbackAction {
24
    Dismiss = 0,
25
    Keep = 1,
26
}
27

            
28
#[must_use]
29
44
pub async fn in_plasma_environment(connection: &zbus::Connection) -> bool {
30
    static IS_PLASMA: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
31
22
    if let Some(cached_value) = IS_PLASMA.get() {
32
6
        return *cached_value;
33
    }
34

            
35
56
    let is_plasma = async {
36
22
        if !std::env::var("XDG_CURRENT_DESKTOP").is_ok_and(|v| v.to_lowercase() == "kde") {
37
12
            return false;
38
        }
39

            
40
        let proxy = match zbus::fdo::DBusProxy::new(connection).await {
41
            Ok(proxy) => proxy,
42
            Err(_) => return false,
43
        };
44
        let activatable_names = match proxy.list_activatable_names().await {
45
            Ok(names) => names,
46
            Err(_) => return false,
47
        };
48
        activatable_names
49
            .iter()
50
            .any(|name| name.as_str() == "org.kde.secretprompter")
51
    }
52
32
    .await;
53

            
54
30
    *IS_PLASMA.get_or_init(|| is_plasma)
55
}
56

            
57
#[zbus::proxy(
58
    default_service = "org.kde.secretprompter",
59
    interface = "org.kde.secretprompter",
60
    default_path = "/SecretPrompter",
61
    gen_blocking = false
62
)]
63
pub trait PlasmaPrompter {
64
    fn unlock_collection_prompt(
65
        &self,
66
        request: &ObjectPath<'_>,
67
        window_id: &str,
68
        activation_token: &str,
69
        collection_name: &str,
70
    ) -> Result<(), ServiceError>;
71
    fn create_collection_prompt(
72
        &self,
73
        request: &ObjectPath<'_>,
74
        window_id: &str,
75
        activation_token: &str,
76
        collection_name: &str,
77
    ) -> Result<(), ServiceError>;
78
}
79

            
80
#[derive(Clone)]
81
pub struct PlasmaPrompterCallback {
82
    service: Service,
83
    prompt_path: OwnedObjectPath,
84
    path: OwnedObjectPath,
85
}
86

            
87
#[zbus::interface(name = "org.kde.secretprompter.request")]
88
impl PlasmaPrompterCallback {
89
28
    pub async fn accepted(&self, result_fd: OwnedFd) -> Result<CallbackAction, ServiceError> {
90
4
        let prompt_path = &self.prompt_path;
91
8
        let Some(prompt) = self.service.prompt(prompt_path).await else {
92
            return Err(ServiceError::NoSuchObject(format!(
93
                "Prompt '{prompt_path}' does not exist."
94
            )));
95
        };
96

            
97
8
        tracing::debug!("User accepted the prompt.");
98

            
99
        let secret = {
100
8
            let borrowed_fd = result_fd.as_fd();
101
            let std_stream = std::os::unix::net::UnixStream::from(
102
4
                borrowed_fd
103
4
                    .try_clone_to_owned()
104
4
                    .expect("Failed to clone fd"),
105
            );
106
4
            let mut stream = tokio::net::UnixStream::from_std(std_stream)
107
                .expect("Failed to create Tokio UnixStream");
108
4
            let mut buffer = String::new();
109
12
            stream
110
4
                .read_to_string(&mut buffer)
111
16
                .await
112
                .expect("error reading secret");
113
4
            tracing::debug!("Read secret from fd, length {}", buffer.len());
114
4
            oo7::Secret::from(buffer)
115
        };
116

            
117
8
        self.on_reply(&prompt, secret).await
118
    }
119

            
120
16
    pub async fn rejected(&self) -> Result<CallbackAction, ServiceError> {
121
8
        tracing::debug!("User rejected the prompt.");
122
8
        self.prompter_dismissed(self.prompt_path.clone()).await?;
123
4
        Ok(CallbackAction::Dismiss) // simply dismiss without further action
124
    }
125

            
126
    pub async fn dismissed(&self) -> Result<(), ServiceError> {
127
        // This is only does check if the prompt is tracked on Service
128
        let path = &self.prompt_path;
129
        if let Some(_prompt) = self.service.prompt(path).await {
130
            self.service
131
                .object_server()
132
                .remove::<Prompt, _>(path)
133
                .await?;
134
            self.service.remove_prompt(path).await;
135
        }
136
        self.service
137
            .object_server()
138
            .remove::<Self, _>(&self.path)
139
            .await?;
140

            
141
        Ok(())
142
    }
143

            
144
    #[zbus(signal)]
145
4
    pub async fn retry(signal_emitter: &SignalEmitter<'_>, reason: &str) -> zbus::Result<()>;
146

            
147
    #[zbus(signal)]
148
    pub async fn dismiss(signal_emitter: &SignalEmitter<'_>) -> zbus::Result<()>;
149
}
150

            
151
impl PlasmaPrompterCallback {
152
16
    pub async fn new(service: Service, prompt_path: OwnedObjectPath) -> Self {
153
8
        let index = service.prompt_index().await;
154
        Self {
155
4
            path: OwnedObjectPath::try_from(format!("/org/plasma/keyring/Prompt/p{index}"))
156
                .unwrap(),
157
            service,
158
            prompt_path,
159
        }
160
    }
161

            
162
4
    pub fn path(&self) -> &ObjectPath<'_> {
163
4
        &self.path
164
    }
165

            
166
4
    pub async fn start(
167
        &self,
168
        role: &PromptRole,
169
        window_id: Option<WindowIdentifierType>,
170
        collection_name: &str,
171
    ) -> Result<(), ServiceError> {
172
8
        let path = self.path.clone();
173
8
        let prompter = PlasmaPrompterProxy::new(self.service.connection()).await?;
174
4
        let window_id = match window_id {
175
            Some(id) => id.to_string(),
176
8
            None => String::new(),
177
        };
178
4
        let collection_name = collection_name.to_string();
179

            
180
4
        match role {
181
            PromptRole::Unlock => {
182
16
                tokio::spawn(async move {
183
12
                    prompter
184
8
                        .unlock_collection_prompt(&path, &window_id, "", collection_name.as_str())
185
16
                        .await
186
                });
187
            }
188
            PromptRole::CreateCollection => {
189
16
                tokio::spawn(async move {
190
12
                    prompter
191
8
                        .create_collection_prompt(&path, &window_id, "", collection_name.as_str())
192
16
                        .await
193
                });
194
            }
195
            PromptRole::ChangePassword => {
196
                tokio::spawn(async move {
197
                    prompter
198
                        .unlock_collection_prompt(&path, &window_id, "", collection_name.as_str())
199
                        .await
200
                });
201
            }
202
        }
203

            
204
4
        Ok(())
205
    }
206

            
207
4
    async fn on_reply(
208
        &self,
209
        prompt: &Prompt,
210
        secret: Secret,
211
    ) -> Result<CallbackAction, ServiceError> {
212
        // Handle each role differently based on what validation/preparation is needed
213
8
        match prompt.role() {
214
            PromptRole::Unlock => {
215
16
                if prompt.on_unlock_collection(secret).await? {
216
4
                    Ok(CallbackAction::Dismiss)
217
                } else {
218
8
                    tracing::debug!("Unlock failed, sending retry signal.");
219
                    let emitter = SignalEmitter::from_parts(
220
8
                        self.service.connection().clone(),
221
8
                        self.path().clone(),
222
                    );
223
                    PlasmaPrompterCallback::retry(
224
4
                        &emitter,
225
8
                        &gettext("The unlock password was incorrect"),
226
                    )
227
16
                    .await?;
228

            
229
4
                    Ok(CallbackAction::Keep) // we retry
230
                }
231
            }
232
            PromptRole::CreateCollection => {
233
12
                prompt.on_create_collection(secret).await?;
234
4
                Ok(CallbackAction::Dismiss)
235
            }
236
            PromptRole::ChangePassword => {
237
                prompt.on_change_password(secret).await?;
238
                Ok(CallbackAction::Dismiss)
239
            }
240
        }
241
    }
242

            
243
16
    async fn prompter_dismissed(&self, prompt_path: OwnedObjectPath) -> Result<(), ServiceError> {
244
8
        let signal_emitter = self.service.signal_emitter(prompt_path)?;
245
8
        let result = zvariant::Value::new::<Vec<OwnedObjectPath>>(vec![])
246
            .try_into_owned()
247
            .unwrap();
248

            
249
12
        tokio::spawn(async move { Prompt::completed(&signal_emitter, true, result).await });
250
4
        Ok(())
251
    }
252
}