fix(ui): move sudo auth and cache check to async timeout-aware tea.Cmd
swadhinbiswas committed 2 months ago
abdcc64
1 parent 3f85206
commit abdcc64
2 files changed
+91 -29
Showing 2 changed files+91-29
▼internal/ui/app.go
+65-29
| @@ -121,6 +121,12 @@ type historyMsg struct { | ||
121 | 121 | entries []history.Entry |
122 | 122 | err error |
123 | 123 | } |
124 | +type sudoAuthMsg struct { | |
125 | + err error | |
126 | +} | |
127 | +type sudoCacheCheckMsg struct { | |
128 | + cached bool | |
129 | +} | |
124 | 130 | |
125 | 131 | type uninstallResult struct { |
126 | 132 | app *model.AppInfo |
| @@ -389,6 +395,27 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | ||
389 | 395 | m.state = stateHistory |
390 | 396 | return m, nil |
391 | 397 | |
398 | + case sudoCacheCheckMsg: | |
399 | + if msg.cached { | |
400 | + return m, m.proceedWithUninstall() | |
401 | + } | |
402 | + m.state = stateSudoPassword | |
403 | + m.sudoError = "" | |
404 | + m.sudoInput.SetValue("") | |
405 | + return m, m.sudoInput.Focus() | |
406 | + | |
407 | + case sudoAuthMsg: | |
408 | + if msg.err != nil { | |
409 | + m.state = stateSudoPassword | |
410 | + m.sudoError = "Incorrect password, please try again." | |
411 | + m.sudoInput.SetValue("") | |
412 | + return m, m.sudoInput.Focus() | |
413 | + } | |
414 | + m.sudoInput.Blur() | |
415 | + m.sudoInput.SetValue("") | |
416 | + m.sudoError = "" | |
417 | + return m, m.proceedWithUninstall() | |
418 | + | |
392 | 419 | case activityMsg: |
393 | 420 | m.activity = append(m.activity, string(msg)) |
394 | 421 | return m, nil |
| @@ -617,11 +644,8 @@ func (m *Model) confirmAndStartUninstall() tea.Cmd { | ||
617 | 644 | m.state = stateReady |
618 | 645 | return nil |
619 | 646 | } |
620 | - if requiresSudo(apps) && !m.isSudoCached() { | |
621 | - m.state = stateSudoPassword | |
622 | - m.sudoError = "" | |
623 | - m.sudoInput.SetValue("") | |
624 | - return m.sudoInput.Focus() | |
647 | + if requiresSudo(apps) { | |
648 | + return checkSudoCachedCmd(m.un) | |
625 | 649 | } |
626 | 650 | return m.proceedWithUninstall() |
627 | 651 | } |
| @@ -654,23 +678,48 @@ func (m *Model) handleSudoPasswordKey(msg tea.KeyMsg) tea.Cmd { | ||
654 | 678 | m.sudoError = "Password cannot be empty." |
655 | 679 | return nil |
656 | 680 | } |
657 | - cmd := exec.Command("sudo", "-S", "-p", "", "-v") | |
658 | - cmd.Stdin = strings.NewReader(pass + "\n") | |
659 | - if err := cmd.Run(); err != nil { | |
660 | - m.sudoError = "Incorrect password, please try again." | |
661 | - m.sudoInput.SetValue("") | |
662 | - return nil | |
663 | - } | |
664 | - m.sudoInput.Blur() | |
665 | - m.sudoInput.SetValue("") | |
666 | - m.sudoError = "" | |
667 | - return m.proceedWithUninstall() | |
681 | + m.sudoError = "Authenticating..." | |
682 | + return verifySudoPasswordCmd(pass) | |
668 | 683 | } |
669 | 684 | var cmd tea.Cmd |
670 | 685 | m.sudoInput, cmd = m.sudoInput.Update(msg) |
671 | 686 | return cmd |
672 | 687 | } |
673 | 688 | |
689 | +func verifySudoPasswordCmd(pass string) tea.Cmd { | |
690 | + return func() tea.Msg { | |
691 | + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
692 | + defer cancel() | |
693 | + cmd := exec.CommandContext(ctx, "sudo", "-S", "-p", "", "-v") | |
694 | + cmd.Stdin = strings.NewReader(pass + "\n") | |
695 | + err := cmd.Run() | |
696 | + return sudoAuthMsg{err: err} | |
697 | + } | |
698 | +} | |
699 | + | |
700 | +func isSudoCached(un *uninstaller.Uninstaller) bool { | |
701 | + if un != nil { | |
702 | + if _, isReal := un.Cmd.(uninstaller.RealCmder); !isReal { | |
703 | + return true | |
704 | + } | |
705 | + } | |
706 | + if os.Geteuid() == 0 { | |
707 | + return true | |
708 | + } | |
709 | + if _, err := exec.LookPath("sudo"); err != nil { | |
710 | + return true | |
711 | + } | |
712 | + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) | |
713 | + defer cancel() | |
714 | + return exec.CommandContext(ctx, "sudo", "-n", "true").Run() == nil | |
715 | +} | |
716 | + | |
717 | +func checkSudoCachedCmd(un *uninstaller.Uninstaller) tea.Cmd { | |
718 | + return func() tea.Msg { | |
719 | + return sudoCacheCheckMsg{cached: isSudoCached(un)} | |
720 | + } | |
721 | +} | |
722 | + | |
674 | 723 | func requiresSudo(apps []*model.AppInfo) bool { |
675 | 724 | for _, a := range apps { |
676 | 725 | if a.Protected { |
| @@ -687,19 +736,6 @@ func requiresSudo(apps []*model.AppInfo) bool { | ||
687 | 736 | return false |
688 | 737 | } |
689 | 738 | |
690 | -func (m *Model) isSudoCached() bool { | |
691 | - if _, isReal := m.un.Cmd.(uninstaller.RealCmder); !isReal { | |
692 | - return true | |
693 | - } | |
694 | - if os.Geteuid() == 0 { | |
695 | - return true | |
696 | - } | |
697 | - if _, err := exec.LookPath("sudo"); err != nil { | |
698 | - return true | |
699 | - } | |
700 | - return exec.Command("sudo", "-n", "true").Run() == nil | |
701 | -} | |
702 | - | |
703 | 739 | // renderDeleteConfirm builds the Yes/No popup content for the staged apps. |
704 | 740 | func (m *Model) renderDeleteConfirm() { |
705 | 741 | sym := model.GetUISymbols() |
706 | 742 | |
▼internal/ui/app_test.go
+26-0
| @@ -2,6 +2,7 @@ package ui | ||
2 | 2 | |
3 | 3 | import ( |
4 | 4 | "context" |
5 | + "errors" | |
5 | 6 | "strings" |
6 | 7 | "testing" |
7 | 8 | "time" |
| @@ -301,6 +302,9 @@ func TestDeleteKeyFlowYes(t *testing.T) { | ||
301 | 302 | } |
302 | 303 | // Yes -> uninstall starts |
303 | 304 | cmd = m.handleKey(keyMsg("y")) |
305 | + msg = runCmd(cmd) | |
306 | + m2, cmd = m.Update(msg) | |
307 | + m = m2.(*Model) | |
304 | 308 | if m.state != stateUninstalling { |
305 | 309 | t.Fatalf("after y state = %d, want stateUninstalling", m.state) |
306 | 310 | } |
| @@ -436,6 +440,17 @@ func TestSudoPasswordModalFlow(t *testing.T) { | ||
436 | 440 | t.Fatal("expected error on empty password") |
437 | 441 | } |
438 | 442 | |
443 | + // Sudo auth failure | |
444 | + m.sudoInput.SetValue("wrongpass") | |
445 | + m2, _ := m.Update(sudoAuthMsg{err: errors.New("auth failed")}) | |
446 | + m = m2.(*Model) | |
447 | + if m.state != stateSudoPassword { | |
448 | + t.Fatalf("state = %d, want stateSudoPassword on auth failure", m.state) | |
449 | + } | |
450 | + if m.sudoError != "Incorrect password, please try again." { | |
451 | + t.Fatalf("unexpected error text: %s", m.sudoError) | |
452 | + } | |
453 | + | |
439 | 454 | // Esc should cancel |
440 | 455 | m.handleKey(tea.KeyMsg{Type: tea.KeyEsc}) |
441 | 456 | if m.state != stateReady { |
| @@ -444,6 +459,17 @@ func TestSudoPasswordModalFlow(t *testing.T) { | ||
444 | 459 | if len(m.pending) != 0 { |
445 | 460 | t.Fatal("expected pending apps to be cleared") |
446 | 461 | } |
462 | + | |
463 | + // Sudo auth success flow | |
464 | + m.pending = []*model.AppInfo{ | |
465 | + {Name: "nginx", Source: "apt", InstallSizeKB: 1024}, | |
466 | + } | |
467 | + m.state = stateSudoPassword | |
468 | + m2, _ = m.Update(sudoAuthMsg{err: nil}) | |
469 | + m = m2.(*Model) | |
470 | + if m.state != stateUninstalling { | |
471 | + t.Fatalf("state = %d, want stateUninstalling on auth success", m.state) | |
472 | + } | |
447 | 473 | } |
448 | 474 | |
449 | 475 | func TestRequiresSudo(t *testing.T) { |
450 | 476 | |