From c7f010e201d783f7dd649aad632fd5ea1a30f664 Mon Sep 17 00:00:00 2001 From: Chris Waldon Date: Thu, 30 Dec 2021 11:32:04 -0500 Subject: [PATCH] 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 --- explorer/explorer_linux.go | 275 +++++++++++++++++++++++++++++++ explorer/explorer_unsupported.go | 7 +- explorer/go.mod | 1 + explorer/go.sum | 4 +- 4 files changed, 282 insertions(+), 5 deletions(-) create mode 100644 explorer/explorer_linux.go diff --git a/explorer/explorer_linux.go b/explorer/explorer_linux.go new file mode 100644 index 0000000..40a3646 --- /dev/null +++ b/explorer/explorer_linux.go @@ -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) +} diff --git a/explorer/explorer_unsupported.go b/explorer/explorer_unsupported.go index 33b3692..b760631 100644 --- a/explorer/explorer_unsupported.go +++ b/explorer/explorer_unsupported.go @@ -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{} diff --git a/explorer/go.mod b/explorer/go.mod index 4cb3354..666857d 100644 --- a/explorer/go.mod +++ b/explorer/go.mod @@ -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 ) diff --git a/explorer/go.sum b/explorer/go.sum index 38378d7..3b5f211 100644 --- a/explorer/go.sum +++ b/explorer/go.sum @@ -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= -- 2.45.2