Ophestra Umiker
da7e404bcf
This does almost exactly what github:intgr/ego does, with some minor optimisations and corrections. Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
352 lines
8.8 KiB
Go
352 lines
8.8 KiB
Go
package main
|
|
|
|
import (
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"os/exec"
|
|
"os/user"
|
|
"path"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
)
|
|
|
|
var Version = "impure"
|
|
|
|
var (
|
|
ego *user.User
|
|
uid int
|
|
env []string
|
|
command []string
|
|
verbose bool
|
|
runtime string
|
|
runDir string
|
|
)
|
|
|
|
const (
|
|
term = "TERM"
|
|
home = "HOME"
|
|
sudoAskPass = "SUDO_ASKPASS"
|
|
xdgRuntimeDir = "XDG_RUNTIME_DIR"
|
|
xdgConfigHome = "XDG_CONFIG_HOME"
|
|
display = "DISPLAY"
|
|
pulseServer = "PULSE_SERVER"
|
|
pulseCookie = "PULSE_COOKIE"
|
|
|
|
// https://manpages.debian.org/experimental/libwayland-doc/wl_display_connect.3.en.html
|
|
waylandDisplay = "WAYLAND_DISPLAY"
|
|
)
|
|
|
|
func main() {
|
|
flag.Parse()
|
|
tryLauncher()
|
|
copyArgs()
|
|
|
|
if u, err := strconv.Atoi(ego.Uid); err != nil {
|
|
// usually unreachable
|
|
panic("ego uid parse")
|
|
} else {
|
|
uid = u
|
|
}
|
|
|
|
if r, ok := os.LookupEnv(xdgRuntimeDir); !ok {
|
|
fatal("Env variable", xdgRuntimeDir, "unset")
|
|
} else {
|
|
runtime = r
|
|
}
|
|
|
|
// Report warning if user home directory does not exist or has wrong ownership
|
|
if stat, err := os.Stat(ego.HomeDir); err != nil {
|
|
if verbose {
|
|
switch {
|
|
case errors.Is(err, fs.ErrPermission):
|
|
fmt.Printf("User %s home directory %s is not accessible", ego.Username, ego.HomeDir)
|
|
case errors.Is(err, fs.ErrNotExist):
|
|
fmt.Printf("User %s home directory %s does not exist", ego.Username, ego.HomeDir)
|
|
default:
|
|
fmt.Printf("Error stat user %s home directory %s: %s", ego.Username, ego.HomeDir, err)
|
|
}
|
|
}
|
|
return
|
|
} else {
|
|
// FreeBSD: not cross-platform
|
|
if u := strconv.Itoa(int(stat.Sys().(*syscall.Stat_t).Uid)); u != ego.Uid {
|
|
fmt.Printf("User %s home directory %s has incorrect ownership (expected UID %s, found %s)", ego.Username, ego.HomeDir, ego.Uid, u)
|
|
}
|
|
}
|
|
|
|
// Add execute perm to runtime dir, e.g. `/run/user/%d`
|
|
if s, err := os.Stat(runtime); err != nil {
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
fatal("Runtime directory does not exist")
|
|
}
|
|
fatal("Error accessing runtime directory:", err)
|
|
} else if !s.IsDir() {
|
|
fatal(fmt.Sprintf("Path '%s' is not a directory", runtime))
|
|
} else {
|
|
// Cleanup: need revert
|
|
if err = aclUpdatePerm(runtime, uid, aclExecute); err != nil {
|
|
fatal("Error preparing runtime dir:", err)
|
|
}
|
|
if verbose {
|
|
fmt.Printf("Runtime data dir '%s' configured\n", runtime)
|
|
}
|
|
}
|
|
|
|
// Create runtime dir for Ego itself (e.g. `/run/user/%d/ego`) and make it readable for target
|
|
runDir = path.Join(runtime, "ego")
|
|
if err := os.Mkdir(runDir, 0700); err != nil && !errors.Is(err, fs.ErrExist) {
|
|
fatal("Error creating Ego runtime dir:", err)
|
|
}
|
|
// Cleanup: need revert
|
|
if err := aclUpdatePerm(runDir, uid, aclExecute); err != nil {
|
|
fatal("Error preparing Ego runtime dir:", err)
|
|
}
|
|
// Cleanup: need register control PID
|
|
|
|
// Add rwx permissions to Wayland socket (e.g. `/run/user/%d/wayland-0`)
|
|
if w, ok := os.LookupEnv(waylandDisplay); !ok {
|
|
if verbose {
|
|
fmt.Println("Wayland: WAYLAND_DISPLAY not set, skipping")
|
|
}
|
|
} else {
|
|
// add environment variable for new process
|
|
env = append(env, waylandDisplay+"="+path.Join(runtime, w))
|
|
// Cleanup: need revert
|
|
if err := aclUpdatePerm(path.Join(runtime, w), uid, aclRead, aclWrite, aclExecute); err != nil {
|
|
fatal(fmt.Sprintf("Error preparing Wayland '%s':", w), err)
|
|
}
|
|
if verbose {
|
|
fmt.Printf("Wayland socket '%s' configured\n", w)
|
|
}
|
|
}
|
|
|
|
// Detect `DISPLAY` and grant permissions via X11 protocol `ChangeHosts` command
|
|
if d, ok := os.LookupEnv(display); !ok {
|
|
if verbose {
|
|
fmt.Println("X11: DISPLAY not set, skipping")
|
|
}
|
|
} else {
|
|
// add environment variable for new process
|
|
env = append(env, display+"="+d)
|
|
// Cleanup: need revert
|
|
if err := changeHosts(xcbHostModeInsert, xcbFamilyServerInterpreted, "localuser\x00"+ego.Username); err != nil {
|
|
fatal(fmt.Sprintf("Error adding XHost entry to '%s':", d), err)
|
|
}
|
|
if verbose {
|
|
fmt.Printf("X11: Adding XHost entry SI:localuser:%s to display '%s'\n", ego.Username, d)
|
|
}
|
|
}
|
|
|
|
// Add execute permissions to PulseAudio directory (e.g. `/run/user/%d/pulse`)
|
|
pulse := path.Join(runtime, "pulse")
|
|
pulseS := path.Join(pulse, "native")
|
|
if s, err := os.Stat(pulse); err != nil {
|
|
if !errors.Is(err, fs.ErrNotExist) {
|
|
fatal("Error accessing PulseAudio directory:", err)
|
|
}
|
|
if mustPulse {
|
|
fatal("PulseAudio is unavailable")
|
|
}
|
|
if verbose {
|
|
fmt.Printf("PulseAudio dir '%s' not found, skipping\n", pulse)
|
|
}
|
|
} else {
|
|
// add environment variable for new process
|
|
env = append(env, pulseServer+"=unix:"+pulseS)
|
|
// Cleanup: need revert
|
|
if err = aclUpdatePerm(pulse, uid, aclExecute); err != nil {
|
|
fatal("Error preparing PulseAudio:", err)
|
|
}
|
|
|
|
// Ensure permissions of PulseAudio socket `/run/user/%d/pulse/native`
|
|
if s, err = os.Stat(pulseS); err != nil {
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
fatal("PulseAudio directory found but socket does not exist")
|
|
}
|
|
fatal("Error accessing PulseAudio socket:", err)
|
|
} else {
|
|
if m := s.Mode(); m&0o006 != 0o006 {
|
|
fatal(fmt.Sprintf("Unexpected permissions on '%s':", pulseS), m)
|
|
}
|
|
}
|
|
|
|
// Publish current user's pulse-cookie for target user
|
|
pulseCookieSource := discoverPulseCookie()
|
|
env = append(env, pulseCookie+"="+pulseCookieSource)
|
|
pulseCookieFinal := path.Join(runDir, "pulse-cookie")
|
|
if verbose {
|
|
fmt.Printf("Publishing PulseAudio cookie '%s' to '%s'\n", pulseCookieSource, pulseCookieFinal)
|
|
}
|
|
if err = copyFile(pulseCookieFinal, pulseCookieSource); err != nil {
|
|
fatal("Error copying PulseAudio cookie:", err)
|
|
}
|
|
// Cleanup: need revert
|
|
if err = aclUpdatePerm(pulseCookieFinal, uid, aclRead); err != nil {
|
|
fatal("Error publishing PulseAudio cookie:", err)
|
|
}
|
|
|
|
if verbose {
|
|
fmt.Printf("PulseAudio dir '%s' configured\n", pulse)
|
|
}
|
|
}
|
|
|
|
// pass $TERM to launcher
|
|
if t, ok := os.LookupEnv(term); ok {
|
|
env = append(env, term+"="+t)
|
|
}
|
|
|
|
f := launchBySudo
|
|
m, b := false, false
|
|
switch {
|
|
case methodFlags[0]: // sudo
|
|
case methodFlags[1]: // bare
|
|
m, b = true, true
|
|
default: // machinectl
|
|
m, b = true, false
|
|
}
|
|
|
|
var toolPath string
|
|
|
|
// dependency checks
|
|
const sudoFallback = "Falling back to 'sudo', some desktop integration features may not work"
|
|
if m {
|
|
if !sdBooted() {
|
|
fmt.Println("This system was not booted through systemd")
|
|
fmt.Println(sudoFallback)
|
|
} else if tp, ok := which("machinectl"); !ok {
|
|
fmt.Println("Did not find 'machinectl' in PATH")
|
|
fmt.Println(sudoFallback)
|
|
} else {
|
|
toolPath = tp
|
|
f = func() []string { return launchByMachineCtl(b) }
|
|
}
|
|
} else if tp, ok := which("sudo"); !ok {
|
|
fatal("Did not find 'sudo' in PATH")
|
|
} else {
|
|
toolPath = tp
|
|
}
|
|
|
|
if verbose {
|
|
fmt.Printf("Selected launcher '%s' bare=%t\n", toolPath, b)
|
|
}
|
|
|
|
cmd := exec.Command(toolPath, f()...)
|
|
cmd.Env = env
|
|
cmd.Stdin = os.Stdin
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
cmd.Dir = runDir
|
|
|
|
if verbose {
|
|
fmt.Println("Executing:", cmd)
|
|
}
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
fatal("Error starting process:", err)
|
|
}
|
|
|
|
// Cleanup: need register
|
|
|
|
var r int
|
|
if err := cmd.Wait(); err != nil {
|
|
var exitError *exec.ExitError
|
|
if !errors.As(err, &exitError) {
|
|
fatal("Error running process:", err)
|
|
}
|
|
}
|
|
|
|
// Cleanup: deregister, call revert
|
|
|
|
if verbose {
|
|
fmt.Println("Process exited with exit code", r)
|
|
}
|
|
os.Exit(r)
|
|
}
|
|
|
|
func launchBySudo() (args []string) {
|
|
args = make([]string, 0, 4+len(env)+len(command))
|
|
|
|
// -Hiu $USER
|
|
args = append(args, "-Hiu", ego.Username)
|
|
|
|
// -A?
|
|
if _, ok := os.LookupEnv(sudoAskPass); ok {
|
|
if verbose {
|
|
fmt.Printf("%s set, adding askpass flag\n", sudoAskPass)
|
|
}
|
|
args = append(args, "-A")
|
|
}
|
|
|
|
// environ
|
|
args = append(args, env...)
|
|
|
|
// -- $@
|
|
args = append(args, "--")
|
|
args = append(args, command...)
|
|
|
|
return
|
|
}
|
|
|
|
func launchByMachineCtl(bare bool) (args []string) {
|
|
args = make([]string, 0, 9+len(env))
|
|
|
|
// shell --uid=$USER
|
|
args = append(args, "shell", "--uid="+ego.Username)
|
|
|
|
// --quiet
|
|
if !verbose {
|
|
args = append(args, "--quiet")
|
|
}
|
|
|
|
// environ
|
|
envQ := make([]string, len(env)+1)
|
|
for i, e := range env {
|
|
envQ[i] = "-E" + e
|
|
}
|
|
envQ[len(env)] = "-E" + launcherPayloadEnv()
|
|
args = append(args, envQ...)
|
|
|
|
// -- .host
|
|
args = append(args, "--", ".host")
|
|
|
|
// /bin/sh -c
|
|
if sh, ok := which("sh"); !ok {
|
|
fatal("Did not find 'sh' in PATH")
|
|
} else {
|
|
args = append(args, sh, "-c")
|
|
}
|
|
|
|
if len(command) == 0 { // execute shell if command is not provided
|
|
command = []string{"$SHELL"}
|
|
}
|
|
|
|
innerCommand := strings.Builder{}
|
|
|
|
if !bare {
|
|
innerCommand.WriteString("dbus-update-activation-environment --systemd")
|
|
for _, e := range env {
|
|
innerCommand.WriteString(" " + strings.SplitN(e, "=", 2)[0])
|
|
}
|
|
innerCommand.WriteString("; systemctl --user start xdg-desktop-portal-gtk; ")
|
|
}
|
|
|
|
if executable, err := os.Executable(); err != nil {
|
|
fatal("Error reading executable path:", err)
|
|
} else {
|
|
innerCommand.WriteString("exec " + executable + " -V")
|
|
}
|
|
args = append(args, innerCommand.String())
|
|
|
|
return
|
|
}
|
|
|
|
func fatal(msg ...any) {
|
|
// Cleanup: call revert
|
|
fmt.Println(msg...)
|
|
os.Exit(1)
|
|
}
|