Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions app.go
Original file line number Diff line number Diff line change
Expand Up @@ -790,7 +790,7 @@ func (a *App) ExportFavorites(req ExportFavoritesRequest) (string, error) {
return "", fmt.Errorf("no favorites to export")
}

dir, err := wailsrt.OpenDirectoryDialog(a.ctx, wailsrt.OpenDialogOptions{
dir, err := a.openDirectoryDialog(wailsrt.OpenDialogOptions{
Title: "Choose Export Directory",
CanCreateDirectories: true,
})
Expand Down Expand Up @@ -882,7 +882,7 @@ func (a *App) ChooseWallpaperFolder() (string, error) {
}
}

dir, err := wailsrt.OpenDirectoryDialog(a.ctx, wailsrt.OpenDialogOptions{
dir, err := a.openDirectoryDialog(wailsrt.OpenDialogOptions{
Title: "Choose Wallpaper Folder",
DefaultDirectory: defaultDir,
CanCreateDirectories: true,
Expand Down Expand Up @@ -1113,7 +1113,7 @@ func (a *App) SaveDataURLToFile(dataURL string, originalPath string) (string, er
// OpenFileDialog opens a native file dialog for selecting an image.
// Returns the selected file path or empty string if cancelled.
func (a *App) OpenFileDialog() (string, error) {
path, err := wailsrt.OpenFileDialog(a.ctx, wailsrt.OpenDialogOptions{
path, err := a.openFileDialog(wailsrt.OpenDialogOptions{
Title: "Select Wallpaper",
Filters: []wailsrt.FileFilter{
{
Expand Down Expand Up @@ -1180,7 +1180,7 @@ func (a *App) ExportTheme(req ExportThemeRequest) (string, error) {
return "", fmt.Errorf("theme name must contain letters or digits")
}

dir, err := wailsrt.OpenDirectoryDialog(a.ctx, wailsrt.OpenDialogOptions{
dir, err := a.openDirectoryDialog(wailsrt.OpenDialogOptions{
Title: "Choose Export Directory",
})
if err != nil || dir == "" {
Expand Down Expand Up @@ -1310,7 +1310,7 @@ func (a *App) ImportFileDialog(fileType string) (*ImportResult, error) {
return nil, fmt.Errorf("unknown file type: %s", fileType)
}

path, err := wailsrt.OpenFileDialog(a.ctx, wailsrt.OpenDialogOptions{
path, err := a.openFileDialog(wailsrt.OpenDialogOptions{
Title: title,
Filters: filters,
})
Expand Down
52 changes: 52 additions & 0 deletions dialogs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package main

import (
"log"
"runtime"

"aether/internal/platform"

wailsrt "github.com/wailsapp/wails/v2/pkg/runtime"
)

// Wails v2 draws GtkFileChooserDialog directly on Linux, which bypasses
// xdg-desktop-portal and so the user's configured file picker. These helpers
// ask the portal first and fall back to the Wails dialog when it is missing.

func (a *App) openFileDialog(opts wailsrt.OpenDialogOptions) (string, error) {
if path, ok := a.portalOpenFile(opts, false); ok {
return path, nil
}
return wailsrt.OpenFileDialog(a.ctx, opts)
}

func (a *App) openDirectoryDialog(opts wailsrt.OpenDialogOptions) (string, error) {
if path, ok := a.portalOpenFile(opts, true); ok {
return path, nil
}
return wailsrt.OpenDirectoryDialog(a.ctx, opts)
}

// portalOpenFile reports ok=false when the portal could not be used, so the
// caller should show the Wails dialog instead. A cancelled pick is ok=true
// with an empty path.
func (a *App) portalOpenFile(opts wailsrt.OpenDialogOptions, directory bool) (string, bool) {
if runtime.GOOS != "linux" {
return "", false
}
filters := make([]platform.FileFilter, 0, len(opts.Filters))
for _, f := range opts.Filters {
filters = append(filters, platform.FileFilter{Name: f.DisplayName, Patterns: []string{f.Pattern}})
}
path, err := platform.PortalOpenFile(a.ctx, platform.FileChooserOptions{
Title: opts.Title,
Directory: directory,
CurrentFolder: opts.DefaultDirectory,
Filters: filters,
})
if err != nil {
log.Printf("file chooser portal unavailable, using GTK dialog: %v", err)
return "", false
}
return path, true
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ module aether
go 1.25.0

require (
github.com/godbus/dbus/v5 v5.1.0
github.com/wailsapp/wails/v2 v2.16.0
golang.org/x/image v0.41.0
)
Expand All @@ -11,7 +12,6 @@ require (
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
Expand Down
209 changes: 209 additions & 0 deletions internal/platform/filechooser.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
package platform

import (
"context"
"fmt"
"net/url"
"strings"
"sync/atomic"
"time"

"github.com/godbus/dbus/v5"
)

const (
portalBusName = "org.freedesktop.portal.Desktop"
portalObjectPath = "/org/freedesktop/portal/desktop"
portalOpenFile = "org.freedesktop.portal.FileChooser.OpenFile"
portalRequestIfc = "org.freedesktop.portal.Request"
portalResponseSig = "Response"

// portalCallTimeout bounds the OpenFile method call only. The portal
// returns a request handle at once, so a slow reply means the portal is
// stuck. The wait for the user's pick has no limit.
portalCallTimeout = 10 * time.Second
)

// FileFilter is one entry in a file chooser's filter list.
type FileFilter struct {
Name string
Patterns []string
}

// FileChooserOptions configures PortalOpenFile.
type FileChooserOptions struct {
Title string
Directory bool
CurrentFolder string
Filters []FileFilter
}

// portalFilter matches the portal's a(sa(us)) filter signature.
type portalFilter struct {
Name string
Rules []portalFilterRule
}

type portalFilterRule struct {
Type uint32 // 0 = glob pattern, 1 = MIME type
Pattern string
}

var portalRequestCounter atomic.Uint64

// PortalOpenFile asks xdg-desktop-portal's FileChooser for one file or
// directory, so the user's configured picker (GTK, KDE, a terminal file
// manager, ...) is used. It returns "" with a nil error when the user cancels,
// and an error when no portal answers or the backend fails, so callers can
// fall back to a toolkit dialog.
func PortalOpenFile(ctx context.Context, opts FileChooserOptions) (string, error) {
conn, err := dbus.SessionBus()
if err != nil {
return "", fmt.Errorf("connect to session bus: %w", err)
}

token := fmt.Sprintf("aether%d", portalRequestCounter.Add(1))
expected := portalRequestPath(conn.Names()[0], token)

// Subscribe before calling: the portal may answer before OpenFile returns.
signals := make(chan *dbus.Signal, 8)
conn.Signal(signals)
defer conn.RemoveSignal(signals)
if err := addResponseMatch(conn, expected); err != nil {
return "", err
}
defer removeResponseMatch(conn, expected)

options := map[string]dbus.Variant{
"handle_token": dbus.MakeVariant(token),
"modal": dbus.MakeVariant(true),
"directory": dbus.MakeVariant(opts.Directory),
}
if filters := toPortalFilters(opts.Filters); len(filters) > 0 {
options["filters"] = dbus.MakeVariant(filters)
}
if opts.CurrentFolder != "" {
options["current_folder"] = dbus.MakeVariant(append([]byte(opts.CurrentFolder), 0))
}

var handle dbus.ObjectPath
callCtx, cancel := context.WithTimeout(ctx, portalCallTimeout)
call := conn.Object(portalBusName, portalObjectPath).CallWithContext(callCtx, portalOpenFile, 0, "", opts.Title, options)
err = call.Store(&handle)
cancel()
if err != nil {
return "", fmt.Errorf("portal OpenFile: %w", err)
}
// The spec lets the returned handle differ from the handle_token path.
if handle != expected {
if err := addResponseMatch(conn, handle); err != nil {
return "", err
}
defer removeResponseMatch(conn, handle)
}

for {
select {
case <-ctx.Done():
return "", ctx.Err()
case sig, ok := <-signals:
if !ok {
return "", fmt.Errorf("session bus closed while waiting for portal")
}
if sig.Path != handle || sig.Name != portalRequestIfc+"."+portalResponseSig {
continue
}
return parsePortalResponse(sig.Body)
}
}
}

func addResponseMatch(conn *dbus.Conn, path dbus.ObjectPath) error {
if err := conn.AddMatchSignal(responseMatch(path)...); err != nil {
return fmt.Errorf("subscribe to portal response: %w", err)
}
return nil
}

func removeResponseMatch(conn *dbus.Conn, path dbus.ObjectPath) {
_ = conn.RemoveMatchSignal(responseMatch(path)...)
}

func responseMatch(path dbus.ObjectPath) []dbus.MatchOption {
return []dbus.MatchOption{
dbus.WithMatchSender(portalBusName),
dbus.WithMatchObjectPath(path),
dbus.WithMatchInterface(portalRequestIfc),
dbus.WithMatchMember(portalResponseSig),
}
}

// portalRequestPath builds the Request object path the portal will use for
// handle_token, per the org.freedesktop.portal.Request documentation.
func portalRequestPath(uniqueName, token string) dbus.ObjectPath {
sender := strings.ReplaceAll(strings.TrimPrefix(uniqueName, ":"), ".", "_")
return dbus.ObjectPath(portalObjectPath + "/request/" + sender + "/" + token)
}

// toPortalFilters converts filters whose patterns may be ";"-joined
// (Wails style, e.g. "*.jpg;*.png") into portal glob rules.
func toPortalFilters(filters []FileFilter) []portalFilter {
out := make([]portalFilter, 0, len(filters))
for _, f := range filters {
pf := portalFilter{Name: f.Name}
for _, joined := range f.Patterns {
for _, p := range strings.Split(joined, ";") {
if p = strings.TrimSpace(p); p != "" {
pf.Rules = append(pf.Rules, portalFilterRule{Type: 0, Pattern: p})
}
}
}
if len(pf.Rules) > 0 {
out = append(out, pf)
}
}
return out
}

// parsePortalResponse reads the (u response, a{sv} results) body of a
// Request.Response signal. Response 1 (cancelled) yields "". Response 2
// (ended in another way) is an error, because portal backends send it when
// they fail, and the caller then falls back to the toolkit dialog.
func parsePortalResponse(body []interface{}) (string, error) {
if len(body) != 2 {
return "", fmt.Errorf("unexpected portal response: %v", body)
}
code, ok := body[0].(uint32)
if !ok {
return "", fmt.Errorf("unexpected portal response code: %v", body[0])
}
switch code {
case 0:
case 1:
return "", nil
default:
return "", fmt.Errorf("portal ended the request with response %d", code)
}
results, ok := body[1].(map[string]dbus.Variant)
if !ok {
return "", fmt.Errorf("unexpected portal results: %v", body[1])
}
uris, ok := results["uris"].Value().([]string)
if !ok || len(uris) == 0 {
return "", nil
}
return fileURIToPath(uris[0])
}

// fileURIToPath decodes a file:// URI. url.Parse keeps "+" literal, which
// url.QueryUnescape would turn into a space.
func fileURIToPath(uri string) (string, error) {
u, err := url.Parse(uri)
if err != nil {
return "", fmt.Errorf("parse portal uri %q: %w", uri, err)
}
if u.Scheme != "file" {
return "", fmt.Errorf("portal returned non-file uri %q", uri)
}
return u.Path, nil
}
75 changes: 75 additions & 0 deletions internal/platform/filechooser_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package platform

import (
"reflect"
"testing"

"github.com/godbus/dbus/v5"
)

func TestPortalRequestPath(t *testing.T) {
got := portalRequestPath(":1.42", "aether7")
want := dbus.ObjectPath("/org/freedesktop/portal/desktop/request/1_42/aether7")
if got != want {
t.Fatalf("portalRequestPath = %q, want %q", got, want)
}
}

func TestToPortalFiltersSplitsWailsPatterns(t *testing.T) {
got := toPortalFilters([]FileFilter{
{Name: "Images", Patterns: []string{"*.jpg; *.png;"}},
{Name: "Empty", Patterns: []string{""}},
})
want := []portalFilter{{
Name: "Images",
Rules: []portalFilterRule{{Type: 0, Pattern: "*.jpg"}, {Type: 0, Pattern: "*.png"}},
}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("toPortalFilters = %#v, want %#v", got, want)
}
}

func TestPortalFilterSignature(t *testing.T) {
sig := dbus.SignatureOf(toPortalFilters([]FileFilter{{Name: "Images", Patterns: []string{"*.png"}}}))
if sig.String() != "a(sa(us))" {
t.Fatalf("filter signature = %s, want a(sa(us))", sig)
}
}

func TestParsePortalResponse(t *testing.T) {
results := map[string]dbus.Variant{
"uris": dbus.MakeVariant([]string{"file:///home/me/My%20Pictures/a+b.png"}),
}
tests := []struct {
name string
body []interface{}
want string
}{
{"selected", []interface{}{uint32(0), results}, "/home/me/My Pictures/a+b.png"},
{"cancelled", []interface{}{uint32(1), map[string]dbus.Variant{}}, ""},
{"no uris", []interface{}{uint32(0), map[string]dbus.Variant{}}, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parsePortalResponse(tt.body)
if err != nil {
t.Fatal(err)
}
if got != tt.want {
t.Fatalf("path = %q, want %q", got, tt.want)
}
})
}
}

func TestParsePortalResponseRejectsMalformedBody(t *testing.T) {
if _, err := parsePortalResponse([]interface{}{uint32(0)}); err == nil {
t.Fatal("expected error for short body")
}
if _, err := parsePortalResponse([]interface{}{uint32(2), map[string]dbus.Variant{}}); err == nil {
t.Fatal("expected error for response 2, so the caller falls back")
}
if _, err := fileURIToPath("https://example.com/a.png"); err == nil {
t.Fatal("expected error for non-file uri")
}
}