Files
yellowjacket/backend/mediacontrols/mpris_linux.go
T
logan e14a34fccf fix(android): let the app reach the user's music
Three of plan 016's four blockers. Each is a different reason the app
could not work at all on a phone.

**It had no permission to read anything.** The generated manifest asked
for INTERNET, VIBRATE, biometrics, location and a camera, and nothing
whatever about storage -- so at targetSdk 35 the app could see its own
private directory and no music. It now declares READ_MEDIA_AUDIO, the
two capped legacy storage permissions, and MANAGE_EXTERNAL_STORAGE.

That last one is deliberate and is the load-bearing choice. This app is
a library manager: audio_files.file_path is the primary key of
ownership, the scanner walks a directory the user chose, and tagwriter
rewrites files in place. MediaStore offers no stable directory to walk
and no in-place write, so scoped storage is not "more work" here, it is
a different application. MANAGE_EXTERNAL_STORAGE is Play-restricted,
which is acceptable only because this ships as an APK through the
package registry -- if it ever targets Play, that line is what has to
go, and plan 016 says what replaces it.

It is granted on a Settings screen rather than in a dialog, so it
cannot be requested with requestPermissions(). MainActivity opens that
screen on every cold start until access exists -- there is no degraded
mode worth offering -- and re-checks in onResume, because the way back
from another task is a resume, emitting android:storageAccess so the
frontend can react.

**The first-run flow could not complete.** All three call sites asked
for a folder through the Wails dialog, which returns an error on
Android: SAF yields tree URIs and this app is keyed on paths. So the
app browses the filesystem itself, which it can now do. ListDirectories
lists directories only (the thing being chosen is a library root),
skips what it cannot stat rather than failing the listing (Android's
storage root holds directories no app may enter), follows symlinks
(os.DirEntry reports the link, so a symlinked music folder would
silently vanish), and hides dotted entries.

utils/pick-directory.ts is the one place that chooses between the two,
so the three call sites changed by one line each. **Which platform is
asked of the backend**, not of System.IsAndroid(): the dialog is
backend code, so the backend is what knows whether it can open one; it
answers for iOS at the same time; and it keeps the fallback testable
through the ordinary transport fake rather than a module mock of the
Wails runtime, whose platform helpers read build constants.

**And MPRIS was compiled into the Android build**, because android
implies the linux build tag, so it went looking for a session bus that
does not exist. mpris_linux.go is `linux && !android` now and the stub
covers Android, which means no lock-screen transport there yet -- a
missing feature rather than a broken one, and the remaining blocker.

The foreground service is typed mediaPlayback rather than the
scaffold's dataSync, with the matching permission, so playback can
survive the screen locking once there is a MediaSession to drive it.
The type in the manifest and the one passed to startForeground must
agree or startForeground throws.
2026-08-16 17:18:03 -04:00

645 lines
14 KiB
Go

//go:build linux && !android
// MPRIS is a D-Bus desktop specification, and `android` implies the
// `linux` build tag -- so without the `!android` this file compiled
// into the Android app and went looking for a session bus that does
// not exist. Desktop-Linux-only files need both halves; see Wails'
// mobile guide, which names this as the Android analogue of
// ios/darwin.
package mediacontrols
import (
"errors"
"fmt"
"log/slog"
"sync"
"github.com/godbus/dbus/v5"
"github.com/godbus/dbus/v5/introspect"
"github.com/godbus/dbus/v5/prop"
)
const (
busName = "org.mpris.MediaPlayer2.yellowjacket"
objectPath = "/org/mpris/MediaPlayer2"
playerIf = "org.mpris.MediaPlayer2.Player"
rootIf = "org.mpris.MediaPlayer2"
usPerSec = 1_000_000
// updateChanSize is the buffer size for the async update
// channel. A small buffer avoids blocking callers while the
// D-Bus goroutine processes updates.
updateChanSize = 64
)
var errNotPrimaryOwner = errors.New(
"failed to become primary owner of bus name",
)
// mprisRoot handles the org.mpris.MediaPlayer2 interface methods.
type mprisRoot struct{}
// Raise is a no-op; YellowJacket does not support raising via MPRIS.
func (r *mprisRoot) Raise() *dbus.Error { return nil }
// Quit is a no-op; shutdown is managed by the Wails lifecycle.
func (r *mprisRoot) Quit() *dbus.Error { return nil }
// mprisPlayer handles the org.mpris.MediaPlayer2.Player
// interface methods. Every D-Bus method callback dispatches to a
// goroutine so that the godbus handler goroutine returns
// immediately and never blocks on player/queue mutexes.
type mprisPlayer struct {
callbacks Callbacks
}
// Play requests playback start/resume.
func (p *mprisPlayer) Play() *dbus.Error {
if p.callbacks.OnPlay != nil {
go p.callbacks.OnPlay()
}
return nil
}
// Pause requests playback pause.
func (p *mprisPlayer) Pause() *dbus.Error {
if p.callbacks.OnPause != nil {
go p.callbacks.OnPause()
}
return nil
}
// PlayPause toggles between play and pause.
func (p *mprisPlayer) PlayPause() *dbus.Error {
if p.callbacks.OnPlayPause != nil {
go p.callbacks.OnPlayPause()
}
return nil
}
// Stop requests playback stop.
func (p *mprisPlayer) Stop() *dbus.Error {
if p.callbacks.OnStop != nil {
go p.callbacks.OnStop()
}
return nil
}
// Next requests skipping to the next track.
func (p *mprisPlayer) Next() *dbus.Error {
if p.callbacks.OnNext != nil {
go p.callbacks.OnNext()
}
return nil
}
// Previous requests skipping to the previous track.
func (p *mprisPlayer) Previous() *dbus.Error {
if p.callbacks.OnPrevious != nil {
go p.callbacks.OnPrevious()
}
return nil
}
// SeekTo requests a relative seek by offset microseconds.
// Exported on D-Bus as "Seek" via ExportWithMap; renamed in Go
// to avoid a false positive from go vet's stdmethods checker.
func (p *mprisPlayer) SeekTo(offsetUs int64) *dbus.Error {
if p.callbacks.OnSeek != nil {
secs := int(offsetUs / usPerSec)
go p.callbacks.OnSeek(secs)
}
return nil
}
// SetPosition requests an absolute seek to positionUs on the
// given track.
func (p *mprisPlayer) SetPosition(
_ dbus.ObjectPath,
positionUs int64,
) *dbus.Error {
if p.callbacks.OnSeek != nil {
secs := int(positionUs / usPerSec)
go p.callbacks.OnSeek(secs)
}
return nil
}
// OpenUri is required by the MPRIS2 spec but not supported.
//
//nolint:revive // D-Bus requires this exact method name.
func (p *mprisPlayer) OpenUri(_ string) *dbus.Error {
return nil
}
// MPRISHandler is the Linux MPRIS2 implementation of Handler.
//
// All public update methods (UpdateMetadata, UpdatePlaybackState,
// NotifySeek, UpdateVolume) send work to a buffered channel that a
// dedicated goroutine drains. This avoids calling into godbus
// (which acquires props.mut and does D-Bus I/O) while the caller
// holds the player mutex, preventing a deadlock between p.mu and
// props.mut.
type MPRISHandler struct {
logger *slog.Logger
conn *dbus.Conn
props *prop.Properties
player *mprisPlayer
updates chan func()
done chan struct{}
mu sync.Mutex
trackID uint64
}
// NewHandler creates a new MPRIS2 handler.
func NewHandler(logger *slog.Logger) Handler {
return &MPRISHandler{
logger: logger.WithGroup("mpris"),
}
}
// Init connects to the D-Bus session bus, exports the MPRIS2
// interfaces, and registers the well-known bus name.
func (h *MPRISHandler) Init(callbacks Callbacks) error {
conn, err := dbus.SessionBus()
if err != nil {
return fmt.Errorf(
"failed to connect to session bus: %w", err,
)
}
h.conn = conn
h.player = &mprisPlayer{callbacks: callbacks}
h.updates = make(chan func(), updateChanSize)
h.done = make(chan struct{})
go h.processUpdates()
// Export properties for both interfaces.
h.props, err = prop.Export(
conn,
objectPath,
h.propertySpec(),
)
if err != nil {
return fmt.Errorf(
"failed to export properties: %w", err,
)
}
// Export method handlers.
root := &mprisRoot{}
if err := conn.Export(
root, objectPath, rootIf,
); err != nil {
return fmt.Errorf(
"failed to export root interface: %w", err,
)
}
if err := conn.ExportWithMap(
h.player,
map[string]string{"SeekTo": "Seek"},
objectPath,
playerIf,
); err != nil {
return fmt.Errorf(
"failed to export player interface: %w", err,
)
}
// Export introspection.
if err := conn.Export(
introspect.NewIntrospectable(h.introspectNode()),
objectPath,
"org.freedesktop.DBus.Introspectable",
); err != nil {
return fmt.Errorf(
"failed to export introspection: %w", err,
)
}
// Claim the well-known bus name.
reply, err := conn.RequestName(
busName, dbus.NameFlagReplaceExisting,
)
if err != nil {
return fmt.Errorf(
"failed to request bus name: %w", err,
)
}
if reply != dbus.RequestNameReplyPrimaryOwner {
return fmt.Errorf(
"%w: %s (reply=%d)",
errNotPrimaryOwner, busName, reply,
)
}
h.logger.Info(
"MPRIS2 registered on D-Bus", "name", busName,
)
return nil
}
// processUpdates drains the update channel on a dedicated
// goroutine. All props.SetMust and conn.Emit calls happen here,
// safely away from the player's mutex.
func (h *MPRISHandler) processUpdates() {
for fn := range h.updates {
fn()
}
close(h.done)
}
// enqueue sends a function to the update goroutine. If the
// channel is full the update is dropped to avoid blocking the
// caller (this is acceptable — the next update will overwrite
// stale state).
func (h *MPRISHandler) enqueue(fn func()) {
select {
case h.updates <- fn:
default:
h.logger.Debug("MPRIS update channel full, dropping")
}
}
// UpdateMetadata pushes track metadata to D-Bus.
func (h *MPRISHandler) UpdateMetadata(meta Metadata) {
h.mu.Lock()
h.trackID++
tid := h.trackID
h.mu.Unlock()
m := map[string]interface{}{
"mpris:trackid": dbus.ObjectPath(
fmt.Sprintf(
"/org/yellowjacket/Track/%d", tid,
),
),
}
if meta.Title != "" {
m["xesam:title"] = meta.Title
}
if meta.Artist != "" {
m["xesam:artist"] = []string{meta.Artist}
}
if meta.Album != "" {
m["xesam:album"] = meta.Album
}
if meta.ArtFilePath != "" {
m["mpris:artUrl"] = "file://" + meta.ArtFilePath
}
if meta.DurationSec > 0 {
m["mpris:length"] = int64(
meta.DurationSec,
) * usPerSec
}
h.enqueue(func() {
h.props.SetMust(playerIf, "Metadata", m)
})
}
// UpdatePlaybackState pushes the playback state and position
// anchor.
func (h *MPRISHandler) UpdatePlaybackState(
state PlaybackState,
positionSec int,
) {
var status string
switch state {
case StatePlaying:
status = "Playing"
case StatePaused:
status = "Paused"
default:
status = "Stopped"
}
posUs := int64(positionSec) * usPerSec
h.enqueue(func() {
// Update Position silently (EmitFalse) then
// PlaybackStatus loudly (EmitTrue). The DE
// re-anchors on the status change.
h.props.SetMust(playerIf, "Position", posUs)
h.props.SetMust(
playerIf, "PlaybackStatus", status,
)
})
}
// NotifySeek emits the MPRIS Seeked signal.
func (h *MPRISHandler) NotifySeek(positionSec int) {
posUs := int64(positionSec) * usPerSec
h.enqueue(func() {
h.props.SetMust(playerIf, "Position", posUs)
if err := h.conn.Emit(
objectPath,
playerIf+".Seeked",
posUs,
); err != nil {
h.logger.Error(
"Failed to emit Seeked signal",
"err", err,
)
}
})
}
// UpdateVolume pushes the current volume (0.0-1.0) to D-Bus.
func (h *MPRISHandler) UpdateVolume(volume float64) {
h.enqueue(func() {
h.props.SetMust(playerIf, "Volume", volume)
})
}
// Close signals the update goroutine to stop, waits for it to
// drain, and closes the D-Bus connection.
func (h *MPRISHandler) Close() {
if h.updates != nil {
close(h.updates)
<-h.done
}
if h.conn != nil {
if err := h.conn.Close(); err != nil {
h.logger.Error(
"Failed to close D-Bus connection",
"err", err,
)
}
h.logger.Info("MPRIS2 D-Bus connection closed")
}
}
// onVolumeChanged is called when an external D-Bus client sets
// the Volume property. The callback runs under props.mut (held by
// godbus), so we dispatch to a goroutine to avoid acquiring p.mu
// under props.mut — which would invert the lock order with the
// update goroutine's SetMust calls.
func (h *MPRISHandler) onVolumeChanged(
c *prop.Change,
) *dbus.Error {
vol, ok := c.Value.(float64)
if !ok {
return nil
}
if h.player.callbacks.OnVolume != nil {
go h.player.callbacks.OnVolume(vol)
}
return nil
}
// onLoopStatusChanged is called when an external D-Bus client
// sets the LoopStatus property.
func (h *MPRISHandler) onLoopStatusChanged(
_ *prop.Change,
) *dbus.Error {
// LoopStatus changes via D-Bus are acknowledged but not
// actively wired to the queue's CycleRepeat. The queue
// cycles through modes and MPRIS reflects the result.
return nil
}
// onShuffleChanged is called when an external D-Bus client sets
// the Shuffle property.
func (h *MPRISHandler) onShuffleChanged(
_ *prop.Change,
) *dbus.Error {
// Shuffle changes via D-Bus are acknowledged but not
// actively wired to the queue's ToggleShuffle. The queue
// toggles and MPRIS reflects the result.
return nil
}
// propertySpec builds the full property map for both MPRIS
// interfaces.
func (h *MPRISHandler) propertySpec() map[string]map[string]*prop.Prop {
noTrack := map[string]interface{}{
"mpris:trackid": dbus.ObjectPath(
"/org/mpris/MediaPlayer2/TrackList/NoTrack",
),
}
return map[string]map[string]*prop.Prop{
rootIf: {
"CanQuit": newReadOnlyProp(false),
"CanRaise": newReadOnlyProp(false),
"HasTrackList": newReadOnlyProp(false),
"Identity": newReadOnlyProp("YellowJacket"),
"DesktopEntry": newReadOnlyProp(
"yellowjacket",
),
"SupportedUriSchemes": newReadOnlyProp(
[]string{},
),
"SupportedMimeTypes": newReadOnlyProp(
[]string{},
),
},
playerIf: {
"PlaybackStatus": newReadOnlyProp("Stopped"),
"LoopStatus": {
Value: "None",
Writable: true,
Emit: prop.EmitTrue,
Callback: h.onLoopStatusChanged,
},
"Rate": newReadOnlyProp(1.0),
"MinimumRate": newReadOnlyProp(1.0),
"MaximumRate": newReadOnlyProp(1.0),
"Shuffle": {
Value: false,
Writable: true,
Emit: prop.EmitTrue,
Callback: h.onShuffleChanged,
},
"Metadata": newReadOnlyProp(noTrack),
"Volume": {
Value: 1.0,
Writable: true,
Emit: prop.EmitTrue,
Callback: h.onVolumeChanged,
},
"Position": {
Value: int64(0),
Writable: false,
Emit: prop.EmitFalse,
},
"CanGoNext": newReadOnlyProp(true),
"CanGoPrevious": newReadOnlyProp(true),
"CanPlay": newReadOnlyProp(true),
"CanPause": newReadOnlyProp(true),
"CanSeek": newReadOnlyProp(true),
"CanControl": newReadOnlyProp(true),
},
}
}
// newReadOnlyProp creates a read-only property with EmitTrue.
// Read-only here means external D-Bus clients cannot set it via
// the Properties.Set interface; the server updates it internally
// via SetMust.
func newReadOnlyProp(value interface{}) *prop.Prop {
return &prop.Prop{
Value: value,
Writable: false,
Emit: prop.EmitTrue,
}
}
// introspectNode builds the introspection data for the MPRIS
// object.
func (h *MPRISHandler) introspectNode() *introspect.Node {
return &introspect.Node{
Name: busName,
Interfaces: []introspect.Interface{
introspect.IntrospectData,
{
Name: rootIf,
Properties: introspectProps(
roProp("CanQuit", "b"),
roProp("CanRaise", "b"),
roProp("HasTrackList", "b"),
roProp("Identity", "s"),
roProp("DesktopEntry", "s"),
roProp(
"SupportedUriSchemes", "as",
),
roProp(
"SupportedMimeTypes", "as",
),
),
Methods: []introspect.Method{
{Name: "Raise"},
{Name: "Quit"},
},
},
{
Name: playerIf,
Properties: introspectProps(
roProp("PlaybackStatus", "s"),
rwProp("LoopStatus", "s"),
rwProp("Rate", "d"),
rwProp("Shuffle", "b"),
roProp("Metadata", "a{sv}"),
rwProp("Volume", "d"),
roProp("Position", "x"),
roProp("MinimumRate", "d"),
roProp("MaximumRate", "d"),
roProp("CanGoNext", "b"),
roProp("CanGoPrevious", "b"),
roProp("CanPlay", "b"),
roProp("CanPause", "b"),
roProp("CanSeek", "b"),
roProp("CanControl", "b"),
),
Signals: []introspect.Signal{
{
Name: "Seeked",
Args: []introspect.Arg{
{
Name: "Position",
Type: "x",
},
},
},
},
Methods: []introspect.Method{
{Name: "Next"},
{Name: "Previous"},
{Name: "Pause"},
{Name: "PlayPause"},
{Name: "Stop"},
{Name: "Play"},
{
Name: "Seek",
Args: []introspect.Arg{
{
Name: "Offset",
Type: "x",
Direction: "in",
},
},
},
{
Name: "SetPosition",
Args: []introspect.Arg{
{
Name: "TrackId",
Type: "o",
Direction: "in",
},
{
Name: "Position",
Type: "x",
Direction: "in",
},
},
},
{
Name: "OpenUri",
Args: []introspect.Arg{
{
Name: "Uri",
Type: "s",
Direction: "in",
},
},
},
},
},
},
}
}
func roProp(name, typ string) introspect.Property {
return introspect.Property{
Name: name,
Type: typ,
Access: "read",
}
}
func rwProp(name, typ string) introspect.Property {
return introspect.Property{
Name: name,
Type: typ,
Access: "readwrite",
}
}
func introspectProps(
props ...introspect.Property,
) []introspect.Property {
return props
}