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
pub async fn in_plasma_environment(connection: &zbus::Connection) -> bool {
30
    static IS_PLASMA: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
31
    if let Some(cached_value) = IS_PLASMA.get() {
32
        return *cached_value;
33
    }
34

            
35
    let is_plasma = async {
36
        if !std::env::var("XDG_CURRENT_DESKTOP").is_ok_and(|v| v.to_lowercase() == "kde") {
37
            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
    .await;
53

            
54
    *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
8
            if buffer.is_empty() {
115
                None
116
            } else {
117
8
                Some(oo7::Secret::from(buffer))
118
            }
119
        };
120

            
121
8
        self.on_reply(&prompt, secret).await
122
    }
123

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

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

            
145
        Ok(())
146
    }
147

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

            
151
    #[zbus(signal)]
152
    pub async fn dismiss(signal_emitter: &SignalEmitter<'_>) -> zbus::Result<()>;
153
}
154

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

            
166
4
    pub fn path(&self) -> &ObjectPath<'_> {
167
4
        &self.path
168
    }
169

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

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

            
208
4
        Ok(())
209
    }
210

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

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

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

            
253
12
        tokio::spawn(async move { Prompt::completed(&signal_emitter, true, result).await });
254
4
        Ok(())
255
    }
256
}