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
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
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
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
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
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
2
3
3
import (
4
4
"context"
5
+ "errors"
5
6
"strings"
6
7
"testing"
7
8
"time"
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
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
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