package main import ( "flag" "fmt" "io" "log" "os" "os/signal" "syscall" "unsafe" tea "github.com/charmbracelet/bubbletea" "latchd/auth" "latchd/config" "latchd/login" "latchd/session" "latchd/tui/bg" ) var ( Version = "0.1.0" Commit = "unknown" BuildTime = "unknown" ) const defaultConfig = "/etc/latchd/config.toml" const defaultVars = "/etc/latchd/variables.toml" func main() { var ( configPath string varsPath string ttyNum int preview bool noLog bool showConf bool printVer bool xsessions string wlsessions string initPath string ) flag.StringVar(&configPath, "config", defaultConfig, "") flag.StringVar(&configPath, "c", defaultConfig, "") flag.StringVar(&varsPath, "variables", defaultVars, "") flag.StringVar(&varsPath, "v", defaultVars, "") flag.IntVar(&ttyNum, "tty", 0, "") flag.BoolVar(&preview, "preview", false, "") flag.BoolVar(&noLog, "no-log", false, "") flag.BoolVar(&showConf, "show-config", false, "") flag.BoolVar(&printVer, "version", false, "") flag.BoolVar(&printVer, "V", false, "") flag.StringVar(&xsessions, "xsessions", "", "") flag.StringVar(&wlsessions, "wlsessions", "", "") flag.StringVar(&initPath, "initial-path", "", "") flag.Parse() if printVer { fmt.Printf("latchd %s commit %s built %s\n", Version, Commit, BuildTime) os.Exit(0) } cfg := config.Default() if initPath != "" { cfg.InitialPath = initPath } if xsessions != "" { cfg.X11.SessionsPath = xsessions } if wlsessions != "" { cfg.Wayland.SessionsPath = wlsessions } vars, _ := config.LoadVariables(varsPath) if err := config.LoadPartial(configPath, vars, &cfg); err != nil { if configPath != defaultConfig { fmt.Fprintf(os.Stderr, "config: %v\n", err) os.Exit(1) } } if showConf { fmt.Printf("tty = %d\npam_service = %q\nshell = %q\nfocus = %q\n", cfg.TTY, cfg.PAMService, cfg.SystemShell, cfg.FocusBehaviour) os.Exit(0) } if !noLog && !preview { setupLog(cfg.MainLogPath) } if !preview { if _, ok := os.LookupEnv("XDG_SESSION_TYPE"); ok { fmt.Fprintln(os.Stderr, "latchd: already in a session — use --preview") os.Exit(1) } if os.Getuid() != 0 { fmt.Fprintf(os.Stderr, "latchd: must be root (uid %d)\n", os.Getuid()) os.Exit(1) } if ttyNum != 0 { cfg.TTY = uint8(ttyNum) } if err := chvt(int(cfg.TTY)); err != nil { log.Printf("chvt %d: %v", cfg.TTY, err) } } for { res := launchUI(cfg, preview) if res == nil { break } if err := spawnSession(res); err != nil { log.Printf("session: %v", err) } if !preview { chvt(int(cfg.TTY)) } } } func launchUI(cfg config.Config, preview bool) *login.Result { m := login.New(cfg, preview) if cfg.Background.ShowBackground { m.SetAutomata(bg.NewAutomata(80, 24)) } p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion()) final, err := p.Run() if err != nil { log.Printf("tui: %v", err) os.Exit(1) } lm, ok := final.(*login.Model) if !ok { return nil } return lm.Result() } func spawnSession(res *login.Result) error { if res.Preview { log.Println("preview — skipping real session") return nil } log.Printf("spawning %s → %s (%s)", res.Username, res.Env.Title, res.Env.Kind) creds, err := auth.Validate(res.Username, res.Password, res.Config.PAMService) if err != nil { return fmt.Errorf("auth: %w", err) } defer creds.CloseSession() if err := creds.OpenSession(); err != nil { log.Printf("pam session warning: %v", err) } session.SetBasicVariables(creds.Username, creds.HomeDir, creds.Shell, res.Config.InitialPath) if res.Env.Kind == "x11" { session.SetDisplay(res.Config.X11.Display) } session.RemoveXDG() session.SetSessionParams(res.Env.Kind) session.SetSeatVars(res.Config.TTY) session.SetSessionVars(creds.UID) session.SetXDGCommonPaths(creds.HomeDir) session.SetDesktopVars(res.Env.DesktopNames, res.Env.SessionDesktop) sess, err := session.Spawn(&res.Env, creds.UID, creds.PrimaryGID, creds.AllGIDs, &res.Config) if err != nil { return fmt.Errorf("spawn: %w", err) } utmpx := auth.AddUtmpx(creds.Username, res.Config.TTY, uint32(sess.PID())) defer utmpx.Close() err = sess.Wait() if err != nil { log.Printf("session exited: %v", err) } log.Printf("session pid %d ended", sess.PID()) return nil } func setupLog(path string) { f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) if err != nil { fmt.Fprintf(os.Stderr, "log %s: %v\n", path, err) os.Exit(1) } log.SetOutput(io.MultiWriter(f, os.Stderr)) log.SetFlags(log.LstdFlags) } const vtA = 0x5606 const vtW = 0x5607 const kbt = 0x4B33 const kb1 = 0x02 const kb2 = 0x01 func isCon(fd uintptr) bool { var a int32 _, _, e := syscall.Syscall(syscall.SYS_IOCTL, fd, kbt, uintptr(unsafe.Pointer(&a))) return e == 0 && (a == kb1 || a == kb2) } func openCon(fn string) (int, error) { for _, fl := range []int{os.O_RDWR, os.O_RDONLY, os.O_WRONLY} { fd, err := syscall.Open(fn, fl, 0) if err != nil { if err == syscall.EACCES { continue } break } if isCon(uintptr(fd)) { return fd, nil } syscall.Close(fd) } return 0, fmt.Errorf("openCon: %s", fn) } func getCon() (int, error) { for _, p := range []string{"/dev/tty", "/dev/tty0", "/dev/vc/0", "/dev/console"} { if fd, err := openCon(p); err == nil { return fd, nil } } for fd := 0; fd < 3; fd++ { if isCon(uintptr(fd)) { return fd, nil } } return 0, fmt.Errorf("no console") } func chvt(n int) error { fd, err := getCon() if err != nil { return err } defer syscall.Close(fd) if _, _, e := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), vtA, uintptr(n)); e != 0 { return fmt.Errorf("VT_ACTIVATE") } if _, _, e := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), vtW, uintptr(n)); e != 0 { return fmt.Errorf("VT_WAITACTIVE") } return nil } func init() { ch := make(chan os.Signal, 1) signal.Notify(ch, syscall.SIGTERM, syscall.SIGINT) go func() { <-ch os.Exit(0) }() }