kitty/kittens/desktop_ui/portal.go
Kovid Goyal c0bb8ae2a0
desktop-ui kitten: Re-read config on every file chooser invocation via portal
Allows easily changing the config without needing to restart the kitten
which is difficult to do given its lifetime is managed by the xdg
portals service.
2026-02-03 21:08:55 +05:30

939 lines
29 KiB
Go

package desktop_ui
import (
"bytes"
"encoding/json"
"fmt"
"log"
"maps"
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/kovidgoyal/dbus"
"github.com/kovidgoyal/dbus/introspect"
"github.com/kovidgoyal/dbus/prop"
"github.com/kovidgoyal/kitty/tools/utils"
"github.com/kovidgoyal/kitty/tools/utils/style"
"golang.org/x/sys/unix"
)
var _ = fmt.Print
const PORTAL_APPEARANCE_NAMESPACE = "org.freedesktop.appearance"
const PORTAL_COLOR_SCHEME_KEY = "color-scheme"
const PORTAL_ACCENT_COLOR_KEY = "accent-color"
const PORTAL_CONTRAST_KEY = "contrast"
const PORTAL_BUS_NAME = "org.freedesktop.impl.portal.desktop.kitty"
const DESKTOP_OBJECT_PATH = "/org/freedesktop/portal/desktop"
const SETTINGS_INTERFACE = "org.freedesktop.impl.portal.Settings"
const FILE_CHOOSER_INTERFACE = "org.freedesktop.impl.portal.FileChooser"
const KITTY_OBJECT_PATH = "/net/kovidgoyal/kitty/portal"
const CHANGE_SETTINGS_INTERFACE = "net.kovidgoyal.kitty.settings"
const DESKTOP_PORTAL_NAME = "org.freedesktop.portal.Desktop"
const REQUEST_INTERFACE = "org.freedesktop.impl.portal.Request"
// Special portal setting used to check if we are being called by xdg-desktop-portal
const SETTINGS_CANARY_NAMESPACE = "net.kovidgoyal.kitty"
const SETTINGS_CANARY_KEY = "status"
type ColorScheme uint32
const (
NO_PREFERENCE ColorScheme = iota
DARK
LIGHT
)
const (
RESPONSE_SUCCESS uint32 = iota
RESPONSE_CANCELED
RESPONSE_ENDED
)
type SettingsMap map[string]map[string]dbus.Variant
type Portal struct {
bus *dbus.Conn
settings SettingsMap
lock sync.Mutex
opts *Config
server_options *ServerOptions
file_chooser_first_instance *exec.Cmd
}
func to_color(spec string) (v dbus.Variant, err error) {
if col, err := style.ParseColor(spec); err == nil {
return dbus.MakeVariant([]float64{float64(col.Red) / 255., float64(col.Green) / 255., float64(col.Blue) / 255.}), nil
}
return
}
func NewPortal(opts *Config, server_options *ServerOptions) (p *Portal, err error) {
ans := Portal{opts: opts, server_options: server_options}
ans.settings = SettingsMap{
SETTINGS_CANARY_NAMESPACE: map[string]dbus.Variant{
SETTINGS_CANARY_KEY: dbus.MakeVariant("running"),
},
}
ans.settings[PORTAL_APPEARANCE_NAMESPACE] = map[string]dbus.Variant{}
switch opts.Color_scheme {
case Color_scheme_dark:
ans.settings[PORTAL_APPEARANCE_NAMESPACE][PORTAL_COLOR_SCHEME_KEY] = dbus.MakeVariant(uint32(DARK))
case Color_scheme_light:
ans.settings[PORTAL_APPEARANCE_NAMESPACE][PORTAL_COLOR_SCHEME_KEY] = dbus.MakeVariant(uint32(LIGHT))
default:
ans.settings[PORTAL_APPEARANCE_NAMESPACE][PORTAL_COLOR_SCHEME_KEY] = dbus.MakeVariant(uint32(NO_PREFERENCE))
}
ans.settings[PORTAL_APPEARANCE_NAMESPACE][PORTAL_ACCENT_COLOR_KEY], err = to_color(opts.Accent_color)
var contrast uint32
if opts.Contrast == Contrast_high {
contrast = 1
}
ans.settings[PORTAL_APPEARANCE_NAMESPACE][PORTAL_CONTRAST_KEY] = dbus.MakeVariant(contrast)
return &ans, nil
}
type PropSpec map[string]*prop.Prop
type SignalSpec map[string][]struct {
Name, Type string
}
type MethodSpec map[string][]struct {
Name, Type string
Out bool
}
func ExportInterface(conn *dbus.Conn, object any, interface_name, object_path string, method_spec MethodSpec, prop_spec PropSpec, signal_spec SignalSpec) (err error) {
op := dbus.ObjectPath(object_path)
method_map := make(map[string]string, len(method_spec))
methods := []introspect.Method{}
if len(method_spec) > 0 {
for method_name, args := range method_spec {
method_map[method_name] = method_name
meth_args := make([]introspect.Arg, len(args))
for i, a := range args {
meth_args[i] = introspect.Arg{
Name: a.Name,
Type: a.Type,
Direction: utils.IfElse(a.Out, "out", "in"),
}
}
methods = append(methods, introspect.Method{
Name: method_name,
Args: meth_args,
})
}
}
if err = conn.ExportWithMap(object, method_map, op, interface_name); err != nil {
return fmt.Errorf("failed to export interface: %s at object path: %s with error: %w", interface_name, object_path, err)
}
var properties []introspect.Property
p := prop.Map{interface_name: prop_spec}
if len(prop_spec) > 0 {
if props, err := prop.Export(conn, op, p); err != nil {
return fmt.Errorf("failed to export properties with error: %w", err)
} else {
properties = props.Introspection(interface_name)
}
}
var signals []introspect.Signal
if len(signal_spec) > 0 {
for signal_name, args := range signal_spec {
sig_args := make([]introspect.Arg, len(args))
for i, a := range args {
sig_args[i] = introspect.Arg{
Name: a.Name,
Type: a.Type,
Direction: "out",
}
}
signals = append(signals, introspect.Signal{
Name: signal_name,
Args: sig_args,
})
}
}
interface_data := introspect.Interface{
Name: interface_name,
Methods: methods,
Properties: properties,
Signals: signals,
}
interfaces := []introspect.Interface{
introspect.IntrospectData, interface_data,
}
if len(properties) > 0 {
interfaces = append(interfaces, prop.IntrospectData)
}
n := &introspect.Node{Name: object_path, Interfaces: interfaces}
if err = conn.Export(introspect.NewIntrospectable(n), op, introspect.IntrospectData.Name); err != nil {
return fmt.Errorf("failed to export introspected methods with error: %w", err)
}
return
}
func (self *Portal) Start() (err error) {
if self.bus, err = dbus.SessionBus(); err != nil {
return fmt.Errorf("could not connect to session D-Bus: %s", err)
}
reply, err := self.bus.RequestName(PORTAL_BUS_NAME, dbus.NameFlagDoNotQueue)
if err != nil {
return fmt.Errorf("failed to register dbus name: %v", err)
}
if reply != dbus.RequestNameReplyPrimaryOwner {
return fmt.Errorf("can't register D-Bus name: name already taken")
}
props := PropSpec{
"version": {Value: uint32(1), Writable: false, Emit: prop.EmitFalse},
}
signals := SignalSpec{
"SettingChanged": {{"namespace", "s"}, {"key", "s"}, {"value", "v"}},
}
methods := MethodSpec{
"Read": {{"namespace", "s", false}, {"key", "s", false}, {"value", "v", true}},
"ReadAll": {{"namespaces", "as", false}, {"value", "a{sa{sv}}", true}},
}
if err = ExportInterface(self.bus, self, SETTINGS_INTERFACE, DESKTOP_OBJECT_PATH, methods, props, signals); err != nil {
return
}
methods = MethodSpec{
"OpenFile": {{"handle", "o", false}, {"app_id", "s", false}, {"parent_window", "s", false}, {"title", "s", false}, {"options", "a{sv}", false},
{"response", "u", true}, {"results", "a{sv}", false},
},
"SaveFile": {{"handle", "o", false}, {"app_id", "s", false}, {"parent_window", "s", false}, {"title", "s", false}, {"options", "a{sv}", false},
{"response", "u", true}, {"results", "a{sv}", false},
},
}
if err = ExportInterface(self.bus, self, FILE_CHOOSER_INTERFACE, DESKTOP_OBJECT_PATH, methods, nil, nil); err != nil {
return
}
methods = MethodSpec{
"ChangeSetting": {{"namespace", "s", false}, {"key", "s", false}, {"value", "v", false}},
"RemoveSetting": {{"namespace", "s", false}, {"key", "s", false}},
}
props["version"].Value = uint32(1)
if err = ExportInterface(self.bus, self, CHANGE_SETTINGS_INTERFACE, KITTY_OBJECT_PATH, methods, props, nil); err != nil {
return
}
return
}
func ParseValueWithSignature(value, value_type_signature string) (v dbus.Variant, err error) {
var s dbus.Signature
if value_type_signature != "" {
if value_type_signature[0] == '@' {
value_type_signature = value_type_signature[1:]
}
s, err = dbus.ParseSignature(value_type_signature)
if err != nil {
return dbus.Variant{}, fmt.Errorf("%s is not a valid type signature: %w", value_type_signature, err)
}
}
v, err = dbus.ParseVariant(value, s)
if err != nil {
if value_type_signature == "" {
return dbus.Variant{}, fmt.Errorf("could not guess the data type of: %s with error: %w", value, err)
}
return dbus.Variant{}, fmt.Errorf("%s is not a valid value for signature: %#v with error: %w", value, value_type_signature, err)
}
return v, nil
}
func ParseValue(value string) (dbus.Variant, error) {
return ParseValueWithSignature(value, "")
}
type ShowSettingsOptions struct {
AsJson bool
AllowOtherBackends bool
InNamespace []string
}
func fetch_settings(conn *dbus.Conn, namespaces ...string) (ans ReadAllType, err error) {
path := "/" + strings.ToLower(strings.ReplaceAll(DESKTOP_PORTAL_NAME, ".", "/"))
obj := conn.Object(DESKTOP_PORTAL_NAME, dbus.ObjectPath(path))
interface_name := strings.ReplaceAll(DESKTOP_PORTAL_NAME, "Desktop", "Settings")
if len(namespaces) == 0 {
namespaces = append(namespaces, "")
}
call := obj.Call(interface_name+".ReadAll", dbus.FlagNoAutoStart, namespaces)
if err = call.Store(&ans); err != nil {
return nil, fmt.Errorf("Failed to read response from ReadAll with error: %w", err)
}
return
}
func show_settings(opts *ShowSettingsOptions) (err error) {
conn, err := dbus.SessionBus()
if err != nil {
return fmt.Errorf("failed to connect to system bus with error: %w", err)
}
defer conn.Close()
var response ReadAllType
response, err = fetch_settings(conn, opts.InNamespace...)
if opts.AsJson {
unwrapped := make(map[string]map[string]any, len(response))
for ns, m := range response {
w := make(map[string]any, len(m))
for k, a := range m {
w[k] = a.Value()
}
unwrapped[ns] = w
}
j, err := json.MarshalIndent(unwrapped, "", " ")
if err != nil {
return fmt.Errorf("Failed to format the response as JSON: %w", err)
}
fmt.Println(string(j))
} else {
for ns, m := range response {
fmt.Println(ns + ":")
for key, v := range m {
fmt.Printf("\t%s: %s\n", key, v)
}
}
}
if !opts.AllowOtherBackends {
is_running_self := false
if m, found := response[SETTINGS_CANARY_NAMESPACE]; found {
_, is_running_self = m[SETTINGS_CANARY_KEY]
}
if !is_running_self {
err = fmt.Errorf("the settings did not come from the desktop-ui kitten. Some other portal backend is providing the service.")
}
}
return
}
var DataDirs = sync.OnceValue(func() (ans []string) {
// $XDG_DATA_DIRS defines the preference-ordered set of base directories
// to search for data files **in addition to the $XDG_DATA_HOME** base
// directory. The directories in $XDG_DATA_DIRS should be separated with
// a colon ':'.
// https://specifications.freedesktop.org/basedir-spec/0.8/#variables
data_dirs := os.Getenv("XDG_DATA_DIRS")
if data_dirs == "" {
data_dirs = "/usr/local/share:/usr/share"
}
data_home := os.Getenv("XDG_DATA_HOME")
if data_home == "" {
data_home = utils.Expanduser("~/.local/share")
}
return utils.Uniq(append([]string{data_home}, strings.Split(data_dirs, ":")...))
})
func IsDir(x string) bool {
s, err := os.Stat(x)
return err == nil && s.IsDir()
}
var WritableDataDirs = sync.OnceValue(func() (ans []string) {
for _, x := range DataDirs() {
if err := os.MkdirAll(x, 0o755); err == nil && unix.Access(x, unix.W_OK) == nil {
ans = append(ans, x)
}
}
return
})
var AllPortalInterfaces = sync.OnceValue(func() (ans []string) {
return []string{SETTINGS_INTERFACE, FILE_CHOOSER_INTERFACE}
})
// enable-portal {{{
func patch_portals_conf(text []byte) []byte {
lines := []string{}
in_preferred := false
patched := false
for _, line := range utils.Splitlines(utils.UnsafeBytesToString(text)) {
sl := strings.TrimSpace(line)
if strings.HasPrefix(sl, "[") {
in_preferred = sl == "[preferred]"
lines = append(lines, line)
for _, iface := range AllPortalInterfaces() {
lines = append(lines, iface+"=kitty;*")
}
patched = true
} else if in_preferred {
remove := false
for _, iface := range AllPortalInterfaces() {
if strings.HasPrefix(sl, iface) {
remove = true
break
}
}
if !remove {
lines = append(lines, line)
}
}
}
if !patched {
// the file was empty or did not contain a section
lines = append(lines, "[preferred]")
for _, iface := range AllPortalInterfaces() {
lines = append(lines, iface+"=kitty;*")
}
}
return utils.UnsafeStringToBytes(strings.Join(lines, "\n"))
}
func enable_portal() (err error) {
if len(WritableDataDirs()) == 0 {
return fmt.Errorf("Could not find any writable data directories. Make sure XDG_DATA_DIRS is set and contains at least one directory for which you have write permission")
}
portals_dir := ""
for _, x := range WritableDataDirs() {
// Find-or-create the first available xdg-desktop-portals/portals directory
q := filepath.Join(x, "xdg-desktop-portal", "portals")
if (unix.Access(q, unix.W_OK) == nil && IsDir(q)) || (os.MkdirAll(q, 0o755) == nil) {
portals_dir = q
break
}
}
if portals_dir == "" {
return fmt.Errorf("Could not find any writable portals directories. Make sure XDG_DATA_HOME is set and point to a directory for which you have write permission.")
}
portals_defn := filepath.Join(portals_dir, "kitty.portal")
if err = os.WriteFile(portals_defn, utils.UnsafeStringToBytes(fmt.Sprintf(
`[portal]
DBusName=%s
Interfaces=%s;
`, PORTAL_BUS_NAME, strings.Join(AllPortalInterfaces(), ";"))), 0o644); err != nil {
return err
}
fmt.Println("Wrote kitty portal definition to:", portals_defn)
dbus_service_dir := ""
for _, x := range WritableDataDirs() {
q := filepath.Join(x, "dbus-1", "services")
if err := os.MkdirAll(q, 0o755); err == nil {
dbus_service_dir = q
break
}
}
if dbus_service_dir == "" {
return fmt.Errorf("Could not find any writable portals directories. Make sure XDG_DATA_HOME is set and point to a directory for which you have write permission.")
}
dbus_service_defn := filepath.Join(dbus_service_dir, PORTAL_BUS_NAME+".service")
exe_path, eerr := os.Executable()
if eerr != nil {
exe_path = utils.Which("kitten")
}
if exe_path, err = filepath.Abs(exe_path); eerr != nil {
return fmt.Errorf("failed to get path to kitten executable with error: %w", err)
}
if err = os.WriteFile(dbus_service_defn, utils.UnsafeStringToBytes(fmt.Sprintf(
`[D-BUS Service]
Name=%s
Exec=%s desktop-ui run-server
`, PORTAL_BUS_NAME, exe_path)), 0o644); err != nil {
return err
}
fmt.Println("Wrote kitty DBUS activation service file to:", dbus_service_defn)
d := os.Getenv("XDG_CURRENT_DESKTOP")
cf := os.Getenv("XDG_CONFIG_HOME")
if cf == "" {
cf = utils.Expanduser("~/.config")
}
cf = filepath.Join(cf, "xdg-desktop-portal")
if err = os.MkdirAll(cf, 0o755); err != nil {
return fmt.Errorf("failed to create %s to store the portals.conf file with error: %w", cf, err)
}
patched_file := ""
desktops := utils.Filter(strings.Split(d, ":"), func(x string) bool { return x != "" })
desktops = append(desktops, "")
for x := range strings.SplitSeq(d, ":") {
q := filepath.Join(cf, utils.IfElse(x == "", "portals.conf", fmt.Sprintf("%s-portals.conf", strings.ToLower(x))))
if text, err := os.ReadFile(q); err == nil {
text := patch_portals_conf(text)
if err = os.WriteFile(q, text, 0o644); err == nil {
patched_file = q
fmt.Printf("Patched %s to use the kitty portals\n", patched_file)
break
}
}
}
if patched_file == "" {
x := desktops[0]
q := filepath.Join(cf, utils.IfElse(x == "", "portals.conf", fmt.Sprintf("%s-portals.conf", strings.ToLower(x))))
text := patch_portals_conf([]byte{})
if err = os.WriteFile(q, text, 0o644); err != nil {
return err
}
patched_file = q
fmt.Printf("Created %s to use the kitty portals\n", patched_file)
}
return
}
// }}}
type SetOptions struct {
Namespace, DataType string
}
func set_variant_setting(namespace, key string, v dbus.Variant, remove_setting bool) (err error) {
conn, err := dbus.SessionBus()
if err != nil {
return fmt.Errorf("failed to connect to system bus with error: %w", err)
}
defer conn.Close()
method := "ChangeSetting"
var vals = []any{namespace, key}
if remove_setting {
method = "RemoveSetting"
} else {
vals = append(vals, v)
}
obj := conn.Object(PORTAL_BUS_NAME, dbus.ObjectPath(KITTY_OBJECT_PATH))
call := obj.Call(CHANGE_SETTINGS_INTERFACE+"."+method, dbus.FlagNoAutoStart, vals...)
if err = call.Store(); err != nil {
return fmt.Errorf("failed to call %s with error: %w", method, err)
}
return
}
func set_setting(key, value string, opts *SetOptions) (err error) {
remove_setting := false
var v dbus.Variant
if value == "" {
remove_setting = true
} else {
if v, err = ParseValueWithSignature(value, opts.DataType); err != nil {
return err
}
}
return set_variant_setting(opts.Namespace, key, v, remove_setting)
}
func set_color_scheme(which string) (err error) {
conn, err := dbus.SessionBus()
if err != nil {
return fmt.Errorf("failed to connect to system bus with error: %w", err)
}
defer conn.Close()
val := NO_PREFERENCE
var res ReadAllType
if res, err = fetch_settings(conn, PORTAL_APPEARANCE_NAMESPACE); err != nil {
return fmt.Errorf("failed to read existing color scheme setting with error: %w", err)
}
if m, found := res[PORTAL_APPEARANCE_NAMESPACE]; found {
if v, found := m[PORTAL_COLOR_SCHEME_KEY]; found {
v.Store(&val)
}
}
nval := val
switch which {
case "toggle":
switch val {
case LIGHT:
nval = DARK
case DARK:
nval = LIGHT
}
case "no-preference":
nval = NO_PREFERENCE
case "light":
nval = LIGHT
case "dark":
nval = DARK
default:
return fmt.Errorf("%s is not a valid value of the color-scheme", which)
}
if val == nval {
return
}
obj := conn.Object(PORTAL_BUS_NAME, dbus.ObjectPath(KITTY_OBJECT_PATH))
call := obj.Call(CHANGE_SETTINGS_INTERFACE+".ChangeSetting", dbus.FlagNoAutoStart, PORTAL_APPEARANCE_NAMESPACE, PORTAL_COLOR_SCHEME_KEY, dbus.MakeVariant(nval))
if err = call.Store(); err != nil {
return fmt.Errorf("failed to call ChangeSetting with error: %w", err)
}
return
}
func (self *Portal) ChangeSetting(namespace, key string, value dbus.Variant) *dbus.Error {
self.lock.Lock()
defer self.lock.Unlock()
if self.settings[namespace] == nil {
self.settings[namespace] = map[string]dbus.Variant{}
}
self.settings[namespace][key] = value
if e := self.bus.Emit(
DESKTOP_OBJECT_PATH,
SETTINGS_INTERFACE+".SettingChanged",
namespace,
key,
value,
); e != nil {
log.Println("Couldn't emit signal:", e)
}
return nil
}
func (self *Portal) RemoveSetting(namespace, key string) *dbus.Error {
self.lock.Lock()
defer self.lock.Unlock()
existed := false
if m := self.settings[namespace]; m != nil {
_, existed = m[key]
}
if !existed {
return nil
}
delete(self.settings[namespace], key)
return nil
}
func (self *Portal) Read(namespace, key string) (dbus.Variant, *dbus.Error) {
self.lock.Lock()
defer self.lock.Unlock()
if m, found := self.settings[namespace]; found {
if v, found := m[key]; found {
return v, nil
}
}
return dbus.Variant{}, dbus.NewError("org.freedesktop.portal.Error.NotFound", []any{fmt.Sprintf("the setting %s in the namespace %s is not supported", key, namespace)})
}
type ReadAllType map[string]map[string]dbus.Variant
func (self *Portal) ReadAll(namespaces []string) (ReadAllType, *dbus.Error) {
self.lock.Lock()
defer self.lock.Unlock()
var matched_namespaces = SettingsMap{}
if len(namespaces) == 0 {
matched_namespaces = self.settings
} else {
for _, namespace := range namespaces {
if namespace == "" {
matched_namespaces = self.settings
break
} else {
if strings.HasSuffix(namespace, ".*") {
namespace = namespace[:len(namespace)-1]
for candidate := range self.settings {
if strings.HasPrefix(candidate, namespace) {
matched_namespaces[candidate] = map[string]dbus.Variant{}
}
}
} else if _, found := self.settings[namespace]; found {
matched_namespaces[namespace] = map[string]dbus.Variant{}
}
}
}
}
values := map[string]map[string]dbus.Variant{}
for namespace := range matched_namespaces {
values[namespace] = make(map[string]dbus.Variant, len(self.settings[namespace]))
maps.Copy(values[namespace], self.settings[namespace])
}
return values, nil
}
func (self *Portal) reload_portal_settings() {
self.lock.Lock()
defer self.lock.Unlock()
if config, err := load_server_config(self.server_options); err == nil {
self.opts = config
}
}
type vmap map[string]dbus.Variant
type Filter_expression struct {
Ftype uint32
Val string
}
type Filter struct {
Name string
Expressions []Filter_expression
}
func (f Filter) Equal(o Filter) bool {
return f.Name == o.Name && slices.Equal(f.Expressions, o.Expressions)
}
type ChooseFilesData struct {
Title string
Mode string
Cwd string
SuggestedSaveFileName, SuggestedSaveFilePath string
Handle dbus.ObjectPath
Filters []Filter
}
func (c *ChooseFilesData) set_filters(options vmap) {
if v, found := options["filters"]; found {
v.Store(&c.Filters)
}
if v, found := options["current_filter"]; found {
var x Filter
if err := v.Store(&x); err == nil {
idx := slices.IndexFunc(c.Filters, func(q Filter) bool { return x.Equal(q) })
if idx > -1 {
c.Filters = slices.Delete(c.Filters, idx, idx+1)
}
c.Filters = slices.Insert(c.Filters, 0, x)
}
}
}
func get_matching_filter(name string, all_filters []Filter) (dbus.Variant, bool) {
for _, x := range all_filters {
if x.Name == name {
return dbus.MakeVariant(x), true
}
}
return dbus.Variant{}, false
}
func (options vmap) get_bool(name string, defval bool) (ans bool) {
if v, found := options[name]; found {
if v.Store(&ans) == nil {
return
}
}
return defval
}
func (self *Portal) Cleanup() {
self.lock.Lock()
defer self.lock.Unlock()
if self.file_chooser_first_instance != nil {
self.file_chooser_first_instance.Process.Signal(unix.SIGTERM)
ch := make(chan int)
go func() {
self.file_chooser_first_instance.Wait()
ch <- 0
}()
select {
case <-ch:
case <-time.After(time.Second):
self.file_chooser_first_instance.Process.Kill()
self.file_chooser_first_instance.Wait()
}
self.file_chooser_first_instance = nil
}
}
type ChooserResponse struct {
Paths []string `json:"paths"`
Error string `json:"error"`
Interrupted bool `json:"interrupted"`
Current_filter string `json:"current_filter"`
}
func (self *Portal) run_file_chooser(cfd ChooseFilesData) (response uint32, result_dict vmap) {
self.reload_portal_settings()
response = RESPONSE_ENDED
tdir, err := os.MkdirTemp("", "kitty-cfd")
if err != nil {
log.Println("cannot run file chooser as failed to create a temporary directory with error: ", err)
return
}
pid_path := filepath.Join(tdir, "pid")
var close_requested, child_killed atomic.Bool
Close := func() *dbus.Error {
close_requested.Store(true)
if !child_killed.Load() {
if raw, err := os.ReadFile(pid_path); err == nil {
if pid, err := strconv.Atoi(string(raw)); err == nil {
child_killed.Store(true)
unix.Kill(pid, unix.SIGTERM)
}
}
}
return nil
}
self.bus.ExportMethodTable(map[string]any{"Close": Close}, cfd.Handle, REQUEST_INTERFACE)
defer func() {
self.bus.ExportMethodTable(nil, cfd.Handle, REQUEST_INTERFACE)
_ = os.RemoveAll(tdir)
}()
output_path := filepath.Join(tdir, "output.json")
cmd := func() *exec.Cmd {
self.lock.Lock()
defer self.lock.Unlock()
edge, lines, columns := `center`, ``, ``
if self.opts.File_chooser_size != "" {
l, c, _ := strings.Cut(strings.TrimSpace(self.opts.File_chooser_size), " ")
l, c = strings.TrimSpace(l), strings.TrimSpace(c)
if li, err := strconv.Atoi(l); err == nil {
if ci, err := strconv.Atoi(c); err == nil {
if li < 10 || ci < 40 {
log.Printf("file chooser size %s too small, ignoring", self.opts.File_chooser_size)
} else {
edge, lines, columns = `center-sized`, l, c
}
} else {
log.Printf("file chooser size %s invalid with error: %s\n", self.opts.File_chooser_size, err)
}
} else {
log.Printf("file chooser size %s invalid with error: %s\n", self.opts.File_chooser_size, err)
}
}
args := []string{
"+kitten", "panel", "--layer=overlay", "--edge=" + edge, "--focus-policy=exclusive",
"-o", "background_opacity=0.85", "--wait-for-single-instance-window-close",
"--grab-keyboard", "--single-instance", "--instance-group", "cfp-" + strconv.Itoa(os.Getpid()),
}
if edge == "center-sized" {
args = append(args, "--lines="+lines, "--columns="+columns)
}
for _, x := range self.opts.File_chooser_kitty_conf {
args = append(args, `-c`, x)
}
for _, x := range self.opts.File_chooser_kitty_override {
args = append(args, `-o`, x)
}
if self.file_chooser_first_instance == nil {
fifo_path := filepath.Join(tdir, "fifo")
if err := unix.Mkfifo(fifo_path, 0600); err != nil {
log.Println("cannot run file chooser as failed to create a fifo directory with error: ", err)
return nil
}
fa := slices.Clone(args)
fa = append(fa, "--start-as-hidden", "sh", "-c", "echo a > '"+fifo_path+"'; read")
cmd := exec.Command(utils.KittyExe(), fa...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Start()
ch := make(chan int)
go func() {
f, err := os.OpenFile(fifo_path, os.O_RDONLY, os.ModeNamedPipe)
if err != nil {
log.Println("cannot run file chooser as failed to open fifo for read with error: ", err)
}
b := []byte{'a', 'b', 'c', 'd'}
f.Read(b)
ch <- 0
}()
select {
case <-ch:
self.file_chooser_first_instance = cmd
case <-time.After(5 * time.Second):
log.Println("cannot run file chooser as panel script timed out writing to fifo")
return nil
}
}
args = append(args, "kitten", `choose-files`, `--mode`, cfd.Mode, `--write-output-to`, output_path, `--output-format=json`, `--display-title`)
if cfd.SuggestedSaveFileName != "" {
args = append(args, `--suggested-save-file-name`, cfd.SuggestedSaveFileName)
}
if cfd.SuggestedSaveFilePath != "" {
args = append(args, `--suggested-save-file-path`, cfd.SuggestedSaveFilePath)
}
if cfd.Title != "" {
args = append(args, "--title", cfd.Title)
}
for _, fs := range cfd.Filters {
for _, exp := range fs.Expressions {
args = append(args, "--file-filter", fmt.Sprintf("%s:%s:%s", utils.IfElse(exp.Ftype == 0, "glob", "mime"), exp.Val, fs.Name))
}
}
args = append(args, "--write-pid-to", pid_path)
args = append(args, utils.IfElse(cfd.Cwd == "", "~", cfd.Cwd))
cmd := exec.Command(utils.KittyExe(), args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd
}()
if cmd == nil || close_requested.Load() {
return
}
log.Println("running file chooser with args:", cmd.Path, utils.Repr(cmd.Args))
if err := cmd.Run(); err != nil {
log.Println("running file chooser failed with error: ", err)
return
}
if close_requested.Load() {
return
}
raw, err := os.ReadFile(output_path)
if err != nil {
log.Println("running file chooser failed, could not read from output file with error: ", err)
return
}
if close_requested.Load() {
return
}
var result ChooserResponse
if err = json.Unmarshal(raw, &result); err != nil {
log.Println("running file chooser failed, invalid JSON response with error: ", err)
return
}
if result.Error != "" {
log.Println("running file chooser failed, with error: ", result.Error)
return
}
if result.Interrupted {
response = RESPONSE_CANCELED
log.Println("running file chooser failed, interrupted by user.")
return
}
response = RESPONSE_SUCCESS
prefix := "file://" + utils.IfElse(runtime.GOOS == "windows", "/", "")
uris := utils.Map(func(path string) string {
path = filepath.ToSlash(path)
u := url.URL{Path: path}
return prefix + u.EscapedPath()
}, result.Paths)
result_dict = vmap{"uris": dbus.MakeVariant(uris)}
if result.Current_filter != "" {
if v, found := get_matching_filter(result.Current_filter, cfd.Filters); found {
result_dict["current_filter"] = v
}
}
return
}
func (options vmap) get_bytearray(name string) string {
if v, found := options[name]; found {
var b []byte
if v.Store(&b) == nil {
// the FileChooser spec requires paths and filenames to be null
// terminated, so remove trailing nulls.
return string(bytes.TrimRight(b, "\x00"))
}
}
return ""
}
func (self *Portal) OpenFile(handle dbus.ObjectPath, app_id string, parent_window string, title string, options vmap) (uint32, vmap, *dbus.Error) {
cfd := ChooseFilesData{Title: title, Cwd: options.get_bytearray("current_folder"), Handle: handle}
cfd.set_filters(options)
dir_only := options.get_bool("directory", false)
multiple := options.get_bool("multiple", false)
if dir_only {
cfd.Mode = utils.IfElse(multiple, "dirs", "dir")
} else {
cfd.Mode = utils.IfElse(multiple, "files", "file")
}
response, result := self.run_file_chooser(cfd)
return response, result, nil
}
func (self *Portal) SaveFile(handle dbus.ObjectPath, app_id string, parent_window string, title string, options vmap) (uint32, vmap, *dbus.Error) {
cfd := ChooseFilesData{
Title: title, Cwd: options.get_bytearray("current_folder"), Handle: handle,
SuggestedSaveFileName: options.get_bytearray("current_name"),
SuggestedSaveFilePath: options.get_bytearray("current_file")}
multiple := options.get_bool("multiple", false)
cfd.set_filters(options)
cfd.Mode = utils.IfElse(multiple, "save-files", "save-file")
response, result := self.run_file_chooser(cfd)
return response, result, nil
}