~whereswaldon/gio-x

c7f010e201d783f7dd649aad632fd5ea1a30f664 — Chris Waldon 2 years ago 8d98a25 explorer-linux
explorer: add linux support using xdg-desktop-portal

This commit implements a Linux file explorer relying upon the XDG
Desktop Portal specification. This should work on most Linux
distributions and ought to also transparently support the Flatpak
sandbox.

Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
4 files changed, 282 insertions(+), 5 deletions(-)

A explorer/explorer_linux.go
M explorer/explorer_unsupported.go
M explorer/go.mod
M explorer/go.sum
A explorer/explorer_linux.go => explorer/explorer_linux.go +275 -0
@@ 0,0 1,275 @@
// SPDX-License-Identifier: Unlicense OR MIT

//go:build linux && !android
// +build linux,!android

package explorer

import (
	"crypto/rand"
	"encoding/hex"
	"fmt"
	"io"
	"mime"
	"net/url"
	"os"
	"strings"

	"gioui.org/app"
	"gioui.org/io/event"
	"github.com/godbus/dbus/v5"
)

// explorer opens file explorers using the xdg-desktop-portal dbus protocol
// defined here:
// https://flatpak.github.io/xdg-desktop-portal/#gdbus-org.freedesktop.portal.FileChooser
type explorer struct {
	X11Window uintptr
}

func newExplorer(w *app.Window) *explorer {
	return new(explorer)
}

func (e *Explorer) listenEvents(ev event.Event) {
	switch ev := ev.(type) {
	case app.ViewEvent:
		e.X11Window = ev.Window
	}
}

// randString generates a string of the form prefix+hexnumber, where hexnumber
// is the hex-encoded form of 16 bytes of cryptographically random data.
func randString(prefix string) (string, error) {
	var bytes [16]byte
	n, err := rand.Read(bytes[:])
	if err != nil {
		return "", fmt.Errorf("unable to generate random handle: %w", err)
	} else if n != len(bytes) {
		return "", fmt.Errorf("unable to read enough random data for handle")
	}
	return prefix + hex.EncodeToString(bytes[:]), nil
}

// extractURIsFromSignal locates the list of file URIs within the body of the
// signal and converts them to a slice of strings. If there were no URIs or
// if they are not a slice of strings, it returns the empty slice.
func extractURIsFromSignal(sig *dbus.Signal) []string {
	var uris []string
	for _, element := range sig.Body {
		asMap, ok := element.(map[string]dbus.Variant)
		if !ok {
			continue
		}
		urisVariant := asMap["uris"]
		uris, ok = urisVariant.Value().([]string)
		if !ok {
			return nil
		}
		break
	}
	return uris
}

// exportFile requests that a dialog be opened to write a file with the given
// name somewhere in the filesystem.
func (e *Explorer) exportFile(fileName string) (io.WriteCloser, error) {
	var filepath string
	if err := e.withDesktopPortal(func(conn *dbus.Conn, desktopPortal dbus.BusObject, config config) error {
		// Invoke the OpenFile method.
		requestHandle := ""
		err := desktopPortal.Call("org.freedesktop.portal.FileChooser.SaveFile", 0, config.parentWindow, "Choose Save Location", map[string]dbus.Variant{
			"handle_token": dbus.MakeVariant(config.handleToken),
			"current_name": dbus.MakeVariant(fileName),
		}).Store(&requestHandle)
		if err != nil {
			return fmt.Errorf("failed to call OpenFile: %w", err)
		}

		// Make sure we got the request object's path right. Update our subscription otherwise.
		if requestHandle != config.expectedRequestHandle {
			if err := conn.AddMatchSignal(dbus.WithMatchObjectPath(dbus.ObjectPath(requestHandle))); err != nil {
				return fmt.Errorf("failed to subscribe to request: %w", err)
			}
			// Reset signal handling.
			signals := make(chan *dbus.Signal, 1)
			conn.Signal(signals)
			config.signals = signals
		}

		// Wait for the response from the file dialog.
		response := <-config.signals
		uris := extractURIsFromSignal(response)

		// Error if no files were selected.
		if len(uris) < 1 {
			return ErrUserDecline
		}

		// Remove the protocol from the URI.
		parsedURL, err := url.Parse(uris[0])
		if err != nil {
			return fmt.Errorf("failed parsing file path %s: %w", uris[0], err)
		}
		filepath = parsedURL.Path
		return nil
	}); err != nil {
		return nil, err
	}
	return os.Create(filepath)
}

// sanitizeSenderName converts the dbusSenderName into the form required in the
// response object path.
// https://flatpak.github.io/xdg-desktop-portal/#gdbus-org.freedesktop.portal.Request
func sanitizeSenderName(dbusSenderName string) string {
	return strings.TrimPrefix(strings.ReplaceAll(dbusSenderName, ".", "_"), ":")
}

type config struct {
	parentWindow          string
	expectedRequestHandle string
	handleToken           string
	signals               chan *dbus.Signal
}

// withDesktopPortal connects to the session dbus and finds the service
// implementing the freedesktop.org portals. It accepts a function that
// it will run with access to the connection, portal, and a set of
// parameters that are useful for making requests against the portal.
func (e *Explorer) withDesktopPortal(work func(conn *dbus.Conn, desktopPortal dbus.BusObject, config config) error) error {
	// Connect to the session bus.
	conn, err := dbus.ConnectSessionBus()
	if err != nil {
		return fmt.Errorf("unable to connect to session bus: %w", err)
	}
	defer conn.Close()
	// Figure out our own connection name.
	senderName := sanitizeSenderName(conn.Names()[0])

	// Determine parameters for the methods we will call.
	obj := conn.Object("org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop")
	parentWindow := ""
	if e.X11Window != 0 {
		parentWindow = "x11:" + fmt.Sprintf("%x", e.X11Window)
	}
	handle, err := randString("giox")
	if err != nil {
		return fmt.Errorf("unable to export file: %w", err)
	}

	// Predict the request object's path.
	expectedRequestHandle := fmt.Sprintf("/org/freedesktop/portal/desktop/request/%s/%s", senderName, handle)

	// Subscribe to signals on the request object's path before submitting the request to avoid
	// race conditions.
	if err := conn.AddMatchSignal(dbus.WithMatchObjectPath(dbus.ObjectPath(expectedRequestHandle))); err != nil {
		return fmt.Errorf("failed to subscribe to request: %w", err)
	}
	// Prepare for signal handling.
	signals := make(chan *dbus.Signal, 1)
	conn.Signal(signals)

	// Perform some work while connected.
	if err := work(conn, obj, config{
		parentWindow:          parentWindow,
		expectedRequestHandle: expectedRequestHandle,
		handleToken:           handle,
		signals:               signals,
	}); err != nil {
		return err
	}
	return nil
}

// makeFilter constructs a file type filter appropriate for the provided extensions
// and encodes it as a dbus variant.
func makeFilter(extensions []string) dbus.Variant {
	// Resolve the provided extensions to their corresponding mime types.
	type mimetype struct {
		// Field names _must_ be exported so that they are available via reflection,
		// otherwise they will not be sent.
		Kind uint
		Name string
	}
	mimes := make([]mimetype, len(extensions))
	for i := range extensions {
		ext := extensions[i]
		if !strings.HasPrefix(ext, ".") {
			ext = "." + ext
		}
		mt := mime.TypeByExtension(ext)
		if mt != "" {
			mimes[i] = mimetype{
				Kind: 1,
				Name: mt,
			}
		} else {
			mimes[i] = mimetype{
				Kind: 0,
				Name: "*" + ext,
			}
		}
	}

	// Transform the filter into its dbus variant form.
	filter := []struct {
		// Field names must be exported so they are available via reflection, otherwise
		// they will not be sent.
		Name  string
		Value []mimetype
	}{
		{
			Name:  "Filter",
			Value: mimes,
		},
	}
	return dbus.MakeVariantWithSignature(filter, dbus.ParseSignatureMust("a(sa(us))"))
}

// importFile opens a file picker to choose a file.
func (e *Explorer) importFile(extensions ...string) (io.ReadCloser, error) {
	var filepath string
	if err := e.withDesktopPortal(func(conn *dbus.Conn, desktopPortal dbus.BusObject, config config) error {
		// Invoke the OpenFile method.
		requestHandle := ""
		err := desktopPortal.Call("org.freedesktop.portal.FileChooser.OpenFile", 0, config.parentWindow, "Choose File", map[string]dbus.Variant{
			"handle_token": dbus.MakeVariant(config.handleToken),
			"filters":      makeFilter(extensions),
		}).Store(&requestHandle)
		if err != nil {
			return fmt.Errorf("failed to call OpenFile: %w", err)
		}

		// Make sure we got the request object's path right. Update our subscription otherwise.
		if requestHandle != config.expectedRequestHandle {
			if err := conn.AddMatchSignal(dbus.WithMatchObjectPath(dbus.ObjectPath(requestHandle))); err != nil {
				return fmt.Errorf("failed to subscribe to request: %w", err)
			}
			// Reset signal handling.
			signals := make(chan *dbus.Signal, 1)
			conn.Signal(signals)
			config.signals = signals
		}

		// Wait for the response from the file dialog.
		response := <-config.signals
		uris := extractURIsFromSignal(response)

		// Error if no files were selected.
		if len(uris) < 1 {
			return ErrUserDecline
		}

		// Remove the protocol from the URI.
		parsedURL, err := url.Parse(uris[0])
		if err != nil {
			return fmt.Errorf("failed parsing file path %s: %w", uris[0], err)
		}
		filepath = parsedURL.Path
		return nil
	}); err != nil {
		return nil, err
	}
	return os.Open(filepath)
}

M explorer/explorer_unsupported.go => explorer/explorer_unsupported.go +4 -3
@@ 1,14 1,15 @@
// SPDX-License-Identifier: Unlicense OR MIT

//go:build !windows && !android && !js && !darwin && !ios
// +build !windows,!android,!js,!darwin,!ios
//go:build !windows && !android && !js && !darwin && !ios && !linux
// +build !windows,!android,!js,!darwin,!ios,!linux

package explorer

import (
	"io"

	"gioui.org/app"
	"gioui.org/io/event"
	"io"
)

type explorer struct{}

M explorer/go.mod => explorer/go.mod +1 -0
@@ 5,5 5,6 @@ go 1.16
require (
	gioui.org v0.0.0-20211202105001-872b4ba41be0
	git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0
	github.com/godbus/dbus/v5 v5.0.6
	golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac
)

M explorer/go.sum => explorer/go.sum +2 -2
@@ 1,8 1,6 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20201218220906-28db891af037/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
gioui.org v0.0.0-20211026101311-9cf7cc75f468 h1:8lKC0jESs/gMz+kYbJWzFB1bkwMdlux36LYvBB9UDtw=
gioui.org v0.0.0-20211026101311-9cf7cc75f468/go.mod h1:yoWOxPng6WkDpsud+NRmkoftmyWn3rkKsYGEcWHpjTI=
gioui.org v0.0.0-20211202105001-872b4ba41be0 h1:rXO+2zdXvX6G19M5oF1fs1U7kmUWb4uXCKWa+WHZSpA=
gioui.org v0.0.0-20211202105001-872b4ba41be0/go.mod h1:yoWOxPng6WkDpsud+NRmkoftmyWn3rkKsYGEcWHpjTI=
gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=


@@ 76,6 74,8 @@ github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG
github.com/go-logr/logr v0.4.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU=
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/godbus/dbus/v5 v5.0.6 h1:mkgN1ofwASrYnJ5W6U/BxG15eXXXjirgZc7CLqkcaro=
github.com/godbus/dbus/v5 v5.0.6/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/googleapis v1.1.0/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=