Skip to main content

teksilo_widgets/
privacy_settings.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! PrivacySettings — a user-facing panel for telemetry consent management.
5//!
6//! Embeddable in any container — typically a `Dialog` for first-run consent
7//! or a dedicated tab in the app's settings UI.  Reads from
8//! [`OpenedTelemetry`] and writes to [`ConsentStore`]; the UI rebuilds
9//! whenever the consent state signal changes.  When no telemetry is registered
10//! in `app_state` the widget renders a graceful placeholder so apps without
11//! analytics pay nothing.
12//!
13//! # Sections (top-to-bottom)
14//!
15//! 1. **Plain-language Art. 13 notice** — controller, processor name,
16//!    purposes, lawful basis, retention, recipients, withdrawal right.
17//!    All strings flow through `tr_widget!` against keys defined in
18//!    [`crates/teksilo-widgets/locales/en-US.ftl`](../../../locales/en-US.ftl)
19//!    and [`fr-FR.ftl`](../../../locales/fr-FR.ftl) under the
20//!    `privacy-*` namespace. Apps install the framework bundle via
21//!    `I18nConfig::framework_locales(teksilo_widgets::framework_locales())`.
22//! 2. **Per-scope toggles** — one per
23//!    [`ConsentScope`] field, intersected
24//!    with `reporter.supported_scopes()` so toggles for
25//!    unsupported scopes are hidden, not just disabled. Toggles work
26//!    from `Unknown` (auto-transition to `Granted` with the toggled
27//!    scope) and `Granted` states; they're disabled when state is
28//!    `Denied` until the user clicks Withdraw → Accept.
29//! 3. **Accept all / Reject all** — equal-prominence buttons (CNIL
30//!    parity rule, GDPR Art. 7).
31//! 4. **Identity row** (pseudonymous mode only) — install_id display,
32//!    Get-my-data button (Art. 15 + 20), Erase-my-data button (Art. 17).
33//! 5. **Inspect data sent** — accordion listing the most-recent events
34//!    from the bundle's recent-log ring buffer.
35//! 6. **Mode switch** (when both adapters configured) — confirm-button
36//!    pair to flip anonymous ↔ pseudonymous.
37//! 7. **Footer** — Withdraw consent (equal prominence to Accept,
38//!    GDPR Art. 7(3)).
39//!
40//! When no [`OpenedTelemetry`] is registered in `app_state`, the
41//! widget renders a "Telemetry not configured" placeholder. Apps that
42//! ship without analytics pay nothing.
43//!
44//! ```ignore
45//! // Embed in a Dialog for first-run consent (compact mode).
46//! let panel = PrivacySettings::new()
47//!     .compact(true)
48//!     .data_processor_name("Acme Corp")
49//!     .privacy_policy_url("https://example.com/privacy");
50//! ```
51
52use teksilo_canvas::{Size, SizeProposal};
53use teksilo_core::accessibility::AccessNodeBuilder;
54use teksilo_core::binding::BindingLevel;
55use teksilo_core::build_context::BuildContext;
56use teksilo_core::signal::Signal;
57use teksilo_core::widget::{LayoutContext, Widget, WidgetPlacement};
58use teksilo_core::widget_id::WidgetId;
59use teksilo_i18n::lit;
60use teksilo_i18n::tr_widget;
61use teksilo_telemetry::{
62    ConsentScope, ConsentState, ConsentStore, OpenedTelemetry, RemoteDataExport, TelemetryExt,
63    TelemetryMode, UsageReporter,
64};
65use teksilo_tokens::TextStyleRole;
66
67use crate::accordion::Accordion;
68use crate::button::{Button, ButtonVariant};
69use crate::message_box::{MessageBox, MessageBoxButtons, StandardButton};
70use crate::panel::Panel;
71use crate::primitives::{HStack, Spacer, TextWidget, VStack};
72use crate::toggle::Toggle;
73use teksilo_i18n::LocalizedString;
74
75/// Settings widget for telemetry consent. Construct with
76/// [`PrivacySettings::new`] and embed in any container.
77pub struct PrivacySettings {
78    /// Compact layout for first-run modals: hides the mode-switch
79    /// section and tightens spacing. Default `false` (full settings
80    /// panel layout).
81    compact: bool,
82    /// Show the install-id + fetch + erase row in pseudonymous mode.
83    /// Default `true`. Set to `false` when the host app ships its
84    /// own equivalent UI.
85    show_identity_row: bool,
86    /// Show the anonymous-vs-pseudonymous mode switch when both
87    /// adapters are configured. Default `true`. No effect when only
88    /// one mode is configured (the section is hidden regardless).
89    show_mode_switch: bool,
90    /// Show the "Inspect data sent" accordion. When enabled, the
91    /// widget peeks the last `inspect_event_count` events from the
92    /// `recent_log` ring buffer and lists them. Default `true`.
93    /// Note: snapshot-at-build — opening and closing the
94    /// accordion refreshes the list to current state.
95    show_inspect: bool,
96    /// How many recent events to show in the accordion. Default 50.
97    inspect_event_count: usize,
98    /// Optional URL surfaced as "Read full privacy policy". When
99    /// `None` the link is hidden — the controller is responsible for
100    /// hosting their own policy text.
101    privacy_policy_url: Option<String>,
102    /// Plain-text controller name surfaced in the Art. 13 notice
103    /// ("Data is processed by `<X>`"). Defaults to "the application".
104    data_processor_name: Option<String>,
105
106    /// Inner — `Some` once `build()` has constructed the layout.
107    root_id: Option<WidgetId>,
108}
109
110impl Default for PrivacySettings {
111    fn default() -> Self {
112        Self::new()
113    }
114}
115
116impl PrivacySettings {
117    /// Create a `PrivacySettings` widget with full layout and all sections shown.
118    pub fn new() -> Self {
119        Self {
120            compact: false,
121            show_identity_row: true,
122            show_mode_switch: true,
123            show_inspect: true,
124            inspect_event_count: 50,
125            privacy_policy_url: None,
126            data_processor_name: None,
127            root_id: None,
128        }
129    }
130
131    /// Use a compact layout suited for first-run modals: hides the mode-switch
132    /// section and tightens spacing. Defaults to `false` (full settings panel).
133    pub fn compact(mut self, compact: bool) -> Self {
134        self.compact = compact;
135        self
136    }
137
138    /// Show or hide the install-id / GDPR Art. 15 + 17 identity row in
139    /// pseudonymous mode. Set to `false` when the host app supplies its own
140    /// equivalent UI. Defaults to `true`.
141    pub fn show_identity_row(mut self, show: bool) -> Self {
142        self.show_identity_row = show;
143        self
144    }
145
146    /// Show or hide the anonymous ↔ pseudonymous mode-switch section when both
147    /// adapters are configured. Has no effect if only one mode is available.
148    /// Defaults to `true`.
149    pub fn show_mode_switch(mut self, show: bool) -> Self {
150        self.show_mode_switch = show;
151        self
152    }
153
154    /// Show or hide the "Inspect data sent" accordion that lists recent events
155    /// from the telemetry ring buffer. Defaults to `true`.
156    pub fn show_inspect(mut self, show: bool) -> Self {
157        self.show_inspect = show;
158        self
159    }
160
161    /// Maximum number of recent events shown in the inspect accordion.
162    /// Clamped to at least 1. Defaults to 50.
163    pub fn inspect_event_count(mut self, n: usize) -> Self {
164        self.inspect_event_count = n.max(1);
165        self
166    }
167
168    /// Surface a "Read full privacy policy" link in the Art. 13 notice.
169    /// When not set the link is hidden — the controller is responsible for
170    /// hosting their own policy page.
171    pub fn privacy_policy_url(mut self, url: impl Into<String>) -> Self {
172        self.privacy_policy_url = Some(url.into());
173        self
174    }
175
176    /// Plain-text controller name used in the Art. 13 notice ("Data is
177    /// processed by `<name>`"). Defaults to "the application".
178    pub fn data_processor_name(mut self, name: impl Into<String>) -> Self {
179        self.data_processor_name = Some(name.into());
180        self
181    }
182}
183
184impl std::fmt::Debug for PrivacySettings {
185    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186        f.debug_struct("PrivacySettings")
187            .field("compact", &self.compact)
188            .field("show_identity_row", &self.show_identity_row)
189            .field("show_mode_switch", &self.show_mode_switch)
190            .finish()
191    }
192}
193
194impl Widget for PrivacySettings {
195    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
196        // Resolve the telemetry handle. Without one we render a
197        // graceful placeholder so apps that don't ship telemetry can
198        // still embed the widget without panicking.
199        let Some(telemetry) = ctx.try_telemetry().cloned() else {
200            let placeholder = ctx.add(VStack::new().spacing(8.0).child(
201                TextWidget::new(tr_widget!(privacy_not_configured())).style(TextStyleRole::Body),
202            ));
203            self.root_id = Some(placeholder);
204            return vec![placeholder];
205        };
206
207        // Rebuild on consent-state change so toggles + action visibility
208        // track the live state.
209        let consent_signal = telemetry.consent.state_signal();
210        consent_signal.bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
211
212        // Live-update the "Inspect data sent" accordion as
213        // events stream in. The reporter bumps `recent_log_revision`
214        // on every `record()` and `discard_pending()`. Bound at
215        // `BindingLevel::Rebuild` so the accordion's snapshot
216        // refreshes without user interaction.
217        let revision_signal = telemetry.reporter.recent_log_revision();
218        revision_signal.bind_to(ctx.self_id(), ctx.binding_registry(), BindingLevel::Rebuild);
219
220        let state = consent_signal.get();
221        let supported = telemetry.reporter.supported_scopes();
222        let endpoint = telemetry.reporter.endpoint().to_string();
223        let pseudonymous = matches!(
224            telemetry.reporter.active_mode(),
225            TelemetryMode::Pseudonymous,
226        ) && telemetry.reporter.install_id().is_some();
227        let supports_mode_switch = telemetry.reporter.supports_mode_switch();
228        let processor = self
229            .data_processor_name
230            .clone()
231            .unwrap_or_else(|| "the application".to_string());
232
233        let scope_panel = build_scope_panel(ctx, &telemetry, &state, supported);
234        let column = VStack::new()
235            .spacing(if self.compact { 12.0 } else { 18.0 })
236            .child(self.build_heading())
237            .child(self.build_notice(&telemetry, &processor, &endpoint, pseudonymous))
238            .child(scope_panel)
239            .child(build_accept_reject(&telemetry, &endpoint))
240            .child_opt(
241                (self.show_identity_row && pseudonymous).then(|| build_identity_row(&telemetry)),
242            )
243            .child_opt(
244                (self.show_inspect && !self.compact)
245                    .then(|| build_inspect_accordion(&telemetry, self.inspect_event_count)),
246            )
247            .child_opt(
248                (self.show_mode_switch && supports_mode_switch && !self.compact)
249                    .then(|| build_mode_switch(&telemetry)),
250            )
251            .child(build_footer(&telemetry));
252
253        let id = ctx.add(column);
254        self.root_id = Some(id);
255        vec![id]
256    }
257
258    fn layout_response(
259        &self,
260        proposal: SizeProposal,
261        ctx: &LayoutContext,
262    ) -> teksilo_core::widget::LayoutResponse {
263        self.root_id
264            .and_then(|c| ctx.child_size(c, proposal))
265            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
266            .into()
267    }
268
269    fn place_children(
270        &self,
271        bounds: teksilo_canvas::Rect,
272        _proposal: SizeProposal,
273        children: &mut [WidgetPlacement],
274        _ctx: &LayoutContext,
275    ) {
276        for child in children.iter_mut() {
277            child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
278            child.size = Size::new(bounds.width, bounds.height);
279        }
280    }
281
282    fn children(&self) -> Vec<WidgetId> {
283        self.root_id.map(|id| vec![id]).unwrap_or_default()
284    }
285
286    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
287        builder.set_role(teksilo_core::accesskit::Role::Group);
288        // Locale-reactive: the AT walker re-resolves on a locale change.
289        builder.set_name(tr_widget!(privacy_a11y_group_name()).resolve_now());
290    }
291}
292
293impl PrivacySettings {
294    fn build_heading(&self) -> TextWidget {
295        TextWidget::new(tr_widget!(privacy_heading())).style(TextStyleRole::BodyBold)
296    }
297
298    fn build_notice(
299        &self,
300        telemetry: &OpenedTelemetry,
301        processor: &str,
302        endpoint: &str,
303        pseudonymous: bool,
304    ) -> Panel {
305        let lawful_basis = if pseudonymous {
306            tr_widget!(privacy_notice_lawful_pseudonymous())
307        } else {
308            tr_widget!(privacy_notice_lawful_anonymous())
309        };
310        let retention_days = telemetry.policy.retention_days as i64;
311        let adapter = telemetry.reporter.adapter_name().to_string();
312        let processor_owned = processor.to_string();
313        let endpoint_owned = endpoint.to_string();
314        let processor_line = tr_widget!(privacy_notice_controller(
315            processor = processor_owned,
316            adapter = adapter,
317            endpoint = endpoint_owned,
318        ));
319
320        let mut notice = VStack::new()
321            .spacing(6.0)
322            .child(TextWidget::new(processor_line).style(TextStyleRole::Body))
323            .child(
324                TextWidget::new(tr_widget!(privacy_notice_purposes())).style(TextStyleRole::Body),
325            )
326            .child(TextWidget::new(lawful_basis).style(TextStyleRole::Body))
327            .child(
328                TextWidget::new(tr_widget!(privacy_notice_retention(days = retention_days)))
329                    .style(TextStyleRole::Body),
330            )
331            .child(
332                TextWidget::new(tr_widget!(privacy_notice_withdrawal_right()))
333                    .style(TextStyleRole::Body),
334            );
335        if let Some(url) = self.privacy_policy_url.clone() {
336            notice = notice.child(
337                TextWidget::new(tr_widget!(privacy_notice_policy_link(url = url)))
338                    .style(TextStyleRole::Small),
339            );
340        }
341        Panel::new().padding(14.0_f32).child(notice)
342    }
343}
344
345// ----- helpers (free functions so the build sites stay small) -------
346
347fn build_scope_panel(
348    ctx: &mut BuildContext,
349    telemetry: &OpenedTelemetry,
350    state: &ConsentState,
351    supported: ConsentScope,
352) -> Panel {
353    let mut column = VStack::new().spacing(8.0).child(
354        TextWidget::new(tr_widget!(privacy_scope_section_heading())).style(TextStyleRole::BodyBold),
355    );
356
357    if supported.anonymous_metrics {
358        column = column.child(scope_row(
359            ctx,
360            tr_widget!(privacy_scope_anonymous_metrics_label()),
361            tr_widget!(privacy_scope_anonymous_metrics_description()),
362            current_value(state, |s| s.anonymous_metrics),
363            !matches!(state, ConsentState::Denied),
364            telemetry.consent.clone(),
365            telemetry.reporter.endpoint().to_string(),
366            |scope, v| scope.anonymous_metrics = v,
367        ));
368    }
369    if supported.crash_reports {
370        column = column.child(scope_row(
371            ctx,
372            tr_widget!(privacy_scope_crash_reports_label()),
373            tr_widget!(privacy_scope_crash_reports_description()),
374            current_value(state, |s| s.crash_reports),
375            !matches!(state, ConsentState::Denied),
376            telemetry.consent.clone(),
377            telemetry.reporter.endpoint().to_string(),
378            |scope, v| scope.crash_reports = v,
379        ));
380    }
381    if supported.feature_flags {
382        column = column.child(scope_row(
383            ctx,
384            tr_widget!(privacy_scope_feature_flags_label()),
385            tr_widget!(privacy_scope_feature_flags_description()),
386            current_value(state, |s| s.feature_flags),
387            !matches!(state, ConsentState::Denied),
388            telemetry.consent.clone(),
389            telemetry.reporter.endpoint().to_string(),
390            |scope, v| scope.feature_flags = v,
391        ));
392    }
393    Panel::new().padding(14.0_f32).child(column)
394}
395
396fn current_value(state: &ConsentState, f: impl FnOnce(&ConsentScope) -> bool) -> bool {
397    match state {
398        ConsentState::Granted(scope) => f(scope),
399        _ => false,
400    }
401}
402
403fn scope_row(
404    ctx: &mut BuildContext,
405    label: LocalizedString,
406    description: LocalizedString,
407    initial: bool,
408    enabled: bool,
409    consent: ConsentStore,
410    endpoint: String,
411    apply: impl Fn(&mut ConsentScope, bool) + 'static,
412) -> HStack {
413    // One-way binding: when the toggle changes, push to the consent
414    // store. The consent store's state signal triggers a widget
415    // rebuild, which constructs a fresh local signal seeded with the
416    // new state — so there's no write-back loop to worry about.
417    let signal = Signal::new(initial);
418    let consent_for_observe = consent.clone();
419    let endpoint_for_observe = endpoint;
420    // Hand the ObserverHandle to BuildContext so it is dropped (and
421    // the observer detached) on rebuild — same lifecycle as ctx.effect,
422    // but the closure here borrows `consent` / `endpoint` by move.
423    let handle = signal.observe(move |&new_value| {
424        let _ = consent_for_observe
425            .set_or_grant_scope(&endpoint_for_observe, |scope| apply(scope, new_value));
426    });
427    ctx.own_handle(handle);
428
429    HStack::new()
430        .spacing(12.0)
431        .child(
432            VStack::new()
433                .spacing(2.0)
434                .child(TextWidget::new(label.clone()).style(TextStyleRole::Body))
435                .child(TextWidget::new(description).style(TextStyleRole::Small)),
436        )
437        .child(Spacer::new())
438        .child(Toggle::new(signal).label(label).enabled(enabled))
439}
440
441fn build_accept_reject(telemetry: &OpenedTelemetry, endpoint: &str) -> HStack {
442    let supported = telemetry.reporter.supported_scopes();
443    let endpoint = endpoint.to_string();
444    let consent_for_reject = telemetry.consent.clone();
445    let consent_for_accept = telemetry.consent.clone();
446
447    let reject = Button::new(tr_widget!(privacy_btn_reject_all()))
448        .variant(ButtonVariant::Plain)
449        .on_activate_fn(move |_ctx| {
450            let _ = consent_for_reject.deny();
451        });
452    let accept = Button::new(tr_widget!(privacy_btn_accept_all()))
453        .variant(ButtonVariant::Filled)
454        .on_activate_fn(move |_ctx| {
455            let _ = consent_for_accept.grant(supported, &endpoint);
456        });
457
458    HStack::new()
459        .spacing(8.0)
460        .child(reject)
461        .child(Spacer::new())
462        .child(accept)
463}
464
465fn build_identity_row(telemetry: &OpenedTelemetry) -> Panel {
466    let install_id = telemetry
467        .install_id
468        .as_ref()
469        .map(|id| id.get())
470        .unwrap_or_else(|| "(none)".to_string());
471    let retention_days = telemetry.policy.retention_days as i64;
472
473    let consent_for_erase = telemetry.consent.clone();
474    let reporter_for_erase = telemetry.reporter.clone();
475    let erase = Button::new(tr_widget!(privacy_btn_erase()))
476        .variant(ButtonVariant::Plain)
477        .tooltip(tr_widget!(privacy_btn_erase_tooltip()))
478        .on_activate_fn(move |ctx| {
479            let consent = consent_for_erase.clone();
480            let reporter = reporter_for_erase.clone();
481            MessageBox::question(tr_widget!(privacy_confirm_erase_title()))
482                .text(tr_widget!(privacy_confirm_erase_text()))
483                .buttons(MessageBoxButtons::OkCancel)
484                .on_result(move |result, _ctx| {
485                    if matches!(result.button, StandardButton::Ok) {
486                        let _ = reporter.erase_remote_data();
487                        let _ = reporter.discard_pending();
488                        let _ = consent.withdraw();
489                    }
490                })
491                .present(ctx);
492        });
493
494    let reporter_for_fetch = telemetry.reporter.clone();
495    let install_id_for_fetch = install_id.clone();
496    let fetch = Button::new(tr_widget!(privacy_btn_fetch()))
497        .variant(ButtonVariant::Filled)
498        .tooltip(tr_widget!(privacy_btn_fetch_tooltip()))
499        .on_activate_fn(move |ctx| {
500            match reporter_for_fetch.fetch_remote_data() {
501                Ok(export) => {
502                    let event_count = export.events.len() as i64;
503                    let json = serde_json_export_label(&export);
504
505                    // Open a "Save as JSON…" dialog via the async
506                    // file-dialog service. The result callback runs
507                    // back on the main thread once the OS dialog
508                    // closes; the event loop keeps ticking in the
509                    // meantime so other windows / animations stay
510                    // responsive.
511                    let suggested_name = format!(
512                        "teksilo-export-{}.json",
513                        sanitize_filename(&install_id_for_fetch)
514                    );
515                    use teksilo_platform::file_dialog::{
516                        EventContextFileDialogExt, FileDialogRequest, FileDialogResult,
517                    };
518                    let request = FileDialogRequest::save_file()
519                        .title("Save your data export as JSON")
520                        .default_file_name(&suggested_name)
521                        .add_filter("JSON", &["json"]);
522
523                    // The closure captures `export`, `json`, and
524                    // `event_count` by move so they remain available
525                    // when the dialog resolves on a later event-loop
526                    // tick.
527                    let submit = ctx.save_file(request, move |result, ctx| match result {
528                        FileDialogResult::Saved(Some(path)) => {
529                            match std::fs::write(&path, json.as_bytes()) {
530                                Ok(()) => {
531                                    let mut details = String::new();
532                                    for (n, ev) in export.events.iter().enumerate().take(20) {
533                                        if !details.is_empty() {
534                                            details.push('\n');
535                                        }
536                                        details.push_str(&format!("{}. {}", n + 1, ev.name));
537                                    }
538                                    if export.events.len() > 20 {
539                                        details.push_str(&format!(
540                                            "\n… and {} more.",
541                                            export.events.len() - 20
542                                        ));
543                                    }
544                                    MessageBox::information(tr_widget!(
545                                        privacy_fetch_success_title()
546                                    ))
547                                    .text(tr_widget!(privacy_fetch_success_text(
548                                        count = event_count
549                                    )))
550                                    .informative_text(lit!(format!(
551                                        "{}\n\n{details}",
552                                        tr_widget!(privacy_fetch_saved_to(
553                                            path = path.display().to_string()
554                                        ))
555                                        .resolve_now()
556                                    )))
557                                    .buttons(MessageBoxButtons::Ok)
558                                    .present(ctx);
559                                }
560                                Err(e) => {
561                                    MessageBox::warning(tr_widget!(privacy_fetch_error_title()))
562                                        .text(tr_widget!(privacy_fetch_write_error(
563                                            path = path.display().to_string(),
564                                            error = e.to_string(),
565                                        )))
566                                        .buttons(MessageBoxButtons::Ok)
567                                        .present(ctx);
568                                }
569                            }
570                        }
571                        FileDialogResult::Saved(None) => {
572                            // User cancelled the save dialog — fall
573                            // back to the inline display so the
574                            // export isn't lost (Art. 20 portability
575                            // requires a working path either way).
576                            let mut details = String::new();
577                            for (n, ev) in export.events.iter().enumerate().take(20) {
578                                if !details.is_empty() {
579                                    details.push('\n');
580                                }
581                                details.push_str(&format!("{}. {}", n + 1, ev.name));
582                            }
583                            if export.events.len() > 20 {
584                                details.push_str(&format!(
585                                    "\n… and {} more.",
586                                    export.events.len() - 20
587                                ));
588                            }
589                            MessageBox::information(tr_widget!(privacy_fetch_success_title()))
590                                .text(tr_widget!(privacy_fetch_success_text(count = event_count)))
591                                .informative_text(lit!(details))
592                                .detailed_text(lit!(json))
593                                .buttons(MessageBoxButtons::Ok)
594                                .present(ctx);
595                        }
596                        FileDialogResult::Error(msg) => {
597                            MessageBox::warning(tr_widget!(privacy_fetch_error_title()))
598                                .text(lit!(msg))
599                                .buttons(MessageBoxButtons::Ok)
600                                .present(ctx);
601                        }
602                        // The save_file kind only returns Saved(_) /
603                        // Error(_) — but match exhaustively for
604                        // forward-compat with future result variants.
605                        _ => {}
606                    });
607                    if let Err(msg) = submit {
608                        MessageBox::warning(tr_widget!(privacy_fetch_error_title()))
609                            .text(lit!(msg))
610                            .buttons(MessageBoxButtons::Ok)
611                            .present(ctx);
612                    }
613                }
614                Err(e) => {
615                    MessageBox::warning(tr_widget!(privacy_fetch_error_title()))
616                        .text(lit!(format!("{e}")))
617                        .buttons(MessageBoxButtons::Ok)
618                        .present(ctx);
619                }
620            }
621        });
622
623    Panel::new().padding(14.0_f32).child(
624        VStack::new()
625            .spacing(6.0)
626            .child(
627                TextWidget::new(tr_widget!(privacy_identity_heading()))
628                    .style(TextStyleRole::BodyBold),
629            )
630            .child(
631                TextWidget::new(tr_widget!(privacy_identity_install_id(
632                    id = install_id.clone()
633                )))
634                .style(TextStyleRole::Small),
635            )
636            .child(
637                TextWidget::new(tr_widget!(privacy_identity_retention(
638                    days = retention_days
639                )))
640                .style(TextStyleRole::Small),
641            )
642            .child(HStack::new().spacing(8.0).child(fetch).child(erase)),
643    )
644}
645
646fn build_mode_switch(telemetry: &OpenedTelemetry) -> Panel {
647    let active = telemetry.reporter.active_mode();
648    let (current_label, target_mode, target_label, target_blurb) = match active {
649        TelemetryMode::Anonymous => (
650            tr_widget!(privacy_mode_current_anonymous()),
651            TelemetryMode::Pseudonymous,
652            tr_widget!(privacy_btn_switch_to_pseudonymous()),
653            tr_widget!(privacy_mode_blurb_pseudonymous()),
654        ),
655        TelemetryMode::Pseudonymous => (
656            tr_widget!(privacy_mode_current_pseudonymous()),
657            TelemetryMode::Anonymous,
658            tr_widget!(privacy_btn_switch_to_anonymous()),
659            tr_widget!(privacy_mode_blurb_anonymous()),
660        ),
661    };
662
663    let reporter = telemetry.reporter.clone();
664    let consent = telemetry.consent.clone();
665    let install_id = telemetry.install_id.clone();
666
667    let switch_btn = Button::new(target_label)
668        .variant(ButtonVariant::Plain)
669        .on_activate_fn(move |ctx| {
670            let reporter = reporter.clone();
671            let consent = consent.clone();
672            let install_id = install_id.clone();
673            let leaving_pseudonymous =
674                matches!(reporter.active_mode(), TelemetryMode::Pseudonymous);
675            let body = if leaving_pseudonymous {
676                tr_widget!(privacy_confirm_mode_switch_leaving_pseudonymous())
677            } else {
678                tr_widget!(privacy_confirm_mode_switch_leaving_anonymous())
679            };
680            MessageBox::question(tr_widget!(privacy_confirm_mode_switch_title()))
681                .text(body)
682                .buttons(MessageBoxButtons::OkCancel)
683                .on_result(move |result, _ctx| {
684                    if !matches!(result.button, StandardButton::Ok) {
685                        return;
686                    }
687                    if leaving_pseudonymous {
688                        let _ = reporter.erase_remote_data();
689                        if let Some(id) = &install_id {
690                            let _ = id.clear();
691                        }
692                    }
693                    let _ = reporter.discard_pending();
694                    let _ = consent.reset();
695                    reporter.set_active_mode(target_mode);
696                })
697                .present(ctx);
698        });
699
700    Panel::new().padding(14.0_f32).child(
701        VStack::new()
702            .spacing(6.0)
703            .child(
704                TextWidget::new(tr_widget!(privacy_mode_heading())).style(TextStyleRole::BodyBold),
705            )
706            .child(TextWidget::new(current_label).style(TextStyleRole::Body))
707            .child(TextWidget::new(target_blurb).style(TextStyleRole::Small))
708            .child(HStack::new().child(switch_btn).child(Spacer::new())),
709    )
710}
711
712fn build_footer(telemetry: &OpenedTelemetry) -> HStack {
713    let consent = telemetry.consent.clone();
714    let withdraw = Button::new(tr_widget!(privacy_btn_withdraw()))
715        .variant(ButtonVariant::Plain)
716        .tooltip(tr_widget!(privacy_btn_withdraw_tooltip()))
717        .on_activate_fn(move |ctx| {
718            let consent = consent.clone();
719            MessageBox::question(tr_widget!(privacy_confirm_withdraw_title()))
720                .text(tr_widget!(privacy_confirm_withdraw_text()))
721                .buttons(MessageBoxButtons::OkCancel)
722                .on_result(move |result, _ctx| {
723                    if matches!(result.button, StandardButton::Ok) {
724                        let _ = consent.withdraw();
725                    }
726                })
727                .present(ctx);
728        });
729    HStack::new().child(Spacer::new()).child(withdraw)
730}
731
732/// Pretty-print a fetched export as JSON for the "Get my data"
733/// detailed-text panel. Falls back to a placeholder if serialization
734/// fails (which shouldn't happen — `RemoteDataExport` is plain data).
735fn serde_json_export_label(export: &RemoteDataExport) -> String {
736    serde_json::to_string_pretty(export)
737        .unwrap_or_else(|e| format!("(failed to serialize export: {e})"))
738}
739
740/// Make a string safe for use as a filename across the three
741/// desktop platforms: ASCII alnum + `-` + `_` survive verbatim;
742/// everything else collapses to `_`. Used to embed the install_id
743/// in the suggested save-dialog filename.
744fn sanitize_filename(s: &str) -> String {
745    s.chars()
746        .map(|c| {
747            if c.is_ascii_alphanumeric() || matches!(c, '-' | '_') {
748                c
749            } else {
750                '_'
751            }
752        })
753        .collect()
754}
755
756/// "Inspect data sent" accordion — shows up to `n` most-recent events
757/// from the recent-log ring buffer (newest first). Snapshot-at-build:
758/// expanding/collapsing the accordion refreshes the list to current
759/// state via the framework's rebuild-on-binding mechanism.
760fn build_inspect_accordion(telemetry: &OpenedTelemetry, n: usize) -> Accordion {
761    use crate::primitives::Padding;
762    use teksilo_telemetry::{EventQueue, OwnedPropValue};
763
764    let recent = telemetry.recent_log.peek_recent(n);
765    let count = recent.len();
766    let count_i64 = count as i64;
767
768    let body = if count == 0 {
769        VStack::new()
770            .spacing(6.0)
771            .child(TextWidget::new(tr_widget!(privacy_inspect_empty())).style(TextStyleRole::Small))
772    } else {
773        let mut col = VStack::new().spacing(4.0).child(
774            TextWidget::new(tr_widget!(privacy_inspect_summary(count = count_i64)))
775                .style(TextStyleRole::Small),
776        );
777        for (idx, event) in recent.iter().enumerate() {
778            let mut props_summary = String::new();
779            for prop in event.props.iter().take(4) {
780                let v = match &prop.value {
781                    OwnedPropValue::Str(s) => s.clone(),
782                    OwnedPropValue::U32(n) => n.to_string(),
783                    OwnedPropValue::I64(n) => n.to_string(),
784                    OwnedPropValue::Bool(b) => b.to_string(),
785                    OwnedPropValue::F64Bucket(b) => format!("{}-{}", b.min_x100, b.max_x100),
786                    OwnedPropValue::HistogramStrU32(_) => "{histogram}".into(),
787                };
788                if !props_summary.is_empty() {
789                    props_summary.push_str(", ");
790                }
791                props_summary.push_str(&format!("{}={v}", prop.key));
792            }
793            if event.props.len() > 4 {
794                props_summary.push_str(&format!(", … +{} more", event.props.len() - 4));
795            }
796            let line = if props_summary.is_empty() {
797                format!("{}. {}", idx + 1, event.name)
798            } else {
799                format!("{}. {} ({})", idx + 1, event.name, props_summary)
800            };
801            col = col.child(TextWidget::new(lit!(line)).style(TextStyleRole::Mono));
802        }
803        col
804    };
805
806    Accordion::new(
807        tr_widget!(privacy_inspect_title(count = count_i64)),
808        Signal::new(false),
809    )
810    .content(Padding::uniform(10.0).child(body))
811}
812
813#[cfg(test)]
814mod tests {
815    use super::*;
816    use teksilo_canvas::SizeProposal;
817    use teksilo_core::widget_tree::WidgetTree;
818    use teksilo_i18n::{
819        I18nConfig, I18nManager, LanguageIdentifier,
820        thread_local::{clear, install},
821    };
822
823    /// Resolve the accessibility name of the single `Role::Group`
824    /// container node the widget emits.
825    fn group_name(tree: &mut WidgetTree) -> String {
826        let update = tree.sync_accessibility();
827        let group = update
828            .nodes
829            .iter()
830            .find(|(_, n)| n.role() == teksilo_core::accesskit::Role::Group)
831            .expect("PrivacySettings emits a Role::Group container");
832        group.1.label().unwrap_or("").to_string()
833    }
834
835    /// The container exposes `Role::Group` with a non-empty accessible
836    /// name. Uses the no-telemetry placeholder path (the `accessibility`
837    /// impl runs regardless of `OpenedTelemetry`). Resolution goes through
838    /// the real framework widget bundle (`tr_widget!`).
839    #[test]
840    fn container_has_group_role_and_name() {
841        clear();
842        let cfg = I18nConfig::test_only("en-US", &[]).framework_locales(crate::framework_locales());
843        install(I18nManager::from_config(&cfg));
844
845        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
846        tree.add(PrivacySettings::new());
847        tree.layout(SizeProposal::exact(600.0, 400.0));
848
849        assert_eq!(group_name(&mut tree), "Privacy & Telemetry settings");
850        clear();
851    }
852
853    /// Regression for the hardcoded-English bug: the accessible name is
854    /// locale-reactive — switching the locale re-resolves it through
855    /// `tr_widget!` against the framework widget bundle instead of
856    /// returning a frozen literal.
857    #[test]
858    fn a11y_name_is_locale_reactive() {
859        clear();
860        let cfg = I18nConfig::test_only("en-US", &[])
861            .with_locale("fr-FR", &[])
862            .framework_locales(crate::framework_locales());
863        let mgr = I18nManager::from_config(&cfg);
864        install(mgr.clone());
865
866        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
867        tree.add(PrivacySettings::new());
868        tree.layout(SizeProposal::exact(600.0, 400.0));
869        assert_eq!(group_name(&mut tree), "Privacy & Telemetry settings");
870
871        let fr: LanguageIdentifier = "fr-FR".parse().unwrap();
872        mgr.set_locale(fr);
873        // Tell the tree the locale changed so it re-emits the AT cache.
874        tree.set_locale("fr-FR".to_string());
875        tree.layout(SizeProposal::exact(600.0, 400.0));
876
877        assert_eq!(
878            group_name(&mut tree),
879            "Paramètres de confidentialité et de télémétrie",
880            "container name must re-resolve on locale change"
881        );
882        clear();
883    }
884}