1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-01-24 13:32:50 -05:00

core/server: add generic dbus service

- Add QML client with subscribe/introspect/getprop/setprop/call
- Add CLI helper `dms notify` that allows async calls with action
  handlers.
This commit is contained in:
bbedward
2026-01-17 22:04:58 -05:00
parent 53f5240d41
commit 162ec909da
20 changed files with 1403 additions and 287 deletions

View File

@@ -511,6 +511,8 @@ func getCommonCommands() []*cobra.Command {
colorCmd,
screenshotCmd,
notifyActionCmd,
notifyCmd,
genericNotifyActionCmd,
matugenCmd,
clipboardCmd,
doctorCmd,

View File

@@ -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 <summary> [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(&notifyAppName, "app", "DMS", "Application name")
notifyCmd.Flags().StringVar(&notifyIcon, "icon", "", "Icon name or path")
notifyCmd.Flags().StringVar(&notifyFile, "file", "", "File path (enables Open/Open Folder actions)")
notifyCmd.Flags().IntVar(&notifyTimeout, "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)
}
}

View File

@@ -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
)

View File

@@ -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=

View File

@@ -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(&notificationID); 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()
}

View File

@@ -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)
}
}

View File

@@ -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})
}

View File

@@ -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)
}

View File

@@ -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"`
}

View File

@@ -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()

View File

@@ -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
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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":

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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);
});
}
}

View File

@@ -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
}
}

View File

@@ -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);
}
}