aboutsummaryrefslogtreecommitdiffhomepage
path: root/internal/app/sppulse.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/app/sppulse.go')
-rw-r--r--internal/app/sppulse.go170
1 files changed, 170 insertions, 0 deletions
diff --git a/internal/app/sppulse.go b/internal/app/sppulse.go
new file mode 100644
index 00000000..d3742db0
--- /dev/null
+++ b/internal/app/sppulse.go
@@ -0,0 +1,170 @@
+package app
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "syscall"
+
+ "hakurei.app/container"
+ "hakurei.app/hst"
+)
+
+const pulseCookieSizeMax = 1 << 8
+
+// spPulseOp exports the PulseAudio server to the container.
+type spPulseOp struct {
+ // PulseAudio cookie data, populated during toSystem if a cookie is present.
+ Cookie *[pulseCookieSizeMax]byte
+}
+
+func (s *spPulseOp) toSystem(state *outcomeStateSys, _ *hst.Config) error {
+ pulseRuntimeDir, pulseSocket := s.commonPaths(state.outcomeState)
+
+ if _, err := state.k.stat(pulseRuntimeDir.String()); err != nil {
+ if !errors.Is(err, fs.ErrNotExist) {
+ return &hst.AppError{Step: fmt.Sprintf("access PulseAudio directory %q", pulseRuntimeDir), Err: err}
+ }
+ return newWithMessage(fmt.Sprintf("PulseAudio directory %q not found", pulseRuntimeDir))
+ }
+
+ if fi, err := state.k.stat(pulseSocket.String()); err != nil {
+ if !errors.Is(err, fs.ErrNotExist) {
+ return &hst.AppError{Step: fmt.Sprintf("access PulseAudio socket %q", pulseSocket), Err: err}
+ }
+ return newWithMessage(fmt.Sprintf("PulseAudio directory %q found but socket does not exist", pulseRuntimeDir))
+ } else {
+ if m := fi.Mode(); m&0o006 != 0o006 {
+ return newWithMessage(fmt.Sprintf("unexpected permissions on %q: %s", pulseSocket, m))
+ }
+ }
+
+ // hard link pulse socket into target-executable share
+ state.sys.Link(pulseSocket, state.runtime().Append("pulse"))
+
+ // publish current user's pulse cookie for target user
+ var paCookiePath *container.Absolute
+ {
+ const paLocateStep = "locate PulseAudio cookie"
+
+ // from environment
+ if p, ok := state.k.lookupEnv("PULSE_COOKIE"); ok {
+ if a, err := container.NewAbs(p); err != nil {
+ return &hst.AppError{Step: paLocateStep, Err: err}
+ } else {
+ // this takes precedence, do not verify whether the file is accessible
+ paCookiePath = a
+ goto out
+ }
+ }
+
+ // $HOME/.pulse-cookie
+ if p, ok := state.k.lookupEnv("HOME"); ok {
+ if a, err := container.NewAbs(p); err != nil {
+ return &hst.AppError{Step: paLocateStep, Err: err}
+ } else {
+ paCookiePath = a.Append(".pulse-cookie")
+ }
+
+ if fi, err := state.k.stat(paCookiePath.String()); err != nil {
+ paCookiePath = nil
+ if !errors.Is(err, fs.ErrNotExist) {
+ return &hst.AppError{Step: "access PulseAudio cookie", Err: err}
+ }
+ // fallthrough
+ } else if fi.IsDir() {
+ paCookiePath = nil
+ } else {
+ goto out
+ }
+ }
+
+ // $XDG_CONFIG_HOME/pulse/cookie
+ if p, ok := state.k.lookupEnv("XDG_CONFIG_HOME"); ok {
+ if a, err := container.NewAbs(p); err != nil {
+ return &hst.AppError{Step: paLocateStep, Err: err}
+ } else {
+ paCookiePath = a.Append("pulse", "cookie")
+ }
+ if fi, err := state.k.stat(paCookiePath.String()); err != nil {
+ paCookiePath = nil
+ if !errors.Is(err, fs.ErrNotExist) {
+ return &hst.AppError{Step: "access PulseAudio cookie", Err: err}
+ }
+ // fallthrough
+ } else if fi.IsDir() {
+ paCookiePath = nil
+ } else {
+ goto out
+ }
+ }
+ out:
+ }
+
+ if paCookiePath != nil {
+ if b, err := state.k.stat(paCookiePath.String()); err != nil {
+ return &hst.AppError{Step: "access PulseAudio cookie", Err: err}
+ } else {
+ if b.IsDir() {
+ return &hst.AppError{Step: "read PulseAudio cookie", Err: &os.PathError{Op: "stat", Path: paCookiePath.String(), Err: syscall.EISDIR}}
+ }
+ if b.Size() > pulseCookieSizeMax {
+ return newWithMessageError(
+ fmt.Sprintf("PulseAudio cookie at %q exceeds maximum expected size", paCookiePath),
+ &os.PathError{Op: "stat", Path: paCookiePath.String(), Err: syscall.ENOMEM},
+ )
+ }
+ }
+
+ var r io.ReadCloser
+ if f, err := state.k.open(paCookiePath.String()); err != nil {
+ return &hst.AppError{Step: "open PulseAudio cookie", Err: err}
+ } else {
+ r = f
+ }
+
+ s.Cookie = new([pulseCookieSizeMax]byte)
+ if n, err := r.Read(s.Cookie[:]); err != nil {
+ if !errors.Is(err, io.EOF) {
+ _ = r.Close()
+ return &hst.AppError{Step: "read PulseAudio cookie", Err: err}
+ }
+ state.msg.Verbosef("copied %d bytes from %q", n, paCookiePath)
+ }
+
+ if err := r.Close(); err != nil {
+ return &hst.AppError{Step: "close PulseAudio cookie", Err: err}
+ }
+ } else {
+ state.msg.Verbose("cannot locate PulseAudio cookie (tried " +
+ "$PULSE_COOKIE, " +
+ "$XDG_CONFIG_HOME/pulse/cookie, " +
+ "$HOME/.pulse-cookie)")
+ }
+
+ return nil
+}
+
+func (s *spPulseOp) toContainer(state *outcomeStateParams) error {
+ innerPulseSocket := state.runtimeDir.Append("pulse", "native")
+ state.params.Bind(state.runtimePath().Append("pulse"), innerPulseSocket, 0)
+ state.env["PULSE_SERVER"] = "unix:" + innerPulseSocket.String()
+
+ if s.Cookie != nil {
+ innerDst := hst.AbsTmp.Append("/pulse-cookie")
+ state.env["PULSE_COOKIE"] = innerDst.String()
+ state.params.Place(innerDst, s.Cookie[:])
+ }
+
+ return nil
+}
+
+func (s *spPulseOp) commonPaths(state *outcomeState) (pulseRuntimeDir, pulseSocket *container.Absolute) {
+ // PulseAudio runtime directory (usually `/run/user/%d/pulse`)
+ pulseRuntimeDir = state.sc.RuntimePath.Append("pulse")
+ // PulseAudio socket (usually `/run/user/%d/pulse/native`)
+ pulseSocket = pulseRuntimeDir.Append("native")
+ return
+}