mirror of
https://github.com/kovidgoyal/kitty.git
synced 2026-05-13 08:26:56 +00:00
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.
939 lines
29 KiB
Go
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
|
|
}
|