Skip to main content

qcli/app/
form.rs

1//! The provider form: the four settings a provider needs, checked before they are written.
2
3use qframe::prelude::*;
4use qframe::widgets::{Field as FormFieldWidget, Form, FormErrors, Modal, TextInput};
5
6use super::Msg;
7use crate::config::Provider;
8use crate::provider::{Key, KeyError};
9
10/// The id of the form's first field, which has focus when the form opens.
11pub const FIRST: &str = "provider-address";
12
13/// The fields of the form.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum Field {
16    /// The address.
17    Address,
18    /// The model.
19    Model,
20    /// The key file's path.
21    KeyFile,
22    /// The header the key goes in.
23    KeyHeader,
24}
25
26impl Field {
27    const ALL: [Self; 4] = [Self::Address, Self::Model, Self::KeyFile, Self::KeyHeader];
28
29    fn id(self) -> &'static str {
30        match self {
31            Self::Address => FIRST,
32            Self::Model => "provider-model",
33            Self::KeyFile => "provider-key-file",
34            Self::KeyHeader => "provider-key-header",
35        }
36    }
37
38    fn label(self) -> String {
39        match self {
40            Self::Address => t!("provider.address"),
41            Self::Model => t!("provider.model"),
42            Self::KeyFile => t!("provider.key-file"),
43            Self::KeyHeader => t!("provider.key-header"),
44        }
45    }
46
47    fn hint(self) -> String {
48        match self {
49            Self::Address => t!("provider.address-hint"),
50            Self::Model => t!("provider.model-hint"),
51            Self::KeyFile => t!("provider.key-file-hint"),
52            Self::KeyHeader => t!("provider.key-header-hint"),
53        }
54    }
55
56    /// Whether the provider cannot be asked without this field.
57    fn needed(self) -> bool {
58        !matches!(self, Self::KeyHeader)
59    }
60}
61
62/// The form's values while it is open, with what the last save found wrong in the framework's own
63/// `FormErrors`, named after the controls the problems belong to.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct ProviderForm {
66    provider: Provider,
67    errors: FormErrors,
68}
69
70impl ProviderForm {
71    /// The form filled with `provider`.
72    #[must_use]
73    pub fn from_provider(provider: &Provider) -> Self {
74        Self { provider: provider.clone(), errors: FormErrors::new() }
75    }
76
77    /// Changes one field.
78    pub fn set(&mut self, field: Field, value: String) {
79        match field {
80            Field::Address => self.provider.address = value,
81            Field::Model => self.provider.model = value,
82            Field::KeyFile => self.provider.key_file = value,
83            Field::KeyHeader => self.provider.key_header = value,
84        }
85    }
86
87    fn value(&self, field: Field) -> &str {
88        match field {
89            Field::Address => &self.provider.address,
90            Field::Model => &self.provider.model,
91            Field::KeyFile => &self.provider.key_file,
92            Field::KeyHeader => &self.provider.key_header,
93        }
94    }
95
96    /// The provider when every field holds what it must, the key file included; otherwise the
97    /// problems are kept for the form to show and there is nothing to save.
98    pub fn check(&mut self) -> Option<Provider> {
99        let provider = Provider {
100            address: self.provider.address.trim().to_owned(),
101            model: self.provider.model.trim().to_owned(),
102            key_file: self.provider.key_file.trim().to_owned(),
103            key_header: self.provider.key_header.trim().to_owned(),
104            source: crate::config::Source::Settings,
105        };
106        let mut errors = FormErrors::new();
107        for field in Field::ALL.into_iter().filter(|field| field.needed()) {
108            let value = match field {
109                Field::Address => &provider.address,
110                Field::Model => &provider.model,
111                _ => &provider.key_file,
112            };
113            errors.check(field.id(), !value.is_empty(), t!("provider.empty"));
114        }
115        if !provider.key_file.is_empty()
116            && let Err(error) = Key::from_file(&provider.key_path())
117        {
118            errors.set(Field::KeyFile.id(), refusal(&error));
119        }
120        self.errors = errors;
121        self.errors.is_empty().then_some(provider)
122    }
123
124    /// What the last save found wrong, for the fields to show and for focus to follow.
125    #[must_use]
126    pub fn errors(&self) -> &FormErrors {
127        &self.errors
128    }
129
130    /// Says that the settings could not be written.
131    pub fn failed(&mut self, error: &std::io::Error) {
132        self.errors = FormErrors::new();
133        self.errors.set(Field::Address.id(), t!("provider.unsaved", reason = error.to_string()));
134    }
135
136    /// What the field's value puts at risk, said under it in place of its hint. A warning never
137    /// keeps the form from being saved: a server on another machine without TLS, or a key file
138    /// others can read, can be what the person means, and they are told rather than stopped.
139    fn warning(&self, field: Field) -> Option<String> {
140        match field {
141            Field::Address => plain_http(&self.provider.address).then(|| t!("provider.plain-http")),
142            Field::KeyFile => {
143                let path = self.provider.key_path();
144                (!self.provider.key_file.trim().is_empty() && others_can_read(&path))
145                    .then(|| t!("provider.key-file-open"))
146            }
147            _ => None,
148        }
149    }
150
151    /// Makes the key file readable by its owner alone, which the person asked for with the button
152    /// beside the warning; what went wrong, if anything, is said under the field.
153    pub fn make_key_file_private(&mut self) {
154        use std::os::unix::fs::PermissionsExt as _;
155        let path = self.provider.key_path();
156        if let Err(error) = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) {
157            self.errors.set(
158                Field::KeyFile.id(),
159                t!("provider.key-unreadable", path = path.display().to_string(), reason = error.to_string()),
160            );
161        }
162    }
163
164    /// Draws the form as a dialog over the chat.
165    pub fn view(&self, ui: &mut View<'_, Msg>) {
166        let dialog = Modal::new()
167            .title(t!("provider.title"))
168            .width(72)
169            .on_close(Msg::CloseProvider)
170            .action(Button::new(t!("provider.cancel")).on_press(Msg::CloseProvider))
171            .action(Button::new(t!("provider.save")).variant("primary").on_press(Msg::SaveProvider));
172        ui.add_with(dialog, |ui| {
173            ui.add(Text::new(t!("provider.intro")).role("secondary"));
174            Form::new().show(ui, |fields| {
175                for field in Field::ALL {
176                    let widget = FormFieldWidget::new(field.label())
177                        .required(field.needed())
178                        .hint(field.hint())
179                        .warning(self.warning(field))
180                        .error(self.errors.get(field.id()));
181                    fields.field(widget, |ui| {
182                        ui.add(
183                            TextInput::new(self.value(field))
184                                .invalid(self.errors.has(field.id()))
185                                .on_change(move |value| Msg::Form(field, value))
186                                .on_submit(|_| Msg::SaveProvider),
187                        )
188                        .id(field.id())
189                        .fill_width();
190                    });
191                    // Done here, with the person's click, rather than a command for them to copy.
192                    if field == Field::KeyFile && self.warning(field).is_some() {
193                        fields
194                            .ui()
195                            .add(Button::new(t!("provider.make-private")).on_press(Msg::PrivateKeyFile))
196                            .id("provider-make-private");
197                    }
198                }
199            });
200        });
201    }
202}
203
204/// What a key file's refusal says, in the language on screen.
205fn refusal(error: &KeyError) -> String {
206    match error {
207        KeyError::Unreadable { path, reason } => {
208            t!("provider.key-unreadable", path = path.display().to_string(), reason = reason.clone())
209        }
210        KeyError::NotAKey { path } => t!("provider.not-a-key", path = path.display().to_string()),
211    }
212}
213
214/// Whether `address` sends the key over plain HTTP to another machine. A server on this machine
215/// (`localhost`, `127.x`, `::1`) never puts it on a network.
216fn plain_http(address: &str) -> bool {
217    let address = address.trim();
218    let Some(rest) = address.get(..7).filter(|scheme| scheme.eq_ignore_ascii_case("http://")).map(|_| &address[7..])
219    else {
220        return false;
221    };
222    let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
223    !crate::config::loopback(crate::config::host(authority))
224}
225
226/// Whether anyone but the owner can read the file at `path`.
227fn others_can_read(path: &std::path::Path) -> bool {
228    use std::os::unix::fs::PermissionsExt as _;
229    std::fs::metadata(path).is_ok_and(|meta| meta.permissions().mode() & 0o044 != 0)
230}