diff --git a/core/cmd/dms/commands_common.go b/core/cmd/dms/commands_common.go index 0c50df5e..e1e8ce9d 100644 --- a/core/cmd/dms/commands_common.go +++ b/core/cmd/dms/commands_common.go @@ -511,6 +511,8 @@ func getCommonCommands() []*cobra.Command { colorCmd, screenshotCmd, notifyActionCmd, + notifyCmd, + genericNotifyActionCmd, matugenCmd, clipboardCmd, doctorCmd, diff --git a/core/cmd/dms/commands_notify.go b/core/cmd/dms/commands_notify.go new file mode 100644 index 00000000..e04b60f1 --- /dev/null +++ b/core/cmd/dms/commands_notify.go @@ -0,0 +1,68 @@ +package main + +import ( + "fmt" + "os" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/notify" + "github.com/spf13/cobra" +) + +var ( + notifyAppName string + notifyIcon string + notifyFile string + notifyTimeout int +) + +var notifyCmd = &cobra.Command{ + Use: "notify [body]", + Short: "Send a desktop notification", + Long: `Send a desktop notification with optional actions. + +If --file is provided, the notification will have "Open" and "Open Folder" actions. + +Examples: + dms notify "Hello" "World" + dms notify "File received" "photo.jpg" --file ~/Downloads/photo.jpg --icon smartphone + dms notify "Download complete" --file ~/Downloads/file.zip --app "My App"`, + Args: cobra.MinimumNArgs(1), + Run: runNotify, +} + +var genericNotifyActionCmd = &cobra.Command{ + Use: "notify-action-generic", + Hidden: true, + Run: func(cmd *cobra.Command, args []string) { + notify.RunActionListener(args) + }, +} + +func init() { + notifyCmd.Flags().StringVar(¬ifyAppName, "app", "DMS", "Application name") + notifyCmd.Flags().StringVar(¬ifyIcon, "icon", "", "Icon name or path") + notifyCmd.Flags().StringVar(¬ifyFile, "file", "", "File path (enables Open/Open Folder actions)") + notifyCmd.Flags().IntVar(¬ifyTimeout, "timeout", 5000, "Timeout in milliseconds") +} + +func runNotify(cmd *cobra.Command, args []string) { + summary := args[0] + body := "" + if len(args) > 1 { + body = args[1] + } + + n := notify.Notification{ + AppName: notifyAppName, + Icon: notifyIcon, + Summary: summary, + Body: body, + FilePath: notifyFile, + Timeout: int32(notifyTimeout), + } + + if err := notify.Send(n); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} diff --git a/core/go.mod b/core/go.mod index 33c7962a..f9fd8477 100644 --- a/core/go.mod +++ b/core/go.mod @@ -16,21 +16,21 @@ require ( github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 go.etcd.io/bbolt v1.4.3 - golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 - golang.org/x/image v0.34.0 + golang.org/x/exp v0.0.0-20260112195511-716be5621a96 + golang.org/x/image v0.35.0 ) require ( github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.3.0 // indirect - github.com/clipperhouse/displaywidth v0.6.2 // indirect + github.com/clipperhouse/displaywidth v0.7.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.3.0 // indirect github.com/cloudflare/circl v1.6.2 // indirect github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/go-git/gcfg/v2 v2.0.2 // indirect - github.com/go-git/go-billy/v6 v6.0.0-20251217170237-e9738f50a3cd // indirect + github.com/go-git/go-billy/v6 v6.0.0-20260114122816-19306b749ecc // indirect github.com/go-logfmt/logfmt v0.6.1 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/kevinburke/ssh_config v1.4.0 // indirect @@ -38,8 +38,8 @@ require ( github.com/pjbgf/sha1cd v0.5.0 // indirect github.com/sergi/go-diff v1.4.0 // indirect github.com/stretchr/objx v0.5.3 // indirect - golang.org/x/crypto v0.46.0 // indirect - golang.org/x/net v0.48.0 // indirect + golang.org/x/crypto v0.47.0 // indirect + golang.org/x/net v0.49.0 // indirect ) require ( @@ -47,12 +47,12 @@ require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect - github.com/charmbracelet/x/ansi v0.11.3 // indirect + github.com/charmbracelet/x/ansi v0.11.4 // indirect github.com/charmbracelet/x/cellbuf v0.0.14 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/go-git/go-git/v6 v6.0.0-20251231065035-29ae690a9f19 + github.com/go-git/go-git/v6 v6.0.0-20260114124804-a8db3a6585a6 github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 github.com/mattn/go-isatty v0.0.20 // indirect @@ -66,7 +66,7 @@ require ( github.com/spf13/afero v1.15.0 github.com/spf13/pflag v1.0.10 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sys v0.39.0 - golang.org/x/text v0.32.0 + golang.org/x/sys v0.40.0 + golang.org/x/text v0.33.0 gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/core/go.sum b/core/go.sum index 85f3b336..3a3a5610 100644 --- a/core/go.sum +++ b/core/go.sum @@ -16,8 +16,6 @@ github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI= -github.com/charmbracelet/colorprofile v0.3.3/go.mod h1:nB1FugsAbzq284eJcjfah2nhdSLppN2NqvfotkfRYP4= github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= @@ -26,24 +24,22 @@ github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoF github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= -github.com/charmbracelet/x/ansi v0.11.2 h1:XAG3FSjiVtFvgEgGrNBkCNNYrsucAt8c6bfxHyROLLs= -github.com/charmbracelet/x/ansi v0.11.2/go.mod h1:9tY2bzX5SiJCU0iWyskjBeI2BRQfvPqI+J760Mjf+Rg= github.com/charmbracelet/x/ansi v0.11.3 h1:6DcVaqWI82BBVM/atTyq6yBoRLZFBsnoDoX9GCu2YOI= github.com/charmbracelet/x/ansi v0.11.3/go.mod h1:yI7Zslym9tCJcedxz5+WBq+eUGMJT0bM06Fqy1/Y4dI= +github.com/charmbracelet/x/ansi v0.11.4 h1:6G65PLu6HjmE858CnTUQY1LXT3ZUWwfvqEROLF8vqHI= +github.com/charmbracelet/x/ansi v0.11.4/go.mod h1:/5AZ+UfWExW3int5H5ugnsG/PWjNcSQcwYsHBlPFQN4= github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4= github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= -github.com/clipperhouse/displaywidth v0.6.0 h1:k32vueaksef9WIKCNcoqRNyKbyvkvkysNYnAWz2fN4s= -github.com/clipperhouse/displaywidth v0.6.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= github.com/clipperhouse/displaywidth v0.6.2 h1:ZDpTkFfpHOKte4RG5O/BOyf3ysnvFswpyYrV7z2uAKo= github.com/clipperhouse/displaywidth v0.6.2/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= +github.com/clipperhouse/displaywidth v0.7.0 h1:QNv1GYsnLX9QBrcWUtMlogpTXuM5FVnBwKWp1O5NwmE= +github.com/clipperhouse/displaywidth v0.7.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= -github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= -github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ= github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -52,8 +48,6 @@ github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= -github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= @@ -64,22 +58,19 @@ github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo= github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs= -github.com/go-git/go-billy/v6 v6.0.0-20251126203821-7f9c95185ee0 h1:eY5aB2GXiVdgTueBcqsBt53WuJTRZAuCdIS/86Pcq5c= -github.com/go-git/go-billy/v6 v6.0.0-20251126203821-7f9c95185ee0/go.mod h1:0NjwVNrwtVFZBReAp5OoGklGJIgJFEbVyHneAr4lc8k= github.com/go-git/go-billy/v6 v6.0.0-20251217170237-e9738f50a3cd h1:Gd/f9cGi/3h1JOPaa6er+CkKUGyGX2DBJdFbDKVO+R0= github.com/go-git/go-billy/v6 v6.0.0-20251217170237-e9738f50a3cd/go.mod h1:d3XQcsHu1idnquxt48kAv+h+1MUiYKLH/e7LAzjP+pI= -github.com/go-git/go-git-fixtures/v5 v5.1.1 h1:OH8i1ojV9bWfr0ZfasfpgtUXQHQyVS8HXik/V1C099w= -github.com/go-git/go-git-fixtures/v5 v5.1.1/go.mod h1:Altk43lx3b1ks+dVoAG2300o5WWUnktvfY3VI6bcaXU= +github.com/go-git/go-billy/v6 v6.0.0-20260114122816-19306b749ecc h1:rhkjrnRkamkRC7woapp425E4CAH6RPcqsS9X8LA93IY= +github.com/go-git/go-billy/v6 v6.0.0-20260114122816-19306b749ecc/go.mod h1:X1oe0Z2qMsa9hkar3AAPuL9hu4Mi3ztXEjdqRhr6fcc= github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20251229094738-4b14af179146 h1:xYfxAopYyL44ot6dMBIb1Z1njFM0ZBQ99HdIB99KxLs= -github.com/go-git/go-git/v6 v6.0.0-20251128074608-48f817f57805 h1:jxQ3BzYeErNRvlI/4+0mpwqMzvB4g97U+ksfgvrUEbY= -github.com/go-git/go-git/v6 v6.0.0-20251128074608-48f817f57805/go.mod h1:dIwT3uWK1ooHInyVnK2JS5VfQ3peVGYaw2QPqX7uFvs= +github.com/go-git/go-git-fixtures/v5 v5.1.2-0.20251229094738-4b14af179146/go.mod h1:QE/75B8tBSLNGyUUbA9tw3EGHoFtYOtypa2h8YJxsWI= github.com/go-git/go-git/v6 v6.0.0-20251231065035-29ae690a9f19 h1:0lz2eJScP8v5YZQsrEw+ggWC5jNySjg4bIZo5BIh6iI= github.com/go-git/go-git/v6 v6.0.0-20251231065035-29ae690a9f19/go.mod h1:L+Evfcs7EdTqxwv854354cb6+++7TFL3hJn3Wy4g+3w= +github.com/go-git/go-git/v6 v6.0.0-20260114124804-a8db3a6585a6 h1:Yo1MlE8LpvD0pr7mZ04b6hKZKQcPvLrQFgyY1jNMEyU= +github.com/go-git/go-git/v6 v6.0.0-20260114124804-a8db3a6585a6/go.mod h1:enMzPHv+9hL4B7tH7OJGQKNzCkMzXovUoaiXfsLF7Xs= github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE= github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/godbus/dbus/v5 v5.2.0 h1:3WexO+U+yg9T70v9FdHr9kCxYlazaAXUhx2VMkbfax8= -github.com/godbus/dbus/v5 v5.2.0/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= @@ -127,16 +118,12 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sblinch/kdl-go v0.0.0-20250930225324-bf4099d4614a h1:8ZZwZWIQKC0YVMyaCkbrdeI8faTjD1QBrRAAWc1TjMI= -github.com/sblinch/kdl-go v0.0.0-20250930225324-bf4099d4614a/go.mod h1:b3oNGuAKOQzhsCKmuLc/urEOPzgHj6fB8vl8bwTBh28= github.com/sblinch/kdl-go v0.0.0-20251203232544-981d4ecc17c3 h1:msKaIZrrNpvofLPDzNBW3152PJBsnPZsoNNosOCS+C0= github.com/sblinch/kdl-go v0.0.0-20251203232544-981d4ecc17c3/go.mod h1:b3oNGuAKOQzhsCKmuLc/urEOPzgHj6fB8vl8bwTBh28= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= -github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= -github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= @@ -153,33 +140,37 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJu go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= -golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 h1:DHNhtq3sNNzrvduZZIiFyXWOL9IWaDPHqTnLJp+rCBY= -golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0= golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= +golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= +golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8= golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I= +golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= -golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/core/internal/notify/notify.go b/core/internal/notify/notify.go new file mode 100644 index 00000000..0867d823 --- /dev/null +++ b/core/internal/notify/notify.go @@ -0,0 +1,170 @@ +package notify + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "syscall" + + "github.com/godbus/dbus/v5" +) + +const ( + notifyDest = "org.freedesktop.Notifications" + notifyPath = "/org/freedesktop/Notifications" + notifyInterface = "org.freedesktop.Notifications" +) + +type Notification struct { + AppName string + Icon string + Summary string + Body string + FilePath string + Timeout int32 +} + +func Send(n Notification) error { + conn, err := dbus.SessionBus() + if err != nil { + return fmt.Errorf("dbus session failed: %w", err) + } + + if n.AppName == "" { + n.AppName = "DMS" + } + if n.Timeout == 0 { + n.Timeout = 5000 + } + + var actions []string + if n.FilePath != "" { + actions = []string{ + "open", "Open", + "folder", "Open Folder", + } + } + + hints := map[string]dbus.Variant{} + if n.FilePath != "" { + hints["image_path"] = dbus.MakeVariant(n.FilePath) + } + + obj := conn.Object(notifyDest, notifyPath) + call := obj.Call( + notifyInterface+".Notify", + 0, + n.AppName, + uint32(0), + n.Icon, + n.Summary, + n.Body, + actions, + hints, + n.Timeout, + ) + + if call.Err != nil { + return fmt.Errorf("notify call failed: %w", call.Err) + } + + var notificationID uint32 + if err := call.Store(¬ificationID); err != nil { + return fmt.Errorf("failed to get notification id: %w", err) + } + + if len(actions) > 0 && n.FilePath != "" { + spawnActionListener(notificationID, n.FilePath) + } + + return nil +} + +func spawnActionListener(notificationID uint32, filePath string) { + exe, err := os.Executable() + if err != nil { + return + } + + cmd := exec.Command(exe, "notify-action-generic", fmt.Sprintf("%d", notificationID), filePath) + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setsid: true, + } + cmd.Start() +} + +func RunActionListener(args []string) { + if len(args) < 2 { + return + } + + notificationID, err := strconv.ParseUint(args[0], 10, 32) + if err != nil { + return + } + + filePath := args[1] + + conn, err := dbus.SessionBus() + if err != nil { + return + } + + if err := conn.AddMatchSignal( + dbus.WithMatchObjectPath(notifyPath), + dbus.WithMatchInterface(notifyInterface), + ); err != nil { + return + } + + signals := make(chan *dbus.Signal, 10) + conn.Signal(signals) + + for sig := range signals { + switch sig.Name { + case notifyInterface + ".ActionInvoked": + if len(sig.Body) < 2 { + continue + } + id, ok := sig.Body[0].(uint32) + if !ok || id != uint32(notificationID) { + continue + } + action, ok := sig.Body[1].(string) + if !ok { + continue + } + handleAction(action, filePath) + return + + case notifyInterface + ".NotificationClosed": + if len(sig.Body) < 1 { + continue + } + id, ok := sig.Body[0].(uint32) + if !ok || id != uint32(notificationID) { + continue + } + return + } + } +} + +func handleAction(action, filePath string) { + switch action { + case "open", "default": + openPath(filePath) + case "folder": + openPath(filepath.Dir(filePath)) + } +} + +func openPath(path string) { + cmd := exec.Command("xdg-open", path) + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setsid: true, + } + cmd.Start() +} diff --git a/core/internal/server/bluez/manager.go b/core/internal/server/bluez/manager.go index 6f0f44da..3d78d135 100644 --- a/core/internal/server/bluez/manager.go +++ b/core/internal/server/bluez/manager.go @@ -7,6 +7,7 @@ import ( "time" "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/pkg/dbusutil" "github.com/godbus/dbus/v5" ) @@ -110,17 +111,15 @@ func (m *Manager) updateAdapterState() error { if err != nil { return err } - powered, _ := poweredVar.Value().(bool) discoveringVar, err := obj.GetProperty(adapter1Iface + ".Discovering") if err != nil { return err } - discovering, _ := discoveringVar.Value().(bool) m.stateMutex.Lock() - m.state.Powered = powered - m.state.Discovering = discovering + m.state.Powered = dbusutil.AsOr(poweredVar, false) + m.state.Discovering = dbusutil.AsOr(discoveringVar, false) m.stateMutex.Unlock() return nil @@ -169,65 +168,20 @@ func (m *Manager) updateDevices() error { } func (m *Manager) deviceFromProps(path string, props map[string]dbus.Variant) Device { - dev := Device{Path: path} - - if v, ok := props["Address"]; ok { - if addr, ok := v.Value().(string); ok { - dev.Address = addr - } + return Device{ + Path: path, + Address: dbusutil.GetOr(props, "Address", ""), + Name: dbusutil.GetOr(props, "Name", ""), + Alias: dbusutil.GetOr(props, "Alias", ""), + Paired: dbusutil.GetOr(props, "Paired", false), + Trusted: dbusutil.GetOr(props, "Trusted", false), + Blocked: dbusutil.GetOr(props, "Blocked", false), + Connected: dbusutil.GetOr(props, "Connected", false), + Class: dbusutil.GetOr(props, "Class", uint32(0)), + Icon: dbusutil.GetOr(props, "Icon", ""), + RSSI: dbusutil.GetOr(props, "RSSI", int16(0)), + LegacyPairing: dbusutil.GetOr(props, "LegacyPairing", false), } - if v, ok := props["Name"]; ok { - if name, ok := v.Value().(string); ok { - dev.Name = name - } - } - if v, ok := props["Alias"]; ok { - if alias, ok := v.Value().(string); ok { - dev.Alias = alias - } - } - if v, ok := props["Paired"]; ok { - if paired, ok := v.Value().(bool); ok { - dev.Paired = paired - } - } - if v, ok := props["Trusted"]; ok { - if trusted, ok := v.Value().(bool); ok { - dev.Trusted = trusted - } - } - if v, ok := props["Blocked"]; ok { - if blocked, ok := v.Value().(bool); ok { - dev.Blocked = blocked - } - } - if v, ok := props["Connected"]; ok { - if connected, ok := v.Value().(bool); ok { - dev.Connected = connected - } - } - if v, ok := props["Class"]; ok { - if class, ok := v.Value().(uint32); ok { - dev.Class = class - } - } - if v, ok := props["Icon"]; ok { - if icon, ok := v.Value().(string); ok { - dev.Icon = icon - } - } - if v, ok := props["RSSI"]; ok { - if rssi, ok := v.Value().(int16); ok { - dev.RSSI = rssi - } - } - if v, ok := props["LegacyPairing"]; ok { - if legacy, ok := v.Value().(bool); ok { - dev.LegacyPairing = legacy - } - } - - return dev } func (m *Manager) startAgent() error { @@ -328,17 +282,13 @@ func (m *Manager) handleAdapterPropertiesChanged(changed map[string]dbus.Variant m.stateMutex.Lock() dirty := false - if v, ok := changed["Powered"]; ok { - if powered, ok := v.Value().(bool); ok { - m.state.Powered = powered - dirty = true - } + if powered, ok := dbusutil.Get[bool](changed, "Powered"); ok { + m.state.Powered = powered + dirty = true } - if v, ok := changed["Discovering"]; ok { - if discovering, ok := v.Value().(bool); ok { - m.state.Discovering = discovering - dirty = true - } + if discovering, ok := dbusutil.Get[bool](changed, "Discovering"); ok { + m.state.Discovering = discovering + dirty = true } m.stateMutex.Unlock() @@ -349,31 +299,28 @@ func (m *Manager) handleAdapterPropertiesChanged(changed map[string]dbus.Variant } func (m *Manager) handleDevicePropertiesChanged(path dbus.ObjectPath, changed map[string]dbus.Variant) { - pairedVar, hasPaired := changed["Paired"] + paired, hasPaired := dbusutil.Get[bool](changed, "Paired") _, hasConnected := changed["Connected"] _, hasTrusted := changed["Trusted"] if hasPaired { devicePath := string(path) - if paired, ok := pairedVar.Value().(bool); ok { - if paired { - _, wasPending := m.pendingPairings.LoadAndDelete(devicePath) - - if wasPending { - select { - case m.eventQueue <- func() { - time.Sleep(300 * time.Millisecond) - log.Infof("[Bluetooth] Auto-connecting newly paired device: %s", devicePath) - if err := m.ConnectDevice(devicePath); err != nil { - log.Warnf("[Bluetooth] Auto-connect failed: %v", err) - } - }: - default: + if paired { + _, wasPending := m.pendingPairings.LoadAndDelete(devicePath) + if wasPending { + select { + case m.eventQueue <- func() { + time.Sleep(300 * time.Millisecond) + log.Infof("[Bluetooth] Auto-connecting newly paired device: %s", devicePath) + if err := m.ConnectDevice(devicePath); err != nil { + log.Warnf("[Bluetooth] Auto-connect failed: %v", err) } + }: + default: } - } else { - m.pendingPairings.Delete(devicePath) } + } else { + m.pendingPairings.Delete(devicePath) } } diff --git a/core/internal/server/dbus/handlers.go b/core/internal/server/dbus/handlers.go new file mode 100644 index 00000000..e625d796 --- /dev/null +++ b/core/internal/server/dbus/handlers.go @@ -0,0 +1,237 @@ +package dbus + +import ( + "fmt" + "net" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/params" +) + +type objectParams struct { + bus string + dest string + path string + iface string +} + +func extractObjectParams(p map[string]any, requirePath bool) (objectParams, error) { + bus, err := params.String(p, "bus") + if err != nil { + return objectParams{}, err + } + dest, err := params.String(p, "dest") + if err != nil { + return objectParams{}, err + } + + var path string + if requirePath { + path, err = params.String(p, "path") + if err != nil { + return objectParams{}, err + } + } else { + path = params.StringOpt(p, "path", "/") + } + + iface, err := params.String(p, "interface") + if err != nil { + return objectParams{}, err + } + + return objectParams{bus: bus, dest: dest, path: path, iface: iface}, nil +} + +func HandleRequest(conn net.Conn, req models.Request, m *Manager, clientID string) { + switch req.Method { + case "dbus.call": + handleCall(conn, req, m) + case "dbus.getProperty": + handleGetProperty(conn, req, m) + case "dbus.setProperty": + handleSetProperty(conn, req, m) + case "dbus.getAllProperties": + handleGetAllProperties(conn, req, m) + case "dbus.introspect": + handleIntrospect(conn, req, m) + case "dbus.listNames": + handleListNames(conn, req, m) + case "dbus.subscribe": + handleSubscribe(conn, req, m, clientID) + case "dbus.unsubscribe": + handleUnsubscribe(conn, req, m) + default: + models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method)) + } +} + +func handleCall(conn net.Conn, req models.Request, m *Manager) { + op, err := extractObjectParams(req.Params, true) + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + method, err := params.String(req.Params, "method") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + var args []any + if argsRaw, ok := params.Any(req.Params, "args"); ok { + if argsSlice, ok := argsRaw.([]any); ok { + args = argsSlice + } + } + + result, err := m.Call(op.bus, op.dest, op.path, op.iface, method, args) + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, result) +} + +func handleGetProperty(conn net.Conn, req models.Request, m *Manager) { + op, err := extractObjectParams(req.Params, true) + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + property, err := params.String(req.Params, "property") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + result, err := m.GetProperty(op.bus, op.dest, op.path, op.iface, property) + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, result) +} + +func handleSetProperty(conn net.Conn, req models.Request, m *Manager) { + op, err := extractObjectParams(req.Params, true) + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + property, err := params.String(req.Params, "property") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + value, ok := params.Any(req.Params, "value") + if !ok { + models.RespondError(conn, req.ID, "missing 'value' parameter") + return + } + + if err := m.SetProperty(op.bus, op.dest, op.path, op.iface, property, value); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true}) +} + +func handleGetAllProperties(conn net.Conn, req models.Request, m *Manager) { + op, err := extractObjectParams(req.Params, true) + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + result, err := m.GetAllProperties(op.bus, op.dest, op.path, op.iface) + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, result) +} + +func handleIntrospect(conn net.Conn, req models.Request, m *Manager) { + bus, err := params.String(req.Params, "bus") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + dest, err := params.String(req.Params, "dest") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + path := params.StringOpt(req.Params, "path", "/") + + result, err := m.Introspect(bus, dest, path) + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, result) +} + +func handleListNames(conn net.Conn, req models.Request, m *Manager) { + bus, err := params.String(req.Params, "bus") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + result, err := m.ListNames(bus) + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, result) +} + +func handleSubscribe(conn net.Conn, req models.Request, m *Manager, clientID string) { + bus, err := params.String(req.Params, "bus") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + sender := params.StringOpt(req.Params, "sender", "") + path := params.StringOpt(req.Params, "path", "") + iface := params.StringOpt(req.Params, "interface", "") + member := params.StringOpt(req.Params, "member", "") + + result, err := m.Subscribe(clientID, bus, sender, path, iface, member) + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, result) +} + +func handleUnsubscribe(conn net.Conn, req models.Request, m *Manager) { + subID, err := params.String(req.Params, "subscriptionId") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if err := m.Unsubscribe(subID); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true}) +} diff --git a/core/internal/server/dbus/manager.go b/core/internal/server/dbus/manager.go new file mode 100644 index 00000000..c6a6ef5c --- /dev/null +++ b/core/internal/server/dbus/manager.go @@ -0,0 +1,362 @@ +package dbus + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/pkg/dbusutil" + "github.com/godbus/dbus/v5" +) + +func NewManager() (*Manager, error) { + systemConn, err := dbus.ConnectSystemBus() + if err != nil { + return nil, fmt.Errorf("failed to connect to system bus: %w", err) + } + + sessionConn, err := dbus.ConnectSessionBus() + if err != nil { + systemConn.Close() + return nil, fmt.Errorf("failed to connect to session bus: %w", err) + } + + m := &Manager{ + systemConn: systemConn, + sessionConn: sessionConn, + } + + go m.processSystemSignals() + go m.processSessionSignals() + + return m, nil +} + +func (m *Manager) getConn(bus string) (*dbus.Conn, error) { + switch bus { + case "system": + if m.systemConn == nil { + return nil, fmt.Errorf("system bus not connected") + } + return m.systemConn, nil + case "session": + if m.sessionConn == nil { + return nil, fmt.Errorf("session bus not connected") + } + return m.sessionConn, nil + default: + return nil, fmt.Errorf("invalid bus: %s (must be 'system' or 'session')", bus) + } +} + +func (m *Manager) Call(bus, dest, path, iface, method string, args []any) (*CallResult, error) { + conn, err := m.getConn(bus) + if err != nil { + return nil, err + } + + obj := conn.Object(dest, dbus.ObjectPath(path)) + fullMethod := iface + "." + method + + call := obj.Call(fullMethod, 0, args...) + if call.Err != nil { + return nil, fmt.Errorf("dbus call failed: %w", call.Err) + } + + return &CallResult{Values: call.Body}, nil +} + +func (m *Manager) GetProperty(bus, dest, path, iface, property string) (*PropertyResult, error) { + conn, err := m.getConn(bus) + if err != nil { + return nil, err + } + + obj := conn.Object(dest, dbus.ObjectPath(path)) + + var variant dbus.Variant + err = obj.Call("org.freedesktop.DBus.Properties.Get", 0, iface, property).Store(&variant) + if err != nil { + return nil, fmt.Errorf("failed to get property: %w", err) + } + + return &PropertyResult{Value: dbusutil.Normalize(variant.Value())}, nil +} + +func (m *Manager) SetProperty(bus, dest, path, iface, property string, value any) error { + conn, err := m.getConn(bus) + if err != nil { + return err + } + + obj := conn.Object(dest, dbus.ObjectPath(path)) + + call := obj.Call("org.freedesktop.DBus.Properties.Set", 0, iface, property, dbus.MakeVariant(value)) + if call.Err != nil { + return fmt.Errorf("failed to set property: %w", call.Err) + } + + return nil +} + +func (m *Manager) GetAllProperties(bus, dest, path, iface string) (map[string]any, error) { + conn, err := m.getConn(bus) + if err != nil { + return nil, err + } + + obj := conn.Object(dest, dbus.ObjectPath(path)) + + var props map[string]dbus.Variant + err = obj.Call("org.freedesktop.DBus.Properties.GetAll", 0, iface).Store(&props) + if err != nil { + return nil, fmt.Errorf("failed to get properties: %w", err) + } + + result := make(map[string]any) + for k, v := range props { + result[k] = dbusutil.Normalize(v.Value()) + } + + return result, nil +} + +func (m *Manager) Introspect(bus, dest, path string) (*IntrospectResult, error) { + conn, err := m.getConn(bus) + if err != nil { + return nil, err + } + + obj := conn.Object(dest, dbus.ObjectPath(path)) + + var xml string + err = obj.Call("org.freedesktop.DBus.Introspectable.Introspect", 0).Store(&xml) + if err != nil { + return nil, fmt.Errorf("failed to introspect: %w", err) + } + + return &IntrospectResult{XML: xml}, nil +} + +func (m *Manager) ListNames(bus string) (*ListNamesResult, error) { + conn, err := m.getConn(bus) + if err != nil { + return nil, err + } + + var names []string + err = conn.BusObject().Call("org.freedesktop.DBus.ListNames", 0).Store(&names) + if err != nil { + return nil, fmt.Errorf("failed to list names: %w", err) + } + + return &ListNamesResult{Names: names}, nil +} + +func (m *Manager) Subscribe(clientID, bus, sender, path, iface, member string) (*SubscribeResult, error) { + conn, err := m.getConn(bus) + if err != nil { + return nil, err + } + + subID := generateSubscriptionID() + + parts := []string{"type='signal'"} + if sender != "" { + parts = append(parts, fmt.Sprintf("sender='%s'", sender)) + } + if path != "" { + parts = append(parts, fmt.Sprintf("path='%s'", path)) + } + if iface != "" { + parts = append(parts, fmt.Sprintf("interface='%s'", iface)) + } + if member != "" { + parts = append(parts, fmt.Sprintf("member='%s'", member)) + } + matchRule := strings.Join(parts, ",") + + call := conn.BusObject().Call("org.freedesktop.DBus.AddMatch", 0, matchRule) + if call.Err != nil { + return nil, fmt.Errorf("failed to add match rule: %w", call.Err) + } + + sub := &signalSubscription{ + Bus: bus, + Sender: sender, + Path: path, + Interface: iface, + Member: member, + ClientID: clientID, + } + m.subscriptions.Store(subID, sub) + + log.Debugf("dbus: subscribed %s to %s", subID, matchRule) + + return &SubscribeResult{SubscriptionID: subID}, nil +} + +func (m *Manager) Unsubscribe(subID string) error { + sub, ok := m.subscriptions.LoadAndDelete(subID) + if !ok { + return fmt.Errorf("subscription not found: %s", subID) + } + + conn, err := m.getConn(sub.Bus) + if err != nil { + return err + } + + parts := []string{"type='signal'"} + if sub.Sender != "" { + parts = append(parts, fmt.Sprintf("sender='%s'", sub.Sender)) + } + if sub.Path != "" { + parts = append(parts, fmt.Sprintf("path='%s'", sub.Path)) + } + if sub.Interface != "" { + parts = append(parts, fmt.Sprintf("interface='%s'", sub.Interface)) + } + if sub.Member != "" { + parts = append(parts, fmt.Sprintf("member='%s'", sub.Member)) + } + matchRule := strings.Join(parts, ",") + + call := conn.BusObject().Call("org.freedesktop.DBus.RemoveMatch", 0, matchRule) + if call.Err != nil { + log.Warnf("dbus: failed to remove match rule: %v", call.Err) + } + + log.Debugf("dbus: unsubscribed %s", subID) + + return nil +} + +func (m *Manager) UnsubscribeClient(clientID string) { + var toDelete []string + m.subscriptions.Range(func(subID string, sub *signalSubscription) bool { + if sub.ClientID == clientID { + toDelete = append(toDelete, subID) + } + return true + }) + + for _, subID := range toDelete { + if err := m.Unsubscribe(subID); err != nil { + log.Warnf("dbus: failed to unsubscribe %s: %v", subID, err) + } + } +} + +func (m *Manager) SubscribeSignals(clientID string) chan SignalEvent { + ch := make(chan SignalEvent, 64) + existing, loaded := m.signalSubscribers.LoadOrStore(clientID, ch) + if loaded { + return existing + } + return ch +} + +func (m *Manager) UnsubscribeSignals(clientID string) { + if ch, ok := m.signalSubscribers.LoadAndDelete(clientID); ok { + close(ch) + } + m.UnsubscribeClient(clientID) +} + +func (m *Manager) processSystemSignals() { + if m.systemConn == nil { + return + } + ch := make(chan *dbus.Signal, 256) + m.systemConn.Signal(ch) + + for sig := range ch { + m.dispatchSignal("system", sig) + } +} + +func (m *Manager) processSessionSignals() { + if m.sessionConn == nil { + return + } + ch := make(chan *dbus.Signal, 256) + m.sessionConn.Signal(ch) + + for sig := range ch { + m.dispatchSignal("session", sig) + } +} + +func (m *Manager) dispatchSignal(bus string, sig *dbus.Signal) { + path := string(sig.Path) + iface := "" + member := sig.Name + + if idx := strings.LastIndex(sig.Name, "."); idx != -1 { + iface = sig.Name[:idx] + member = sig.Name[idx+1:] + } + + m.subscriptions.Range(func(subID string, sub *signalSubscription) bool { + if sub.Bus != bus { + return true + } + if sub.Path != "" && sub.Path != path && !strings.HasPrefix(path, sub.Path) { + return true + } + if sub.Interface != "" && sub.Interface != iface { + return true + } + if sub.Member != "" && sub.Member != member { + return true + } + + event := SignalEvent{ + SubscriptionID: subID, + Sender: sig.Sender, + Path: path, + Interface: iface, + Member: member, + Body: dbusutil.NormalizeSlice(sig.Body), + } + + ch, ok := m.signalSubscribers.Load(sub.ClientID) + if !ok { + return true + } + + select { + case ch <- event: + default: + log.Warnf("dbus: channel full for %s, dropping signal", subID) + } + + return true + }) +} + +func (m *Manager) Close() { + m.signalSubscribers.Range(func(clientID string, ch chan SignalEvent) bool { + close(ch) + m.signalSubscribers.Delete(clientID) + return true + }) + + if m.systemConn != nil { + m.systemConn.Close() + } + if m.sessionConn != nil { + m.sessionConn.Close() + } +} + +func generateSubscriptionID() string { + b := make([]byte, 8) + if _, err := rand.Read(b); err != nil { + log.Warnf("dbus: failed to generate random subscription ID: %v", err) + } + return hex.EncodeToString(b) +} diff --git a/core/internal/server/dbus/types.go b/core/internal/server/dbus/types.go new file mode 100644 index 00000000..55c45bf4 --- /dev/null +++ b/core/internal/server/dbus/types.go @@ -0,0 +1,52 @@ +package dbus + +import ( + "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap" + "github.com/godbus/dbus/v5" +) + +type Manager struct { + systemConn *dbus.Conn + sessionConn *dbus.Conn + + subscriptions syncmap.Map[string, *signalSubscription] + signalSubscribers syncmap.Map[string, chan SignalEvent] +} + +type signalSubscription struct { + Bus string + Sender string + Path string + Interface string + Member string + ClientID string +} + +type SignalEvent struct { + SubscriptionID string `json:"subscriptionId"` + Sender string `json:"sender"` + Path string `json:"path"` + Interface string `json:"interface"` + Member string `json:"member"` + Body []any `json:"body"` +} + +type CallResult struct { + Values []any `json:"values"` +} + +type PropertyResult struct { + Value any `json:"value"` +} + +type IntrospectResult struct { + XML string `json:"xml"` +} + +type ListNamesResult struct { + Names []string `json:"names"` +} + +type SubscribeResult struct { + SubscriptionID string `json:"subscriptionId"` +} diff --git a/core/internal/server/freedesktop/manager.go b/core/internal/server/freedesktop/manager.go index efd6b2c6..2a1f1f1d 100644 --- a/core/internal/server/freedesktop/manager.go +++ b/core/internal/server/freedesktop/manager.go @@ -6,6 +6,7 @@ import ( "os" "sync" + "github.com/AvengeMedia/DankMaterialShell/core/pkg/dbusutil" "github.com/godbus/dbus/v5" ) @@ -110,61 +111,17 @@ func (m *Manager) updateAccountsState() error { m.stateMutex.Lock() defer m.stateMutex.Unlock() - if v, ok := props["IconFile"]; ok { - if val, ok := v.Value().(string); ok { - m.state.Accounts.IconFile = val - } - } - if v, ok := props["RealName"]; ok { - if val, ok := v.Value().(string); ok { - m.state.Accounts.RealName = val - } - } - if v, ok := props["UserName"]; ok { - if val, ok := v.Value().(string); ok { - m.state.Accounts.UserName = val - } - } - if v, ok := props["AccountType"]; ok { - if val, ok := v.Value().(int32); ok { - m.state.Accounts.AccountType = val - } - } - if v, ok := props["HomeDirectory"]; ok { - if val, ok := v.Value().(string); ok { - m.state.Accounts.HomeDirectory = val - } - } - if v, ok := props["Shell"]; ok { - if val, ok := v.Value().(string); ok { - m.state.Accounts.Shell = val - } - } - if v, ok := props["Email"]; ok { - if val, ok := v.Value().(string); ok { - m.state.Accounts.Email = val - } - } - if v, ok := props["Language"]; ok { - if val, ok := v.Value().(string); ok { - m.state.Accounts.Language = val - } - } - if v, ok := props["Location"]; ok { - if val, ok := v.Value().(string); ok { - m.state.Accounts.Location = val - } - } - if v, ok := props["Locked"]; ok { - if val, ok := v.Value().(bool); ok { - m.state.Accounts.Locked = val - } - } - if v, ok := props["PasswordMode"]; ok { - if val, ok := v.Value().(int32); ok { - m.state.Accounts.PasswordMode = val - } - } + m.state.Accounts.IconFile = dbusutil.GetOr(props, "IconFile", "") + m.state.Accounts.RealName = dbusutil.GetOr(props, "RealName", "") + m.state.Accounts.UserName = dbusutil.GetOr(props, "UserName", "") + m.state.Accounts.AccountType = dbusutil.GetOr(props, "AccountType", int32(0)) + m.state.Accounts.HomeDirectory = dbusutil.GetOr(props, "HomeDirectory", "") + m.state.Accounts.Shell = dbusutil.GetOr(props, "Shell", "") + m.state.Accounts.Email = dbusutil.GetOr(props, "Email", "") + m.state.Accounts.Language = dbusutil.GetOr(props, "Language", "") + m.state.Accounts.Location = dbusutil.GetOr(props, "Location", "") + m.state.Accounts.Locked = dbusutil.GetOr(props, "Locked", false) + m.state.Accounts.PasswordMode = dbusutil.GetOr(props, "PasswordMode", int32(0)) return nil } @@ -180,7 +137,7 @@ func (m *Manager) updateSettingsState() error { return err } - if colorScheme, ok := variant.Value().(uint32); ok { + if colorScheme, ok := dbusutil.As[uint32](variant); ok { m.stateMutex.Lock() m.state.Settings.ColorScheme = colorScheme m.stateMutex.Unlock() diff --git a/core/internal/server/loginctl/manager.go b/core/internal/server/loginctl/manager.go index 3a3d9d9b..e8595f79 100644 --- a/core/internal/server/loginctl/manager.go +++ b/core/internal/server/loginctl/manager.go @@ -7,6 +7,7 @@ import ( "sync" "time" + "github.com/AvengeMedia/DankMaterialShell/core/pkg/dbusutil" "github.com/godbus/dbus/v5" ) @@ -132,37 +133,15 @@ func (m *Manager) updateSessionState() error { m.stateMutex.Lock() defer m.stateMutex.Unlock() - if v, ok := props["Active"]; ok { - if val, ok := v.Value().(bool); ok { - m.state.Active = val - } - } - if v, ok := props["IdleHint"]; ok { - if val, ok := v.Value().(bool); ok { - m.state.IdleHint = val - } - } - if v, ok := props["IdleSinceHint"]; ok { - if val, ok := v.Value().(uint64); ok { - m.state.IdleSinceHint = val - } - } - if v, ok := props["LockedHint"]; ok { - if val, ok := v.Value().(bool); ok { - m.state.LockedHint = val - m.state.Locked = val - } - } - if v, ok := props["Type"]; ok { - if val, ok := v.Value().(string); ok { - m.state.SessionType = val - } - } - if v, ok := props["Class"]; ok { - if val, ok := v.Value().(string); ok { - m.state.SessionClass = val - } + m.state.Active = dbusutil.GetOr(props, "Active", m.state.Active) + m.state.IdleHint = dbusutil.GetOr(props, "IdleHint", m.state.IdleHint) + m.state.IdleSinceHint = dbusutil.GetOr(props, "IdleSinceHint", m.state.IdleSinceHint) + if lockedHint, ok := dbusutil.Get[bool](props, "LockedHint"); ok { + m.state.LockedHint = lockedHint + m.state.Locked = lockedHint } + m.state.SessionType = dbusutil.GetOr(props, "Type", m.state.SessionType) + m.state.SessionClass = dbusutil.GetOr(props, "Class", m.state.SessionClass) if v, ok := props["User"]; ok { if userArr, ok := v.Value().([]any); ok && len(userArr) >= 1 { if uid, ok := userArr[0].(uint32); ok { @@ -170,36 +149,12 @@ func (m *Manager) updateSessionState() error { } } } - if v, ok := props["Name"]; ok { - if val, ok := v.Value().(string); ok { - m.state.UserName = val - } - } - if v, ok := props["RemoteHost"]; ok { - if val, ok := v.Value().(string); ok { - m.state.RemoteHost = val - } - } - if v, ok := props["Service"]; ok { - if val, ok := v.Value().(string); ok { - m.state.Service = val - } - } - if v, ok := props["TTY"]; ok { - if val, ok := v.Value().(string); ok { - m.state.TTY = val - } - } - if v, ok := props["Display"]; ok { - if val, ok := v.Value().(string); ok { - m.state.Display = val - } - } - if v, ok := props["Remote"]; ok { - if val, ok := v.Value().(bool); ok { - m.state.Remote = val - } - } + m.state.UserName = dbusutil.GetOr(props, "Name", m.state.UserName) + m.state.RemoteHost = dbusutil.GetOr(props, "RemoteHost", m.state.RemoteHost) + m.state.Service = dbusutil.GetOr(props, "Service", m.state.Service) + m.state.TTY = dbusutil.GetOr(props, "TTY", m.state.TTY) + m.state.Display = dbusutil.GetOr(props, "Display", m.state.Display) + m.state.Remote = dbusutil.GetOr(props, "Remote", m.state.Remote) if v, ok := props["Seat"]; ok { if seatArr, ok := v.Value().([]any); ok && len(seatArr) >= 1 { if seatID, ok := seatArr[0].(string); ok { @@ -207,11 +162,7 @@ func (m *Manager) updateSessionState() error { } } } - if v, ok := props["VTNr"]; ok { - if val, ok := v.Value().(uint32); ok { - m.state.VTNr = val - } - } + m.state.VTNr = dbusutil.GetOr(props, "VTNr", m.state.VTNr) return nil } diff --git a/core/internal/server/loginctl/monitor.go b/core/internal/server/loginctl/monitor.go index c4301050..4c0605a0 100644 --- a/core/internal/server/loginctl/monitor.go +++ b/core/internal/server/loginctl/monitor.go @@ -3,6 +3,7 @@ package loginctl import ( "time" + "github.com/AvengeMedia/DankMaterialShell/core/pkg/dbusutil" "github.com/godbus/dbus/v5" ) @@ -117,31 +118,28 @@ func (m *Manager) handlePropertiesChanged(sig *dbus.Signal) { for key, variant := range changes { switch key { case "Active": - if val, ok := variant.Value().(bool); ok { + if val, ok := dbusutil.As[bool](variant); ok { m.stateMutex.Lock() m.state.Active = val m.stateMutex.Unlock() needsUpdate = true } - case "IdleHint": - if val, ok := variant.Value().(bool); ok { + if val, ok := dbusutil.As[bool](variant); ok { m.stateMutex.Lock() m.state.IdleHint = val m.stateMutex.Unlock() needsUpdate = true } - case "IdleSinceHint": - if val, ok := variant.Value().(uint64); ok { + if val, ok := dbusutil.As[uint64](variant); ok { m.stateMutex.Lock() m.state.IdleSinceHint = val m.stateMutex.Unlock() needsUpdate = true } - case "LockedHint": - if val, ok := variant.Value().(bool); ok { + if val, ok := dbusutil.As[bool](variant); ok { m.stateMutex.Lock() m.state.LockedHint = val m.state.Locked = val diff --git a/core/internal/server/network/priority.go b/core/internal/server/network/priority.go index b5836a2c..8ee7fdf8 100644 --- a/core/internal/server/network/priority.go +++ b/core/internal/server/network/priority.go @@ -150,19 +150,11 @@ func (m *Manager) setConnectionPriority(connType string, autoconnectPriority int } if err := exec.Command("nmcli", "con", "mod", connName, - "connection.autoconnect-priority", fmt.Sprintf("%d", autoconnectPriority)).Run(); err != nil { - log.Warnf("Failed to set autoconnect-priority for %v: %v", connName, err) - continue - } - - if err := exec.Command("nmcli", "con", "mod", connName, - "ipv4.route-metric", fmt.Sprintf("%d", routeMetric)).Run(); err != nil { - log.Warnf("Failed to set ipv4.route-metric for %v: %v", connName, err) - } - - if err := exec.Command("nmcli", "con", "mod", connName, + "connection.autoconnect-priority", fmt.Sprintf("%d", autoconnectPriority), + "ipv4.route-metric", fmt.Sprintf("%d", routeMetric), "ipv6.route-metric", fmt.Sprintf("%d", routeMetric)).Run(); err != nil { - log.Warnf("Failed to set ipv6.route-metric for %v: %v", connName, err) + log.Warnf("Failed to set priority for %s: %v", connName, err) + continue } log.Infof("Updated %v: autoconnect-priority=%d, route-metric=%d", connName, autoconnectPriority, routeMetric) diff --git a/core/internal/server/router.go b/core/internal/server/router.go index 412bfd7a..87b639f5 100644 --- a/core/internal/server/router.go +++ b/core/internal/server/router.go @@ -10,6 +10,7 @@ import ( "github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/clipboard" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups" + serverDbus "github.com/AvengeMedia/DankMaterialShell/core/internal/server/dbus" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/dwl" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/extworkspace" @@ -154,6 +155,15 @@ func RouteRequest(conn net.Conn, req models.Request) { return } + if strings.HasPrefix(req.Method, "dbus.") { + if dbusManager == nil { + models.RespondError(conn, req.ID, "dbus manager not initialized") + return + } + serverDbus.HandleRequest(conn, req, dbusManager, dbusClientID) + return + } + if strings.HasPrefix(req.Method, "clipboard.") { switch req.Method { case "clipboard.getConfig": diff --git a/core/internal/server/server.go b/core/internal/server/server.go index 55c8b0b1..9294d488 100644 --- a/core/internal/server/server.go +++ b/core/internal/server/server.go @@ -20,6 +20,7 @@ import ( "github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/clipboard" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups" + serverDbus "github.com/AvengeMedia/DankMaterialShell/core/internal/server/dbus" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/dwl" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/extworkspace" @@ -65,8 +66,11 @@ var brightnessManager *brightness.Manager var wlrOutputManager *wlroutput.Manager var evdevManager *evdev.Manager var clipboardManager *clipboard.Manager +var dbusManager *serverDbus.Manager var wlContext *wlcontext.SharedContext +const dbusClientID = "dms-dbus-client" + var capabilitySubscribers syncmap.Map[string, chan ServerInfo] var cupsSubscribers syncmap.Map[string, bool] var cupsSubscriberCount atomic.Int32 @@ -363,6 +367,19 @@ func InitializeClipboardManager() error { return nil } +func InitializeDbusManager() error { + manager, err := serverDbus.NewManager() + if err != nil { + log.Warnf("Failed to initialize dbus manager: %v", err) + return err + } + + dbusManager = manager + + log.Info("DBus manager initialized") + return nil +} + func handleConnection(conn net.Conn) { defer conn.Close() @@ -440,6 +457,10 @@ func getCapabilities() Capabilities { caps = append(caps, "clipboard") } + if dbusManager != nil { + caps = append(caps, "dbus") + } + return Capabilities{Capabilities: caps} } @@ -498,6 +519,10 @@ func getServerInfo() ServerInfo { caps = append(caps, "clipboard") } + if dbusManager != nil { + caps = append(caps, "dbus") + } + return ServerInfo{ APIVersion: APIVersion, CLIVersion: CLIVersion, @@ -1133,6 +1158,31 @@ func handleSubscribe(conn net.Conn, req models.Request) { }() } + if shouldSubscribe("dbus") && dbusManager != nil { + wg.Add(1) + dbusChan := dbusManager.SubscribeSignals(dbusClientID) + go func() { + defer wg.Done() + defer dbusManager.UnsubscribeSignals(dbusClientID) + + for { + select { + case event, ok := <-dbusChan: + if !ok { + return + } + select { + case eventChan <- ServiceEvent{Service: "dbus", Data: event}: + case <-stopChan: + return + } + case <-stopChan: + return + } + } + }() + } + go func() { wg.Wait() close(eventChan) @@ -1198,6 +1248,9 @@ func cleanupManagers() { if clipboardManager != nil { clipboardManager.Close() } + if dbusManager != nil { + dbusManager.Close() + } if wlContext != nil { wlContext.Close() } @@ -1490,6 +1543,14 @@ func Start(printDocs bool) error { } }() + go func() { + if err := InitializeDbusManager(); err != nil { + log.Warnf("DBus manager unavailable: %v", err) + } else { + notifyCapabilityChange() + } + }() + log.Info("") log.Infof("Ready! Capabilities: %v", getCapabilities().Capabilities) diff --git a/core/pkg/dbusutil/variant.go b/core/pkg/dbusutil/variant.go new file mode 100644 index 00000000..3ef76144 --- /dev/null +++ b/core/pkg/dbusutil/variant.go @@ -0,0 +1,69 @@ +package dbusutil + +import "github.com/godbus/dbus/v5" + +func As[T any](v dbus.Variant) (T, bool) { + val, ok := v.Value().(T) + return val, ok +} + +func AsOr[T any](v dbus.Variant, def T) T { + if val, ok := v.Value().(T); ok { + return val + } + return def +} + +func Get[T any](m map[string]dbus.Variant, key string) (T, bool) { + v, ok := m[key] + if !ok { + var zero T + return zero, false + } + return As[T](v) +} + +func GetOr[T any](m map[string]dbus.Variant, key string, def T) T { + v, ok := m[key] + if !ok { + return def + } + return AsOr(v, def) +} + +func Normalize(v any) any { + switch val := v.(type) { + case dbus.Variant: + return Normalize(val.Value()) + case dbus.ObjectPath: + return string(val) + case []dbus.ObjectPath: + result := make([]string, len(val)) + for i, p := range val { + result[i] = string(p) + } + return result + case map[string]dbus.Variant: + result := make(map[string]any) + for k, vv := range val { + result[k] = Normalize(vv.Value()) + } + return result + case []any: + result := make([]any, len(val)) + for i, item := range val { + result[i] = Normalize(item) + } + return result + default: + return v + } +} + +func NormalizeSlice(values []any) []any { + result := make([]any, len(values)) + for i, v := range values { + result[i] = Normalize(v) + } + return result +} diff --git a/core/pkg/dbusutil/variant_test.go b/core/pkg/dbusutil/variant_test.go new file mode 100644 index 00000000..a927e54b --- /dev/null +++ b/core/pkg/dbusutil/variant_test.go @@ -0,0 +1,155 @@ +package dbusutil + +import ( + "testing" + + "github.com/godbus/dbus/v5" + "github.com/stretchr/testify/assert" +) + +func TestAs(t *testing.T) { + t.Run("string", func(t *testing.T) { + v := dbus.MakeVariant("hello") + val, ok := As[string](v) + assert.True(t, ok) + assert.Equal(t, "hello", val) + }) + + t.Run("bool", func(t *testing.T) { + v := dbus.MakeVariant(true) + val, ok := As[bool](v) + assert.True(t, ok) + assert.True(t, val) + }) + + t.Run("int32", func(t *testing.T) { + v := dbus.MakeVariant(int32(42)) + val, ok := As[int32](v) + assert.True(t, ok) + assert.Equal(t, int32(42), val) + }) + + t.Run("wrong type", func(t *testing.T) { + v := dbus.MakeVariant("hello") + _, ok := As[int](v) + assert.False(t, ok) + }) +} + +func TestAsOr(t *testing.T) { + t.Run("exists", func(t *testing.T) { + v := dbus.MakeVariant("hello") + val := AsOr(v, "default") + assert.Equal(t, "hello", val) + }) + + t.Run("wrong type uses default", func(t *testing.T) { + v := dbus.MakeVariant(123) + val := AsOr(v, "default") + assert.Equal(t, "default", val) + }) +} + +func TestGet(t *testing.T) { + m := map[string]dbus.Variant{ + "name": dbus.MakeVariant("test"), + "enabled": dbus.MakeVariant(true), + "count": dbus.MakeVariant(int32(5)), + } + + t.Run("exists", func(t *testing.T) { + val, ok := Get[string](m, "name") + assert.True(t, ok) + assert.Equal(t, "test", val) + }) + + t.Run("missing key", func(t *testing.T) { + _, ok := Get[string](m, "missing") + assert.False(t, ok) + }) + + t.Run("wrong type", func(t *testing.T) { + _, ok := Get[int](m, "name") + assert.False(t, ok) + }) +} + +func TestGetOr(t *testing.T) { + m := map[string]dbus.Variant{ + "name": dbus.MakeVariant("test"), + } + + t.Run("exists", func(t *testing.T) { + val := GetOr(m, "name", "default") + assert.Equal(t, "test", val) + }) + + t.Run("missing uses default", func(t *testing.T) { + val := GetOr(m, "missing", "default") + assert.Equal(t, "default", val) + }) + + t.Run("wrong type uses default", func(t *testing.T) { + val := GetOr(m, "name", 42) + assert.Equal(t, 42, val) + }) +} + +func TestNormalize(t *testing.T) { + t.Run("variant unwrap", func(t *testing.T) { + v := dbus.MakeVariant("hello") + result := Normalize(v) + assert.Equal(t, "hello", result) + }) + + t.Run("nested variant", func(t *testing.T) { + v := dbus.MakeVariant(dbus.MakeVariant("nested")) + result := Normalize(v) + assert.Equal(t, "nested", result) + }) + + t.Run("object path", func(t *testing.T) { + v := dbus.ObjectPath("/org/test") + result := Normalize(v) + assert.Equal(t, "/org/test", result) + }) + + t.Run("object path slice", func(t *testing.T) { + v := []dbus.ObjectPath{"/org/a", "/org/b"} + result := Normalize(v) + assert.Equal(t, []string{"/org/a", "/org/b"}, result) + }) + + t.Run("variant map", func(t *testing.T) { + v := map[string]dbus.Variant{ + "key": dbus.MakeVariant("value"), + } + result := Normalize(v) + expected := map[string]any{"key": "value"} + assert.Equal(t, expected, result) + }) + + t.Run("any slice", func(t *testing.T) { + v := []any{dbus.MakeVariant("a"), dbus.ObjectPath("/b")} + result := Normalize(v) + expected := []any{"a", "/b"} + assert.Equal(t, expected, result) + }) + + t.Run("passthrough primitives", func(t *testing.T) { + assert.Equal(t, "hello", Normalize("hello")) + assert.Equal(t, 42, Normalize(42)) + assert.Equal(t, true, Normalize(true)) + }) +} + +func TestNormalizeSlice(t *testing.T) { + input := []any{ + dbus.MakeVariant("a"), + dbus.ObjectPath("/b"), + "c", + } + result := NormalizeSlice(input) + expected := []any{"a", "/b", "c"} + assert.Equal(t, expected, result) +} diff --git a/quickshell/Services/DMSService.qml b/quickshell/Services/DMSService.qml index 7635ae20..b3dabe89 100644 --- a/quickshell/Services/DMSService.qml +++ b/quickshell/Services/DMSService.qml @@ -64,7 +64,7 @@ Singleton { property bool screensaverInhibited: false property var screensaverInhibitors: [] - property var activeSubscriptions: ["network", "network.credentials", "loginctl", "freedesktop", "freedesktop.screensaver", "gamma", "bluetooth", "bluetooth.pairing", "dwl", "brightness", "wlroutput", "evdev", "browser"] + property var activeSubscriptions: ["network", "network.credentials", "loginctl", "freedesktop", "freedesktop.screensaver", "gamma", "bluetooth", "bluetooth.pairing", "dwl", "brightness", "wlroutput", "evdev", "browser", "dbus"] Component.onCompleted: { if (socketPath && socketPath.length > 0) { @@ -191,17 +191,19 @@ Singleton { if (!line || line.length === 0) return; + let response; try { - const response = JSON.parse(line); - const isClipboard = clipboardRequestIds[response.id]; - if (isClipboard) - delete clipboardRequestIds[response.id]; - else - console.log("DMSService: Request socket <<", line); - handleResponse(response); + response = JSON.parse(line); } catch (e) { - console.warn("DMSService: Failed to parse request response"); + console.warn("DMSService: Failed to parse request response:", line.substring(0, 100)); + return; } + const isClipboard = clipboardRequestIds[response.id]; + if (isClipboard) + delete clipboardRequestIds[response.id]; + else + console.log("DMSService: Request socket <<", line); + handleResponse(response); } } } @@ -223,14 +225,16 @@ Singleton { if (!line || line.length === 0) return; + let response; try { - const response = JSON.parse(line); - if (!line.includes("clipboard")) - console.log("DMSService: Subscribe socket <<", line); - handleSubscriptionEvent(response); + response = JSON.parse(line); } catch (e) { - console.warn("DMSService: Failed to parse subscription event"); + console.warn("DMSService: Failed to parse subscription event:", line.substring(0, 100)); + return; } + if (!line.includes("clipboard")) + console.log("DMSService: Subscribe socket <<", line); + handleSubscriptionEvent(response); } } } @@ -300,7 +304,7 @@ Singleton { excludeServices = [excludeServices]; } - const allServices = ["network", "loginctl", "freedesktop", "gamma", "bluetooth", "cups", "dwl", "brightness", "extworkspace", "browser"]; + const allServices = ["network", "loginctl", "freedesktop", "gamma", "bluetooth", "cups", "dwl", "brightness", "extworkspace", "browser", "dbus"]; const filtered = allServices.filter(s => !excludeServices.includes(s)); subscribe(filtered); } @@ -383,6 +387,8 @@ Singleton { screensaverInhibited = data.inhibited || false; screensaverInhibitors = data.inhibitors || []; screensaverStateUpdate(data); + } else if (service === "dbus") { + dbusSignalReceived(data.subscriptionId || "", data); } } @@ -646,4 +652,89 @@ Singleton { "token": token }, callback); } + + signal dbusSignalReceived(string subscriptionId, var data) + + property var dbusSubscriptions: ({}) + + function dbusCall(bus, dest, path, iface, method, args, callback) { + sendRequest("dbus.call", { + "bus": bus, + "dest": dest, + "path": path, + "interface": iface, + "method": method, + "args": args || [] + }, callback); + } + + function dbusGetProperty(bus, dest, path, iface, property, callback) { + sendRequest("dbus.getProperty", { + "bus": bus, + "dest": dest, + "path": path, + "interface": iface, + "property": property + }, callback); + } + + function dbusSetProperty(bus, dest, path, iface, property, value, callback) { + sendRequest("dbus.setProperty", { + "bus": bus, + "dest": dest, + "path": path, + "interface": iface, + "property": property, + "value": value + }, callback); + } + + function dbusGetAllProperties(bus, dest, path, iface, callback) { + sendRequest("dbus.getAllProperties", { + "bus": bus, + "dest": dest, + "path": path, + "interface": iface + }, callback); + } + + function dbusIntrospect(bus, dest, path, callback) { + sendRequest("dbus.introspect", { + "bus": bus, + "dest": dest, + "path": path || "/" + }, callback); + } + + function dbusListNames(bus, callback) { + sendRequest("dbus.listNames", { + "bus": bus + }, callback); + } + + function dbusSubscribe(bus, sender, path, iface, member, callback) { + sendRequest("dbus.subscribe", { + "bus": bus, + "sender": sender || "", + "path": path || "", + "interface": iface || "", + "member": member || "" + }, response => { + if (!response.error && response.result?.subscriptionId) { + dbusSubscriptions[response.result.subscriptionId] = true; + } + if (callback) callback(response); + }); + } + + function dbusUnsubscribe(subscriptionId, callback) { + sendRequest("dbus.unsubscribe", { + "subscriptionId": subscriptionId + }, response => { + if (!response.error) { + delete dbusSubscriptions[subscriptionId]; + } + if (callback) callback(response); + }); + } } diff --git a/quickshell/Widgets/DankActionButton.qml b/quickshell/Widgets/DankActionButton.qml index 1b98e604..c23e84b6 100644 --- a/quickshell/Widgets/DankActionButton.qml +++ b/quickshell/Widgets/DankActionButton.qml @@ -13,6 +13,7 @@ StyledRect { property bool enabled: true property int buttonSize: 32 property var tooltipText: null + property string tooltipSide: "bottom" signal clicked signal entered @@ -38,5 +39,6 @@ StyledRect { onEntered: root.entered() onExited: root.exited() tooltipText: root.tooltipText + tooltipSide: root.tooltipSide } } diff --git a/quickshell/Widgets/StateLayer.qml b/quickshell/Widgets/StateLayer.qml index 9f7ce216..7bbd7cd2 100644 --- a/quickshell/Widgets/StateLayer.qml +++ b/quickshell/Widgets/StateLayer.qml @@ -8,6 +8,7 @@ MouseArea { property color stateColor: Theme.surfaceText property real cornerRadius: parent && parent.radius !== undefined ? parent.radius : Theme.cornerRadius property var tooltipText: null + property string tooltipSide: "bottom" readonly property real stateOpacity: disabled ? 0 : pressed ? 0.12 : containsMouse ? 0.08 : 0 @@ -26,7 +27,7 @@ MouseArea { interval: 400 repeat: false onTriggered: { - tooltip.show(root.tooltipText, root, 0, 0, "bottom"); + tooltip.show(root.tooltipText, root, 0, 0, root.tooltipSide); } }