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 }