Skip to repository content1923 lines · 75.8 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T03:36:05.628Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
identity_section.rs
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(¤t_profile),
1918 &first_identity
1919 ));
1920 assert!(!profile_matches_identity(None, &first_identity));
1921 }
1922}
1923