Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T04:44:44.894Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

identity_section.rs

1923 lines · 75.8 KB · rust
1use std::{fmt, path::PathBuf, sync::Arc};
2
3use db::kvp::KeyValueStore;
4use gpui::{
5    AnyElement, App, AppContext, Context, Entity, IntoElement, PathPromptOptions, Render,
6    SharedString, Task, Window,
7};
8use omega_identity::{
9    CandidateRef, CustodyConflictReason, CustodyError, CustodyResult, CustodyState,
10    IdentityInspection, IdentityRef, IdentityService, ImportedSecret, PendingIdentityOperation,
11    PreparedRecovery, PublicIdentity, ReceiptRef, RecoveryCandidate, RecoveryPassword,
12    RecoveryProtectionState, RecoveryResolution, RecoveryResolutionState,
13};
14use ui::prelude::*;
15use ui_input::InputField;
16use util::ResultExt as _;
17use uuid::Uuid;
18use zeroize::Zeroizing;
19
20use crate::{
21    identity_controller::{
22        IdentityControllerState, IdentityOperation, IdentityUiError, OperationToken,
23    },
24    identity_profile::{LocalIdentityProfile, install_local_avatar},
25    secure_input::SecureInput,
26};
27
28const IDENTITY_TAB_SLOTS: isize = 12;
29
30#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
31pub(crate) enum IdentitySectionPresentation {
32    #[default]
33    Full,
34    Compact,
35}
36
37trait IdentityBackend: Send + Sync {
38    fn inspect(&self) -> Result<IdentityInspection, CustodyError>;
39    fn create(&self, receipt_ref: ReceiptRef) -> Result<IdentityInspection, CustodyError>;
40    fn resume_incomplete_create(&self) -> Result<IdentityInspection, CustodyError>;
41    fn prepare_import(
42        &self,
43        imported_secret: ImportedSecret,
44    ) -> Result<PreparedRecovery, CustodyError>;
45    fn discover_artifact(&self, path: PathBuf) -> Result<RecoveryCandidate, CustodyError>;
46    fn prepare_artifact(
47        &self,
48        candidate: &RecoveryCandidate,
49        password: RecoveryPassword,
50    ) -> Result<PreparedRecovery, CustodyError>;
51    fn reconcile(&self, candidates: &[&PreparedRecovery]) -> RecoveryResolution;
52    fn adopt(
53        &self,
54        candidates: Vec<PreparedRecovery>,
55        selected_candidate_ref: &CandidateRef,
56        receipt_ref: ReceiptRef,
57    ) -> Result<IdentityInspection, CustodyError>;
58    fn resolve_conflict(
59        &self,
60        candidates: Vec<PreparedRecovery>,
61        selected_candidate_ref: &CandidateRef,
62        receipt_ref: ReceiptRef,
63    ) -> Result<IdentityInspection, CustodyError>;
64    fn export_artifact(
65        &self,
66        identity_ref: &IdentityRef,
67        path: PathBuf,
68        password: RecoveryPassword,
69    ) -> Result<IdentityInspection, CustodyError>;
70    fn reset(
71        &self,
72        identity_ref: &IdentityRef,
73        receipt_ref: ReceiptRef,
74    ) -> Result<CustodyResult, CustodyError>;
75    fn resume_reset(&self) -> Result<CustodyResult, CustodyError>;
76}
77
78struct SystemIdentityBackend {
79    service: IdentityService,
80}
81
82impl SystemIdentityBackend {
83    fn new() -> Self {
84        Self {
85            service: IdentityService::system(*app_identity::CHANNEL),
86        }
87    }
88}
89
90impl IdentityBackend for SystemIdentityBackend {
91    fn inspect(&self) -> Result<IdentityInspection, CustodyError> {
92        self.service.inspect_details()
93    }
94
95    fn create(&self, receipt_ref: ReceiptRef) -> Result<IdentityInspection, CustodyError> {
96        self.service.create(receipt_ref)?;
97        self.service.inspect_details()
98    }
99
100    fn resume_incomplete_create(&self) -> Result<IdentityInspection, CustodyError> {
101        self.service.resume_incomplete_create()?;
102        self.service.inspect_details()
103    }
104
105    fn prepare_import(
106        &self,
107        imported_secret: ImportedSecret,
108    ) -> Result<PreparedRecovery, CustodyError> {
109        self.service.prepare_import(imported_secret)
110    }
111
112    fn discover_artifact(&self, path: PathBuf) -> Result<RecoveryCandidate, CustodyError> {
113        self.service.discover_recovery_artifact(path)
114    }
115
116    fn prepare_artifact(
117        &self,
118        candidate: &RecoveryCandidate,
119        password: RecoveryPassword,
120    ) -> Result<PreparedRecovery, CustodyError> {
121        self.service.prepare_recovery_artifact(candidate, password)
122    }
123
124    fn reconcile(&self, candidates: &[&PreparedRecovery]) -> RecoveryResolution {
125        self.service.reconcile_recoveries(candidates)
126    }
127
128    fn adopt(
129        &self,
130        candidates: Vec<PreparedRecovery>,
131        selected_candidate_ref: &CandidateRef,
132        receipt_ref: ReceiptRef,
133    ) -> Result<IdentityInspection, CustodyError> {
134        let selected = self
135            .service
136            .select_recovery(candidates, selected_candidate_ref)?;
137        self.service.adopt(selected, receipt_ref)?;
138        self.service.inspect_details()
139    }
140
141    fn resolve_conflict(
142        &self,
143        candidates: Vec<PreparedRecovery>,
144        selected_candidate_ref: &CandidateRef,
145        receipt_ref: ReceiptRef,
146    ) -> Result<IdentityInspection, CustodyError> {
147        let selected = self
148            .service
149            .select_recovery(candidates, selected_candidate_ref)?;
150        self.service.resolve_conflict(selected, receipt_ref)?;
151        self.service.inspect_details()
152    }
153
154    fn export_artifact(
155        &self,
156        identity_ref: &IdentityRef,
157        path: PathBuf,
158        password: RecoveryPassword,
159    ) -> Result<IdentityInspection, CustodyError> {
160        self.service
161            .export_recovery_artifact(identity_ref, &path, password)?;
162        self.service.inspect_details()
163    }
164
165    fn reset(
166        &self,
167        identity_ref: &IdentityRef,
168        receipt_ref: ReceiptRef,
169    ) -> Result<CustodyResult, CustodyError> {
170        self.service.reset(identity_ref, receipt_ref)
171    }
172
173    fn resume_reset(&self) -> Result<CustodyResult, CustodyError> {
174        self.service.resume_pending_reset()
175    }
176}
177
178#[derive(Debug, Copy, Clone, PartialEq, Eq)]
179enum IdentityAction {
180    Create,
181    Recover,
182    Retry,
183    ResumeCreate,
184    RetryReset,
185    Reset,
186    Relaunch,
187    Protect,
188    ReplaceRecovery,
189}
190
191impl IdentityAction {
192    fn id(self) -> &'static str {
193        match self {
194            Self::Create => "create",
195            Self::Recover => "recover",
196            Self::Retry => "retry",
197            Self::ResumeCreate => "resume-create",
198            Self::RetryReset => "retry-reset",
199            Self::Reset => "reset",
200            Self::Relaunch => "relaunch",
201            Self::Protect => "protect",
202            Self::ReplaceRecovery => "replace-recovery",
203        }
204    }
205
206    fn label(self) -> &'static str {
207        match self {
208            Self::Create => "Create identity",
209            Self::Recover => "Recover identity",
210            Self::Retry => "Try again",
211            Self::ResumeCreate => "Resume setup",
212            Self::RetryReset => "Retry reset",
213            Self::Reset => "Reset identity",
214            Self::Relaunch => "Relaunch Omega",
215            Self::Protect => "Protect recovery",
216            // A distinct action rather than a relabelled Protect, so the
217            // protected and unprotected states can never present the same
218            // control again. Offering "Protect recovery" beside "Recovery
219            // protected" reads as though the protection did not take.
220            Self::ReplaceRecovery => "Replace recovery file",
221        }
222    }
223
224    fn primary(self) -> bool {
225        matches!(
226            self,
227            Self::Create
228                | Self::Retry
229                | Self::ResumeCreate
230                | Self::RetryReset
231                | Self::Relaunch
232                | Self::Protect
233        )
234    }
235}
236
237struct IdentityPresentation {
238    title: &'static str,
239    description: &'static str,
240    icon: IconName,
241    color: Color,
242    actions: Vec<IdentityAction>,
243}
244
245impl IdentityPresentation {
246    fn accessibility_label(&self) -> String {
247        format!("{}: {}", self.title, self.description)
248    }
249}
250
251#[derive(Debug, Copy, Clone, PartialEq, Eq)]
252enum RecoveryMode {
253    Choose,
254    AdvancedImport,
255    ArtifactPassword,
256    Review,
257    Protect,
258}
259
260struct RecoverySession {
261    prepared: Vec<PreparedRecovery>,
262    resolution: RecoveryResolution,
263}
264
265impl fmt::Debug for RecoverySession {
266    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
267        formatter
268            .debug_struct("RecoverySession")
269            .field("candidate_count", &self.resolution.candidates.len())
270            .field("resolution", &self.resolution.state)
271            .field("secret", &"[REDACTED]")
272            .finish()
273    }
274}
275
276pub(crate) struct IdentitySection {
277    backend: Arc<dyn IdentityBackend>,
278    controller: IdentityControllerState,
279    presentation: IdentitySectionPresentation,
280    first_tab_index: isize,
281    operation_task: Option<Task<()>>,
282    profile_task: Option<Task<()>>,
283    recovery_mode: Option<RecoveryMode>,
284    recovery_candidate: Option<RecoveryCandidate>,
285    recovery_session: Option<RecoverySession>,
286    secret_input: Entity<SecureInput>,
287    password_input: Entity<SecureInput>,
288    confirmation_input: Entity<SecureInput>,
289    display_name_input: Entity<InputField>,
290    local_profile: Option<LocalIdentityProfile>,
291    profile_message: Option<SharedString>,
292}
293
294impl IdentitySection {
295    pub(crate) fn new(first_tab_index: isize, window: &mut Window, cx: &mut App) -> Entity<Self> {
296        Self::new_with_backend(
297            first_tab_index,
298            Arc::new(SystemIdentityBackend::new()),
299            window,
300            cx,
301        )
302    }
303
304    fn new_with_backend(
305        first_tab_index: isize,
306        backend: Arc<dyn IdentityBackend>,
307        window: &mut Window,
308        cx: &mut App,
309    ) -> Entity<Self> {
310        let secret_input = cx.new(|cx| {
311            SecureInput::new(
312                "Paste an nsec or 64-character secret",
313                "Nostr secret key",
314                first_tab_index + 2,
315                cx,
316            )
317        });
318        let password_input = cx.new(|cx| {
319            SecureInput::new(
320                "Recovery password",
321                "Recovery password",
322                first_tab_index + 2,
323                cx,
324            )
325        });
326        let confirmation_input = cx.new(|cx| {
327            SecureInput::new(
328                "Confirm recovery password",
329                "Confirm recovery password",
330                first_tab_index + 3,
331                cx,
332            )
333        });
334        let display_name_input = cx.new(|cx| {
335            InputField::new(window, cx, "Optional local display name")
336                .label("Local display name")
337                .tab_index(first_tab_index + 8)
338        });
339
340        let section = cx.new(|_| Self {
341            backend,
342            controller: IdentityControllerState::default(),
343            presentation: IdentitySectionPresentation::default(),
344            first_tab_index,
345            operation_task: None,
346            profile_task: None,
347            recovery_mode: None,
348            recovery_candidate: None,
349            recovery_session: None,
350            secret_input,
351            password_input,
352            confirmation_input,
353            display_name_input,
354            local_profile: None,
355            profile_message: None,
356        });
357        section.update(cx, |section, cx| section.start_inspect(window, cx));
358        section
359    }
360
361    pub(crate) fn is_ready(&self) -> bool {
362        self.controller
363            .durable()
364            .is_some_and(|inspection| inspection.custody.state == CustodyState::Ready)
365    }
366
367    pub(crate) fn ready_identity_ref(&self) -> Option<IdentityRef> {
368        self.controller
369            .durable()
370            .filter(|inspection| inspection.custody.state == CustodyState::Ready)
371            .and_then(|inspection| inspection.custody.identity.as_ref())
372            .map(|identity| identity.identity_ref().clone())
373    }
374
375    pub(crate) fn clear_transient_state(&mut self, cx: &mut Context<Self>) {
376        self.controller.cancel();
377        self.operation_task = None;
378        self.recovery_mode = None;
379        self.recovery_candidate = None;
380        self.recovery_session = None;
381        self.secret_input.update(cx, |input, cx| input.clear(cx));
382        self.password_input.update(cx, |input, cx| input.clear(cx));
383        self.confirmation_input
384            .update(cx, |input, cx| input.clear(cx));
385        cx.notify();
386    }
387
388    pub(crate) fn deactivate_and_reinspect(&mut self, window: &mut Window, cx: &mut Context<Self>) {
389        self.controller.cancel();
390        self.controller.forget_durable();
391        let previous_operation = self.operation_task.take();
392        self.clear_recovery_material(cx);
393
394        let token = self.controller.replace(IdentityOperation::Inspect);
395        let backend = self.backend.clone();
396        self.operation_task = Some(cx.spawn_in(window, async move |this, cx| {
397            if let Some(previous_operation) = previous_operation {
398                previous_operation.await;
399            }
400            let result = cx
401                .background_spawn(async move { backend.inspect() })
402                .await
403                .map_err(map_custody_error);
404            this.update_in(cx, |this, window, cx| {
405                this.apply_inspection_result(&token, result, window, cx);
406            })
407            .log_err();
408        }));
409        cx.notify();
410    }
411
412    fn next_receipt(prefix: &str) -> Result<ReceiptRef, IdentityUiError> {
413        ReceiptRef::new(format!("{prefix}-{}", Uuid::new_v4().simple()))
414            .map_err(|_| IdentityUiError::OperationFailed)
415    }
416
417    fn start_inspect(&mut self, window: &mut Window, cx: &mut Context<Self>) {
418        let token = self.controller.replace(IdentityOperation::Inspect);
419        let backend = self.backend.clone();
420        let background = cx.background_spawn(async move { backend.inspect() });
421        self.operation_task = Some(cx.spawn_in(window, async move |this, cx| {
422            let result = background.await.map_err(map_custody_error);
423            this.update_in(cx, |this, window, cx| {
424                this.apply_inspection_result(&token, result, window, cx);
425            })
426            .log_err();
427        }));
428        cx.notify();
429    }
430
431    fn apply_inspection_result(
432        &mut self,
433        token: &OperationToken,
434        result: Result<IdentityInspection, IdentityUiError>,
435        window: &mut Window,
436        cx: &mut Context<Self>,
437    ) {
438        if self.controller.apply(token, result) {
439            self.clear_recovery_material(cx);
440            self.load_local_profile(window, cx);
441            cx.notify();
442        }
443    }
444
445    fn start_inspection_operation(
446        &mut self,
447        operation: IdentityOperation,
448        window: &mut Window,
449        cx: &mut Context<Self>,
450        run: impl FnOnce(Arc<dyn IdentityBackend>) -> Result<IdentityInspection, CustodyError>
451        + Send
452        + 'static,
453    ) {
454        let Some(token) = self.controller.begin(operation) else {
455            return;
456        };
457        let backend = self.backend.clone();
458        let background = cx.background_spawn(async move { run(backend) });
459        self.operation_task = Some(cx.spawn_in(window, async move |this, cx| {
460            let result = background.await.map_err(map_custody_error);
461            this.update_in(cx, |this, window, cx| {
462                this.apply_inspection_result(&token, result, window, cx);
463            })
464            .log_err();
465        }));
466        cx.notify();
467    }
468
469    fn start_custody_operation(
470        &mut self,
471        operation: IdentityOperation,
472        window: &mut Window,
473        cx: &mut Context<Self>,
474        run: impl FnOnce(Arc<dyn IdentityBackend>) -> Result<CustodyResult, CustodyError>
475        + Send
476        + 'static,
477    ) {
478        let Some(token) = self.controller.begin(operation) else {
479            return;
480        };
481        let backend = self.backend.clone();
482        let background = cx.background_spawn(async move { run(backend) });
483        self.operation_task = Some(cx.spawn_in(window, async move |this, cx| {
484            let result = background.await.map_err(map_custody_error);
485            this.update_in(cx, |this, _, cx| {
486                if this.controller.apply_custody(&token, result) {
487                    this.clear_recovery_material(cx);
488                    cx.notify();
489                }
490            })
491            .log_err();
492        }));
493        cx.notify();
494    }
495
496    fn clear_recovery_material(&mut self, cx: &mut Context<Self>) {
497        self.recovery_mode = None;
498        self.recovery_candidate = None;
499        self.recovery_session = None;
500        self.secret_input.update(cx, |input, cx| input.clear(cx));
501        self.password_input.update(cx, |input, cx| input.clear(cx));
502        self.confirmation_input
503            .update(cx, |input, cx| input.clear(cx));
504    }
505
506    fn load_local_profile(&mut self, window: &mut Window, cx: &mut Context<Self>) {
507        let Some(identity_ref) = self
508            .controller
509            .durable()
510            .and_then(|inspection| inspection.custody.identity.as_ref())
511            .map(|identity| identity.identity_ref().clone())
512        else {
513            self.local_profile = None;
514            self.display_name_input
515                .update(cx, |input, cx| input.clear(window, cx));
516            return;
517        };
518
519        let profile = KeyValueStore::global(cx)
520            .read_kvp(&LocalIdentityProfile::kvp_key(&identity_ref))
521            .ok()
522            .flatten()
523            .and_then(|json| {
524                LocalIdentityProfile::from_canonical_json(&json, &identity_ref).log_err()
525            })
526            .unwrap_or_else(|| LocalIdentityProfile::new(identity_ref));
527        let display_name = profile.display_name().unwrap_or_default().to_string();
528        self.display_name_input
529            .update(cx, |input, cx| input.set_text(&display_name, window, cx));
530        self.local_profile = Some(profile);
531    }
532
533    fn handle_action(
534        &mut self,
535        action: IdentityAction,
536        window: &mut Window,
537        cx: &mut Context<Self>,
538    ) {
539        match action {
540            IdentityAction::Create => {
541                let Ok(receipt_ref) = Self::next_receipt("omega-onboarding-create") else {
542                    self.controller
543                        .report_error(IdentityUiError::OperationFailed);
544                    cx.notify();
545                    return;
546                };
547                self.start_inspection_operation(
548                    IdentityOperation::Create {
549                        receipt_ref: receipt_ref.clone(),
550                    },
551                    window,
552                    cx,
553                    move |backend| backend.create(receipt_ref),
554                );
555            }
556            IdentityAction::Recover => {
557                self.controller.cancel();
558                self.clear_recovery_material(cx);
559                self.recovery_mode = Some(RecoveryMode::Choose);
560                cx.notify();
561            }
562            IdentityAction::Retry => self.start_inspect(window, cx),
563            IdentityAction::ResumeCreate => self.start_inspection_operation(
564                IdentityOperation::ResumeIncomplete,
565                window,
566                cx,
567                |backend| backend.resume_incomplete_create(),
568            ),
569            IdentityAction::RetryReset => self.start_custody_operation(
570                IdentityOperation::ResumeReset,
571                window,
572                cx,
573                |backend| backend.resume_reset(),
574            ),
575            IdentityAction::Reset => self.confirm_reset(window, cx),
576            IdentityAction::Relaunch => cx.restart(),
577            IdentityAction::Protect | IdentityAction::ReplaceRecovery => {
578                self.controller.cancel();
579                self.clear_recovery_material(cx);
580                self.recovery_mode = Some(RecoveryMode::Protect);
581                cx.notify();
582            }
583        }
584    }
585
586    fn confirm_reset(&mut self, window: &mut Window, cx: &mut Context<Self>) {
587        let Some(identity_ref) = self
588            .controller
589            .durable()
590            .and_then(|inspection| inspection.custody.identity.as_ref())
591            .map(|identity| identity.identity_ref().clone())
592        else {
593            return;
594        };
595        let Ok(receipt_ref) = Self::next_receipt("omega-onboarding-reset") else {
596            self.controller
597                .report_error(IdentityUiError::OperationFailed);
598            cx.notify();
599            return;
600        };
601        self.operation_task = Some(cx.spawn_in(window, async move |this, cx| {
602            let response = cx.prompt(
603                gpui::PromptLevel::Critical,
604                "Reset this Omega identity?",
605                Some(
606                    "Reset removes the signing key from secure custody. Continue only if you have a verified encrypted recovery file.",
607                ),
608                &["Reset identity", "Cancel"],
609            );
610            if response.await != Ok(0) {
611                return;
612            }
613            this.update_in(cx, |this, window, cx| {
614                this.start_custody_operation(
615                    IdentityOperation::Reset {
616                        receipt_ref: receipt_ref.clone(),
617                    },
618                    window,
619                    cx,
620                    move |backend| backend.reset(&identity_ref, receipt_ref),
621                );
622            })
623            .log_err();
624        }));
625    }
626
627    fn choose_recovery_artifact(&mut self, window: &mut Window, cx: &mut Context<Self>) {
628        let Some(token) = self.controller.begin(IdentityOperation::PrepareRecovery) else {
629            return;
630        };
631        let paths = cx.prompt_for_paths(PathPromptOptions {
632            files: true,
633            directories: false,
634            multiple: false,
635            prompt: Some("Choose an encrypted Omega recovery file".into()),
636        });
637        let backend = self.backend.clone();
638        self.operation_task = Some(cx.spawn_in(window, async move |this, cx| {
639            let path = match paths.await {
640                Ok(Ok(Some(paths))) => paths.into_iter().next(),
641                _ => None,
642            };
643            let Some(path) = path else {
644                this.update(cx, |this, cx| {
645                    if this.controller.cancel_if_current(&token) {
646                        cx.notify();
647                    }
648                })
649                .log_err();
650                return;
651            };
652            let discovery = cx
653                .background_spawn(async move { backend.discover_artifact(path) })
654                .await
655                .map_err(map_custody_error);
656            this.update(cx, |this, cx| match discovery {
657                Ok(candidate) if this.controller.accept(&token) => {
658                    this.recovery_candidate = Some(candidate);
659                    this.recovery_mode = Some(RecoveryMode::ArtifactPassword);
660                    cx.notify();
661                }
662                Err(error) if this.controller.reject(&token, error) => {
663                    this.clear_recovery_material(cx);
664                    this.recovery_mode = Some(RecoveryMode::Choose);
665                    cx.notify();
666                }
667                _ => {}
668            })
669            .log_err();
670        }));
671        cx.notify();
672    }
673
674    fn submit_advanced_import(&mut self, window: &mut Window, cx: &mut Context<Self>) {
675        let raw_secret = self.secret_input.update(cx, |input, cx| input.take(cx));
676        let imported_secret = match ImportedSecret::new(raw_secret) {
677            Ok(secret) => secret,
678            Err(_) => {
679                let token = self.controller.replace(IdentityOperation::PrepareRecovery);
680                self.controller
681                    .reject(&token, IdentityUiError::InvalidSecret);
682                cx.notify();
683                return;
684            }
685        };
686        let existing = self
687            .recovery_session
688            .take()
689            .map(|session| session.prepared)
690            .unwrap_or_default();
691        let Some(token) = self.controller.begin(IdentityOperation::PrepareRecovery) else {
692            return;
693        };
694        let backend = self.backend.clone();
695        let background = cx.background_spawn(async move {
696            let mut prepared = existing;
697            prepared.push(backend.prepare_import(imported_secret)?);
698            let resolution = backend.reconcile(&prepared.iter().collect::<Vec<_>>());
699            Ok::<_, CustodyError>(RecoverySession {
700                prepared,
701                resolution,
702            })
703        });
704        self.await_recovery_session(token, background, window, cx);
705    }
706
707    fn submit_artifact_password(&mut self, window: &mut Window, cx: &mut Context<Self>) {
708        let raw_password = self.password_input.update(cx, |input, cx| input.take(cx));
709        let password = match RecoveryPassword::new(raw_password) {
710            Ok(password) => password,
711            Err(_) => {
712                let token = self.controller.replace(IdentityOperation::PrepareRecovery);
713                self.controller
714                    .reject(&token, IdentityUiError::InvalidPassword);
715                cx.notify();
716                return;
717            }
718        };
719        let Some(candidate) = self.recovery_candidate.take() else {
720            return;
721        };
722        let existing = self
723            .recovery_session
724            .take()
725            .map(|session| session.prepared)
726            .unwrap_or_default();
727        let Some(token) = self.controller.begin(IdentityOperation::PrepareRecovery) else {
728            return;
729        };
730        let backend = self.backend.clone();
731        let background = cx.background_spawn(async move {
732            let mut prepared = existing;
733            prepared.push(backend.prepare_artifact(&candidate, password)?);
734            let resolution = backend.reconcile(&prepared.iter().collect::<Vec<_>>());
735            Ok::<_, CustodyError>(RecoverySession {
736                prepared,
737                resolution,
738            })
739        });
740        self.await_recovery_session(token, background, window, cx);
741    }
742
743    fn await_recovery_session(
744        &mut self,
745        token: OperationToken,
746        background: Task<Result<RecoverySession, CustodyError>>,
747        window: &mut Window,
748        cx: &mut Context<Self>,
749    ) {
750        self.operation_task = Some(cx.spawn_in(window, async move |this, cx| {
751            let result = background.await.map_err(map_custody_error);
752            this.update(cx, |this, cx| match result {
753                Ok(session) if this.controller.accept(&token) => {
754                    this.recovery_session = Some(session);
755                    this.recovery_mode = Some(RecoveryMode::Review);
756                    cx.notify();
757                }
758                Err(error) if this.controller.reject(&token, error) => {
759                    this.clear_recovery_material(cx);
760                    this.recovery_mode = Some(RecoveryMode::Choose);
761                    cx.notify();
762                }
763                _ => {}
764            })
765            .log_err();
766        }));
767        cx.notify();
768    }
769
770    fn adopt_candidate(
771        &mut self,
772        candidate_ref: CandidateRef,
773        window: &mut Window,
774        cx: &mut Context<Self>,
775    ) {
776        let Some(session) = self.recovery_session.take() else {
777            return;
778        };
779        let resolving_conflict = self.controller.durable().is_some_and(|inspection| {
780            inspection.custody.state == CustodyState::Conflict
781                || inspection
782                    .pending_transaction
783                    .as_ref()
784                    .is_some_and(|pending| {
785                        pending.operation == PendingIdentityOperation::ResolveConflict
786                    })
787        });
788        let receipt_ref = self
789            .controller
790            .durable()
791            .and_then(|inspection| inspection.pending_transaction.as_ref())
792            .filter(|pending| {
793                pending.operation
794                    == if resolving_conflict {
795                        PendingIdentityOperation::ResolveConflict
796                    } else {
797                        PendingIdentityOperation::Import
798                    }
799            })
800            .map(|pending| pending.receipt_ref.clone())
801            .map(Ok)
802            .unwrap_or_else(|| Self::next_receipt("omega-onboarding-recover"));
803        let Ok(receipt_ref) = receipt_ref else {
804            self.controller
805                .report_error(IdentityUiError::OperationFailed);
806            cx.notify();
807            return;
808        };
809        let operation = if resolving_conflict {
810            IdentityOperation::ResolveConflict {
811                receipt_ref: receipt_ref.clone(),
812            }
813        } else {
814            IdentityOperation::AdoptRecovery {
815                receipt_ref: receipt_ref.clone(),
816            }
817        };
818        self.start_inspection_operation(operation, window, cx, move |backend| {
819            if resolving_conflict {
820                backend.resolve_conflict(session.prepared, &candidate_ref, receipt_ref)
821            } else {
822                backend.adopt(session.prepared, &candidate_ref, receipt_ref)
823            }
824        });
825    }
826
827    fn export_recovery(&mut self, window: &mut Window, cx: &mut Context<Self>) {
828        let mut raw_password =
829            Zeroizing::new(self.password_input.update(cx, |input, cx| input.take(cx)));
830        let confirmation = Zeroizing::new(
831            self.confirmation_input
832                .update(cx, |input, cx| input.take(cx)),
833        );
834        if raw_password != confirmation {
835            let token = self.controller.replace(IdentityOperation::ExportRecovery);
836            self.controller
837                .reject(&token, IdentityUiError::InvalidPassword);
838            cx.notify();
839            return;
840        }
841        let password = match RecoveryPassword::new(std::mem::take(&mut *raw_password)) {
842            Ok(password) => password,
843            Err(_) => {
844                let token = self.controller.replace(IdentityOperation::ExportRecovery);
845                self.controller
846                    .reject(&token, IdentityUiError::InvalidPassword);
847                cx.notify();
848                return;
849            }
850        };
851        let Some(identity_ref) = self
852            .controller
853            .durable()
854            .and_then(|inspection| inspection.custody.identity.as_ref())
855            .map(|identity| identity.identity_ref().clone())
856        else {
857            return;
858        };
859        let Some(token) = self.controller.begin(IdentityOperation::ExportRecovery) else {
860            return;
861        };
862        let paths = cx.prompt_for_paths(PathPromptOptions {
863            files: false,
864            directories: true,
865            multiple: false,
866            prompt: Some("Choose a folder for the encrypted recovery file".into()),
867        });
868        let backend = self.backend.clone();
869        self.operation_task = Some(cx.spawn_in(window, async move |this, cx| {
870            let directory = match paths.await {
871                Ok(Ok(Some(paths))) => paths.into_iter().next(),
872                _ => None,
873            };
874            let Some(directory) = directory else {
875                this.update(cx, |this, cx| {
876                    if this.controller.cancel_if_current(&token) {
877                        cx.notify();
878                    }
879                })
880                .log_err();
881                return;
882            };
883            let path = directory.join("omega-identity-recovery.ncryptsec");
884            let result = cx
885                .background_spawn(
886                    async move { backend.export_artifact(&identity_ref, path, password) },
887                )
888                .await
889                .map_err(map_custody_error);
890            this.update_in(cx, |this, window, cx| {
891                this.apply_inspection_result(&token, result, window, cx);
892            })
893            .log_err();
894        }));
895        cx.notify();
896    }
897
898    fn save_local_profile(&mut self, cx: &mut Context<Self>) {
899        let Some(mut profile) = self.local_profile.clone() else {
900            return;
901        };
902        let display_name = self.display_name_input.read(cx).text(cx);
903        let display_name = (!display_name.trim().is_empty()).then_some(display_name);
904        if profile.set_display_name(display_name).is_err() {
905            self.profile_message = Some("Display name must be 80 characters or fewer.".into());
906            cx.notify();
907            return;
908        }
909        let Ok(json) = profile.canonical_json() else {
910            self.profile_message = Some("Local profile could not be saved.".into());
911            cx.notify();
912            return;
913        };
914        let key = LocalIdentityProfile::kvp_key(profile.identity_ref());
915        let kvp = KeyValueStore::global(cx);
916        let write = cx.background_spawn(async move { kvp.write_kvp(key, json).await });
917        self.profile_task = Some(cx.spawn(async move |this, cx| {
918            let result = write.await;
919            this.update(cx, |this, cx| {
920                this.profile_message = Some(if let Err(error) = result {
921                    zlog::error!("failed to save local identity profile: {error:#}");
922                    "Local profile could not be saved.".into()
923                } else {
924                    "Saved locally. Nothing was published.".into()
925                });
926                cx.notify();
927            })
928            .log_err();
929        }));
930        self.local_profile = Some(profile);
931        self.profile_message = Some("Saving locally…".into());
932        cx.notify();
933    }
934
935    fn choose_local_avatar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
936        let Some(identity_ref) = self
937            .local_profile
938            .as_ref()
939            .map(|profile| profile.identity_ref().clone())
940        else {
941            return;
942        };
943        let paths = cx.prompt_for_paths(PathPromptOptions {
944            files: true,
945            directories: false,
946            multiple: false,
947            prompt: Some("Choose a local profile image".into()),
948        });
949        self.profile_task = Some(cx.spawn_in(window, async move |this, cx| {
950            let source = match paths.await {
951                Ok(Ok(Some(paths))) => paths.into_iter().next(),
952                _ => None,
953            };
954            let Some(source) = source else {
955                return;
956            };
957            let result = cx
958                .background_spawn(async move { install_local_avatar(source) })
959                .await;
960            this.update(cx, |this, cx| {
961                if !profile_matches_identity(this.local_profile.as_ref(), &identity_ref) {
962                    return;
963                }
964                match result {
965                    Ok(avatar_reference) => {
966                        if let Some(profile) = &mut this.local_profile {
967                            if profile.set_avatar_reference(Some(avatar_reference)).is_ok() {
968                                this.profile_message = Some(
969                                    "Avatar selected. Save the local profile to keep it.".into(),
970                                );
971                            } else {
972                                this.profile_message =
973                                    Some("The local avatar reference was invalid.".into());
974                            }
975                        }
976                    }
977                    Err(error) => {
978                        zlog::error!("failed to install local identity avatar: {error}");
979                        this.profile_message =
980                            Some("The selected avatar could not be stored locally.".into());
981                    }
982                }
983                cx.notify();
984            })
985            .log_err();
986        }));
987    }
988
989    fn presentation(&self) -> IdentityPresentation {
990        if let Some(operation) = self.controller.operation() {
991            let (title, description) = match operation {
992                IdentityOperation::Inspect => (
993                    "Checking secure identity…",
994                    "Omega is reading public identity facts and secure-custody availability.",
995                ),
996                IdentityOperation::Create { .. } => (
997                    "Creating your identity…",
998                    "Omega is creating one Nostr identity, installing it in secure custody, and verifying read-back.",
999                ),
1000                IdentityOperation::ResumeIncomplete => (
1001                    "Resuming identity setup…",
1002                    "Omega is finishing the durable transaction without rotating the known identity.",
1003                ),
1004                IdentityOperation::PrepareRecovery => (
1005                    "Checking recovery candidate…",
1006                    "Omega is deriving public identity details in the background. No secret is shown or published.",
1007                ),
1008                IdentityOperation::AdoptRecovery { .. }
1009                | IdentityOperation::ResolveConflict { .. } => (
1010                    "Recovering your identity…",
1011                    "Omega is installing the explicitly selected identity in secure custody.",
1012                ),
1013                IdentityOperation::ExportRecovery => (
1014                    "Protecting recovery…",
1015                    "Omega is creating and verifying an encrypted NIP-49 recovery file.",
1016                ),
1017                IdentityOperation::Reset { .. } | IdentityOperation::ResumeReset => (
1018                    "Verifying identity reset…",
1019                    "Omega remains blocked until secure deletion and public-state cleanup are verified.",
1020                ),
1021            };
1022            return IdentityPresentation {
1023                title,
1024                description,
1025                icon: IconName::Lock,
1026                color: Color::Accent,
1027                actions: Vec::new(),
1028            };
1029        }
1030
1031        let Some(inspection) = self.controller.durable() else {
1032            return IdentityPresentation {
1033                title: "Checking secure identity",
1034                description: "Omega has not established a durable identity fact yet.",
1035                icon: IconName::Lock,
1036                color: Color::Muted,
1037                actions: vec![IdentityAction::Retry],
1038            };
1039        };
1040
1041        Self::durable_presentation(inspection)
1042    }
1043
1044    fn error_message(&self) -> Option<&'static str> {
1045        self.controller.error().map(|error| match error {
1046            IdentityUiError::InvalidSecret => {
1047                "That secret key is invalid. The field was cleared; paste it again to retry."
1048            }
1049            IdentityUiError::InvalidPassword => {
1050                "The password was invalid or did not match. Both password fields were cleared."
1051            }
1052            IdentityUiError::UnsafeRecoveryFile => {
1053                "That recovery file is invalid or does not meet Omega's local safety checks."
1054            }
1055            IdentityUiError::RecoveryFileExists => {
1056                "The destination already contains an Omega recovery file. Choose another folder."
1057            }
1058            IdentityUiError::SecureStorageUnavailable => {
1059                "Secure identity storage is unavailable. Unlock it and try again."
1060            }
1061            IdentityUiError::OperationFailed => {
1062                "Identity setup did not finish. Omega kept the last durable state unchanged."
1063            }
1064        })
1065    }
1066
1067    fn ready_identity(&self) -> Option<&PublicIdentity> {
1068        self.controller
1069            .durable()
1070            .filter(|inspection| inspection.custody.state == CustodyState::Ready)
1071            .and_then(|inspection| inspection.custody.identity.as_ref())
1072    }
1073
1074    fn public_identities(&self) -> Vec<PublicIdentity> {
1075        self.controller
1076            .durable()
1077            .map(|inspection| {
1078                inspection
1079                    .conflict
1080                    .as_ref()
1081                    .map(|conflict| conflict.identities.clone())
1082                    .filter(|identities| !identities.is_empty())
1083                    .unwrap_or_else(|| inspection.custody.identity.clone().into_iter().collect())
1084            })
1085            .unwrap_or_default()
1086    }
1087
1088    fn compact_recovery_protection(&self) -> Option<(&'static str, Color)> {
1089        self.controller
1090            .durable()
1091            .and_then(Self::compact_recovery_protection_for)
1092    }
1093
1094    fn compact_recovery_protection_for(
1095        inspection: &IdentityInspection,
1096    ) -> Option<(&'static str, Color)> {
1097        if inspection.custody.state != CustodyState::Ready {
1098            return None;
1099        }
1100        Some(match inspection.recovery_protection.state {
1101            RecoveryProtectionState::Protected => ("Recovery protected", Color::Success),
1102            RecoveryProtectionState::Needed => ("Recovery protection needed", Color::Warning),
1103            RecoveryProtectionState::NotApplicable => {
1104                ("Recovery protection unavailable", Color::Muted)
1105            }
1106        })
1107    }
1108
1109    fn durable_presentation(inspection: &IdentityInspection) -> IdentityPresentation {
1110        match inspection.custody.state {
1111            CustodyState::ResetFailed => IdentityPresentation {
1112                title: "Reset didn't finish",
1113                description: "Omega kept identity setup blocked so the previous identity is not silently replaced.",
1114                icon: IconName::Warning,
1115                color: Color::Error,
1116                actions: vec![IdentityAction::RetryReset, IdentityAction::Relaunch],
1117            },
1118            CustodyState::Locked => IdentityPresentation {
1119                title: "System keychain locked",
1120                description: "Unlock the system keychain before Omega checks or uses your identity.",
1121                icon: IconName::Lock,
1122                color: Color::Warning,
1123                actions: vec![IdentityAction::Retry],
1124            },
1125            CustodyState::RelaunchRequired => IdentityPresentation {
1126                title: "Relaunch required",
1127                description: "Identity maintenance finished safely. Relaunch Omega to continue.",
1128                icon: IconName::Info,
1129                color: Color::Accent,
1130                actions: vec![IdentityAction::Relaunch],
1131            },
1132            CustodyState::Conflict => {
1133                let ambiguous = inspection.conflict.as_ref().is_some_and(|conflict| {
1134                    conflict.reason == CustodyConflictReason::AmbiguousSecureStore
1135                });
1136                IdentityPresentation {
1137                    title: if ambiguous {
1138                        "Secure storage needs attention"
1139                    } else {
1140                        "Identity choice required"
1141                    },
1142                    description: if ambiguous {
1143                        "The system keychain returned more than one matching credential. Omega will not guess which one to use."
1144                    } else {
1145                        "Public identity facts disagree. Recover the identity you own or explicitly reset after verifying a backup."
1146                    },
1147                    icon: IconName::Warning,
1148                    color: Color::Warning,
1149                    actions: if ambiguous {
1150                        vec![IdentityAction::Retry]
1151                    } else {
1152                        vec![IdentityAction::Recover]
1153                    },
1154                }
1155            }
1156            CustodyState::Lost => IdentityPresentation {
1157                title: "Recovery needed",
1158                description: "The public identity is known, but its signing key is not available in secure custody.",
1159                icon: IconName::LockOff,
1160                color: Color::Error,
1161                actions: vec![IdentityAction::Recover, IdentityAction::Reset],
1162            },
1163            CustodyState::Incomplete => {
1164                let pending_create = inspection
1165                    .pending_transaction
1166                    .as_ref()
1167                    .is_some_and(|pending| pending.operation == PendingIdentityOperation::Create);
1168                IdentityPresentation {
1169                    title: "Identity setup needs repair",
1170                    description: if pending_create {
1171                        "A prior Create transaction can be resumed with its original durable receipt."
1172                    } else {
1173                        "A prior recovery transaction needs the same owner-authorized identity candidate."
1174                    },
1175                    icon: IconName::Warning,
1176                    color: Color::Warning,
1177                    actions: if pending_create {
1178                        vec![IdentityAction::ResumeCreate]
1179                    } else {
1180                        vec![IdentityAction::Recover]
1181                    },
1182                }
1183            }
1184            CustodyState::Absent => IdentityPresentation {
1185                title: "Create your Omega identity",
1186                description: "Create a local Nostr identity for signed work, portable social context, and agent coordination.",
1187                icon: IconName::Person,
1188                color: Color::Accent,
1189                actions: vec![IdentityAction::Create, IdentityAction::Recover],
1190            },
1191            CustodyState::Ready => {
1192                let needs_recovery =
1193                    inspection.recovery_protection.state != RecoveryProtectionState::Protected;
1194                IdentityPresentation {
1195                    title: "Identity ready",
1196                    description: if needs_recovery {
1197                        "Your signing key is in secure local custody. Create an encrypted recovery file before relying on this identity."
1198                    } else {
1199                        "Your public identity is ready and an encrypted recovery file has been verified."
1200                    },
1201                    icon: IconName::UserCheck,
1202                    color: if needs_recovery {
1203                        Color::Warning
1204                    } else {
1205                        Color::Success
1206                    },
1207                    actions: vec![if needs_recovery {
1208                        IdentityAction::Protect
1209                    } else {
1210                        IdentityAction::ReplaceRecovery
1211                    }],
1212                }
1213            }
1214        }
1215    }
1216
1217    fn render_recovery_controls(&self, cx: &mut Context<Self>) -> AnyElement {
1218        let Some(mode) = self.recovery_mode else {
1219            return div().into_any_element();
1220        };
1221        let cancel = Button::new("omega-identity-recovery-cancel", "Cancel")
1222            .style(ButtonStyle::OutlinedGhost)
1223            .tab_index(self.first_tab_index + 6)
1224            .on_click(cx.listener(|this, _, _, cx| {
1225                this.clear_transient_state(cx);
1226            }));
1227
1228        match mode {
1229            RecoveryMode::Choose => v_flex()
1230                .gap_2()
1231                .child(Label::new("Recover an existing identity"))
1232                .child(
1233                    Label::new(
1234                        "Use an encrypted NIP-49 recovery file, or open the advanced secret-key path.",
1235                    )
1236                    .color(Color::Muted)
1237                    .size(LabelSize::Small),
1238                )
1239                .child(
1240                    h_flex()
1241                        .gap_2()
1242                        .flex_wrap()
1243                        .child(
1244                            Button::new("omega-identity-recovery-file", "Choose recovery file")
1245                                .style(ButtonStyle::Filled)
1246                                .tab_index(self.first_tab_index + 2)
1247                                .on_click(cx.listener(|this, _, window, cx| {
1248                                    this.choose_recovery_artifact(window, cx);
1249                                })),
1250                        )
1251                        .child(
1252                            Button::new(
1253                                "omega-identity-recovery-advanced",
1254                                "Advanced secret import",
1255                            )
1256                            .style(ButtonStyle::OutlinedGhost)
1257                            .tab_index(self.first_tab_index + 3)
1258                            .on_click(cx.listener(|this, _, _, cx| {
1259                                this.recovery_mode = Some(RecoveryMode::AdvancedImport);
1260                                cx.notify();
1261                            })),
1262                        )
1263                        .child(cancel),
1264                )
1265                .into_any_element(),
1266            RecoveryMode::AdvancedImport => v_flex()
1267                .gap_2()
1268                .child(Label::new("Advanced Nostr secret import"))
1269                .child(
1270                    Label::new(
1271                        "The value stays in a zeroizing field with copy and cut disabled, and is cleared before background validation.",
1272                    )
1273                    .color(Color::Muted)
1274                    .size(LabelSize::Small),
1275                )
1276                .child(self.secret_input.clone())
1277                .child(
1278                    h_flex()
1279                        .gap_2()
1280                        .child(
1281                            Button::new("omega-identity-preview-secret", "Preview identity")
1282                                .style(ButtonStyle::Filled)
1283                                .tab_index(self.first_tab_index + 3)
1284                                .on_click(cx.listener(|this, _, window, cx| {
1285                                    this.submit_advanced_import(window, cx);
1286                                })),
1287                        )
1288                        .child(cancel),
1289                )
1290                .into_any_element(),
1291            RecoveryMode::ArtifactPassword => v_flex()
1292                .gap_2()
1293                .child(Label::new("Unlock the selected recovery file"))
1294                .child(
1295                    Label::new(
1296                        "Omega validates file safety and derives only public identity details for review.",
1297                    )
1298                    .color(Color::Muted)
1299                    .size(LabelSize::Small),
1300                )
1301                .child(self.password_input.clone())
1302                .child(
1303                    h_flex()
1304                        .gap_2()
1305                        .child(
1306                            Button::new("omega-identity-preview-artifact", "Preview identity")
1307                                .style(ButtonStyle::Filled)
1308                                .tab_index(self.first_tab_index + 3)
1309                                .on_click(cx.listener(|this, _, window, cx| {
1310                                    this.submit_artifact_password(window, cx);
1311                                })),
1312                        )
1313                        .child(cancel),
1314                )
1315                .into_any_element(),
1316            RecoveryMode::Review => {
1317                let candidates = self
1318                    .recovery_session
1319                    .as_ref()
1320                    .map(|session| session.resolution.candidates.clone())
1321                    .unwrap_or_default();
1322                let requires_selection = self.recovery_session.as_ref().is_some_and(|session| {
1323                    session.resolution.state == RecoveryResolutionState::OwnerSelectionRequired
1324                });
1325                v_flex()
1326                    .gap_2()
1327                    .child(Label::new(if requires_selection {
1328                        "Choose the identity you own"
1329                    } else {
1330                        "Confirm recovered identity"
1331                    }))
1332                    .child(
1333                        Label::new(if requires_selection {
1334                            "The candidates resolve to different public identities. Omega will not choose for you."
1335                        } else {
1336                            "Only public identity details are shown. The secret remains opaque and will be consumed on adoption."
1337                        })
1338                        .color(Color::Muted)
1339                        .size(LabelSize::Small),
1340                    )
1341                    .children(candidates.into_iter().enumerate().map(|(index, candidate)| {
1342                        let candidate_ref = candidate.candidate_ref.clone();
1343                        let npub = candidate.identity.npub().as_str().to_string();
1344                        let fingerprint = candidate.identity.fingerprint().display();
1345                        h_flex()
1346                            .min_w_0()
1347                            .gap_2()
1348                            .justify_between()
1349                            .child(
1350                                v_flex()
1351                                    .min_w_0()
1352                                    .child(
1353                                        Label::new(wrapping_public_identity(&npub))
1354                                            .size(LabelSize::Small),
1355                                    )
1356                                    .child(
1357                                        Label::new(format!("Fingerprint {fingerprint}"))
1358                                            .color(Color::Muted)
1359                                            .size(LabelSize::XSmall),
1360                                    ),
1361                            )
1362                            .child(
1363                                Button::new(
1364                                    format!(
1365                                        "omega-identity-adopt-{}",
1366                                        candidate_ref.as_str()
1367                                    ),
1368                                    "Use this identity",
1369                                )
1370                                .style(ButtonStyle::Filled)
1371                                .tab_index(self.first_tab_index + 2 + index as isize)
1372                                .on_click(cx.listener(move |this, _, window, cx| {
1373                                    this.adopt_candidate(candidate_ref.clone(), window, cx);
1374                                })),
1375                            )
1376                    }))
1377                    .child(
1378                        h_flex()
1379                            .gap_2()
1380                            .child(
1381                                Button::new(
1382                                    "omega-identity-add-recovery",
1383                                    "Add another recovery file",
1384                                )
1385                                .style(ButtonStyle::OutlinedGhost)
1386                                .tab_index(self.first_tab_index + 5)
1387                                .on_click(cx.listener(|this, _, window, cx| {
1388                                    this.choose_recovery_artifact(window, cx);
1389                                })),
1390                            )
1391                            .child(cancel),
1392                    )
1393                    .into_any_element()
1394            }
1395            RecoveryMode::Protect => v_flex()
1396                .gap_2()
1397                .child(Label::new("Create encrypted recovery"))
1398                .child(
1399                    Label::new(
1400                        "Choose a strong password. Omega writes a standard NIP-49 file and never displays the raw secret.",
1401                    )
1402                    .color(Color::Muted)
1403                    .size(LabelSize::Small),
1404                )
1405                .child(self.password_input.clone())
1406                .child(self.confirmation_input.clone())
1407                .child(
1408                    h_flex()
1409                        .gap_2()
1410                        .child(
1411                            Button::new(
1412                                "omega-identity-export-recovery",
1413                                "Choose folder and save",
1414                            )
1415                            .style(ButtonStyle::Filled)
1416                            .tab_index(self.first_tab_index + 4)
1417                            .on_click(cx.listener(|this, _, window, cx| {
1418                                this.export_recovery(window, cx);
1419                            })),
1420                        )
1421                        .child(cancel),
1422                )
1423                .into_any_element(),
1424        }
1425    }
1426
1427    fn render_local_profile(&self, cx: &mut Context<Self>) -> AnyElement {
1428        if self.ready_identity().is_none() {
1429            return div().into_any_element();
1430        }
1431        let avatar_status = if self
1432            .local_profile
1433            .as_ref()
1434            .and_then(LocalIdentityProfile::avatar_reference)
1435            .is_some()
1436        {
1437            "Local avatar selected"
1438        } else {
1439            "No local avatar selected"
1440        };
1441        v_flex()
1442            .gap_2()
1443            .child(Label::new("Local profile"))
1444            .child(
1445                Label::new(
1446                    "Display name and avatar stay on this device. Omega does not publish a Nostr kind 0 profile.",
1447                )
1448                .color(Color::Muted)
1449                .size(LabelSize::Small),
1450            )
1451            .child(self.display_name_input.clone())
1452            .child(
1453                h_flex()
1454                    .gap_2()
1455                    .child(
1456                        Button::new("omega-identity-choose-avatar", "Choose local avatar")
1457                            .style(ButtonStyle::OutlinedGhost)
1458                            .tab_index(self.first_tab_index + 9)
1459                            .on_click(cx.listener(|this, _, window, cx| {
1460                                this.choose_local_avatar(window, cx);
1461                            })),
1462                    )
1463                    .child(
1464                        Button::new("omega-identity-save-profile", "Save locally")
1465                            .style(ButtonStyle::OutlinedGhost)
1466                            .tab_index(self.first_tab_index + 10)
1467                            .on_click(cx.listener(|this, _, _, cx| {
1468                                this.save_local_profile(cx);
1469                            })),
1470                    ),
1471            )
1472            .child(
1473                Label::new(avatar_status)
1474                    .color(Color::Muted)
1475                    .size(LabelSize::XSmall),
1476            )
1477            .when_some(self.profile_message.clone(), |this, message| {
1478                this.child(
1479                    Label::new(message)
1480                        .color(Color::Muted)
1481                        .size(LabelSize::XSmall),
1482                )
1483            })
1484            .into_any_element()
1485    }
1486}
1487
1488fn map_custody_error(error: CustodyError) -> IdentityUiError {
1489    match error {
1490        CustodyError::InvalidImportedSecret => IdentityUiError::InvalidSecret,
1491        CustodyError::RecoveryDecryptionFailed => IdentityUiError::InvalidPassword,
1492        CustodyError::InvalidRecoveryArtifact => IdentityUiError::UnsafeRecoveryFile,
1493        CustodyError::RecoveryArtifactExists => IdentityUiError::RecoveryFileExists,
1494        CustodyError::SecureStoreUnavailable | CustodyError::MutationLock => {
1495            IdentityUiError::SecureStorageUnavailable
1496        }
1497        _ => IdentityUiError::OperationFailed,
1498    }
1499}
1500
1501fn profile_matches_identity(
1502    profile: Option<&LocalIdentityProfile>,
1503    expected_identity_ref: &IdentityRef,
1504) -> bool {
1505    profile.is_some_and(|profile| profile.identity_ref() == expected_identity_ref)
1506}
1507
1508fn wrapping_public_identity(value: &str) -> String {
1509    let mut display = String::with_capacity(value.len() + value.len() / 12);
1510    for (index, character) in value.chars().enumerate() {
1511        if index > 0 && index % 12 == 0 {
1512            display.push('\u{200b}');
1513        }
1514        display.push(character);
1515    }
1516    display
1517}
1518
1519impl IdentitySection {
1520    fn render_actions(&self, actions: Vec<IdentityAction>, cx: &mut Context<Self>) -> AnyElement {
1521        h_flex()
1522            .gap_2()
1523            .flex_wrap()
1524            .children(actions.into_iter().enumerate().map(|(index, action)| {
1525                Button::new(format!("omega-identity-{}", action.id()), action.label())
1526                    .style(if action.primary() {
1527                        ButtonStyle::Filled
1528                    } else {
1529                        ButtonStyle::OutlinedGhost
1530                    })
1531                    .size(ButtonSize::Medium)
1532                    .tab_index(self.first_tab_index + index as isize)
1533                    .on_click(cx.listener(move |this, _, window, cx| {
1534                        this.handle_action(action, window, cx);
1535                    }))
1536            }))
1537            .into_any_element()
1538    }
1539
1540    fn render_full(&mut self, cx: &mut Context<Self>) -> AnyElement {
1541        let presentation = self.presentation();
1542        let accessibility_label = presentation.accessibility_label();
1543        let public_identities = self.public_identities();
1544        let error_message = self.error_message();
1545        let actions = presentation.actions;
1546
1547        v_flex()
1548            .min_w_0()
1549            .gap_3()
1550            .child(
1551                v_flex()
1552                    .gap_0p5()
1553                    .child(Label::new("Your identity"))
1554                    .child(
1555                        Label::new(
1556                            "Omega uses a Nostr key pair as your portable public identity. Your private key stays in secure local custody.",
1557                        )
1558                        .color(Color::Muted),
1559                    ),
1560            )
1561            .child(
1562                v_flex()
1563                    .id("omega-identity-status")
1564                    .role(gpui::Role::Status)
1565                    .aria_label(accessibility_label)
1566                    .min_w_0()
1567                    .gap_2()
1568                    .child(
1569                        h_flex()
1570                            .min_w_0()
1571                            .gap_2()
1572                            .child(
1573                                Icon::new(presentation.icon)
1574                                    .size(IconSize::Small)
1575                                    .color(presentation.color),
1576                            )
1577                            .child(Label::new(presentation.title)),
1578                    )
1579                    .child(
1580                        Label::new(presentation.description)
1581                            .color(Color::Muted)
1582                            .size(LabelSize::Small),
1583                    )
1584                    .children(public_identities.into_iter().enumerate().map(
1585                        |(index, identity)| {
1586                            v_flex()
1587                                .min_w_0()
1588                                .gap_0p5()
1589                                .child(
1590                                    Label::new(if index == 0 {
1591                                        "Public identity"
1592                                    } else {
1593                                        "Conflicting public identity"
1594                                    })
1595                                    .color(Color::Muted)
1596                                    .size(LabelSize::XSmall),
1597                                )
1598                                .child(
1599                                    Label::new(wrapping_public_identity(identity.npub().as_str()))
1600                                        .size(LabelSize::Small),
1601                                )
1602                                .child(
1603                                    Label::new(format!(
1604                                        "Fingerprint {}",
1605                                        identity.fingerprint().display()
1606                                    ))
1607                                    .color(Color::Muted)
1608                                    .size(LabelSize::XSmall),
1609                                )
1610                        },
1611                    ))
1612                    .when_some(error_message, |this, message| {
1613                        this.child(
1614                            h_flex()
1615                                .gap_2()
1616                                .child(
1617                                    Icon::new(IconName::Warning)
1618                                        .size(IconSize::Small)
1619                                        .color(Color::Error),
1620                                )
1621                                .child(
1622                                    Label::new(message)
1623                                        .color(Color::Error)
1624                                        .size(LabelSize::Small),
1625                                ),
1626                        )
1627                    })
1628                    .when(!actions.is_empty(), |this| {
1629                        this.child(self.render_actions(actions, cx))
1630                    })
1631                    .when(self.recovery_mode.is_some(), |this| {
1632                        this.child(self.render_recovery_controls(cx))
1633                    })
1634                    .when(self.ready_identity().is_some(), |this| {
1635                        this.child(self.render_local_profile(cx))
1636                    }),
1637            )
1638            .into_any_element()
1639    }
1640
1641    fn render_compact(&mut self, cx: &mut Context<Self>) -> AnyElement {
1642        let presentation = self.presentation();
1643        let accessibility_label = presentation.accessibility_label();
1644        let public_identities = self.public_identities();
1645        let error_message = self.error_message();
1646        let recovery_protection = self.compact_recovery_protection();
1647        let actions = presentation.actions;
1648
1649        v_flex()
1650            .id("omega-identity-status-compact")
1651            .role(gpui::Role::Status)
1652            .aria_label(accessibility_label)
1653            .min_w_0()
1654            .gap_2()
1655            .child(
1656                h_flex()
1657                    .min_w_0()
1658                    .gap_2()
1659                    .child(
1660                        Icon::new(presentation.icon)
1661                            .size(IconSize::Small)
1662                            .color(presentation.color),
1663                    )
1664                    .child(Label::new(presentation.title)),
1665            )
1666            .child(
1667                Label::new(presentation.description)
1668                    .color(Color::Muted)
1669                    .size(LabelSize::Small),
1670            )
1671            .children(
1672                public_identities
1673                    .into_iter()
1674                    .enumerate()
1675                    .map(|(index, identity)| {
1676                        Label::new(format!(
1677                            "{} {}",
1678                            if index == 0 {
1679                                "Fingerprint"
1680                            } else {
1681                                "Conflicting fingerprint"
1682                            },
1683                            identity.fingerprint().display()
1684                        ))
1685                        .color(Color::Muted)
1686                        .size(LabelSize::XSmall)
1687                    }),
1688            )
1689            .when_some(recovery_protection, |this, (label, color)| {
1690                this.child(Label::new(label).color(color).size(LabelSize::XSmall))
1691            })
1692            .when_some(error_message, |this, message| {
1693                this.child(
1694                    h_flex()
1695                        .gap_2()
1696                        .child(
1697                            Icon::new(IconName::Warning)
1698                                .size(IconSize::Small)
1699                                .color(Color::Error),
1700                        )
1701                        .child(
1702                            Label::new(message)
1703                                .color(Color::Error)
1704                                .size(LabelSize::Small),
1705                        ),
1706                )
1707            })
1708            .when(!actions.is_empty(), |this| {
1709                this.child(self.render_actions(actions, cx))
1710            })
1711            .when(self.recovery_mode.is_some(), |this| {
1712                this.child(self.render_recovery_controls(cx))
1713            })
1714            .into_any_element()
1715    }
1716}
1717
1718impl Render for IdentitySection {
1719    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1720        match self.presentation {
1721            IdentitySectionPresentation::Full => self.render_full(cx),
1722            IdentitySectionPresentation::Compact => self.render_compact(cx),
1723        }
1724    }
1725}
1726
1727pub(crate) fn render_identity_section(
1728    tab_index: &mut isize,
1729    section: &Entity<IdentitySection>,
1730    presentation: IdentitySectionPresentation,
1731    cx: &mut App,
1732) -> impl IntoElement {
1733    *tab_index += IDENTITY_TAB_SLOTS;
1734    section.update(cx, |section, _| {
1735        section.presentation = presentation;
1736    });
1737    section.clone()
1738}
1739
1740#[cfg(test)]
1741mod tests {
1742    use omega_identity::{CustodyConflict, PendingIdentityTransaction, RecoveryProtectionStatus};
1743
1744    use super::*;
1745
1746    fn inspection(state: CustodyState) -> IdentityInspection {
1747        IdentityInspection {
1748            custody: CustodyResult {
1749                state,
1750                identity: None,
1751                receipt_ref: None,
1752            },
1753            pending_transaction: None,
1754            conflict: None,
1755            recovery_protection: RecoveryProtectionStatus {
1756                state: RecoveryProtectionState::NotApplicable,
1757                record: None,
1758            },
1759        }
1760    }
1761
1762    #[test]
1763    fn public_identity_has_safe_narrow_window_breaks() {
1764        let npub = "npub1lpkyfgk7jhv3fx63c63f4l4thgnycx8zlf7ynh5ngf9qc455w7zs7s8hua";
1765        assert_eq!(wrapping_public_identity(npub).replace('\u{200b}', ""), npub);
1766    }
1767
1768    #[test]
1769    fn identity_reserves_focus_before_theme() {
1770        assert!(IDENTITY_TAB_SLOTS >= 10);
1771    }
1772
1773    #[test]
1774    fn identity_presentations_default_to_full_first_run_content() {
1775        assert_eq!(
1776            IdentitySectionPresentation::default(),
1777            IdentitySectionPresentation::Full
1778        );
1779        assert_ne!(
1780            IdentitySectionPresentation::Full,
1781            IdentitySectionPresentation::Compact
1782        );
1783    }
1784
1785    #[test]
1786    fn every_durable_custody_fact_has_an_explicit_presentation() {
1787        let cases = [
1788            (CustodyState::Absent, "Create your Omega identity"),
1789            (CustodyState::Ready, "Identity ready"),
1790            (CustodyState::Locked, "System keychain locked"),
1791            (CustodyState::Incomplete, "Identity setup needs repair"),
1792            (CustodyState::Lost, "Recovery needed"),
1793            (CustodyState::Conflict, "Identity choice required"),
1794            (CustodyState::ResetFailed, "Reset didn't finish"),
1795            (CustodyState::RelaunchRequired, "Relaunch required"),
1796        ];
1797        for (state, title) in cases {
1798            let presentation = IdentitySection::durable_presentation(&inspection(state));
1799            assert_eq!(presentation.title, title);
1800            let accessibility_label = presentation.accessibility_label();
1801            assert!(accessibility_label.starts_with(title));
1802            assert!(!accessibility_label.contains("nsec"));
1803        }
1804    }
1805
1806    #[test]
1807    fn incomplete_create_uses_the_durable_resume_path() {
1808        let mut inspection = inspection(CustodyState::Incomplete);
1809        inspection.pending_transaction = Some(PendingIdentityTransaction {
1810            operation: PendingIdentityOperation::Create,
1811            receipt_ref: ReceiptRef::new("original-create").expect("valid receipt"),
1812            expected_identity: None,
1813        });
1814        assert_eq!(
1815            IdentitySection::durable_presentation(&inspection).actions,
1816            vec![IdentityAction::ResumeCreate]
1817        );
1818    }
1819
1820    #[test]
1821    fn ambiguous_keychain_conflict_never_offers_fake_owner_selection() {
1822        let mut inspection = inspection(CustodyState::Conflict);
1823        inspection.conflict = Some(CustodyConflict {
1824            reason: CustodyConflictReason::AmbiguousSecureStore,
1825            identities: Vec::new(),
1826        });
1827        let presentation = IdentitySection::durable_presentation(&inspection);
1828        assert_eq!(presentation.title, "Secure storage needs attention");
1829        assert_eq!(presentation.actions, vec![IdentityAction::Retry]);
1830    }
1831
1832    #[test]
1833    fn public_identity_mismatch_requires_proven_recovery_selection() {
1834        let mut inspection = inspection(CustodyState::Conflict);
1835        inspection.conflict = Some(CustodyConflict {
1836            reason: CustodyConflictReason::PublicManifestCustodyMismatch,
1837            identities: Vec::new(),
1838        });
1839        let presentation = IdentitySection::durable_presentation(&inspection);
1840        assert_eq!(presentation.title, "Identity choice required");
1841        assert_eq!(presentation.actions, vec![IdentityAction::Recover]);
1842    }
1843
1844    #[test]
1845    fn ready_without_a_durable_recovery_fact_stays_visibly_unprotected() {
1846        let mut inspection = inspection(CustodyState::Ready);
1847        inspection.recovery_protection = RecoveryProtectionStatus {
1848            state: RecoveryProtectionState::Needed,
1849            record: None,
1850        };
1851        let presentation = IdentitySection::durable_presentation(&inspection);
1852        assert!(presentation.description.contains("encrypted recovery"));
1853        assert_eq!(presentation.actions, vec![IdentityAction::Protect]);
1854    }
1855
1856    /// The protected and unprotected states must never present the same
1857    /// control. Offering "Protect recovery" beside "Recovery protected" reads
1858    /// as though the protection did not take, which is what omega#68 reported.
1859    ///
1860    /// Rotation stays reachable, relabelled, so this fix does not quietly
1861    /// remove the ability to replace a recovery file.
1862    #[test]
1863    fn a_protected_identity_offers_replacement_rather_than_protection() {
1864        let mut inspection = inspection(CustodyState::Ready);
1865        inspection.recovery_protection = RecoveryProtectionStatus {
1866            state: RecoveryProtectionState::Protected,
1867            record: None,
1868        };
1869        let protected = IdentitySection::durable_presentation(&inspection);
1870        assert_eq!(protected.title, "Identity ready");
1871        assert_eq!(protected.actions, vec![IdentityAction::ReplaceRecovery]);
1872        assert_eq!(
1873            IdentityAction::ReplaceRecovery.label(),
1874            "Replace recovery file"
1875        );
1876        assert_eq!(
1877            IdentitySection::compact_recovery_protection_for(&inspection).map(|(label, _)| label),
1878            Some("Recovery protected")
1879        );
1880
1881        inspection.recovery_protection = RecoveryProtectionStatus {
1882            state: RecoveryProtectionState::Needed,
1883            record: None,
1884        };
1885        let needed = IdentitySection::durable_presentation(&inspection);
1886        assert_eq!(needed.actions, vec![IdentityAction::Protect]);
1887
1888        assert_eq!(
1889            IdentitySection::compact_recovery_protection_for(&inspection).map(|(label, _)| label),
1890            Some("Recovery protection needed")
1891        );
1892
1893        // The two states must differ. If they ever converge again, this fails.
1894        assert_ne!(protected.actions, needed.actions);
1895    }
1896
1897    #[test]
1898    fn compact_conflicts_keep_their_resolution_actions_reachable() {
1899        let mut inspection = inspection(CustodyState::Conflict);
1900        inspection.conflict = Some(CustodyConflict {
1901            reason: CustodyConflictReason::PublicManifestCustodyMismatch,
1902            identities: Vec::new(),
1903        });
1904        assert_eq!(
1905            IdentitySection::durable_presentation(&inspection).actions,
1906            vec![IdentityAction::Recover]
1907        );
1908    }
1909
1910    #[test]
1911    fn avatar_completion_is_fenced_to_the_identity_that_started_it() {
1912        let first_identity = IdentityRef::new("omega-first").expect("valid identity");
1913        let second_identity = IdentityRef::new("omega-second").expect("valid identity");
1914        let current_profile = LocalIdentityProfile::new(second_identity);
1915
1916        assert!(!profile_matches_identity(
1917            Some(&current_profile),
1918            &first_identity
1919        ));
1920        assert!(!profile_matches_identity(None, &first_identity));
1921    }
1922}
1923
Served at tenant.openagents/omega Member data and write actions are omitted.