From e2b89433d794263ddd4c3c47f0e762da983afcea Mon Sep 17 00:00:00 2001 From: Nikita Date: Sun, 12 Jul 2026 22:03:10 +0300 Subject: [PATCH 01/47] migrate tray from fyne systray to wails v3 --- .gitignore | 5 +- frontend/dist/.gitkeep | 0 frontend/embed.go | 9 ++ go.mod | 11 ++- go.sum | 32 +++++-- pkg/deej/deej.go | 1 + pkg/deej/tray.go | 195 ++++++++++++++++++++++++----------------- 7 files changed, 159 insertions(+), 94 deletions(-) create mode 100644 frontend/dist/.gitkeep create mode 100644 frontend/embed.go diff --git a/.gitignore b/.gitignore index eba73a37..803f6a3f 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,7 @@ releases/ *.bin logs/ preferences.yaml -config.yaml \ No newline at end of file +config.yaml +frontend/node_modules/ +frontend/dist/* +!frontend/dist/.gitkeep \ No newline at end of file diff --git a/frontend/dist/.gitkeep b/frontend/dist/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/frontend/embed.go b/frontend/embed.go new file mode 100644 index 00000000..08607c04 --- /dev/null +++ b/frontend/embed.go @@ -0,0 +1,9 @@ +// Package frontend embeds the built settings UI assets. +package frontend + +import "embed" + +// Dist holds the compiled frontend assets (dist is populated by "npm run build") +// +//go:embed all:dist +var Dist embed.FS diff --git a/go.mod b/go.mod index f4bde9bd..357022a0 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,6 @@ module github.com/nik9play/deej go 1.25.0 require ( - fyne.io/systray v1.12.1-0.20260224174210-614d12c91a50 git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 github.com/andreykaipov/goobs v1.7.1 github.com/fsnotify/fsnotify v1.9.0 @@ -18,18 +17,24 @@ require ( github.com/pelletier/go-toml/v2 v2.2.4 github.com/spf13/viper v1.21.0 github.com/thoas/go-funk v0.9.3 + github.com/wailsapp/wails/v3 v3.0.0-alpha2.117 go.bug.st/serial v1.6.4 go.uber.org/zap v1.27.1 - golang.org/x/sys v0.41.0 - golang.org/x/text v0.34.0 + golang.org/x/sys v0.43.0 + golang.org/x/text v0.37.0 ) require ( + github.com/adrg/xdg v0.5.3 // indirect github.com/buger/jsonparser v1.1.1 // indirect + github.com/coder/websocket v1.8.14 // indirect github.com/creack/goselect v0.1.3 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/hashicorp/logutils v1.0.0 // indirect + github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mmcloughlin/profile v0.1.1 // indirect github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d // indirect diff --git a/go.sum b/go.sum index 68b5f91f..f293d592 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,15 @@ -fyne.io/systray v1.12.1-0.20260224174210-614d12c91a50 h1:DJQzXVC8K+ecBxf6wJgRLFPeCCrikKXWGOYhY0GuC0E= -fyne.io/systray v1.12.1-0.20260224174210-614d12c91a50/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA= git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= +github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= github.com/andreykaipov/goobs v1.7.1 h1:1Wh7batGb/eRs6ywVwqD7JljxdNxQpz1kpJK2j4dhAI= github.com/andreykaipov/goobs v1.7.1/go.mod h1:4Dl/G+rQXCywV85hdAKwsj8h352KsfILP+QUB1FvRSc= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/creack/goselect v0.1.3 h1:MaGNMclRo7P2Jl21hBpR1Cn33ITSbKP6E49RtfblLKc= github.com/creack/goselect v0.1.3/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -17,6 +19,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU= +github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= @@ -24,14 +28,16 @@ github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPE github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 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/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= +github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ= +github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE= github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o= github.com/jfreymuth/pulse v0.1.1 h1:9WLNBNCijmtZ14ZJpatgJPu/NjwAl3TIKItSFnTh+9A= @@ -40,6 +46,12 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= +github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= @@ -76,6 +88,8 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/thoas/go-funk v0.9.3 h1:7+nAEx3kn5ZJcnDm2Bh23N2yOtweO14bi//dvRtgLpw= github.com/thoas/go-funk v0.9.3/go.mod h1:+IWnUfUmFO1+WVYQWQtIJHeRRdaIyyYglZN7xzUPe4Q= +github.com/wailsapp/wails/v3 v3.0.0-alpha2.117 h1:udyjqPG3AIgkod5QDR/WblCkpV8R86BFPSrsWxSyt5Y= +github.com/wailsapp/wails/v3 v3.0.0-alpha2.117/go.mod h1:74WH2FScMsgucZvHHvv7eOefDXCm/CjuIxqhhZgPhKg= go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A= go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -87,11 +101,13 @@ go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/pkg/deej/deej.go b/pkg/deej/deej.go index 599d6999..94135f18 100644 --- a/pkg/deej/deej.go +++ b/pkg/deej/deej.go @@ -34,6 +34,7 @@ type Deej struct { obs *OBSClient bundle *i18n.Bundle localizer *i18n.Localizer + tray trayState stopChannel chan bool version string diff --git a/pkg/deej/tray.go b/pkg/deej/tray.go index a94ade8d..d4a3fae4 100644 --- a/pkg/deej/tray.go +++ b/pkg/deej/tray.go @@ -1,16 +1,27 @@ package deej import ( + "io/fs" + "log/slog" "strconv" "strings" + "time" - "fyne.io/systray" "github.com/nicksnyder/go-i18n/v2/i18n" + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/events" + "github.com/nik9play/deej/frontend" "github.com/nik9play/deej/pkg/deej/util" "github.com/nik9play/deej/pkg/icon" ) +// trayState holds the wails application that powers the tray icon and settings window +type trayState struct { + app *application.App + shutdownDone chan struct{} +} + func getConfigItemText(d *Deej) (string, string) { configTitle := d.localizer.MustLocalize(&i18n.LocalizeConfig{ DefaultMessage: &i18n.Message{ @@ -131,135 +142,155 @@ func getSessionsCountString(d *Deej) string { func (d *Deej) initializeTray(onDone func()) { logger := d.logger.Named("tray") - onReady := func() { - logger.Debug("Tray instance ready") + d.tray.shutdownDone = make(chan struct{}) + + dist, err := fs.Sub(frontend.Dist, "dist") + if err != nil { + logger.Errorw("Failed to open frontend assets", "error", err) + } - systray.SetTemplateIcon(icon.TrayDeejLogo, icon.TrayDeejLogo) + app := application.New(application.Options{ + Name: "deej", + Icon: icon.TrayDeejLogo, + // deej sets up its own interrupt handler + DisableDefaultSignalHandler: true, + // keep running with zero open windows; the tray is the app + Windows: application.WindowsOptions{DisableQuitOnLastWindowClosed: true}, + Linux: application.LinuxOptions{DisableQuitOnLastWindowClosed: true}, + Assets: application.AssetOptions{Handler: application.AssetFileServerFS(dist)}, + PostShutdown: func() { + close(d.tray.shutdownDone) + }, + LogLevel: slog.LevelError, + }) + d.tray.app = app - systray.SetTooltip("deej") + tray := app.SystemTray.New() + tray.SetIcon(icon.TrayDeejLogo) + tray.SetTooltip("deej") - setTooltip := func() { - title := "deej\n" + getStatusItemTitle(d) - if d.serial.GetState() { - title += "\n" + getValuesString(d) - } - systray.SetTooltip(title) + setTooltip := func() { + title := "deej\n" + getStatusItemTitle(d) + if d.serial.GetState() { + title += "\n" + getValuesString(d) } - setTooltip() + tray.SetTooltip(title) + } - settingsTitle, settingsDescription := getSettingsItemText(d) - settings := systray.AddMenuItem(settingsTitle, settingsDescription) - settings.SetIcon(icon.EditConfigIcon) + menu := app.NewMenu() - configTitle, configDescription := getConfigItemText(d) - editConfig := settings.AddSubMenuItem(configTitle, configDescription) + settingsTitle, _ := getSettingsItemText(d) + settings := menu.AddSubmenu(settingsTitle) - autostartTitle, autostartDescription := getAutostartItemText(d) - autostart := settings.AddSubMenuItemCheckbox(autostartTitle, autostartDescription, util.GetAutostartState()) + configTitle, _ := getConfigItemText(d) + settings.Add(configTitle).OnClick(func(*application.Context) { + logger.Info("Edit config menu item clicked, opening config for editing") - if util.Linux() { - autostart.Hide() + if err := util.OpenExternal(logger, d.config.configPath); err != nil { + logger.Warnw("Failed to open config file for editing", "error", err) } + }) - systray.AddSeparator() + if !util.Linux() { + autostartTitle, _ := getAutostartItemText(d) + settings.AddCheckbox(autostartTitle, util.GetAutostartState()).OnClick(func(ctx *application.Context) { + if err := util.SetAutostartState(ctx.ClickedMenuItem().Checked()); err != nil { + logger.Warnw("Failed to set autostart state", "error", err) + } + }) + } - statusInfo := systray.AddMenuItem(getStatusItemTitle(d), "") - statusInfo.Disable() + menu.AddSeparator() - valuesInfo := systray.AddMenuItem("...", "") - valuesInfo.Disable() - valuesInfo.Hide() + statusInfo := menu.Add(getStatusItemTitle(d)).SetEnabled(false) - setValuesInfo := func() { - if d.serial.GetState() { - valuesInfo.SetTitle(getValuesString(d)) - valuesInfo.Show() - } else { - valuesInfo.Hide() - } + valuesInfo := menu.Add("...").SetEnabled(false).SetHidden(true) + + setValuesInfo := func() { + if d.serial.GetState() { + valuesInfo.SetLabel(getValuesString(d)) + valuesInfo.SetHidden(false) + } else { + valuesInfo.SetHidden(true) } - setValuesInfo() + } - sessionsInfo := systray.AddMenuItem(getSessionsCountString(d), "") - sessionsInfo.Disable() + sessionsInfo := menu.Add(getSessionsCountString(d)).SetEnabled(false) - setSessionsInfo := func() { - sessionsInfo.SetTitle(getSessionsCountString(d)) - } + if d.version != "" { + menu.Add(d.version).SetEnabled(false) + } - if d.version != "" { - versionInfo := systray.AddMenuItem(d.version, "") - versionInfo.Disable() - } + menu.AddSeparator() - systray.AddSeparator() + quitTitle, _ := getQuitItemText(d) + menu.Add(quitTitle).OnClick(func(*application.Context) { + logger.Info("Quit menu item clicked, stopping") - quitTitle, quitDescription := getQuitItemText(d) - quit := systray.AddMenuItem(quitTitle, quitDescription) + d.signalStop() + }) + + tray.SetMenu(menu) + + app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) { + logger.Debug("Tray instance ready") + + setTooltip() sliderMovedChannel := d.serial.SubscribeToSliderMoveEvents() stateChangeChannel := d.serial.SubscribeToStateChangeEvent() sessionCountChangeChannel := d.sessions.SubscribeToSessionCountChange() - // wait on things to happen + // wait on things to happen; menu item mutations must run on the wails main thread go func() { for { select { // slider moved case <-sliderMovedChannel: setTooltip() - setValuesInfo() + application.InvokeAsync(setValuesInfo) // connection state changed case <-stateChangeChannel: setTooltip() - setValuesInfo() - statusInfo.SetTitle(getStatusItemTitle(d)) + application.InvokeAsync(func() { + setValuesInfo() + statusInfo.SetLabel(getStatusItemTitle(d)) + }) // session count changed case <-sessionCountChangeChannel: - setSessionsInfo() - - // quit - case <-quit.ClickedCh: - logger.Info("Quit menu item clicked, stopping") - - d.signalStop() - - // edit config - case <-editConfig.ClickedCh: - logger.Info("Edit config menu item clicked, opening config for editing") - - if err := util.OpenExternal(logger, d.config.configPath); err != nil { - logger.Warnw("Failed to open config file for editing", "error", err) - } - - case <-autostart.ClickedCh: - util.SetAutostartState(!util.GetAutostartState()) - if util.GetAutostartState() { - autostart.Check() - } else { - autostart.Uncheck() - } - + application.InvokeAsync(func() { + sessionsInfo.SetLabel(getSessionsCountString(d)) + }) } } }() // actually start the main runtime go onDone() - } - - onExit := func() { - logger.Debug("Tray exited") - } + }) // start the tray icon logger.Debug("Running in tray") - systray.Run(onReady, onExit) + if err := app.Run(); err != nil { + logger.Errorw("Wails application exited with error", "error", err) + } } func (d *Deej) stopTray() { + if d.tray.app == nil { + return + } + d.logger.Debug("Quitting tray") - systray.Quit() + d.tray.app.Quit() + + // wait for wails to tear down the tray icon and any open windows before + // run() exits the process, to avoid leaving a ghost tray icon behind + select { + case <-d.tray.shutdownDone: + case <-time.After(5 * time.Second): + d.logger.Warn("Timed out waiting for tray shutdown") + } } From cc15ac47b069e9849aa6dda816d7616af5da14e9 Mon Sep 17 00:00:00 2001 From: Nikita Date: Sun, 12 Jul 2026 22:07:59 +0300 Subject: [PATCH 02/47] add gui config write path with settings dto and validation --- go.mod | 2 +- pkg/deej/config.go | 18 ++ pkg/deej/config_settings.go | 306 +++++++++++++++++++++++++++++++ pkg/deej/config_settings_test.go | 236 ++++++++++++++++++++++++ pkg/deej/session_map.go | 17 ++ 5 files changed, 578 insertions(+), 1 deletion(-) create mode 100644 pkg/deej/config_settings.go create mode 100644 pkg/deej/config_settings_test.go diff --git a/go.mod b/go.mod index 357022a0..71ce5f4f 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( github.com/wailsapp/wails/v3 v3.0.0-alpha2.117 go.bug.st/serial v1.6.4 go.uber.org/zap v1.27.1 + go.yaml.in/yaml/v3 v3.0.4 golang.org/x/sys v0.43.0 golang.org/x/text v0.37.0 ) @@ -44,6 +45,5 @@ require ( github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect ) diff --git a/pkg/deej/config.go b/pkg/deej/config.go index d628461c..d37fa81b 100644 --- a/pkg/deej/config.go +++ b/pkg/deej/config.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "strings" + "sync" "sync/atomic" "time" @@ -70,6 +71,13 @@ type CanonicalConfig struct { userConfig *viper.Viper internalConfig *viper.Viper + // guards viper access between the file watcher and the settings GUI + viperLock sync.Mutex + + // unix-nano timestamp of the last GUI-initiated config write, used to + // suppress the file watcher event caused by our own save + lastSelfWrite atomic.Int64 + configPath string } @@ -180,6 +188,9 @@ func NewConfig(logger *zap.SugaredLogger, notifier notify.Notifier, configPath s // Load reads deej's config files from disk and tries to parse them func (cc *CanonicalConfig) Load(localizer *i18n.Localizer) error { + cc.viperLock.Lock() + defer cc.viperLock.Unlock() + cc.logger.Debugw("Loading config", "path", cc.configPath) // make sure it exists @@ -299,6 +310,13 @@ func (cc *CanonicalConfig) WatchConfigFileChanges(localizer *i18n.Localizer) { return } + // ignore events caused by a GUI save; it already loaded and applied + // the new config synchronously, and shows its own confirmation + if time.Since(time.Unix(0, cc.lastSelfWrite.Load())) < selfWriteSuppressWindow { + cc.logger.Debug("Ignoring config file event caused by GUI save") + return + } + // when we get a write event... if event.Op&fsnotify.Write == fsnotify.Write { diff --git a/pkg/deej/config_settings.go b/pkg/deej/config_settings.go new file mode 100644 index 00000000..6c789644 --- /dev/null +++ b/pkg/deej/config_settings.go @@ -0,0 +1,306 @@ +package deej + +import ( + "fmt" + "os" + "sort" + "strconv" + "strings" + "time" + + "github.com/nicksnyder/go-i18n/v2/i18n" + "github.com/thoas/go-funk" + "go.yaml.in/yaml/v3" +) + +// SliderMappingEntry is a JSON-friendly representation of a single slider's targets +type SliderMappingEntry struct { + Slider int `json:"slider"` + Targets []string `json:"targets"` +} + +// SettingsDTO is a JSON-friendly mirror of the user config file, used by the settings GUI +type SettingsDTO struct { + ComPort string `json:"comPort"` + BaudRate int `json:"baudRate"` + ComVID string `json:"comVid"` // 16-bit hex string, e.g. "1A86" + ComPID string `json:"comPid"` + InvertSliders bool `json:"invertSliders"` + NoiseReduction string `json:"noiseReduction"` + Language string `json:"language"` + OBSEnabled bool `json:"obsEnabled"` + OBSHost string `json:"obsHost"` + OBSPort int `json:"obsPort"` + OBSPassword string `json:"obsPassword"` + SliderMapping []SliderMappingEntry `json:"sliderMapping"` +} + +// how long after a GUI-initiated config write to ignore the resulting +// filesystem event, since the save already loads and applies the config itself +const selfWriteSuppressWindow = 2 * time.Second + +var validNoiseReductionLevels = []string{"", "low", "default", "high", "none"} +var validLanguages = []string{"auto", "en", "ru"} + +// Validate checks the DTO for values that would produce a broken config file +func (dto *SettingsDTO) Validate() error { + if dto.ComPort == "" { + return fmt.Errorf("com port must not be empty") + } + + if dto.BaudRate <= 0 { + return fmt.Errorf("baud rate must be a positive number") + } + + if _, err := parseHexWord(dto.ComVID); err != nil { + return fmt.Errorf("com vid: %w", err) + } + + if _, err := parseHexWord(dto.ComPID); err != nil { + return fmt.Errorf("com pid: %w", err) + } + + if !funk.ContainsString(validNoiseReductionLevels, dto.NoiseReduction) { + return fmt.Errorf("invalid noise reduction level: %s", dto.NoiseReduction) + } + + if !funk.ContainsString(validLanguages, dto.Language) { + return fmt.Errorf("invalid language: %s", dto.Language) + } + + if dto.OBSPort < 1 || dto.OBSPort > 65535 { + return fmt.Errorf("obs port must be between 1 and 65535") + } + + seenSliders := map[int]bool{} + for _, entry := range dto.SliderMapping { + if entry.Slider < 0 { + return fmt.Errorf("slider index must not be negative: %d", entry.Slider) + } + if seenSliders[entry.Slider] { + return fmt.Errorf("duplicate slider index: %d", entry.Slider) + } + seenSliders[entry.Slider] = true + } + + return nil +} + +// parseHexWord parses a 16-bit hex string like "1A86" or "0x1A86" +func parseHexWord(value string) (uint64, error) { + trimmed := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(value)), "0x") + parsed, err := strconv.ParseUint(trimmed, 16, 16) + if err != nil { + return 0, fmt.Errorf("must be a 16-bit hex value: %s", value) + } + + return parsed, nil +} + +// UserSettings returns the current contents of the user config file as a DTO. +// It reads the user config only, so mappings merged from the internal config +// never leak into the user's config file on save +func (cc *CanonicalConfig) UserSettings() SettingsDTO { + cc.viperLock.Lock() + defer cc.viperLock.Unlock() + + dto := SettingsDTO{ + ComPort: cc.userConfig.GetString(configKeyCOMPort), + BaudRate: cc.userConfig.GetInt(configKeyBaudRate), + ComVID: fmt.Sprintf("%04X", cc.userConfig.GetUint64(configKeyComVID)), + ComPID: fmt.Sprintf("%04X", cc.userConfig.GetUint64(configKeyComPID)), + InvertSliders: cc.userConfig.GetBool(configKeyInvertSliders), + NoiseReduction: cc.userConfig.GetString(configKeyNoiseReductionLevel), + Language: cc.userConfig.GetString(configKeyLanguage), + OBSEnabled: cc.userConfig.GetBool(configKeyOBSEnabled), + OBSHost: cc.userConfig.GetString(configKeyOBSHost), + OBSPort: cc.userConfig.GetInt(configKeyOBSPort), + OBSPassword: cc.userConfig.GetString(configKeyOBSPassword), + SliderMapping: []SliderMappingEntry{}, + } + + for sliderIdxString, targets := range cc.userConfig.GetStringMapStringSlice(configKeySliderMapping) { + sliderIdx, err := strconv.Atoi(sliderIdxString) + if err != nil { + continue + } + + dto.SliderMapping = append(dto.SliderMapping, SliderMappingEntry{ + Slider: sliderIdx, + Targets: funk.FilterString(targets, func(s string) bool { return s != "" }), + }) + } + + sort.Slice(dto.SliderMapping, func(i, j int) bool { + return dto.SliderMapping[i].Slider < dto.SliderMapping[j].Slider + }) + + return dto +} + +// SaveUserSettings validates the DTO, patches the user config file on disk +// (preserving comments, key order and unknown keys), and applies the new +// config immediately +func (cc *CanonicalConfig) SaveUserSettings(dto SettingsDTO, localizer *i18n.Localizer) error { + if err := dto.Validate(); err != nil { + return err + } + + vid, _ := parseHexWord(dto.ComVID) + pid, _ := parseHexWord(dto.ComPID) + + data, err := os.ReadFile(cc.configPath) + if err != nil { + cc.logger.Warnw("Failed to read config file for saving", "error", err) + return fmt.Errorf("read config for save: %w", err) + } + + var doc yaml.Node + if err := yaml.Unmarshal(data, &doc); err != nil { + cc.logger.Warnw("Failed to parse config file for saving", "error", err) + return fmt.Errorf("parse config for save: %w", err) + } + + root := yamlEnsureDocumentMapping(&doc) + + yamlSetKey(root, configKeySliderMapping, sliderMappingNode(dto.SliderMapping)) + yamlSetKey(root, configKeyInvertSliders, yamlBoolNode(dto.InvertSliders)) + yamlSetKey(root, configKeyCOMPort, yamlStringNode(dto.ComPort)) + yamlSetKey(root, configKeyBaudRate, yamlIntNode(dto.BaudRate)) + yamlSetKey(root, "com_vid", yamlHexNode(vid)) + yamlSetKey(root, "com_pid", yamlHexNode(pid)) + yamlSetKey(root, configKeyLanguage, yamlStringNode(dto.Language)) + + // don't add an empty noise_reduction key to configs that never had one + if dto.NoiseReduction != "" || yamlFindValue(root, configKeyNoiseReductionLevel) != nil { + yamlSetKey(root, configKeyNoiseReductionLevel, yamlStringNode(dto.NoiseReduction)) + } + + obs := yamlFindValue(root, "obs") + if obs == nil || obs.Kind != yaml.MappingNode { + obs = &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} + yamlSetKey(root, "obs", obs) + } + yamlSetKey(obs, "enabled", yamlBoolNode(dto.OBSEnabled)) + yamlSetKey(obs, "host", yamlStringNode(dto.OBSHost)) + yamlSetKey(obs, "port", yamlIntNode(dto.OBSPort)) + yamlSetKey(obs, "password", yamlStringNode(dto.OBSPassword)) + + out, err := yaml.Marshal(&doc) + if err != nil { + return fmt.Errorf("marshal config for save: %w", err) + } + + // the write below must happen in place (truncate and write): the file + // watcher only reacts to write events, and reacting to it twice is + // prevented by suppressing events for the duration of the window + cc.lastSelfWrite.Store(time.Now().UnixNano()) + + if err := os.WriteFile(cc.configPath, out, 0o644); err != nil { + cc.logger.Warnw("Failed to write config file", "error", err) + return fmt.Errorf("write config for save: %w", err) + } + + cc.logger.Infow("Saved user settings to config file", "path", cc.configPath) + + // apply immediately instead of relying on the watcher's debounce timing + if err := cc.Load(localizer); err != nil { + return fmt.Errorf("load config after save: %w", err) + } + cc.onConfigReloaded() + + return nil +} + +func yamlStringNode(value string) *yaml.Node { + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value} +} + +func yamlIntNode(value int) *yaml.Node { + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!int", Value: strconv.Itoa(value)} +} + +func yamlHexNode(value uint64) *yaml.Node { + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!int", Value: fmt.Sprintf("0x%04X", value)} +} + +func yamlBoolNode(value bool) *yaml.Node { + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!bool", Value: strconv.FormatBool(value)} +} + +// sliderMappingNode builds the slider_mapping value: single targets are +// written as plain scalars, multiple targets as sequences, matching the +// style of the example config +func sliderMappingNode(entries []SliderMappingEntry) *yaml.Node { + sorted := make([]SliderMappingEntry, len(entries)) + copy(sorted, entries) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].Slider < sorted[j].Slider }) + + mapping := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} + + for _, entry := range sorted { + targets := funk.FilterString(entry.Targets, func(s string) bool { return s != "" }) + if len(targets) == 0 { + continue + } + + var value *yaml.Node + if len(targets) == 1 { + value = yamlStringNode(targets[0]) + } else { + value = &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"} + for _, target := range targets { + value.Content = append(value.Content, yamlStringNode(target)) + } + } + + mapping.Content = append(mapping.Content, yamlIntNode(entry.Slider), value) + } + + return mapping +} + +// yamlEnsureDocumentMapping returns the document's root mapping node, +// creating the structure if the file was empty +func yamlEnsureDocumentMapping(doc *yaml.Node) *yaml.Node { + if doc.Kind == yaml.DocumentNode && len(doc.Content) > 0 && doc.Content[0].Kind == yaml.MappingNode { + return doc.Content[0] + } + + root := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} + doc.Kind = yaml.DocumentNode + doc.Tag = "" + doc.Value = "" + doc.Content = []*yaml.Node{root} + + return root +} + +// yamlFindValue returns the value node for a key in a mapping node, or nil +func yamlFindValue(mapping *yaml.Node, key string) *yaml.Node { + for i := 0; i+1 < len(mapping.Content); i += 2 { + if mapping.Content[i].Value == key { + return mapping.Content[i+1] + } + } + + return nil +} + +// yamlSetKey replaces the value of a key in a mapping node, keeping the key +// node (and any comments attached to it) intact; missing keys are appended +func yamlSetKey(mapping *yaml.Node, key string, value *yaml.Node) { + for i := 0; i+1 < len(mapping.Content); i += 2 { + if mapping.Content[i].Value == key { + // keep comments that were attached to the old value node + value.HeadComment = mapping.Content[i+1].HeadComment + value.LineComment = mapping.Content[i+1].LineComment + value.FootComment = mapping.Content[i+1].FootComment + mapping.Content[i+1] = value + return + } + } + + mapping.Content = append(mapping.Content, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}, value) +} diff --git a/pkg/deej/config_settings_test.go b/pkg/deej/config_settings_test.go new file mode 100644 index 00000000..26ce41af --- /dev/null +++ b/pkg/deej/config_settings_test.go @@ -0,0 +1,236 @@ +package deej + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/nicksnyder/go-i18n/v2/i18n" + "go.uber.org/zap" + "golang.org/x/text/language" +) + +type fakeNotifier struct{} + +func (fakeNotifier) Notify(string, string) {} + +func newTestLocalizer() *i18n.Localizer { + bundle := i18n.NewBundle(language.English) + return i18n.NewLocalizer(bundle, "en") +} + +func newTestConfig(t *testing.T, contents string) *CanonicalConfig { + t.Helper() + + dir := t.TempDir() + configPath := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(configPath, []byte(contents), 0o644); err != nil { + t.Fatalf("write test config: %v", err) + } + + cc, err := NewConfig(zap.NewNop().Sugar(), fakeNotifier{}, configPath) + if err != nil { + t.Fatalf("create config: %v", err) + } + + if err := cc.Load(newTestLocalizer()); err != nil { + t.Fatalf("load config: %v", err) + } + + return cc +} + +const testConfigContents = `# top comment about mappings +slider_mapping: + 0: master + 1: [chrome.exe, firefox.exe] + +# whether sliders are inverted +invert_sliders: false + +com_port: COM4 +baud_rate: 9600 + +# some unknown key deej doesn't manage +my_custom_key: hello + +obs: + enabled: false + host: localhost + port: 4455 + password: "" +` + +func TestSaveUserSettingsPreservesCommentsAndUnknownKeys(t *testing.T) { + cc := newTestConfig(t, testConfigContents) + + dto := cc.UserSettings() + dto.ComPort = "COM7" + dto.InvertSliders = true + dto.NoiseReduction = "high" + dto.OBSEnabled = true + dto.SliderMapping = []SliderMappingEntry{ + {Slider: 0, Targets: []string{"master"}}, + {Slider: 2, Targets: []string{"discord.exe", "spotify.exe"}}, + } + + if err := cc.SaveUserSettings(dto, newTestLocalizer()); err != nil { + t.Fatalf("save settings: %v", err) + } + + data, err := os.ReadFile(cc.configPath) + if err != nil { + t.Fatalf("read saved config: %v", err) + } + saved := string(data) + + for _, want := range []string{ + "# top comment about mappings", + "# whether sliders are inverted", + "# some unknown key deej doesn't manage", + "my_custom_key: hello", + "com_port: COM7", + "invert_sliders: true", + "noise_reduction: high", + "discord.exe", + } { + if !strings.Contains(saved, want) { + t.Errorf("saved config missing %q:\n%s", want, saved) + } + } + + // the save must have applied the new values immediately + values := cc.Values() + if values.ConnectionInfo.COMPort != "COM7" { + t.Errorf("com port not applied, got %q", values.ConnectionInfo.COMPort) + } + if !values.InvertSliders { + t.Error("invert_sliders not applied") + } + if !values.OBSConfig.Enabled { + t.Error("obs.enabled not applied") + } + targets, ok := values.SliderMapping.get(2) + if !ok || len(targets) != 2 { + t.Errorf("slider mapping not applied, got %v", targets) + } +} + +func TestSaveUserSettingsRoundTrip(t *testing.T) { + cc := newTestConfig(t, testConfigContents) + + dto := cc.UserSettings() + dto.BaudRate = 115200 + dto.ComVID = "2341" + dto.ComPID = "0043" + dto.Language = "ru" + dto.OBSHost = "192.168.1.5" + dto.OBSPort = 4456 + dto.OBSPassword = "secret" + + if err := cc.SaveUserSettings(dto, newTestLocalizer()); err != nil { + t.Fatalf("save settings: %v", err) + } + + got := cc.UserSettings() + if got.BaudRate != 115200 || got.ComVID != "2341" || got.ComPID != "0043" || + got.Language != "ru" || got.OBSHost != "192.168.1.5" || + got.OBSPort != 4456 || got.OBSPassword != "secret" { + t.Errorf("round trip mismatch: %+v", got) + } + + if len(got.SliderMapping) != 2 { + t.Fatalf("expected 2 mapping entries, got %v", got.SliderMapping) + } + if got.SliderMapping[1].Slider != 1 || len(got.SliderMapping[1].Targets) != 2 { + t.Errorf("multi-target mapping mismatch: %+v", got.SliderMapping[1]) + } +} + +func TestSaveUserSettingsNotifiesConsumers(t *testing.T) { + cc := newTestConfig(t, testConfigContents) + + reloaded := cc.SubscribeToChanges() + done := make(chan bool) + go func() { + done <- <-reloaded + }() + + dto := cc.UserSettings() + if err := cc.SaveUserSettings(dto, newTestLocalizer()); err != nil { + t.Fatalf("save settings: %v", err) + } + + if !<-done { + t.Error("expected reload notification") + } +} + +func TestSettingsDTOValidate(t *testing.T) { + valid := SettingsDTO{ + ComPort: "auto", + BaudRate: 9600, + ComVID: "1A86", + ComPID: "7523", + NoiseReduction: "default", + Language: "auto", + OBSPort: 4455, + SliderMapping: []SliderMappingEntry{ + {Slider: 0, Targets: []string{"master"}}, + }, + } + + if err := valid.Validate(); err != nil { + t.Errorf("valid DTO rejected: %v", err) + } + + cases := []struct { + name string + mutate func(*SettingsDTO) + }{ + {"empty com port", func(d *SettingsDTO) { d.ComPort = "" }}, + {"zero baud rate", func(d *SettingsDTO) { d.BaudRate = 0 }}, + {"bad vid", func(d *SettingsDTO) { d.ComVID = "xyz" }}, + {"vid too large", func(d *SettingsDTO) { d.ComVID = "12345" }}, + {"bad noise level", func(d *SettingsDTO) { d.NoiseReduction = "extreme" }}, + {"bad language", func(d *SettingsDTO) { d.Language = "de" }}, + {"obs port too large", func(d *SettingsDTO) { d.OBSPort = 70000 }}, + {"negative slider", func(d *SettingsDTO) { + d.SliderMapping = []SliderMappingEntry{{Slider: -1, Targets: []string{"a"}}} + }}, + {"duplicate slider", func(d *SettingsDTO) { + d.SliderMapping = []SliderMappingEntry{ + {Slider: 1, Targets: []string{"a"}}, + {Slider: 1, Targets: []string{"b"}}, + } + }}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dto := valid + tc.mutate(&dto) + if err := dto.Validate(); err == nil { + t.Error("expected validation error") + } + }) + } +} + +func TestSaveUserSettingsRejectsInvalid(t *testing.T) { + cc := newTestConfig(t, testConfigContents) + + dto := cc.UserSettings() + dto.BaudRate = -1 + + if err := cc.SaveUserSettings(dto, newTestLocalizer()); err == nil { + t.Error("expected save to fail validation") + } + + // file must be untouched + data, _ := os.ReadFile(cc.configPath) + if string(data) != testConfigContents { + t.Error("config file was modified by a rejected save") + } +} diff --git a/pkg/deej/session_map.go b/pkg/deej/session_map.go index ef87b976..f4ca5ed1 100644 --- a/pkg/deej/session_map.go +++ b/pkg/deej/session_map.go @@ -2,6 +2,7 @@ package deej import ( "fmt" + "sort" "strings" "sync" @@ -447,6 +448,22 @@ func (m *sessionMap) getSessionCount() int { return count } +// sessionKeys returns a sorted copy of the current session keys, used by the +// settings GUI for slider mapping suggestions +func (m *sessionMap) sessionKeys() []string { + m.lock.Lock() + defer m.lock.Unlock() + + keys := make([]string, 0, len(m.m)) + for key := range m.m { + keys = append(keys, key) + } + + sort.Strings(keys) + + return keys +} + func (m *sessionMap) String() string { return fmt.Sprintf("<%d audio sessions>", m.getSessionCount()) } From a62dc862acfdbf3c73204ff55e3a343b3df6cea3 Mon Sep 17 00:00:00 2001 From: Nikita Date: Sun, 12 Jul 2026 22:10:11 +0300 Subject: [PATCH 03/47] add settings service and generated bindings --- .../nik9play/deej/pkg/deej/index.ts | 14 ++ .../nik9play/deej/pkg/deej/models.ts | 199 ++++++++++++++++++ .../nik9play/deej/pkg/deej/settingsservice.ts | 68 ++++++ .../wailsapp/wails/v3/internal/eventcreate.ts | 9 + .../wailsapp/wails/v3/internal/eventdata.d.ts | 2 + pkg/deej/deej.go | 4 + pkg/deej/settings_service.go | 91 ++++++++ pkg/deej/tray.go | 3 + 8 files changed, 390 insertions(+) create mode 100644 frontend/bindings/github.com/nik9play/deej/pkg/deej/index.ts create mode 100644 frontend/bindings/github.com/nik9play/deej/pkg/deej/models.ts create mode 100644 frontend/bindings/github.com/nik9play/deej/pkg/deej/settingsservice.ts create mode 100644 frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.ts create mode 100644 frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts create mode 100644 pkg/deej/settings_service.go diff --git a/frontend/bindings/github.com/nik9play/deej/pkg/deej/index.ts b/frontend/bindings/github.com/nik9play/deej/pkg/deej/index.ts new file mode 100644 index 00000000..cc491e8e --- /dev/null +++ b/frontend/bindings/github.com/nik9play/deej/pkg/deej/index.ts @@ -0,0 +1,14 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +import * as SettingsService from "./settingsservice.js"; +export { + SettingsService +}; + +export { + AppInfoDTO, + SerialPortDTO, + SettingsDTO, + SliderMappingEntry +} from "./models.js"; diff --git a/frontend/bindings/github.com/nik9play/deej/pkg/deej/models.ts b/frontend/bindings/github.com/nik9play/deej/pkg/deej/models.ts new file mode 100644 index 00000000..fd3c8177 --- /dev/null +++ b/frontend/bindings/github.com/nik9play/deej/pkg/deej/models.ts @@ -0,0 +1,199 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import { Create as $Create } from "@wailsio/runtime"; + +/** + * AppInfoDTO describes static application info for the settings GUI + */ +export class AppInfoDTO { + "version": string; + "configPath": string; + "resolvedLanguage": string; + "specialTargets": string[]; + + /** Creates a new AppInfoDTO instance. */ + constructor($$source: Partial = {}) { + if (!("version" in $$source)) { + this["version"] = ""; + } + if (!("configPath" in $$source)) { + this["configPath"] = ""; + } + if (!("resolvedLanguage" in $$source)) { + this["resolvedLanguage"] = ""; + } + if (!("specialTargets" in $$source)) { + this["specialTargets"] = []; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new AppInfoDTO instance from a string or object. + */ + static createFrom($$source: any = {}): AppInfoDTO { + const $$createField3_0 = $$createType0; + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + if ("specialTargets" in $$parsedSource) { + $$parsedSource["specialTargets"] = $$createField3_0($$parsedSource["specialTargets"]); + } + return new AppInfoDTO($$parsedSource as Partial); + } +} + +/** + * SerialPortDTO describes an available serial port + */ +export class SerialPortDTO { + "name": string; + "isUsb": boolean; + "vid": string; + "pid": string; + "product": string; + + /** Creates a new SerialPortDTO instance. */ + constructor($$source: Partial = {}) { + if (!("name" in $$source)) { + this["name"] = ""; + } + if (!("isUsb" in $$source)) { + this["isUsb"] = false; + } + if (!("vid" in $$source)) { + this["vid"] = ""; + } + if (!("pid" in $$source)) { + this["pid"] = ""; + } + if (!("product" in $$source)) { + this["product"] = ""; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new SerialPortDTO instance from a string or object. + */ + static createFrom($$source: any = {}): SerialPortDTO { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new SerialPortDTO($$parsedSource as Partial); + } +} + +/** + * SettingsDTO is a JSON-friendly mirror of the user config file, used by the settings GUI + */ +export class SettingsDTO { + "comPort": string; + "baudRate": number; + + /** + * 16-bit hex string, e.g. "1A86" + */ + "comVid": string; + "comPid": string; + "invertSliders": boolean; + "noiseReduction": string; + "language": string; + "obsEnabled": boolean; + "obsHost": string; + "obsPort": number; + "obsPassword": string; + "sliderMapping": SliderMappingEntry[]; + + /** Creates a new SettingsDTO instance. */ + constructor($$source: Partial = {}) { + if (!("comPort" in $$source)) { + this["comPort"] = ""; + } + if (!("baudRate" in $$source)) { + this["baudRate"] = 0; + } + if (!("comVid" in $$source)) { + this["comVid"] = ""; + } + if (!("comPid" in $$source)) { + this["comPid"] = ""; + } + if (!("invertSliders" in $$source)) { + this["invertSliders"] = false; + } + if (!("noiseReduction" in $$source)) { + this["noiseReduction"] = ""; + } + if (!("language" in $$source)) { + this["language"] = ""; + } + if (!("obsEnabled" in $$source)) { + this["obsEnabled"] = false; + } + if (!("obsHost" in $$source)) { + this["obsHost"] = ""; + } + if (!("obsPort" in $$source)) { + this["obsPort"] = 0; + } + if (!("obsPassword" in $$source)) { + this["obsPassword"] = ""; + } + if (!("sliderMapping" in $$source)) { + this["sliderMapping"] = []; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new SettingsDTO instance from a string or object. + */ + static createFrom($$source: any = {}): SettingsDTO { + const $$createField11_0 = $$createType2; + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + if ("sliderMapping" in $$parsedSource) { + $$parsedSource["sliderMapping"] = $$createField11_0($$parsedSource["sliderMapping"]); + } + return new SettingsDTO($$parsedSource as Partial); + } +} + +/** + * SliderMappingEntry is a JSON-friendly representation of a single slider's targets + */ +export class SliderMappingEntry { + "slider": number; + "targets": string[]; + + /** Creates a new SliderMappingEntry instance. */ + constructor($$source: Partial = {}) { + if (!("slider" in $$source)) { + this["slider"] = 0; + } + if (!("targets" in $$source)) { + this["targets"] = []; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new SliderMappingEntry instance from a string or object. + */ + static createFrom($$source: any = {}): SliderMappingEntry { + const $$createField1_0 = $$createType0; + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + if ("targets" in $$parsedSource) { + $$parsedSource["targets"] = $$createField1_0($$parsedSource["targets"]); + } + return new SliderMappingEntry($$parsedSource as Partial); + } +} + +// Private type creation functions +const $$createType0 = $Create.Array($Create.Any); +const $$createType1 = SliderMappingEntry.createFrom; +const $$createType2 = $Create.Array($$createType1); diff --git a/frontend/bindings/github.com/nik9play/deej/pkg/deej/settingsservice.ts b/frontend/bindings/github.com/nik9play/deej/pkg/deej/settingsservice.ts new file mode 100644 index 00000000..acaf0f28 --- /dev/null +++ b/frontend/bindings/github.com/nik9play/deej/pkg/deej/settingsservice.ts @@ -0,0 +1,68 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +/** + * SettingsService exposes configuration APIs to the settings GUI frontend + * @module + */ + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime"; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as $models from "./models.js"; + +/** + * GetAppInfo returns version and localization info along with the list of + * special slider targets deej supports + */ +export function GetAppInfo(): $CancellablePromise<$models.AppInfoDTO> { + return $Call.ByID(1852388263).then(($result: any) => { + return $$createType0($result); + }); +} + +/** + * GetSessionNames returns the current audio session keys, for slider mapping + * suggestions + */ +export function GetSessionNames(): $CancellablePromise { + return $Call.ByID(2577377956).then(($result: any) => { + return $$createType1($result); + }); +} + +/** + * GetSettings returns the current contents of the user config file + */ +export function GetSettings(): $CancellablePromise<$models.SettingsDTO> { + return $Call.ByID(1935164325).then(($result: any) => { + return $$createType2($result); + }); +} + +/** + * ListSerialPorts enumerates the serial ports available on this machine + */ +export function ListSerialPorts(): $CancellablePromise<$models.SerialPortDTO[]> { + return $Call.ByID(102515344).then(($result: any) => { + return $$createType4($result); + }); +} + +/** + * SaveSettings validates and writes the given settings to the user config + * file, applying them immediately + */ +export function SaveSettings(dto: $models.SettingsDTO): $CancellablePromise { + return $Call.ByID(523154580, dto); +} + +// Private type creation functions +const $$createType0 = $models.AppInfoDTO.createFrom; +const $$createType1 = $Create.Array($Create.Any); +const $$createType2 = $models.SettingsDTO.createFrom; +const $$createType3 = $models.SerialPortDTO.createFrom; +const $$createType4 = $Create.Array($$createType3); diff --git a/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.ts b/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.ts new file mode 100644 index 00000000..1ea10585 --- /dev/null +++ b/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventcreate.ts @@ -0,0 +1,9 @@ +//@ts-check +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import { Create as $Create } from "@wailsio/runtime"; + +Object.freeze($Create.Events); diff --git a/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts b/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts new file mode 100644 index 00000000..3dd1807b --- /dev/null +++ b/frontend/bindings/github.com/wailsapp/wails/v3/internal/eventdata.d.ts @@ -0,0 +1,2 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT diff --git a/pkg/deej/deej.go b/pkg/deej/deej.go index 94135f18..a1866b25 100644 --- a/pkg/deej/deej.go +++ b/pkg/deej/deej.go @@ -36,6 +36,9 @@ type Deej struct { localizer *i18n.Localizer tray trayState + // the language the localizer ended up using, after resolving "auto" + resolvedLanguage string + stopChannel chan bool version string verbose bool @@ -172,6 +175,7 @@ func (d *Deej) updateLocalizer() error { } d.logger.Infof("Selected language: %s", lang) d.localizer = i18n.NewLocalizer(d.bundle, lang, "en") + d.resolvedLanguage = lang return nil } diff --git a/pkg/deej/settings_service.go b/pkg/deej/settings_service.go new file mode 100644 index 00000000..94ea783b --- /dev/null +++ b/pkg/deej/settings_service.go @@ -0,0 +1,91 @@ +package deej + +import ( + "sort" + + "go.bug.st/serial/enumerator" +) + +// SettingsService exposes configuration APIs to the settings GUI frontend +type SettingsService struct { + d *Deej +} + +func newSettingsService(d *Deej) *SettingsService { + return &SettingsService{d: d} +} + +// SerialPortDTO describes an available serial port +type SerialPortDTO struct { + Name string `json:"name"` + IsUSB bool `json:"isUsb"` + VID string `json:"vid"` + PID string `json:"pid"` + Product string `json:"product"` +} + +// AppInfoDTO describes static application info for the settings GUI +type AppInfoDTO struct { + Version string `json:"version"` + ConfigPath string `json:"configPath"` + ResolvedLanguage string `json:"resolvedLanguage"` + SpecialTargets []string `json:"specialTargets"` +} + +// GetSettings returns the current contents of the user config file +func (s *SettingsService) GetSettings() SettingsDTO { + return s.d.config.UserSettings() +} + +// SaveSettings validates and writes the given settings to the user config +// file, applying them immediately +func (s *SettingsService) SaveSettings(dto SettingsDTO) error { + return s.d.config.SaveUserSettings(dto, s.d.localizer) +} + +// GetAppInfo returns version and localization info along with the list of +// special slider targets deej supports +func (s *SettingsService) GetAppInfo() AppInfoDTO { + return AppInfoDTO{ + Version: s.d.version, + ConfigPath: s.d.config.configPath, + ResolvedLanguage: s.d.resolvedLanguage, + SpecialTargets: []string{ + masterSessionName, + systemSessionName, + inputSessionName, + specialTargetTransformPrefix + specialTargetCurrentWindow, + specialTargetTransformPrefix + specialTargetCurrentFullscreenWindow, + specialTargetTransformPrefix + specialTargetAllUnmapped, + }, + } +} + +// GetSessionNames returns the current audio session keys, for slider mapping +// suggestions +func (s *SettingsService) GetSessionNames() []string { + return s.d.sessions.sessionKeys() +} + +// ListSerialPorts enumerates the serial ports available on this machine +func (s *SettingsService) ListSerialPorts() ([]SerialPortDTO, error) { + ports, err := enumerator.GetDetailedPortsList() + if err != nil { + return nil, err + } + + result := make([]SerialPortDTO, 0, len(ports)) + for _, port := range ports { + result = append(result, SerialPortDTO{ + Name: port.Name, + IsUSB: port.IsUSB, + VID: port.VID, + PID: port.PID, + Product: port.Product, + }) + } + + sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name }) + + return result, nil +} diff --git a/pkg/deej/tray.go b/pkg/deej/tray.go index d4a3fae4..067ea63c 100644 --- a/pkg/deej/tray.go +++ b/pkg/deej/tray.go @@ -158,6 +158,9 @@ func (d *Deej) initializeTray(onDone func()) { Windows: application.WindowsOptions{DisableQuitOnLastWindowClosed: true}, Linux: application.LinuxOptions{DisableQuitOnLastWindowClosed: true}, Assets: application.AssetOptions{Handler: application.AssetFileServerFS(dist)}, + Services: []application.Service{ + application.NewService(newSettingsService(d)), + }, PostShutdown: func() { close(d.tray.shutdownDone) }, From fec106a9621dbcd82ddecc49a5d0355d222443e7 Mon Sep 17 00:00:00 2001 From: Nikita Date: Sun, 12 Jul 2026 22:19:47 +0300 Subject: [PATCH 04/47] add svelte settings frontend and tray menu entry --- frontend/index.html | 12 + frontend/package-lock.json | 1270 +++++++++++++++++ frontend/package.json | 25 + frontend/src/App.svelte | 88 ++ .../src/components/BehaviorSection.svelte | 28 + .../src/components/ConnectionSection.svelte | 65 + .../src/components/LanguageSection.svelte | 19 + frontend/src/components/MappingEditor.svelte | 99 ++ frontend/src/components/ObsSection.svelte | 33 + frontend/src/lib/en.json | 43 + frontend/src/lib/i18n.ts | 19 + frontend/src/lib/ru.json | 43 + frontend/src/main.ts | 20 + frontend/src/style.css | 258 ++++ frontend/src/vite-env.d.ts | 2 + frontend/svelte.config.js | 5 + frontend/tsconfig.json | 13 + frontend/vite.config.ts | 13 + pkg/deej/lang/active.en.toml | 2 + pkg/deej/lang/active.ru.toml | 6 + pkg/deej/tray.go | 59 + 21 files changed, 2122 insertions(+) create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.svelte create mode 100644 frontend/src/components/BehaviorSection.svelte create mode 100644 frontend/src/components/ConnectionSection.svelte create mode 100644 frontend/src/components/LanguageSection.svelte create mode 100644 frontend/src/components/MappingEditor.svelte create mode 100644 frontend/src/components/ObsSection.svelte create mode 100644 frontend/src/lib/en.json create mode 100644 frontend/src/lib/i18n.ts create mode 100644 frontend/src/lib/ru.json create mode 100644 frontend/src/main.ts create mode 100644 frontend/src/style.css create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/svelte.config.js create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 00000000..6476266d --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + deej + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 00000000..0e2cf21f --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1270 @@ +{ + "name": "deej-frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "deej-frontend", + "version": "0.0.0", + "dependencies": { + "@wailsio/runtime": "latest" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@tsconfig/svelte": "^5.0.8", + "svelte": "^5.46.4", + "svelte-check": "^4.4.8", + "tslib": "^2.6.2", + "typescript": "^5.2.2", + "vite": "^8.0.5" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz", + "integrity": "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.0.tgz", + "integrity": "sha512-1LgZ/qUqSoq+QorD83lk2hka79Px0wXNW2q5V1nZlxGhQgw1jrsIbVz5YiCeucVLo4XvFLjXukUaQjIiqowkcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.2.0.tgz", + "integrity": "sha512-1SpkuMSRLfugrVX+IrKfE1RUegzo8AQzKQ6qQPfVzbcWi5IhuTPaKb5ZrLpucleFznkc4/RTeSPoRnGWFxX+EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.2" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" + } + }, + "node_modules/@tsconfig/svelte": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz", + "integrity": "sha512-UkNnw1/oFEfecR8ypyHIQuWYdkPvHiwcQ78sh+ymIiYoF+uc5H1UBetbjyqT+vgGJ3qQN6nhucJviX6HesWtKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@wailsio/runtime": { + "version": "3.0.0-alpha.97", + "resolved": "https://registry.npmjs.org/@wailsio/runtime/-/runtime-3.0.0-alpha.97.tgz", + "integrity": "sha512-alvEG6B0YqW7AcBcNaUWNMD9q8JWqx5AO6BPOZpijBeq4ZjhQxSom3jvvq8Z1mWSMKPEjSvlFirKIh9NLI3ajA==", + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.13", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.13.tgz", + "integrity": "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.17", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.17.tgz", + "integrity": "sha512-J7EF+8X+CzRPaJPOv9Ck2wNWJvGnnl3PcNPAdGg6GTLjyVpyQ0yATMSXRFRV01BviT/9Gwuc3rjEyJbDJG9a4w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svelte": { + "version": "5.56.4", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz", + "integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.12", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.7.2", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.2.tgz", + "integrity": "sha512-GoS4XJdGswlq0rIT1vtFLzJY1bvHtY37McY9H9Gkm1Ggw/ICdZYn8J/Z8Yi0BEL0i3R4+jtaWVePjyppMlij/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "^0.2.0", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", + "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.16", + "rolldown": "~1.1.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 00000000..25b1b84d --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,25 @@ +{ + "name": "deej-frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build:dev": "vite build --minify false --mode development", + "build": "vite build --mode production", + "preview": "vite preview", + "check": "svelte-check --tsconfig ./tsconfig.json" + }, + "dependencies": { + "@wailsio/runtime": "latest" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@tsconfig/svelte": "^5.0.8", + "svelte": "^5.46.4", + "svelte-check": "^4.4.8", + "tslib": "^2.6.2", + "typescript": "^5.2.2", + "vite": "^8.0.5" + } +} diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte new file mode 100644 index 00000000..9397fe64 --- /dev/null +++ b/frontend/src/App.svelte @@ -0,0 +1,88 @@ + + +

{t("title")}

+ +{#if settings} + + + + + + +
+ {#if appInfo?.version}{t("version")}: {appInfo.version} ·{/if} + {t("configPath")}: {appInfo?.configPath} +
+ + +{:else if statusText} +

{statusText}

+{/if} diff --git a/frontend/src/components/BehaviorSection.svelte b/frontend/src/components/BehaviorSection.svelte new file mode 100644 index 00000000..8e18f3eb --- /dev/null +++ b/frontend/src/components/BehaviorSection.svelte @@ -0,0 +1,28 @@ + + +
+

{t("behavior")}

+ +
+ + +
+ +
+ + +
+
diff --git a/frontend/src/components/ConnectionSection.svelte b/frontend/src/components/ConnectionSection.svelte new file mode 100644 index 00000000..7723aa1a --- /dev/null +++ b/frontend/src/components/ConnectionSection.svelte @@ -0,0 +1,65 @@ + + +
+

{t("connection")}

+ +
+
+ +
+ + +
+ + + {#each ports as port (port.name)} + + {/each} + +
+ +
+ + + + + + + + + +
+
+ + {#if settings.comPort === "auto"} +
+
+ + +
+
+ + +
+
+
{t("vidPidHint")}
+ {/if} +
diff --git a/frontend/src/components/LanguageSection.svelte b/frontend/src/components/LanguageSection.svelte new file mode 100644 index 00000000..8f0043ea --- /dev/null +++ b/frontend/src/components/LanguageSection.svelte @@ -0,0 +1,19 @@ + + +
+

{t("language")}

+ +
+ +
{t("languageHint")}
+
+
diff --git a/frontend/src/components/MappingEditor.svelte b/frontend/src/components/MappingEditor.svelte new file mode 100644 index 00000000..2c370102 --- /dev/null +++ b/frontend/src/components/MappingEditor.svelte @@ -0,0 +1,99 @@ + + +
+

{t("sliderMapping")}

+
{t("sliderMappingHint")}
+ + {#each rows as entry (entry.slider)} +
+ {t("slider")} {entry.slider} + +
+ {#each entry.targets as target (target)} + + {target} + + + {/each} + {#if entry.targets.length === 0} + {t("noTargets")} + {/if} + commitTarget(entry.slider)} + onkeydown={(e) => e.key === "Enter" && commitTarget(entry.slider)} + /> +
+ + +
+ {/each} + + + {#each suggestions as suggestion (suggestion)} + + {/each} + + +
+ +
+
diff --git a/frontend/src/components/ObsSection.svelte b/frontend/src/components/ObsSection.svelte new file mode 100644 index 00000000..ab2a96c1 --- /dev/null +++ b/frontend/src/components/ObsSection.svelte @@ -0,0 +1,33 @@ + + +
+

{t("obs")}

+ +
+ + +
+
{t("obsHint")}
+ + {#if settings.obsEnabled} +
+
+ + +
+
+ + +
+
+ + +
+
+ {/if} +
diff --git a/frontend/src/lib/en.json b/frontend/src/lib/en.json new file mode 100644 index 00000000..367a577d --- /dev/null +++ b/frontend/src/lib/en.json @@ -0,0 +1,43 @@ +{ + "title": "deej settings", + "connection": "Connection", + "comPort": "COM port", + "comPortAuto": "auto (detect by USB VID/PID)", + "refreshPorts": "Refresh port list", + "baudRate": "Baud rate", + "vid": "USB VID", + "pid": "USB PID", + "vidPidHint": "Used to find the mixer when the COM port is set to \"auto\". Hex values, e.g. 1A86.", + "behavior": "Behavior", + "invertSliders": "Invert sliders", + "invertSlidersHint": "Top is 0%, bottom is 100%", + "noiseReduction": "Noise reduction", + "noiseDefault": "default (regular hardware)", + "noiseLow": "low (excellent hardware)", + "noiseHigh": "high (noisy hardware)", + "noiseNone": "none (done on the hardware)", + "language": "Language", + "languageAuto": "auto (system language)", + "languageHint": "Applied to notifications and the tray menu after restarting deej.", + "obs": "OBS integration", + "obsEnabled": "Enable OBS WebSocket integration", + "obsHint": "Control OBS audio sources using deej.obs: targets.", + "obsHost": "Host", + "obsPort": "Port", + "obsPassword": "Password", + "sliderMapping": "Slider mapping", + "sliderMappingHint": "Slider indexes start at 0, regardless of which analog pins you're using.", + "slider": "Slider", + "addSlider": "Add slider", + "removeSlider": "Remove slider", + "addTargetPlaceholder": "Add a target (process name or special target)...", + "removeTarget": "Remove target", + "noTargets": "No targets - this slider does nothing", + "save": "Save", + "revert": "Revert", + "saved": "Settings saved", + "saveError": "Failed to save settings", + "loadError": "Failed to load settings", + "configPath": "Configuration file", + "version": "Version" +} diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts new file mode 100644 index 00000000..d18e59e3 --- /dev/null +++ b/frontend/src/lib/i18n.ts @@ -0,0 +1,19 @@ +import en from "./en.json"; +import ru from "./ru.json"; + +type Dictionary = Record; + +let dict: Dictionary = en; + +// initI18n must be called before the app is mounted +export function initI18n(language: string): void { + if (language.toLowerCase().startsWith("ru")) { + dict = ru; + } else { + dict = en; + } +} + +export function t(key: string): string { + return dict[key] ?? key; +} diff --git a/frontend/src/lib/ru.json b/frontend/src/lib/ru.json new file mode 100644 index 00000000..6676108d --- /dev/null +++ b/frontend/src/lib/ru.json @@ -0,0 +1,43 @@ +{ + "title": "Настройки deej", + "connection": "Подключение", + "comPort": "COM-порт", + "comPortAuto": "auto (поиск по USB VID/PID)", + "refreshPorts": "Обновить список портов", + "baudRate": "Скорость (бод)", + "vid": "USB VID", + "pid": "USB PID", + "vidPidHint": "Используется для поиска микшера, когда COM-порт установлен в \"auto\". Шестнадцатеричные значения, например 1A86.", + "behavior": "Поведение", + "invertSliders": "Инвертировать слайдеры", + "invertSlidersHint": "Верх - 0%, низ - 100%", + "noiseReduction": "Шумоподавление", + "noiseDefault": "default (обычное железо)", + "noiseLow": "low (отличное железо)", + "noiseHigh": "high (шумное железо)", + "noiseNone": "none (выполняется на устройстве)", + "language": "Язык", + "languageAuto": "auto (язык системы)", + "languageHint": "Применяется к уведомлениям и меню в трее после перезапуска deej.", + "obs": "Интеграция с OBS", + "obsEnabled": "Включить интеграцию OBS WebSocket", + "obsHint": "Управляйте источниками звука OBS с помощью целей deej.obs:<имя источника>.", + "obsHost": "Хост", + "obsPort": "Порт", + "obsPassword": "Пароль", + "sliderMapping": "Назначение слайдеров", + "sliderMappingHint": "Индексы слайдеров начинаются с 0, независимо от используемых аналоговых пинов.", + "slider": "Слайдер", + "addSlider": "Добавить слайдер", + "removeSlider": "Удалить слайдер", + "addTargetPlaceholder": "Добавить цель (имя процесса или специальная цель)...", + "removeTarget": "Удалить цель", + "noTargets": "Нет целей - этот слайдер ничего не делает", + "save": "Сохранить", + "revert": "Отменить", + "saved": "Настройки сохранены", + "saveError": "Не удалось сохранить настройки", + "loadError": "Не удалось загрузить настройки", + "configPath": "Файл конфигурации", + "version": "Версия" +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 00000000..82260b3f --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,20 @@ +import { mount } from "svelte"; +import App from "./App.svelte"; +import { initI18n } from "./lib/i18n"; +import { AppInfoDTO, SettingsService } from "../bindings/github.com/nik9play/deej/pkg/deej"; +import "./style.css"; + +let appInfo: AppInfoDTO | null = null; +try { + appInfo = await SettingsService.GetAppInfo(); + initI18n(appInfo.resolvedLanguage); +} catch (err) { + console.error("failed to load app info", err); +} + +document.title = "deej"; + +mount(App, { + target: document.getElementById("app")!, + props: { appInfo }, +}); diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 00000000..6b225cab --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,258 @@ +:root { + --bg: #f4f4f6; + --card-bg: #ffffff; + --text: #1c1c22; + --text-muted: #6b6b76; + --border: #d9d9e0; + --accent: #4a6cf7; + --accent-text: #ffffff; + --danger: #d64550; + --success: #2e9e5b; + --input-bg: #ffffff; + --chip-bg: #eceef8; + + font-family: "Segoe UI", system-ui, -apple-system, sans-serif; + font-size: 14px; + line-height: 1.45; + color-scheme: light dark; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #1e1e24; + --card-bg: #27272f; + --text: #e8e8ec; + --text-muted: #9a9aa6; + --border: #3a3a44; + --accent: #6f8bff; + --accent-text: #ffffff; + --danger: #ff6b75; + --success: #4fc47f; + --input-bg: #1e1e24; + --chip-bg: #33333e; + } +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); + user-select: none; +} + +#app { + max-width: 860px; + margin: 0 auto; + padding: 16px 20px 84px; +} + +h1 { + font-size: 20px; + margin: 8px 0 16px; +} + +section.card { + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: 10px; + padding: 16px 18px; + margin-bottom: 14px; +} + +section.card h2 { + font-size: 15px; + margin: 0 0 12px; +} + +.field { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 12px; +} + +.field-row { + display: flex; + gap: 14px; + flex-wrap: wrap; +} + +.field-row .field { + flex: 1 1 160px; +} + +label { + font-weight: 600; + font-size: 13px; +} + +.hint { + color: var(--text-muted); + font-size: 12px; +} + +input[type="text"], +input[type="number"], +input[type="password"], +select { + padding: 7px 10px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--input-bg); + color: var(--text); + font: inherit; + width: 100%; +} + +input:focus-visible, +select:focus-visible, +button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +.checkbox-field { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; +} + +.checkbox-field input { + width: 16px; + height: 16px; + accent-color: var(--accent); +} + +.checkbox-field label { + font-weight: 500; +} + +button { + padding: 7px 14px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--card-bg); + color: var(--text); + font: inherit; + cursor: pointer; +} + +button:hover { + border-color: var(--accent); +} + +button.primary { + background: var(--accent); + border-color: var(--accent); + color: var(--accent-text); + font-weight: 600; +} + +button.primary:disabled { + opacity: 0.5; + cursor: default; +} + +button.icon { + padding: 6px 9px; + line-height: 1; +} + +.mapping-row { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 0; + border-top: 1px solid var(--border); +} + +.mapping-row:first-of-type { + border-top: none; +} + +.mapping-index { + font-weight: 600; + min-width: 74px; + padding-top: 5px; + white-space: nowrap; +} + +.mapping-targets { + flex: 1; + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} + +.chip { + display: inline-flex; + align-items: center; + gap: 6px; + background: var(--chip-bg); + border: 1px solid var(--border); + border-radius: 14px; + padding: 3px 6px 3px 10px; + font-size: 13px; +} + +.chip button { + border: none; + background: none; + padding: 0 4px; + color: var(--text-muted); + font-size: 14px; +} + +.chip button:hover { + color: var(--danger); +} + +.mapping-targets input { + flex: 1 1 220px; + min-width: 160px; +} + +.empty-note { + color: var(--text-muted); + font-size: 12px; + font-style: italic; +} + +.footer { + position: fixed; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + gap: 10px; + padding: 12px 20px; + background: var(--card-bg); + border-top: 1px solid var(--border); +} + +.footer .status { + margin-left: auto; + font-size: 13px; +} + +.status.ok { + color: var(--success); +} + +.status.error { + color: var(--danger); +} + +.meta { + color: var(--text-muted); + font-size: 12px; + margin-top: 4px; + user-select: text; +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 00000000..4078e747 --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js new file mode 100644 index 00000000..8abe4369 --- /dev/null +++ b/frontend/svelte.config.js @@ -0,0 +1,5 @@ +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' + +export default { + preprocess: vitePreprocess(), +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 00000000..1f1ac3a6 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "@tsconfig/svelte/tsconfig.json", + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "module": "ESNext", + "resolveJsonModule": true, + "allowJs": true, + "checkJs": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte", "bindings"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 00000000..96e8650d --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vite"; +import { svelte } from "@sveltejs/vite-plugin-svelte"; +import wails from "@wailsio/runtime/plugins/vite"; + +// https://vitejs.dev/config/ +export default defineConfig({ + server: { + host: "127.0.0.1", + port: Number(process.env.WAILS_VITE_PORT) || 9245, + strictPort: true, + }, + plugins: [svelte(), wails("./bindings")], +}); diff --git a/pkg/deej/lang/active.en.toml b/pkg/deej/lang/active.en.toml index c9a057f2..4d5811f1 100644 --- a/pkg/deej/lang/active.en.toml +++ b/pkg/deej/lang/active.en.toml @@ -14,6 +14,8 @@ ConfigReloadDescription = "Your changes have been applied." ConfigReloadTitle = "Configuration reloaded!" EditConfigDescription = "Open config file with notepad" EditConfigTitle = "Edit configuration" +OpenSettingsDescription = "Open the settings window" +OpenSettingsTitle = "Open settings" QuitDescription = "Stop deej and quit" QuitTitle = "Quit" SettingsDescription = "Settings" diff --git a/pkg/deej/lang/active.ru.toml b/pkg/deej/lang/active.ru.toml index da8373ab..3b232f0d 100644 --- a/pkg/deej/lang/active.ru.toml +++ b/pkg/deej/lang/active.ru.toml @@ -69,6 +69,12 @@ other = "Редактировать файл конфигурации" hash = "sha1-8139ad1d0afcd3f4a2d34b1cafb1c4a1e8a51825" other = "Редактировать конфигурацию" +[OpenSettingsDescription] +other = "Открыть окно настроек" + +[OpenSettingsTitle] +other = "Открыть настройки" + [QuitDescription] hash = "sha1-2683afe6d5eb3d1a51548bd95d2ea0af240e381f" other = "Остановить deej и выйти" diff --git a/pkg/deej/tray.go b/pkg/deej/tray.go index 067ea63c..209c445a 100644 --- a/pkg/deej/tray.go +++ b/pkg/deej/tray.go @@ -5,6 +5,7 @@ import ( "log/slog" "strconv" "strings" + "sync" "time" "github.com/nicksnyder/go-i18n/v2/i18n" @@ -20,8 +21,13 @@ import ( type trayState struct { app *application.App shutdownDone chan struct{} + + // guards against concurrent settings window creation + settingsLock sync.Mutex } +const settingsWindowName = "deej-settings" + func getConfigItemText(d *Deej) (string, string) { configTitle := d.localizer.MustLocalize(&i18n.LocalizeConfig{ DefaultMessage: &i18n.Message{ @@ -56,6 +62,23 @@ func getSettingsItemText(d *Deej) (string, string) { return configTitle, configDescription } +func getOpenSettingsItemText(d *Deej) (string, string) { + openSettingsTitle := d.localizer.MustLocalize(&i18n.LocalizeConfig{ + DefaultMessage: &i18n.Message{ + ID: "OpenSettingsTitle", + Other: "Open settings", + }, + }) + openSettingsDescription := d.localizer.MustLocalize(&i18n.LocalizeConfig{ + DefaultMessage: &i18n.Message{ + ID: "OpenSettingsDescription", + Other: "Open the settings window", + }, + }) + + return openSettingsTitle, openSettingsDescription +} + func getAutostartItemText(d *Deej) (string, string) { configTitle := d.localizer.MustLocalize(&i18n.LocalizeConfig{ DefaultMessage: &i18n.Message{ @@ -185,6 +208,13 @@ func (d *Deej) initializeTray(onDone func()) { settingsTitle, _ := getSettingsItemText(d) settings := menu.AddSubmenu(settingsTitle) + openSettingsTitle, _ := getOpenSettingsItemText(d) + settings.Add(openSettingsTitle).OnClick(func(*application.Context) { + logger.Info("Open settings menu item clicked, opening settings window") + + d.openSettingsWindow() + }) + configTitle, _ := getConfigItemText(d) settings.Add(configTitle).OnClick(func(*application.Context) { logger.Info("Edit config menu item clicked, opening config for editing") @@ -235,6 +265,10 @@ func (d *Deej) initializeTray(onDone func()) { tray.SetMenu(menu) + tray.OnDoubleClick(func() { + d.openSettingsWindow() + }) + app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) { logger.Debug("Tray instance ready") @@ -281,6 +315,31 @@ func (d *Deej) initializeTray(onDone func()) { } } +// openSettingsWindow creates a fresh settings window, or focuses the existing +// one if it's already open. The window is fully destroyed when closed +func (d *Deej) openSettingsWindow() { + d.tray.settingsLock.Lock() + defer d.tray.settingsLock.Unlock() + + if win, ok := d.tray.app.Window.GetByName(settingsWindowName); ok { + win.Restore() + win.Focus() + return + } + + settingsTitle, _ := getSettingsItemText(d) + + d.tray.app.Window.NewWithOptions(application.WebviewWindowOptions{ + Name: settingsWindowName, + Title: "deej - " + settingsTitle, + Width: 860, + Height: 680, + MinWidth: 600, + MinHeight: 440, + URL: "/", + }) +} + func (d *Deej) stopTray() { if d.tray.app == nil { return From 547e4a3b202a6ace099b807bd02b86eab74dff91 Mon Sep 17 00:00:00 2001 From: Nikita Date: Sun, 12 Jul 2026 22:22:25 +0300 Subject: [PATCH 05/47] build frontend in build scripts and ci --- .github/workflows/main.yml | 53 ++++++++++++++++++++++++++++---------- frontend/README.md | 30 +++++++++++++++++++++ frontend/vite.config.ts | 17 ++++++++++-- scripts/README.md | 8 +++++- scripts/linux/build.sh | 13 ++++++++++ scripts/windows/build.bat | 16 ++++++++++++ 6 files changed, 120 insertions(+), 17 deletions(-) create mode 100644 frontend/README.md diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 81603c14..fab39c90 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -23,6 +23,13 @@ jobs: with: go-version: "1.25.7" + # the wails linux backend is cgo and needs GTK/WebKitGTK headers + - name: Install GTK dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libgtk-4-dev libwebkitgtk-6.0-dev + - name: Run tests run: go test ./... @@ -40,6 +47,13 @@ jobs: with: go-version: "1.25.7" + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: frontend/package-lock.json + - name: Build deej (amd64) run: scripts/windows/build-all.bat shell: cmd @@ -70,9 +84,18 @@ jobs: name: deej-windows path: build/ + # linux builds are cgo (wails GTK backend), so each architecture builds + # natively on a matching runner instead of cross-compiling build-linux: - name: Build (linux) - runs-on: ubuntu-latest + name: Build (linux ${{ matrix.arch }}) + runs-on: ${{ matrix.runner }} + strategy: + matrix: + include: + - arch: amd64 + runner: ubuntu-latest + - arch: arm64 + runner: ubuntu-24.04-arm steps: - name: Checkout uses: actions/checkout@v4 @@ -84,28 +107,30 @@ jobs: with: go-version: "1.25.7" - - name: Build deej (amd64) - run: scripts/linux/build-all.sh + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: frontend/package-lock.json - - name: Rename binaries (amd64) + - name: Install GTK dependencies run: | - mv build/deej-release build/deej-linux-amd64 - mv build/deej-dev build/deej-linux-amd64-dev + sudo apt-get update + sudo apt-get install -y libgtk-4-dev libwebkitgtk-6.0-dev - - name: Build deej (arm64) + - name: Build deej run: scripts/linux/build-all.sh - env: - GOARCH: arm64 - - name: Rename binaries (arm64) + - name: Rename binaries run: | - mv build/deej-release build/deej-linux-arm64 - mv build/deej-dev build/deej-linux-arm64-dev + mv build/deej-release build/deej-linux-${{ matrix.arch }} + mv build/deej-dev build/deej-linux-${{ matrix.arch }}-dev - name: Upload artifacts uses: actions/upload-artifact@v4 with: - name: deej-linux + name: deej-linux-${{ matrix.arch }} path: build/ release: diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 00000000..a6f0f275 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,30 @@ +# deej settings frontend + +Svelte 5 + Vite frontend for the settings window, embedded into the deej binary via `go:embed` (see `embed.go`). The build scripts in [`scripts/`](../scripts) run `npm ci` and `npm run build` automatically. + +## Development loop + +For live-reload development of the frontend: + +``` +cd frontend +npm run dev +``` + +Then run deej with the dev server URL so the settings window loads from Vite instead of the embedded assets: + +``` +FRONTEND_DEVSERVER_URL=http://127.0.0.1:9245 ./build/deej-dev +``` + +(On Windows: `set FRONTEND_DEVSERVER_URL=http://127.0.0.1:9245` before starting `deej-dev.exe`.) + +## Regenerating bindings + +The TypeScript bindings in `bindings/` are generated from the Go `SettingsService` and are committed, so plain `go build` and CI never need the generator. Regenerate them after changing the service surface: + +``` +go run github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-alpha2.117 generate bindings -ts -clean -d frontend/bindings ./pkg/deej +``` + +Keep the CLI version pinned to the `github.com/wailsapp/wails/v3` version in `go.mod`. diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 96e8650d..b71ae050 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,7 +1,20 @@ -import { defineConfig } from "vite"; +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { defineConfig, type Plugin } from "vite"; import { svelte } from "@sveltejs/vite-plugin-svelte"; import wails from "@wailsio/runtime/plugins/vite"; +// dist/.gitkeep is committed so the Go embed compiles before the first build; +// recreate it after vite empties the output directory +function keepGitkeep(): Plugin { + return { + name: "keep-gitkeep", + closeBundle() { + writeFileSync(join(import.meta.dirname, "dist", ".gitkeep"), ""); + }, + }; +} + // https://vitejs.dev/config/ export default defineConfig({ server: { @@ -9,5 +22,5 @@ export default defineConfig({ port: Number(process.env.WAILS_VITE_PORT) || 9245, strictPort: true, }, - plugins: [svelte(), wails("./bindings")], + plugins: [svelte(), wails("./bindings"), keepGitkeep()], }); diff --git a/scripts/README.md b/scripts/README.md index 77cf9c80..cfc40c55 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -19,4 +19,10 @@ This document lists the various scripts in the project and their purposes. - [`build-release.sh`](./linux/build-release.sh): Builds deej for releases - [`build-all.sh`](./linux/build-all.sh): Helper script to build all variants -The shell scripts honor `GOOS`/`GOARCH` for cross-compilation (e.g. `GOOS=windows GOARCH=arm64 ./scripts/linux/build-release.sh`); the CI workflow uses this to build all platforms on a single Linux runner. +### Build requirements + +All build scripts build the settings GUI frontend first, which requires [Node.js](https://nodejs.org) (see [`frontend/`](../frontend)). + +Linux builds use cgo (the [Wails](https://wails.io) GTK backend for the tray and settings window) and need GTK4/WebKitGTK headers, e.g. on Ubuntu 24.04+: `sudo apt-get install libgtk-4-dev libwebkitgtk-6.0-dev`. For older distros that lack `webkitgtk-6.0`, build with `-tags gtk3` and `libgtk-3-dev libwebkit2gtk-4.1-dev` instead. + +Windows binaries are cgo-free, so the shell scripts can still cross-compile them (e.g. `GOOS=windows GOARCH=arm64 ./scripts/linux/build-release.sh`). Linux targets must be built natively on a matching architecture; CI uses a native arm64 runner for the arm64 build. diff --git a/scripts/linux/build.sh b/scripts/linux/build.sh index 06689dac..da31cb46 100755 --- a/scripts/linux/build.sh +++ b/scripts/linux/build.sh @@ -27,6 +27,19 @@ if [ "$(go env GOOS)" = "windows" ]; then GUIFLAG="-H=windowsgui " fi +# Build the settings GUI frontend (embedded into the binary) +echo "Building frontend..." +( + cd frontend || exit 1 + if [ ! -d node_modules ]; then + npm ci || exit 1 + fi + npm run build || exit 1 +) || { + echo 'Error: frontend build failed.' + exit 1 +} + # Get git commit and version tag GIT_COMMIT=$(git rev-list -1 --abbrev-commit HEAD) VERSION_TAG=$(git describe --tags --always) diff --git a/scripts/windows/build.bat b/scripts/windows/build.bat index 451adeb7..b4659660 100644 --- a/scripts/windows/build.bat +++ b/scripts/windows/build.bat @@ -32,6 +32,22 @@ IF DEFINED NEED_RSRC ( go generate %DEEJ_ROOT%/... ) +ECHO Building frontend... +PUSHD "%DEEJ_ROOT%\frontend" +IF NOT EXIST node_modules ( + CALL npm ci + IF ERRORLEVEL 1 ( + POPD + GOTO BUILDERROR + ) +) +CALL npm run build +IF ERRORLEVEL 1 ( + POPD + GOTO BUILDERROR +) +POPD + ECHO Embedding build-time parameters: ECHO - gitCommit %GIT_COMMIT% ECHO - versionTag %VERSION_TAG% From 628c658411841f8234c9e792b47f27a43890c777 Mon Sep 17 00:00:00 2001 From: Nikita Date: Sun, 12 Jul 2026 22:44:17 +0300 Subject: [PATCH 06/47] use png tray icon on windows, wails cannot load ico containers --- pkg/icon/assets/tray-icon.png | Bin 0 -> 2987 bytes pkg/icon/icon_windows.go | 5 +++-- pkg/notify/notify.go | 6 ++---- 3 files changed, 5 insertions(+), 6 deletions(-) create mode 100644 pkg/icon/assets/tray-icon.png diff --git a/pkg/icon/assets/tray-icon.png b/pkg/icon/assets/tray-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..35bf7abd9f14567fb12e7f03783cfc1b61a1fd74 GIT binary patch literal 2987 zcmV;c3sm%pP){&Nrp)9DlTtW>m~kw&NDL;6?dG!EtTwAwV=?q} znsx2XhTi%bZO5gH+Sc=D%;xKDW2)d7$Rxx`MKcp7E?O2hVa_7HI3DX_p93~$^kg&QxYt^c+Lsw>2Sv_fYYUd_Im8zxB{POR7-`~1Cnm|Wm#;PJZ(P%L*Hn#_1IWAP{BC>Z zJ^%k(f*{FA8$FVk1dElBzF-NPBawAApE?1+8H`cEP62Mg1AF!-Oqjno1cyZoHyV$B zUU6v6V*t#f@do1n?#zciJ`|^zHIE+k?hHDcPaiM)Y{in%oG{uHAZI+V=RkbQoCT2% z@lZ)1PDvNVX63ZkA1Vzr9RfNafA$}?D>9d@WZ;+iCzL8Du{c6eXUpj?Lv}!F_KN?@ zDSGv-NRK2{lEqIgH0WBcYI|ya@@1#b9N=2#=dbwc0?FiX14YO$1@-}x>BJ{<^YuMV z^&{H|AN*v|yls0~1c%FT0{;y}GWq=Jv$wtHE8=f^kTH4PhP3SEYa<=HbW>DxVv^a^ z+u7H3@vOJK!=`{hAc(nt<+%nnJ2JKXSi@r0^nF@Vm=4_9Ne-I=%Dk6e;jl#_DxOYP zu&`mZI4Is((|K4rUl0`Ui)+PDj3+5Y0hcS5<`u2q>}~Tc2MC4IjNH}F5l(;#CB~A- z&0q(`o0`OeGL;n+Z%zgWi7_N4-CUHJ`_v17NPL?Ea+IrzStOrNb*_03h$k(Xg$--P zxVM0dEF~M8pOGMU5{I(fV2H(z5@b(Vxo)Hb2xZn2#dKv|x1xU9W)<-8<|Y|PCIkCQ z_4wz9TIlo+Y+f(H(ggzlay>@Spx?yImBk+0-G`#_@kKL*!em;Gu^1!%dm|~4#F`Z% zeDtXfU!6AHd|q+RjB^*w*jOyV)g~LNYb}0d`;r?fgyOXHxVXCuy1LGuaeEF>wG&S3B@*C4_Z9i38P+r-O`{(fe%cZe4P$(BajG-`KBrEP)MCY0dKhG6% z7HaA&e*3xFQsk_VWQI#g$#WLEWRhHNf?OD#k`~b8m6QV znOe0S@9b1#{WD@r%3$MSl?8UY6Iq!Y$mJyVyr)HLi_LGQr*jIpAd_Tdks@JIimxqU zxbP_NPo}EgiY;4uFl!bcg@rtDI1E%)_G9;M4Z6DQe*1Z3)2VB8v^ET*Hl@E}kj6b>(+PhfIB2P6sP z=5o;8HGuZ^0U9(N+#HbvNH*O$0HZ{}ZGHUkPMglf^2bFmn;p1PXGKCHiPwG|gL9Sr z_+YONHXAj?Nd}ukdJYijMldoN0eG`=ICyfc7>5rU@bw8J978X~<;g4*KP$qj)gruG zqNS`O0mp!q&b8H33k1a%U|nCjOn~z5`f>7@5l;K9=j|;vl)R%w{xmL96f8=5ItMIf z!fMfI?o=J%i_aIYM8b53`6$LH8JR4!`~MI){4|p`vrYsi%|B8`_$wj(BVw6R&QOVfMTz zsIJ&>?4SW$E`jGaOYqQR(IH2Qy2lQ?9gr)0AAz~5$nkO(x;qCb>8;ndG?9Jk`Wj{O zf(OG*Bupl-Zi5)T-FCdYLj$YjR%TA0#KyXfV)S&_QTC0=M-mzYF(R4U%uZCDGh^X{ z0yJE4 zO(F_2nwEwd4F5 zDs@2W1QwcAHkgK#8HvjmELgBq0I8G-b?=BGr zq(qAYdl-20?N}WAK!@{XgKxX38sDQ;)tXE_H{5%Rrnaw+h8srh%>z)Rv)!8-U+oz= zY#0p=sJrbUB|>kq4^@0N3$gr}%1)Tv*BgBnObDT93X(S%YG^%N3eTxddf($ewu zG4E-06G8~3>mE7Vz~hJ{G)%Rh9gODQo_F`pP->yoc%BWrAYp-}-)iqSPE5RSAzjPO z&xuZ)-|}*EUuBuM%?uxoVy<2AMR}5J%Ji`ujXRCD?#i;=#~+wA@_PechphI`zkha> zeW1Uet~EE(WVIO$N6x&o+SkVxKCj93TQ$9U)AjaD`NAcU4qH0t^YRzhG&sTZ z_XIoCHB~&0SSFWEDTuTIROn3Y&aLHjf7|NM&VV;X8?K)^ks!&*ixny-Mf%ofIDeO0 zj+7p%-1KzVS7lutFSQ&zm@J)ow=6m}J<_8Y3f12@dF-R_RzC_L;7f#Hz84s%YB^Av zAj!!Y=Z=KP8#fwVZarN3;h^!pubPDD0JjsWTMw4G?O3=8Fp5odkJsW1Bs%~vzxXW!3cuvn2c)|;$0qwzrH#x>tvaVNZ>&$t*NIN&B? zqEjb6GJVH;$uZLk!|6{qTDmLCO3%Hxx?9)S5NhAB954iCQTpnq=Vib4Ruo4pr64D4 zm@MkvW7WU^Rpr&aCE! zlDiA;k}rNZD{=0^*y!{OD#AK+^|f^!C%>p}J6zh*=RU@w^E$_vIA9ofJXvD8v~b3F zQEuKikupmfrAUot$Hej2VkwJYb6h+&r^VcF(W>> Date: Mon, 13 Jul 2026 01:07:40 +0300 Subject: [PATCH 07/47] emit live slider and connection events and add status service method --- .../nik9play/deej/pkg/deej/index.ts | 3 +- .../nik9play/deej/pkg/deej/models.ts | 41 +++++++++++++++++++ .../nik9play/deej/pkg/deej/settingsservice.ts | 17 ++++++-- pkg/deej/serial.go | 26 ++++++++++++ pkg/deej/settings_service.go | 17 ++++++++ pkg/deej/tray.go | 24 +++++++++++ 6 files changed, 124 insertions(+), 4 deletions(-) diff --git a/frontend/bindings/github.com/nik9play/deej/pkg/deej/index.ts b/frontend/bindings/github.com/nik9play/deej/pkg/deej/index.ts index cc491e8e..dd649c46 100644 --- a/frontend/bindings/github.com/nik9play/deej/pkg/deej/index.ts +++ b/frontend/bindings/github.com/nik9play/deej/pkg/deej/index.ts @@ -10,5 +10,6 @@ export { AppInfoDTO, SerialPortDTO, SettingsDTO, - SliderMappingEntry + SliderMappingEntry, + StatusDTO } from "./models.js"; diff --git a/frontend/bindings/github.com/nik9play/deej/pkg/deej/models.ts b/frontend/bindings/github.com/nik9play/deej/pkg/deej/models.ts index fd3c8177..707831aa 100644 --- a/frontend/bindings/github.com/nik9play/deej/pkg/deej/models.ts +++ b/frontend/bindings/github.com/nik9play/deej/pkg/deej/models.ts @@ -193,7 +193,48 @@ export class SliderMappingEntry { } } +/** + * StatusDTO describes the live connection state for the settings GUI + */ +export class StatusDTO { + "connected": boolean; + "comPort": string; + + /** + * 0..1, as sessions receive them + */ + "sliderValues": number[]; + + /** Creates a new StatusDTO instance. */ + constructor($$source: Partial = {}) { + if (!("connected" in $$source)) { + this["connected"] = false; + } + if (!("comPort" in $$source)) { + this["comPort"] = ""; + } + if (!("sliderValues" in $$source)) { + this["sliderValues"] = []; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new StatusDTO instance from a string or object. + */ + static createFrom($$source: any = {}): StatusDTO { + const $$createField2_0 = $$createType3; + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + if ("sliderValues" in $$parsedSource) { + $$parsedSource["sliderValues"] = $$createField2_0($$parsedSource["sliderValues"]); + } + return new StatusDTO($$parsedSource as Partial); + } +} + // Private type creation functions const $$createType0 = $Create.Array($Create.Any); const $$createType1 = SliderMappingEntry.createFrom; const $$createType2 = $Create.Array($$createType1); +const $$createType3 = $Create.Array($Create.Any); diff --git a/frontend/bindings/github.com/nik9play/deej/pkg/deej/settingsservice.ts b/frontend/bindings/github.com/nik9play/deej/pkg/deej/settingsservice.ts index acaf0f28..08f03b80 100644 --- a/frontend/bindings/github.com/nik9play/deej/pkg/deej/settingsservice.ts +++ b/frontend/bindings/github.com/nik9play/deej/pkg/deej/settingsservice.ts @@ -43,12 +43,22 @@ export function GetSettings(): $CancellablePromise<$models.SettingsDTO> { }); } +/** + * GetStatus returns the current serial connection state and slider values, + * so the settings window doesn't have to wait for the first live event + */ +export function GetStatus(): $CancellablePromise<$models.StatusDTO> { + return $Call.ByID(4223089242).then(($result: any) => { + return $$createType3($result); + }); +} + /** * ListSerialPorts enumerates the serial ports available on this machine */ export function ListSerialPorts(): $CancellablePromise<$models.SerialPortDTO[]> { return $Call.ByID(102515344).then(($result: any) => { - return $$createType4($result); + return $$createType5($result); }); } @@ -64,5 +74,6 @@ export function SaveSettings(dto: $models.SettingsDTO): $CancellablePromise Date: Mon, 13 Jul 2026 01:11:49 +0300 Subject: [PATCH 08/47] add tailwind and bits-ui, restyle settings into dialog with frameless titlebar --- frontend/package-lock.json | 512 +++++++++++++++++- frontend/package.json | 6 +- frontend/src/App.svelte | 89 +-- frontend/src/app.css | 113 ++++ .../src/components/BehaviorSection.svelte | 14 +- .../src/components/ConnectionSection.svelte | 39 +- .../src/components/LanguageSection.svelte | 6 +- frontend/src/components/ObsSection.svelte | 30 +- frontend/src/components/SettingsDialog.svelte | 111 ++++ frontend/src/components/Titlebar.svelte | 91 ++++ frontend/src/lib/en.json | 5 + frontend/src/lib/ru.json | 5 + frontend/src/main.ts | 2 +- frontend/src/style.css | 258 --------- frontend/vite.config.ts | 3 +- pkg/deej/tray.go | 9 +- 16 files changed, 883 insertions(+), 410 deletions(-) create mode 100644 frontend/src/app.css create mode 100644 frontend/src/components/SettingsDialog.svelte create mode 100644 frontend/src/components/Titlebar.svelte delete mode 100644 frontend/src/style.css diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0e2cf21f..8934d78d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,13 +8,17 @@ "name": "deej-frontend", "version": "0.0.0", "dependencies": { - "@wailsio/runtime": "latest" + "@lucide/svelte": "^1.24.0", + "@wailsio/runtime": "latest", + "bits-ui": "^2.18.1" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@tailwindcss/vite": "^4.3.2", "@tsconfig/svelte": "^5.0.8", "svelte": "^5.46.4", "svelte-check": "^4.4.8", + "tailwindcss": "^4.3.2", "tslib": "^2.6.2", "typescript": "^5.2.2", "vite": "^8.0.5" @@ -54,11 +58,45 @@ "tslib": "^2.4.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, + "node_modules/@internationalized/date": { + "version": "3.12.2", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.2.tgz", + "integrity": "sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -69,7 +107,6 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -80,7 +117,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -90,20 +126,27 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lucide/svelte": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.24.0.tgz", + "integrity": "sha512-yXwewA7ANQ5hfaSDrvsecosWjZn5RglzeXUZRSnxeANBskpNwblOkEJTqD0ujDdNKIKL8E9eVc2U/P3ziJr7OA==", + "license": "ISC", + "peerDependencies": { + "svelte": "^5" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", @@ -401,7 +444,6 @@ "version": "1.0.11", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz", "integrity": "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==", - "dev": true, "license": "MIT", "peerDependencies": { "acorn": "^8.9.0" @@ -437,6 +479,288 @@ "vite": "^8.0.0-beta.7 || ^8.0.0" } }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", + "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz", + "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-x64": "4.3.2", + "@tailwindcss/oxide-freebsd-x64": "4.3.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-x64-musl": "4.3.2", + "@tailwindcss/oxide-wasm32-wasi": "4.3.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", + "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", + "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", + "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", + "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", + "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", + "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", + "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", + "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz", + "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", + "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", + "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz", + "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz", + "integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.2", + "@tailwindcss/oxide": "4.3.2", + "tailwindcss": "4.3.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, "node_modules/@tsconfig/svelte": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz", @@ -459,14 +783,12 @@ "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, "license": "MIT" }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "dev": true, "license": "MIT" }, "node_modules/@wailsio/runtime": { @@ -479,7 +801,6 @@ "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", - "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -492,7 +813,6 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -502,12 +822,35 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" } }, + "node_modules/bits-ui": { + "version": "2.18.1", + "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.18.1.tgz", + "integrity": "sha512-KkemzKFH4T3gt3H+P86JcnAWExjByv/6vlwjm/BoCwTPHu03yiCdxbghdJLvFReQTe0acCAiRcKfmixxD6XvlA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.1", + "@floating-ui/dom": "^1.7.1", + "esm-env": "^1.1.2", + "runed": "^0.35.1", + "svelte-toolbelt": "^0.10.6", + "tabbable": "^6.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/huntabyte" + }, + "peerDependencies": { + "@internationalized/date": "^3.8.1", + "svelte": "^5.33.0" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -528,7 +871,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -544,6 +886,15 @@ "node": ">=0.10.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -558,21 +909,32 @@ "version": "5.8.1", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", - "dev": true, "license": "MIT" }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/esm-env": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", - "dev": true, "license": "MIT" }, "node_modules/esrap": { "version": "2.2.13", "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.13.tgz", "integrity": "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" @@ -619,16 +981,38 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, "node_modules/is-reference": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.6" } }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -894,14 +1278,21 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", - "dev": true, "license": "MIT" }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -1047,6 +1438,30 @@ "@rolldown/binding-win32-x64-msvc": "1.1.5" } }, + "node_modules/runed": { + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/runed/-/runed-0.35.1.tgz", + "integrity": "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==", + "funding": [ + "https://github.com/sponsors/huntabyte", + "https://github.com/sponsors/tglide" + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "esm-env": "^1.0.0", + "lz-string": "^1.5.0" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.21.0", + "svelte": "^5.7.0" + }, + "peerDependenciesMeta": { + "@sveltejs/kit": { + "optional": true + } + } + }, "node_modules/sade": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", @@ -1070,11 +1485,19 @@ "node": ">=0.10.0" } }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/svelte": { "version": "5.56.4", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz", "integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.4", @@ -1123,6 +1546,53 @@ "typescript": ">=5.0.0" } }, + "node_modules/svelte-toolbelt": { + "version": "0.10.6", + "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz", + "integrity": "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==", + "funding": [ + "https://github.com/sponsors/huntabyte" + ], + "dependencies": { + "clsx": "^2.1.1", + "runed": "^0.35.1", + "style-to-object": "^1.0.8" + }, + "engines": { + "node": ">=18", + "pnpm": ">=8.7.0" + }, + "peerDependencies": { + "svelte": "^5.30.2" + } + }, + "node_modules/tabbable": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz", + "integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==", + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", + "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -1144,7 +1614,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/typescript": { @@ -1263,7 +1732,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", - "dev": true, "license": "MIT" } } diff --git a/frontend/package.json b/frontend/package.json index 25b1b84d..d8f54e36 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,13 +11,17 @@ "check": "svelte-check --tsconfig ./tsconfig.json" }, "dependencies": { - "@wailsio/runtime": "latest" + "@lucide/svelte": "^1.24.0", + "@wailsio/runtime": "latest", + "bits-ui": "^2.18.1" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@tailwindcss/vite": "^4.3.2", "@tsconfig/svelte": "^5.0.8", "svelte": "^5.46.4", "svelte-check": "^4.4.8", + "tailwindcss": "^4.3.2", "tslib": "^2.6.2", "typescript": "^5.2.2", "vite": "^8.0.5" diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 9397fe64..7dff577f 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -1,88 +1,17 @@ -

{t("title")}

- -{#if settings} - - - - - + (settingsOpen = true)} /> -
- {#if appInfo?.version}{t("version")}: {appInfo.version} ·{/if} - {t("configPath")}: {appInfo?.configPath} -
+
+ +
- -{:else if statusText} -

{statusText}

-{/if} + diff --git a/frontend/src/app.css b/frontend/src/app.css new file mode 100644 index 00000000..b60ccc73 --- /dev/null +++ b/frontend/src/app.css @@ -0,0 +1,113 @@ +@import "tailwindcss"; + +/* theme tokens; light values with dark overrides via prefers-color-scheme, + exposed to tailwind through @theme inline */ +:root { + --ui-surface: #f4f4f6; + --ui-card: #ffffff; + --ui-body: #1c1c22; + --ui-muted: #6b6b76; + --ui-edge: #d9d9e0; + --ui-accent: #4a6cf7; + --ui-on-accent: #ffffff; + --ui-danger: #d64550; + --ui-success: #2e9e5b; + --ui-field: #ffffff; + --ui-chip: #eceef8; + --ui-track: #e2e2e8; + + color-scheme: light dark; +} + +@media (prefers-color-scheme: dark) { + :root { + --ui-surface: #1e1e24; + --ui-card: #27272f; + --ui-body: #e8e8ec; + --ui-muted: #9a9aa6; + --ui-edge: #3a3a44; + --ui-accent: #6f8bff; + --ui-on-accent: #ffffff; + --ui-danger: #ff6b75; + --ui-success: #4fc47f; + --ui-field: #1e1e24; + --ui-chip: #33333e; + --ui-track: #33333e; + } +} + +@theme inline { + --color-surface: var(--ui-surface); + --color-card: var(--ui-card); + --color-body: var(--ui-body); + --color-muted: var(--ui-muted); + --color-edge: var(--ui-edge); + --color-accent: var(--ui-accent); + --color-on-accent: var(--ui-on-accent); + --color-danger: var(--ui-danger); + --color-success: var(--ui-success); + --color-field: var(--ui-field); + --color-chip: var(--ui-chip); + --color-track: var(--ui-track); + + --font-sans: "Segoe UI", system-ui, -apple-system, sans-serif; +} + +@layer base { + html, + body { + height: 100%; + overflow: hidden; + } + + body { + @apply bg-surface font-sans text-sm text-body select-none; + cursor: default; + } + + #app { + height: 100dvh; + display: flex; + flex-direction: column; + } + + ::-webkit-scrollbar { + width: 8px; + height: 8px; + } + + ::-webkit-scrollbar-thumb { + background: var(--ui-edge); + border-radius: 4px; + } + + ::-webkit-scrollbar-track { + background: transparent; + } +} + +@layer components { + .input { + @apply w-full rounded-md border border-edge bg-field px-2.5 py-1.5 text-sm text-body focus-visible:outline-2 focus-visible:outline-accent; + } + + .label { + @apply text-[13px] font-semibold; + } + + .hint { + @apply text-xs text-muted; + } + + .btn { + @apply cursor-pointer rounded-md border border-edge bg-card px-3.5 py-1.5 text-sm text-body transition-colors hover:border-accent focus-visible:outline-2 focus-visible:outline-accent disabled:cursor-default disabled:opacity-50; + } + + .btn-primary { + @apply border-accent bg-accent font-semibold text-on-accent hover:border-accent; + } + + .card { + @apply rounded-lg border border-edge bg-card p-4; + } +} diff --git a/frontend/src/components/BehaviorSection.svelte b/frontend/src/components/BehaviorSection.svelte index 8e18f3eb..4852f5d9 100644 --- a/frontend/src/components/BehaviorSection.svelte +++ b/frontend/src/components/BehaviorSection.svelte @@ -6,16 +6,16 @@
-

{t("behavior")}

+

{t("behavior")}

-
- - +
+ +
-
- - {#if settings.noiseReduction === ""} {/if} diff --git a/frontend/src/components/ConnectionSection.svelte b/frontend/src/components/ConnectionSection.svelte index 7723aa1a..f426f610 100644 --- a/frontend/src/components/ConnectionSection.svelte +++ b/frontend/src/components/ConnectionSection.svelte @@ -1,5 +1,6 @@
-

{t("connection")}

+

{t("connection")}

-
-
- -
- - +
+
+ +
+ +
@@ -36,9 +39,9 @@
-
- - +
+ + @@ -50,16 +53,16 @@
{#if settings.comPort === "auto"} -
-
- - +
+
+ +
-
- - +
+ +
-
{t("vidPidHint")}
+
{t("vidPidHint")}
{/if}
diff --git a/frontend/src/components/LanguageSection.svelte b/frontend/src/components/LanguageSection.svelte index 8f0043ea..866b7aee 100644 --- a/frontend/src/components/LanguageSection.svelte +++ b/frontend/src/components/LanguageSection.svelte @@ -6,10 +6,10 @@
-

{t("language")}

+

{t("language")}

-
- diff --git a/frontend/src/components/ObsSection.svelte b/frontend/src/components/ObsSection.svelte index ab2a96c1..d838a0b0 100644 --- a/frontend/src/components/ObsSection.svelte +++ b/frontend/src/components/ObsSection.svelte @@ -6,27 +6,27 @@
-

{t("obs")}

+

{t("obs")}

-
- - +
+ +
-
{t("obsHint")}
+
{t("obsHint")}
{#if settings.obsEnabled} -
-
- - +
+
+ +
-
- - +
+ +
-
- - +
+ +
{/if} diff --git a/frontend/src/components/SettingsDialog.svelte b/frontend/src/components/SettingsDialog.svelte new file mode 100644 index 00000000..c9280d40 --- /dev/null +++ b/frontend/src/components/SettingsDialog.svelte @@ -0,0 +1,111 @@ + + + + + + +
+ {t("settings")} + + + +
+ +
+ {#if settings} + + + + + +
+ {#if appInfo?.version}{t("version")}: {appInfo.version} ·{/if} + {t("configPath")}: {appInfo?.configPath} +
+ {/if} +
+ +
+ + + {#if statusText} + {statusText} + {/if} +
+
+
+
diff --git a/frontend/src/components/Titlebar.svelte b/frontend/src/components/Titlebar.svelte new file mode 100644 index 00000000..8cb4bab3 --- /dev/null +++ b/frontend/src/components/Titlebar.svelte @@ -0,0 +1,91 @@ + + + +
+ deej + {#if version} + {version} + {/if} + +
+ + + + +
+
diff --git a/frontend/src/lib/en.json b/frontend/src/lib/en.json index 367a577d..305c167e 100644 --- a/frontend/src/lib/en.json +++ b/frontend/src/lib/en.json @@ -33,6 +33,11 @@ "addTargetPlaceholder": "Add a target (process name or special target)...", "removeTarget": "Remove target", "noTargets": "No targets - this slider does nothing", + "settings": "Settings", + "minimize": "Minimize", + "maximize": "Maximize", + "restore": "Restore", + "close": "Close", "save": "Save", "revert": "Revert", "saved": "Settings saved", diff --git a/frontend/src/lib/ru.json b/frontend/src/lib/ru.json index 6676108d..8a2dd49e 100644 --- a/frontend/src/lib/ru.json +++ b/frontend/src/lib/ru.json @@ -33,6 +33,11 @@ "addTargetPlaceholder": "Добавить цель (имя процесса или специальная цель)...", "removeTarget": "Удалить цель", "noTargets": "Нет целей - этот слайдер ничего не делает", + "settings": "Настройки", + "minimize": "Свернуть", + "maximize": "Развернуть", + "restore": "Восстановить", + "close": "Закрыть", "save": "Сохранить", "revert": "Отменить", "saved": "Настройки сохранены", diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 82260b3f..32ec1202 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -2,7 +2,7 @@ import { mount } from "svelte"; import App from "./App.svelte"; import { initI18n } from "./lib/i18n"; import { AppInfoDTO, SettingsService } from "../bindings/github.com/nik9play/deej/pkg/deej"; -import "./style.css"; +import "./app.css"; let appInfo: AppInfoDTO | null = null; try { diff --git a/frontend/src/style.css b/frontend/src/style.css deleted file mode 100644 index 6b225cab..00000000 --- a/frontend/src/style.css +++ /dev/null @@ -1,258 +0,0 @@ -:root { - --bg: #f4f4f6; - --card-bg: #ffffff; - --text: #1c1c22; - --text-muted: #6b6b76; - --border: #d9d9e0; - --accent: #4a6cf7; - --accent-text: #ffffff; - --danger: #d64550; - --success: #2e9e5b; - --input-bg: #ffffff; - --chip-bg: #eceef8; - - font-family: "Segoe UI", system-ui, -apple-system, sans-serif; - font-size: 14px; - line-height: 1.45; - color-scheme: light dark; -} - -@media (prefers-color-scheme: dark) { - :root { - --bg: #1e1e24; - --card-bg: #27272f; - --text: #e8e8ec; - --text-muted: #9a9aa6; - --border: #3a3a44; - --accent: #6f8bff; - --accent-text: #ffffff; - --danger: #ff6b75; - --success: #4fc47f; - --input-bg: #1e1e24; - --chip-bg: #33333e; - } -} - -* { - box-sizing: border-box; -} - -body { - margin: 0; - background: var(--bg); - color: var(--text); - user-select: none; -} - -#app { - max-width: 860px; - margin: 0 auto; - padding: 16px 20px 84px; -} - -h1 { - font-size: 20px; - margin: 8px 0 16px; -} - -section.card { - background: var(--card-bg); - border: 1px solid var(--border); - border-radius: 10px; - padding: 16px 18px; - margin-bottom: 14px; -} - -section.card h2 { - font-size: 15px; - margin: 0 0 12px; -} - -.field { - display: flex; - flex-direction: column; - gap: 4px; - margin-bottom: 12px; -} - -.field-row { - display: flex; - gap: 14px; - flex-wrap: wrap; -} - -.field-row .field { - flex: 1 1 160px; -} - -label { - font-weight: 600; - font-size: 13px; -} - -.hint { - color: var(--text-muted); - font-size: 12px; -} - -input[type="text"], -input[type="number"], -input[type="password"], -select { - padding: 7px 10px; - border: 1px solid var(--border); - border-radius: 6px; - background: var(--input-bg); - color: var(--text); - font: inherit; - width: 100%; -} - -input:focus-visible, -select:focus-visible, -button:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 1px; -} - -.checkbox-field { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 12px; -} - -.checkbox-field input { - width: 16px; - height: 16px; - accent-color: var(--accent); -} - -.checkbox-field label { - font-weight: 500; -} - -button { - padding: 7px 14px; - border: 1px solid var(--border); - border-radius: 6px; - background: var(--card-bg); - color: var(--text); - font: inherit; - cursor: pointer; -} - -button:hover { - border-color: var(--accent); -} - -button.primary { - background: var(--accent); - border-color: var(--accent); - color: var(--accent-text); - font-weight: 600; -} - -button.primary:disabled { - opacity: 0.5; - cursor: default; -} - -button.icon { - padding: 6px 9px; - line-height: 1; -} - -.mapping-row { - display: flex; - align-items: flex-start; - gap: 10px; - padding: 10px 0; - border-top: 1px solid var(--border); -} - -.mapping-row:first-of-type { - border-top: none; -} - -.mapping-index { - font-weight: 600; - min-width: 74px; - padding-top: 5px; - white-space: nowrap; -} - -.mapping-targets { - flex: 1; - display: flex; - flex-wrap: wrap; - gap: 6px; - align-items: center; -} - -.chip { - display: inline-flex; - align-items: center; - gap: 6px; - background: var(--chip-bg); - border: 1px solid var(--border); - border-radius: 14px; - padding: 3px 6px 3px 10px; - font-size: 13px; -} - -.chip button { - border: none; - background: none; - padding: 0 4px; - color: var(--text-muted); - font-size: 14px; -} - -.chip button:hover { - color: var(--danger); -} - -.mapping-targets input { - flex: 1 1 220px; - min-width: 160px; -} - -.empty-note { - color: var(--text-muted); - font-size: 12px; - font-style: italic; -} - -.footer { - position: fixed; - left: 0; - right: 0; - bottom: 0; - display: flex; - align-items: center; - gap: 10px; - padding: 12px 20px; - background: var(--card-bg); - border-top: 1px solid var(--border); -} - -.footer .status { - margin-left: auto; - font-size: 13px; -} - -.status.ok { - color: var(--success); -} - -.status.error { - color: var(--danger); -} - -.meta { - color: var(--text-muted); - font-size: 12px; - margin-top: 4px; - user-select: text; -} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index b71ae050..a953b426 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -2,6 +2,7 @@ import { writeFileSync } from "node:fs"; import { join } from "node:path"; import { defineConfig, type Plugin } from "vite"; import { svelte } from "@sveltejs/vite-plugin-svelte"; +import tailwindcss from "@tailwindcss/vite"; import wails from "@wailsio/runtime/plugins/vite"; // dist/.gitkeep is committed so the Go embed compiles before the first build; @@ -22,5 +23,5 @@ export default defineConfig({ port: Number(process.env.WAILS_VITE_PORT) || 9245, strictPort: true, }, - plugins: [svelte(), wails("./bindings"), keepGitkeep()], + plugins: [tailwindcss(), svelte(), wails("./bindings"), keepGitkeep()], }); diff --git a/pkg/deej/tray.go b/pkg/deej/tray.go index 12698c73..c7477671 100644 --- a/pkg/deej/tray.go +++ b/pkg/deej/tray.go @@ -356,10 +356,11 @@ func (d *Deej) openSettingsWindow() { d.tray.app.Window.NewWithOptions(application.WebviewWindowOptions{ Name: settingsWindowName, Title: "deej - " + settingsTitle, - Width: 860, - Height: 680, - MinWidth: 600, - MinHeight: 440, + Width: 640, + Height: 480, + MinWidth: 460, + MinHeight: 380, + Frameless: true, URL: "/", }) } From 87506f5d11026c75fe4fdc6e0aa990854e33ee8d Mon Sep 17 00:00:00 2001 From: Nikita Date: Mon, 13 Jul 2026 01:14:31 +0300 Subject: [PATCH 09/47] replace mapping editor with live mixer and target dialog --- frontend/src/App.svelte | 16 +- frontend/src/components/MappingEditor.svelte | 99 ----------- frontend/src/components/Mixer.svelte | 42 +++++ frontend/src/components/SliderColumn.svelte | 49 +++++ frontend/src/components/TargetDialog.svelte | 178 +++++++++++++++++++ frontend/src/lib/en.json | 7 + frontend/src/lib/ru.json | 7 + frontend/src/lib/state.svelte.ts | 50 ++++++ 8 files changed, 348 insertions(+), 100 deletions(-) delete mode 100644 frontend/src/components/MappingEditor.svelte create mode 100644 frontend/src/components/Mixer.svelte create mode 100644 frontend/src/components/SliderColumn.svelte create mode 100644 frontend/src/components/TargetDialog.svelte create mode 100644 frontend/src/lib/state.svelte.ts diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 7dff577f..10a7cdc4 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -1,17 +1,31 @@ (settingsOpen = true)} />
- +
+ diff --git a/frontend/src/components/MappingEditor.svelte b/frontend/src/components/MappingEditor.svelte deleted file mode 100644 index 2c370102..00000000 --- a/frontend/src/components/MappingEditor.svelte +++ /dev/null @@ -1,99 +0,0 @@ - - -
-

{t("sliderMapping")}

-
{t("sliderMappingHint")}
- - {#each rows as entry (entry.slider)} -
- {t("slider")} {entry.slider} - -
- {#each entry.targets as target (target)} - - {target} - - - {/each} - {#if entry.targets.length === 0} - {t("noTargets")} - {/if} - commitTarget(entry.slider)} - onkeydown={(e) => e.key === "Enter" && commitTarget(entry.slider)} - /> -
- - -
- {/each} - - - {#each suggestions as suggestion (suggestion)} - - {/each} - - -
- -
-
diff --git a/frontend/src/components/Mixer.svelte b/frontend/src/components/Mixer.svelte new file mode 100644 index 00000000..8ffc78dc --- /dev/null +++ b/frontend/src/components/Mixer.svelte @@ -0,0 +1,42 @@ + + +
+ {#if !app.connected} +
+ {t("waitingForDevice")}{app.comPort ? ` (${app.comPort})` : ""} +
+ {/if} + +
+ {#each columns as column (column.slider)} + onEditTargets(column.slider)} + /> + {/each} +
+
diff --git a/frontend/src/components/SliderColumn.svelte b/frontend/src/components/SliderColumn.svelte new file mode 100644 index 00000000..c7704f06 --- /dev/null +++ b/frontend/src/components/SliderColumn.svelte @@ -0,0 +1,49 @@ + + +
+ {percent}% + +
+
+
+ + +
diff --git a/frontend/src/components/TargetDialog.svelte b/frontend/src/components/TargetDialog.svelte new file mode 100644 index 00000000..2d8f5a62 --- /dev/null +++ b/frontend/src/components/TargetDialog.svelte @@ -0,0 +1,178 @@ + + + + + + +
+ {t("targetsFor")} {slider} + + + +
+ +
+ {#if targets.length > 0} +
+ {#each targets as target (target)} + + {target} + + + {/each} +
+ {:else} +
{t("noTargets")}
+ {/if} + + +
+ (search = e.currentTarget.value)} + onkeydown={onInputKeydown} + /> + + + +
+ + + {#each suggestions as suggestion (suggestion)} + + {#snippet children({ selected })} + {suggestion} + {#if selected} + + {/if} + {/snippet} + + {:else} +
{t("pressEnterToAdd")}
+ {/each} +
+
+
+
+ +
+ + + {#if errorText} + {errorText} + {/if} +
+
+
+
diff --git a/frontend/src/lib/en.json b/frontend/src/lib/en.json index 305c167e..b6054fb3 100644 --- a/frontend/src/lib/en.json +++ b/frontend/src/lib/en.json @@ -34,6 +34,13 @@ "removeTarget": "Remove target", "noTargets": "No targets - this slider does nothing", "settings": "Settings", + "waitingForDevice": "Waiting for device...", + "unmapped": "unmapped", + "targetsFor": "Targets for slider", + "addTarget": "Add target", + "pressEnterToAdd": "Press Enter to add", + "ok": "OK", + "cancel": "Cancel", "minimize": "Minimize", "maximize": "Maximize", "restore": "Restore", diff --git a/frontend/src/lib/ru.json b/frontend/src/lib/ru.json index 8a2dd49e..439f471a 100644 --- a/frontend/src/lib/ru.json +++ b/frontend/src/lib/ru.json @@ -34,6 +34,13 @@ "removeTarget": "Удалить цель", "noTargets": "Нет целей - этот слайдер ничего не делает", "settings": "Настройки", + "waitingForDevice": "Ожидание устройства...", + "unmapped": "не назначен", + "targetsFor": "Цели для слайдера", + "addTarget": "Добавить цель", + "pressEnterToAdd": "Нажмите Enter, чтобы добавить", + "ok": "ОК", + "cancel": "Отмена", "minimize": "Свернуть", "maximize": "Развернуть", "restore": "Восстановить", diff --git a/frontend/src/lib/state.svelte.ts b/frontend/src/lib/state.svelte.ts new file mode 100644 index 00000000..d7a0314e --- /dev/null +++ b/frontend/src/lib/state.svelte.ts @@ -0,0 +1,50 @@ +import { Events } from "@wailsio/runtime"; +import { SettingsDTO, SettingsService } from "../../bindings/github.com/nik9play/deej/pkg/deej"; + +// live application state, fed by wails events from the Go side +export const app = $state({ + connected: false, + comPort: "", + values: [] as number[], // 0..1 per slider, as sessions receive them + settings: null as SettingsDTO | null, +}); + +export async function refreshSettings(): Promise { + try { + const loaded = await SettingsService.GetSettings(); + // clone into a plain object so svelte's deep reactivity applies + app.settings = JSON.parse(JSON.stringify(loaded)); + } catch (err) { + console.error("failed to load settings", err); + } +} + +// init subscribes to live events (before fetching initial state, so nothing +// is missed) and returns a cleanup function for onDestroy +export function init(): () => void { + const offs = [ + Events.On("deej:sliders", (ev) => { + app.values = (ev.data as number[]) ?? []; + }), + Events.On("deej:state", (ev) => { + const data = ev.data as { connected: boolean; comPort: string }; + app.connected = data.connected; + app.comPort = data.comPort; + }), + Events.On("deej:config", () => { + void refreshSettings(); + }), + ]; + + SettingsService.GetStatus() + .then((status) => { + app.connected = status.connected; + app.comPort = status.comPort; + app.values = status.sliderValues ?? []; + }) + .catch((err) => console.error("failed to load status", err)); + + void refreshSettings(); + + return () => offs.forEach((off) => off()); +} From b074b5403fbcefc4039232dec4d1a9c2ead18def Mon Sep 17 00:00:00 2001 From: Nikita Date: Mon, 13 Jul 2026 12:08:03 +0300 Subject: [PATCH 10/47] use bits-ui controls throughout the settings dialog --- .../src/components/BehaviorSection.svelte | 29 +++--- .../src/components/ConnectionSection.svelte | 40 +++++--- .../src/components/LanguageSection.svelte | 13 ++- frontend/src/components/ObsSection.svelte | 6 +- .../src/components/ui/FieldCheckbox.svelte | 25 +++++ .../src/components/ui/FieldCombobox.svelte | 91 +++++++++++++++++++ frontend/src/components/ui/FieldSelect.svelte | 46 ++++++++++ 7 files changed, 214 insertions(+), 36 deletions(-) create mode 100644 frontend/src/components/ui/FieldCheckbox.svelte create mode 100644 frontend/src/components/ui/FieldCombobox.svelte create mode 100644 frontend/src/components/ui/FieldSelect.svelte diff --git a/frontend/src/components/BehaviorSection.svelte b/frontend/src/components/BehaviorSection.svelte index 4852f5d9..83130510 100644 --- a/frontend/src/components/BehaviorSection.svelte +++ b/frontend/src/components/BehaviorSection.svelte @@ -1,28 +1,33 @@

{t("behavior")}

-
- - +
+
- +
diff --git a/frontend/src/components/ConnectionSection.svelte b/frontend/src/components/ConnectionSection.svelte index f426f610..353d4ca8 100644 --- a/frontend/src/components/ConnectionSection.svelte +++ b/frontend/src/components/ConnectionSection.svelte @@ -3,6 +3,7 @@ import RefreshCw from "@lucide/svelte/icons/refresh-cw"; import { SettingsDTO, SerialPortDTO, SettingsService } from "../../bindings/github.com/nik9play/deej/pkg/deej"; import { t } from "../lib/i18n"; + import FieldCombobox from "./ui/FieldCombobox.svelte"; let { settings }: { settings: SettingsDTO } = $props(); @@ -17,6 +18,22 @@ console.error("failed to list serial ports", err); } } + + const portItems = $derived([ + { value: "auto", hint: t("comPortAuto") }, + ...ports.map((port) => ({ + value: port.name, + hint: port.product || (port.isUsb ? `USB ${port.vid}:${port.pid}` : ""), + })), + ]); + + const baudItems = [ + { value: "9600" }, + { value: "19200" }, + { value: "38400" }, + { value: "57600" }, + { value: "115200" }, + ];
@@ -26,29 +43,22 @@
- +
+ +
- - - {#each ports as port (port.name)} - - {/each} -
- - - - - - - - + String(settings.baudRate || ""), (v) => (settings.baudRate = parseInt(v, 10) || 0)} + />
diff --git a/frontend/src/components/LanguageSection.svelte b/frontend/src/components/LanguageSection.svelte index 866b7aee..68a2fa06 100644 --- a/frontend/src/components/LanguageSection.svelte +++ b/frontend/src/components/LanguageSection.svelte @@ -1,19 +1,22 @@

{t("language")}

- +
{t("languageHint")}
diff --git a/frontend/src/components/ObsSection.svelte b/frontend/src/components/ObsSection.svelte index d838a0b0..2f1e24dc 100644 --- a/frontend/src/components/ObsSection.svelte +++ b/frontend/src/components/ObsSection.svelte @@ -1,6 +1,7 @@ @@ -8,10 +9,7 @@

{t("obs")}

-
- - -
+
{t("obsHint")}
{#if settings.obsEnabled} diff --git a/frontend/src/components/ui/FieldCheckbox.svelte b/frontend/src/components/ui/FieldCheckbox.svelte new file mode 100644 index 00000000..018b4051 --- /dev/null +++ b/frontend/src/components/ui/FieldCheckbox.svelte @@ -0,0 +1,25 @@ + + +
+ + {#snippet children({ checked: isChecked })} + {#if isChecked} + + {/if} + {/snippet} + + +
diff --git a/frontend/src/components/ui/FieldCombobox.svelte b/frontend/src/components/ui/FieldCombobox.svelte new file mode 100644 index 00000000..643acc61 --- /dev/null +++ b/frontend/src/components/ui/FieldCombobox.svelte @@ -0,0 +1,91 @@ + + + { + value = selected; + search = ""; + }} + onOpenChange={(open) => { + if (open) search = ""; + }} +> +
+ { + value = e.currentTarget.value; + search = e.currentTarget.value; + }} + /> + + + +
+ + + {#each filtered as item (item.value)} + + {#snippet children({ selected })} + {item.value} + + {#if item.hint} + {item.hint} + {/if} + {#if selected} + + {/if} + + {/snippet} + + {/each} + + +
diff --git a/frontend/src/components/ui/FieldSelect.svelte b/frontend/src/components/ui/FieldSelect.svelte new file mode 100644 index 00000000..18b09dcf --- /dev/null +++ b/frontend/src/components/ui/FieldSelect.svelte @@ -0,0 +1,46 @@ + + + + + {selectedLabel} + + + + + + {#each items as item (item.value)} + + {#snippet children({ selected })} + {item.label} + {#if selected} + + {/if} + {/snippet} + + {/each} + + + + From 3841012f132925af63dfdfc5458440675abd8f55 Mon Sep 17 00:00:00 2001 From: Nikita Date: Mon, 13 Jul 2026 12:36:01 +0300 Subject: [PATCH 11/47] add friendly target names and tabbed target picker --- .../nik9play/deej/pkg/deej/index.ts | 1 + .../nik9play/deej/pkg/deej/models.ts | 32 ++ .../nik9play/deej/pkg/deej/settingsservice.ts | 34 +- frontend/src/components/SliderColumn.svelte | 5 +- frontend/src/components/TargetDialog.svelte | 305 +++++++++++++----- frontend/src/lib/en.json | 23 +- frontend/src/lib/ru.json | 23 +- frontend/src/lib/state.svelte.ts | 15 +- frontend/src/lib/targets.ts | 52 +++ pkg/deej/obs.go | 24 ++ pkg/deej/session.go | 11 + pkg/deej/session_finder_linux.go | 8 +- pkg/deej/session_linux.go | 2 + pkg/deej/session_map.go | 23 +- pkg/deej/session_windows.go | 7 + pkg/deej/settings_service.go | 25 +- pkg/deej/tray.go | 8 +- pkg/deej/util/procinfo_windows.go | 81 +++++ 18 files changed, 558 insertions(+), 121 deletions(-) create mode 100644 frontend/src/lib/targets.ts create mode 100644 pkg/deej/util/procinfo_windows.go diff --git a/frontend/bindings/github.com/nik9play/deej/pkg/deej/index.ts b/frontend/bindings/github.com/nik9play/deej/pkg/deej/index.ts index dd649c46..c7718783 100644 --- a/frontend/bindings/github.com/nik9play/deej/pkg/deej/index.ts +++ b/frontend/bindings/github.com/nik9play/deej/pkg/deej/index.ts @@ -9,6 +9,7 @@ export { export { AppInfoDTO, SerialPortDTO, + SessionInfoDTO, SettingsDTO, SliderMappingEntry, StatusDTO diff --git a/frontend/bindings/github.com/nik9play/deej/pkg/deej/models.ts b/frontend/bindings/github.com/nik9play/deej/pkg/deej/models.ts index 707831aa..2388310f 100644 --- a/frontend/bindings/github.com/nik9play/deej/pkg/deej/models.ts +++ b/frontend/bindings/github.com/nik9play/deej/pkg/deej/models.ts @@ -85,6 +85,38 @@ export class SerialPortDTO { } } +/** + * SessionInfoDTO describes a running audio session for target suggestions + */ +export class SessionInfoDTO { + "key": string; + + /** + * friendly name, may be empty + */ + "displayName": string; + + /** Creates a new SessionInfoDTO instance. */ + constructor($$source: Partial = {}) { + if (!("key" in $$source)) { + this["key"] = ""; + } + if (!("displayName" in $$source)) { + this["displayName"] = ""; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new SessionInfoDTO instance from a string or object. + */ + static createFrom($$source: any = {}): SessionInfoDTO { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new SessionInfoDTO($$parsedSource as Partial); + } +} + /** * SettingsDTO is a JSON-friendly mirror of the user config file, used by the settings GUI */ diff --git a/frontend/bindings/github.com/nik9play/deej/pkg/deej/settingsservice.ts b/frontend/bindings/github.com/nik9play/deej/pkg/deej/settingsservice.ts index 08f03b80..12d0aaf2 100644 --- a/frontend/bindings/github.com/nik9play/deej/pkg/deej/settingsservice.ts +++ b/frontend/bindings/github.com/nik9play/deej/pkg/deej/settingsservice.ts @@ -25,21 +25,31 @@ export function GetAppInfo(): $CancellablePromise<$models.AppInfoDTO> { } /** - * GetSessionNames returns the current audio session keys, for slider mapping - * suggestions + * GetOBSInputs returns the input names of the connected OBS instance, for + * slider mapping suggestions */ -export function GetSessionNames(): $CancellablePromise { - return $Call.ByID(2577377956).then(($result: any) => { +export function GetOBSInputs(): $CancellablePromise { + return $Call.ByID(1620740735).then(($result: any) => { return $$createType1($result); }); } +/** + * GetSessions returns the current audio sessions with friendly display + * names, for slider mapping suggestions + */ +export function GetSessions(): $CancellablePromise<$models.SessionInfoDTO[]> { + return $Call.ByID(1202968053).then(($result: any) => { + return $$createType3($result); + }); +} + /** * GetSettings returns the current contents of the user config file */ export function GetSettings(): $CancellablePromise<$models.SettingsDTO> { return $Call.ByID(1935164325).then(($result: any) => { - return $$createType2($result); + return $$createType4($result); }); } @@ -49,7 +59,7 @@ export function GetSettings(): $CancellablePromise<$models.SettingsDTO> { */ export function GetStatus(): $CancellablePromise<$models.StatusDTO> { return $Call.ByID(4223089242).then(($result: any) => { - return $$createType3($result); + return $$createType5($result); }); } @@ -58,7 +68,7 @@ export function GetStatus(): $CancellablePromise<$models.StatusDTO> { */ export function ListSerialPorts(): $CancellablePromise<$models.SerialPortDTO[]> { return $Call.ByID(102515344).then(($result: any) => { - return $$createType5($result); + return $$createType7($result); }); } @@ -73,7 +83,9 @@ export function SaveSettings(dto: $models.SettingsDTO): $CancellablePromise import { t } from "../lib/i18n"; + import { targetLabel } from "../lib/targets"; let { slider, @@ -40,9 +41,9 @@ {#if targets.length === 0} {t("unmapped")} {:else if targets.length === 1} - {targets[0]} + {targetLabel(targets[0])} {:else} - {targets[0]} +{targets.length - 1} + {targetLabel(targets[0])} +{targets.length - 1} {/if} diff --git a/frontend/src/components/TargetDialog.svelte b/frontend/src/components/TargetDialog.svelte index 2d8f5a62..0f70acb5 100644 --- a/frontend/src/components/TargetDialog.svelte +++ b/frontend/src/components/TargetDialog.svelte @@ -1,11 +1,15 @@ +{#snippet itemRow(target: string, label: string, hint: string)} + +{/snippet} +
{t("targetsFor")} {slider} @@ -102,68 +172,129 @@
-
- {#if targets.length > 0} -
- {#each targets as target (target)} - - {target} - - +
+
+ {#each targets as target (target)} + + {targetLabel(target)} + + + {:else} + {t("noTargets")} + {/each} +
+ + + + {#each [{ value: "apps", label: t("tabApps"), Icon: AppWindow }, { value: "special", label: t("tabSpecial"), Icon: Sparkles }, { value: "obs", label: t("tabObs"), Icon: Video }] as tabItem (tabItem.value)} + + + {tabItem.label} + {/each} -
- {:else} -
{t("noTargets")}
- {/if} + - -
- (search = e.currentTarget.value)} - onkeydown={onInputKeydown} + + { + if (e.key === "Enter") { + e.preventDefault(); + addFreeText(); + } + }} /> - - - -
- - - {#each suggestions as suggestion (suggestion)} - + {#if freeText !== ""} + + {/if} + {#each appItems as session (session.key)} + {@render itemRow( + session.key, + session.displayName || prettifyProcessName(session.key), + session.displayName ? session.key : "", + )} {:else} -
{t("pressEnterToAdd")}
+ {#if freeText === ""} +
{t("noSessions")}
+ {/if} {/each} -
-
-
+
+ + + +
+ {#each specialTargets as target (target)} + {@render itemRow(target, specialTargetLabel(target) ?? target, specialTargetDescription(target) ?? "")} + {/each} +
+
+ + + {#if !app.settings?.obsEnabled} +
{t("obsDisabled")}
+ {:else} +
+ { + if (e.key === "Enter") { + e.preventDefault(); + addObsInput(); + } + }} + /> + +
+
+ {#if obsError} +
{t("obsNotConnected")}
+ {:else} + {#each obsInputs as inputName (inputName)} + {@render itemRow(OBS_PREFIX + inputName, inputName, "")} + {:else} +
{t("noObsInputs")}
+ {/each} + {/if} +
+ {/if} +
+
diff --git a/frontend/src/lib/en.json b/frontend/src/lib/en.json index b6054fb3..a2c9fe0f 100644 --- a/frontend/src/lib/en.json +++ b/frontend/src/lib/en.json @@ -30,7 +30,6 @@ "slider": "Slider", "addSlider": "Add slider", "removeSlider": "Remove slider", - "addTargetPlaceholder": "Add a target (process name or special target)...", "removeTarget": "Remove target", "noTargets": "No targets - this slider does nothing", "settings": "Settings", @@ -38,7 +37,27 @@ "unmapped": "unmapped", "targetsFor": "Targets for slider", "addTarget": "Add target", - "pressEnterToAdd": "Press Enter to add", + "tabApps": "Apps", + "tabSpecial": "Special", + "tabObs": "OBS", + "searchApps": "Search apps or type a process name...", + "noSessions": "No running audio sessions", + "obsDisabled": "OBS integration is disabled. Enable it in settings first.", + "obsNotConnected": "Not connected to OBS. You can still add an input name manually.", + "obsInputPlaceholder": "OBS input name...", + "noObsInputs": "No inputs found in OBS", + "targetMaster": "Master volume", + "targetMasterDesc": "The default output device volume", + "targetSystem": "System sounds", + "targetSystemDesc": "System notification sounds", + "targetMic": "Microphone", + "targetMicDesc": "The default input device level", + "targetCurrent": "Current app", + "targetCurrentDesc": "Whichever app is currently in focus", + "targetCurrentFullscreen": "Current app (fullscreen)", + "targetCurrentFullscreenDesc": "The focused app, only while it's fullscreen", + "targetUnmapped": "Unmapped apps", + "targetUnmappedDesc": "All apps not assigned to any other slider", "ok": "OK", "cancel": "Cancel", "minimize": "Minimize", diff --git a/frontend/src/lib/ru.json b/frontend/src/lib/ru.json index 439f471a..b2d49fa2 100644 --- a/frontend/src/lib/ru.json +++ b/frontend/src/lib/ru.json @@ -30,7 +30,6 @@ "slider": "Слайдер", "addSlider": "Добавить слайдер", "removeSlider": "Удалить слайдер", - "addTargetPlaceholder": "Добавить цель (имя процесса или специальная цель)...", "removeTarget": "Удалить цель", "noTargets": "Нет целей - этот слайдер ничего не делает", "settings": "Настройки", @@ -38,7 +37,27 @@ "unmapped": "не назначен", "targetsFor": "Цели для слайдера", "addTarget": "Добавить цель", - "pressEnterToAdd": "Нажмите Enter, чтобы добавить", + "tabApps": "Приложения", + "tabSpecial": "Специальные", + "tabObs": "OBS", + "searchApps": "Поиск приложений или имя процесса...", + "noSessions": "Нет активных аудиосессий", + "obsDisabled": "Интеграция с OBS отключена. Сначала включите её в настройках.", + "obsNotConnected": "Нет подключения к OBS. Имя источника можно добавить вручную.", + "obsInputPlaceholder": "Имя источника OBS...", + "noObsInputs": "В OBS не найдено источников", + "targetMaster": "Общая громкость", + "targetMasterDesc": "Громкость устройства вывода по умолчанию", + "targetSystem": "Системные звуки", + "targetSystemDesc": "Звуки системных уведомлений", + "targetMic": "Микрофон", + "targetMicDesc": "Уровень устройства ввода по умолчанию", + "targetCurrent": "Текущее приложение", + "targetCurrentDesc": "Приложение, которое сейчас в фокусе", + "targetCurrentFullscreen": "Текущее приложение (полный экран)", + "targetCurrentFullscreenDesc": "Приложение в фокусе, только в полноэкранном режиме", + "targetUnmapped": "Неназначенные приложения", + "targetUnmappedDesc": "Все приложения, не назначенные другим слайдерам", "ok": "ОК", "cancel": "Отмена", "minimize": "Свернуть", diff --git a/frontend/src/lib/state.svelte.ts b/frontend/src/lib/state.svelte.ts index d7a0314e..73210cde 100644 --- a/frontend/src/lib/state.svelte.ts +++ b/frontend/src/lib/state.svelte.ts @@ -1,5 +1,5 @@ import { Events } from "@wailsio/runtime"; -import { SettingsDTO, SettingsService } from "../../bindings/github.com/nik9play/deej/pkg/deej"; +import { SessionInfoDTO, SettingsDTO, SettingsService } from "../../bindings/github.com/nik9play/deej/pkg/deej"; // live application state, fed by wails events from the Go side export const app = $state({ @@ -7,6 +7,7 @@ export const app = $state({ comPort: "", values: [] as number[], // 0..1 per slider, as sessions receive them settings: null as SettingsDTO | null, + sessions: [] as SessionInfoDTO[], // running audio sessions with friendly names }); export async function refreshSettings(): Promise { @@ -19,6 +20,14 @@ export async function refreshSettings(): Promise { } } +export async function refreshSessions(): Promise { + try { + app.sessions = (await SettingsService.GetSessions()) ?? []; + } catch (err) { + console.error("failed to load sessions", err); + } +} + // init subscribes to live events (before fetching initial state, so nothing // is missed) and returns a cleanup function for onDestroy export function init(): () => void { @@ -34,6 +43,9 @@ export function init(): () => void { Events.On("deej:config", () => { void refreshSettings(); }), + Events.On("deej:sessions", () => { + void refreshSessions(); + }), ]; SettingsService.GetStatus() @@ -45,6 +57,7 @@ export function init(): () => void { .catch((err) => console.error("failed to load status", err)); void refreshSettings(); + void refreshSessions(); return () => offs.forEach((off) => off()); } diff --git a/frontend/src/lib/targets.ts b/frontend/src/lib/targets.ts new file mode 100644 index 00000000..686fc8db --- /dev/null +++ b/frontend/src/lib/targets.ts @@ -0,0 +1,52 @@ +import { t } from "./i18n"; +import { app } from "./state.svelte"; + +export const OBS_PREFIX = "deej.obs:"; + +// special target -> i18n label/description key suffix +const specialTargetKeys: Record = { + master: "Master", + system: "System", + mic: "Mic", + "deej.current": "Current", + "deej.current.fullscreen": "CurrentFullscreen", + "deej.unmapped": "Unmapped", +}; + +export function specialTargetLabel(target: string): string | null { + const key = specialTargetKeys[target.toLowerCase()]; + return key ? t(`target${key}`) : null; +} + +export function specialTargetDescription(target: string): string | null { + const key = specialTargetKeys[target.toLowerCase()]; + return key ? t(`target${key}Desc`) : null; +} + +// targetLabel resolves a slider target to a human-friendly display name: +// localized names for special targets, the input name for OBS targets, the +// session's display name (e.g. exe file description) when it's running, and +// a prettified process name otherwise +export function targetLabel(target: string): string { + const special = specialTargetLabel(target); + if (special !== null) { + return special; + } + + const lower = target.toLowerCase(); + if (lower.startsWith(OBS_PREFIX)) { + return `${target.slice(OBS_PREFIX.length)} (OBS)`; + } + + const session = app.sessions.find((s) => s.key === lower); + if (session?.displayName) { + return session.displayName; + } + + return prettifyProcessName(target); +} + +export function prettifyProcessName(name: string): string { + const base = name.toLowerCase().endsWith(".exe") ? name.slice(0, -4) : name; + return base.charAt(0).toUpperCase() + base.slice(1); +} diff --git a/pkg/deej/obs.go b/pkg/deej/obs.go index a148ae82..f9f31cd5 100644 --- a/pkg/deej/obs.go +++ b/pkg/deej/obs.go @@ -3,6 +3,7 @@ package deej import ( "errors" "fmt" + "sort" "time" "github.com/andreykaipov/goobs" @@ -98,6 +99,29 @@ func (o *OBSClient) SetInputVolume(inputName string, percent float32) error { return nil } +// ListInputs returns the sorted names of all inputs in the connected OBS +// instance, used by the settings GUI for target suggestions +func (o *OBSClient) ListInputs() ([]string, error) { + conn, ok := o.reconnector.Current() + if !ok { + return nil, errors.New("not connected to OBS") + } + + resp, err := conn.client.Inputs.GetInputList() + if err != nil { + return nil, fmt.Errorf("get OBS input list: %w", err) + } + + names := make([]string, 0, len(resp.Inputs)) + for _, input := range resp.Inputs { + names = append(names, input.InputName) + } + + sort.Strings(names) + + return names, nil +} + func (o *OBSClient) onUp(conn obsConn) { // re-check the snapshot now that the connection is adopted if o.deej.config.Values().OBSConfig != conn.cfg { diff --git a/pkg/deej/session.go b/pkg/deej/session.go index 2ce15cf2..ecfe869b 100644 --- a/pkg/deej/session.go +++ b/pkg/deej/session.go @@ -17,6 +17,10 @@ type Session interface { Key() string + // DisplayName returns a human-friendly name for this session (e.g. the + // executable's file description), or "" if none is known + DisplayName() string + // IsDevice returns true for device master sessions (a specific audio // device's volume, as opposed to a process session or the default // master/mic session) @@ -46,6 +50,13 @@ type baseSession struct { // used by String(), needs to be set by child humanReadableDesc string + + // optional human-friendly name for the settings GUI, may be empty + displayName string +} + +func (s *baseSession) DisplayName() string { + return s.displayName } func (s *baseSession) IsDevice() bool { diff --git a/pkg/deej/session_finder_linux.go b/pkg/deej/session_finder_linux.go index c756fd18..94572e66 100644 --- a/pkg/deej/session_finder_linux.go +++ b/pkg/deej/session_finder_linux.go @@ -407,12 +407,18 @@ func (sf *paSessionFinder) addSinkInputFromInfo(info *proto.GetSinkInputInfoRepl } } + // friendly name for the settings GUI, e.g. "Firefox" + var displayName string + if friendly, ok := info.Properties["application.name"]; ok { + displayName = friendly.String() + } + sf.mu.Lock() if _, exists := sf.sinkInputs[info.SinkInputIndex]; exists { sf.mu.Unlock() return } - session := newPASession(sf.sessionLogger, conn, info.SinkInputIndex, info.Channels, name.String()) + session := newPASession(sf.sessionLogger, conn, info.SinkInputIndex, info.Channels, name.String(), displayName) sf.sinkInputs[info.SinkInputIndex] = session sf.mu.Unlock() diff --git a/pkg/deej/session_linux.go b/pkg/deej/session_linux.go index 07a6f0ac..24f29fda 100644 --- a/pkg/deej/session_linux.go +++ b/pkg/deej/session_linux.go @@ -38,6 +38,7 @@ func newPASession( sinkInputIndex uint32, sinkInputChannels byte, processName string, + displayName string, ) *paSession { s := &paSession{ @@ -49,6 +50,7 @@ func newPASession( s.processName = processName s.name = processName s.humanReadableDesc = processName + s.displayName = displayName // use a self-identifying session name e.g. deej.sessions.chrome s.logger = logger.Named(s.Key()) diff --git a/pkg/deej/session_map.go b/pkg/deej/session_map.go index f4ca5ed1..73c44836 100644 --- a/pkg/deej/session_map.go +++ b/pkg/deej/session_map.go @@ -448,20 +448,27 @@ func (m *sessionMap) getSessionCount() int { return count } -// sessionKeys returns a sorted copy of the current session keys, used by the -// settings GUI for slider mapping suggestions -func (m *sessionMap) sessionKeys() []string { +// sessionInfos returns a sorted copy of the current session keys with +// friendly display names, used by the settings GUI for target suggestions +func (m *sessionMap) sessionInfos() []SessionInfoDTO { m.lock.Lock() defer m.lock.Unlock() - keys := make([]string, 0, len(m.m)) - for key := range m.m { - keys = append(keys, key) + infos := make([]SessionInfoDTO, 0, len(m.m)) + for key, sessions := range m.m { + info := SessionInfoDTO{Key: key} + for _, session := range sessions { + if name := session.DisplayName(); name != "" { + info.DisplayName = name + break + } + } + infos = append(infos, info) } - sort.Strings(keys) + sort.Slice(infos, func(i, j int) bool { return infos[i].Key < infos[j].Key }) - return keys + return infos } func (m *sessionMap) String() string { diff --git a/pkg/deej/session_windows.go b/pkg/deej/session_windows.go index 1821cffc..a0d93796 100644 --- a/pkg/deej/session_windows.go +++ b/pkg/deej/session_windows.go @@ -9,6 +9,8 @@ import ( ps "github.com/mitchellh/go-ps" wca "github.com/moutend/go-wca/pkg/wca" "go.uber.org/zap" + + "github.com/nik9play/deej/pkg/deej/util" ) var errNoSuchProcess = errors.New("no such process") @@ -75,6 +77,11 @@ func newWCASession( s.processName = process.Executable() s.name = s.processName s.humanReadableDesc = fmt.Sprintf("%s (pid %d)", s.processName, s.pid) + + // best-effort friendly name for the settings GUI + if desc, err := util.GetProcessFileDescription(pid); err == nil { + s.displayName = desc + } } // use a self-identifying session name e.g. deej.sessions.chrome diff --git a/pkg/deej/settings_service.go b/pkg/deej/settings_service.go index 4d60073c..386d0512 100644 --- a/pkg/deej/settings_service.go +++ b/pkg/deej/settings_service.go @@ -1,6 +1,7 @@ package deej import ( + "errors" "sort" "go.bug.st/serial/enumerator" @@ -32,6 +33,12 @@ type AppInfoDTO struct { SpecialTargets []string `json:"specialTargets"` } +// SessionInfoDTO describes a running audio session for target suggestions +type SessionInfoDTO struct { + Key string `json:"key"` + DisplayName string `json:"displayName"` // friendly name, may be empty +} + // StatusDTO describes the live connection state for the settings GUI type StatusDTO struct { Connected bool `json:"connected"` @@ -78,10 +85,20 @@ func (s *SettingsService) GetStatus() StatusDTO { } } -// GetSessionNames returns the current audio session keys, for slider mapping -// suggestions -func (s *SettingsService) GetSessionNames() []string { - return s.d.sessions.sessionKeys() +// GetSessions returns the current audio sessions with friendly display +// names, for slider mapping suggestions +func (s *SettingsService) GetSessions() []SessionInfoDTO { + return s.d.sessions.sessionInfos() +} + +// GetOBSInputs returns the input names of the connected OBS instance, for +// slider mapping suggestions +func (s *SettingsService) GetOBSInputs() ([]string, error) { + if s.d.obs == nil { + return nil, errors.New("OBS integration is not initialized") + } + + return s.d.obs.ListInputs() } // ListSerialPorts enumerates the serial ports available on this machine diff --git a/pkg/deej/tray.go b/pkg/deej/tray.go index c7477671..c92dbc67 100644 --- a/pkg/deej/tray.go +++ b/pkg/deej/tray.go @@ -30,9 +30,10 @@ const settingsWindowName = "deej-settings" // wails events pushed to the settings window const ( - eventSliders = "deej:sliders" // []float32, 0..1 per slider - eventState = "deej:state" // {connected bool, comPort string} - eventConfig = "deej:config" // no payload; config was (re)applied + eventSliders = "deej:sliders" // []float32, 0..1 per slider + eventState = "deej:state" // {connected bool, comPort string} + eventConfig = "deej:config" // no payload; config was (re)applied + eventSessions = "deej:sessions" // no payload; audio sessions changed ) func getConfigItemText(d *Deej) (string, string) { @@ -318,6 +319,7 @@ func (d *Deej) initializeTray(onDone func()) { application.InvokeAsync(func() { sessionsInfo.SetLabel(getSessionsCountString(d)) }) + app.Event.Emit(eventSessions) // config applied (GUI save or manual edit); this case must always // be drained, since onConfigReloaded blocks on every consumer diff --git a/pkg/deej/util/procinfo_windows.go b/pkg/deej/util/procinfo_windows.go new file mode 100644 index 00000000..a70d83af --- /dev/null +++ b/pkg/deej/util/procinfo_windows.go @@ -0,0 +1,81 @@ +package util + +import ( + "errors" + "fmt" + "unsafe" + + "golang.org/x/sys/windows" +) + +// GetProcessFileDescription returns the FileDescription version-info string +// of the executable behind the given process (e.g. "Google Chrome" for +// chrome.exe), for use as a friendly display name +func GetProcessFileDescription(pid uint32) (string, error) { + handle, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid) + if err != nil { + return "", fmt.Errorf("open process: %w", err) + } + defer func() { + _ = windows.CloseHandle(handle) + }() + + buf := make([]uint16, 1024) + size := uint32(len(buf)) + if err := windows.QueryFullProcessImageName(handle, 0, &buf[0], &size); err != nil { + return "", fmt.Errorf("query process image name: %w", err) + } + + return getFileDescription(windows.UTF16ToString(buf[:size])) +} + +func getFileDescription(path string) (string, error) { + infoSize, err := windows.GetFileVersionInfoSize(path, nil) + if err != nil { + return "", fmt.Errorf("get version info size: %w", err) + } + + info := make([]byte, infoSize) + if err := windows.GetFileVersionInfo(path, 0, infoSize, unsafe.Pointer(&info[0])); err != nil { + return "", fmt.Errorf("get version info: %w", err) + } + + // try the file's own translation table first, then the common + // US English + Unicode fallback some executables rely on + subBlocks := []string{`\StringFileInfo\040904B0\FileDescription`} + + var translation *uint32 + var translationLen uint32 + if err := windows.VerQueryValue( + unsafe.Pointer(&info[0]), + `\VarFileInfo\Translation`, + unsafe.Pointer(&translation), + &translationLen, + ); err == nil && translationLen >= 4 { + // each translation entry is a language ID + codepage pair + subBlocks = append([]string{fmt.Sprintf( + `\StringFileInfo\%04x%04x\FileDescription`, + *translation&0xffff, + *translation>>16, + )}, subBlocks...) + } + + for _, subBlock := range subBlocks { + var descPtr *uint16 + var descLen uint32 + if err := windows.VerQueryValue( + unsafe.Pointer(&info[0]), + subBlock, + unsafe.Pointer(&descPtr), + &descLen, + ); err != nil || descLen == 0 { + continue + } + + if desc := windows.UTF16PtrToString(descPtr); desc != "" { + return desc, nil + } + } + + return "", errors.New("no file description") +} From 955694dd03a823a70d97c6af0739d9a628f75d9c Mon Sep 17 00:00:00 2001 From: Nikita Date: Mon, 13 Jul 2026 13:17:34 +0300 Subject: [PATCH 12/47] split audio devices into their own target picker tab --- .../nik9play/deej/pkg/deej/models.ts | 8 ++++++++ frontend/src/components/TargetDialog.svelte | 19 ++++++++++++++++--- frontend/src/lib/en.json | 2 ++ frontend/src/lib/ru.json | 2 ++ pkg/deej/session_linux.go | 2 ++ pkg/deej/session_map.go | 8 +++++--- pkg/deej/session_windows.go | 2 ++ pkg/deej/settings_service.go | 1 + 8 files changed, 38 insertions(+), 6 deletions(-) diff --git a/frontend/bindings/github.com/nik9play/deej/pkg/deej/models.ts b/frontend/bindings/github.com/nik9play/deej/pkg/deej/models.ts index 2388310f..7c2e8673 100644 --- a/frontend/bindings/github.com/nik9play/deej/pkg/deej/models.ts +++ b/frontend/bindings/github.com/nik9play/deej/pkg/deej/models.ts @@ -96,6 +96,11 @@ export class SessionInfoDTO { */ "displayName": string; + /** + * device master session, not a process + */ + "isDevice": boolean; + /** Creates a new SessionInfoDTO instance. */ constructor($$source: Partial = {}) { if (!("key" in $$source)) { @@ -104,6 +109,9 @@ export class SessionInfoDTO { if (!("displayName" in $$source)) { this["displayName"] = ""; } + if (!("isDevice" in $$source)) { + this["isDevice"] = false; + } Object.assign(this, $$source); } diff --git a/frontend/src/components/TargetDialog.svelte b/frontend/src/components/TargetDialog.svelte index 0f70acb5..b95c1770 100644 --- a/frontend/src/components/TargetDialog.svelte +++ b/frontend/src/components/TargetDialog.svelte @@ -4,6 +4,7 @@ import Check from "@lucide/svelte/icons/check"; import Plus from "@lucide/svelte/icons/plus"; import Sparkles from "@lucide/svelte/icons/sparkles"; + import Speaker from "@lucide/svelte/icons/speaker"; import Video from "@lucide/svelte/icons/video"; import X from "@lucide/svelte/icons/x"; import { AppInfoDTO, SettingsDTO, SettingsService } from "../../bindings/github.com/nik9play/deej/pkg/deej"; @@ -63,7 +64,7 @@ const appItems = $derived.by(() => { const query = appSearch.trim().toLowerCase(); return app.sessions - .filter((session) => !specialSessionKeys.includes(session.key)) + .filter((session) => !session.isDevice && !specialSessionKeys.includes(session.key)) .filter( (session) => query === "" || @@ -72,6 +73,8 @@ ); }); + const deviceItems = $derived(app.sessions.filter((session) => session.isDevice)); + // arbitrary process names are allowed - offer to add the typed text when it // doesn't exactly match a running session const freeText = $derived.by(() => { @@ -197,10 +200,10 @@ - {#each [{ value: "apps", label: t("tabApps"), Icon: AppWindow }, { value: "special", label: t("tabSpecial"), Icon: Sparkles }, { value: "obs", label: t("tabObs"), Icon: Video }] as tabItem (tabItem.value)} + {#each [{ value: "apps", label: t("tabApps"), Icon: AppWindow }, { value: "devices", label: t("tabDevices"), Icon: Speaker }, { value: "special", label: t("tabSpecial"), Icon: Sparkles }, { value: "obs", label: t("tabObs"), Icon: Video }] as tabItem (tabItem.value)} {tabItem.label} @@ -246,6 +249,16 @@
+ +
+ {#each deviceItems as session (session.key)} + {@render itemRow(session.key, session.displayName || session.key, "")} + {:else} +
{t("noDevices")}
+ {/each} +
+
+
{#each specialTargets as target (target)} diff --git a/frontend/src/lib/en.json b/frontend/src/lib/en.json index a2c9fe0f..f21180bf 100644 --- a/frontend/src/lib/en.json +++ b/frontend/src/lib/en.json @@ -38,8 +38,10 @@ "targetsFor": "Targets for slider", "addTarget": "Add target", "tabApps": "Apps", + "tabDevices": "Devices", "tabSpecial": "Special", "tabObs": "OBS", + "noDevices": "No audio devices found", "searchApps": "Search apps or type a process name...", "noSessions": "No running audio sessions", "obsDisabled": "OBS integration is disabled. Enable it in settings first.", diff --git a/frontend/src/lib/ru.json b/frontend/src/lib/ru.json index b2d49fa2..1291b62b 100644 --- a/frontend/src/lib/ru.json +++ b/frontend/src/lib/ru.json @@ -38,8 +38,10 @@ "targetsFor": "Цели для слайдера", "addTarget": "Добавить цель", "tabApps": "Приложения", + "tabDevices": "Устройства", "tabSpecial": "Специальные", "tabObs": "OBS", + "noDevices": "Аудиоустройства не найдены", "searchApps": "Поиск приложений или имя процесса...", "noSessions": "Нет активных аудиосессий", "obsDisabled": "Интеграция с OBS отключена. Сначала включите её в настройках.", diff --git a/pkg/deej/session_linux.go b/pkg/deej/session_linux.go index 24f29fda..731e05cd 100644 --- a/pkg/deej/session_linux.go +++ b/pkg/deej/session_linux.go @@ -95,6 +95,8 @@ func newNamedMasterSession( s.master = true s.name = name s.humanReadableDesc = name + // keeps the original casing (Key() lowercases), shown for device sessions + s.displayName = name s.logger.Debugw(sessionCreationLogMessage, "session", s) diff --git a/pkg/deej/session_map.go b/pkg/deej/session_map.go index 73c44836..3a378038 100644 --- a/pkg/deej/session_map.go +++ b/pkg/deej/session_map.go @@ -458,9 +458,11 @@ func (m *sessionMap) sessionInfos() []SessionInfoDTO { for key, sessions := range m.m { info := SessionInfoDTO{Key: key} for _, session := range sessions { - if name := session.DisplayName(); name != "" { - info.DisplayName = name - break + if session.IsDevice() { + info.IsDevice = true + } + if info.DisplayName == "" { + info.DisplayName = session.DisplayName() } } infos = append(infos, info) diff --git a/pkg/deej/session_windows.go b/pkg/deej/session_windows.go index a0d93796..695a0c54 100644 --- a/pkg/deej/session_windows.go +++ b/pkg/deej/session_windows.go @@ -108,6 +108,8 @@ func newMasterSession( s.master = true s.name = key s.humanReadableDesc = key + // keeps the original casing (Key() lowercases), shown for device sessions + s.displayName = key s.logger.Debugw(sessionCreationLogMessage, "session", s) diff --git a/pkg/deej/settings_service.go b/pkg/deej/settings_service.go index 386d0512..2cb9c004 100644 --- a/pkg/deej/settings_service.go +++ b/pkg/deej/settings_service.go @@ -37,6 +37,7 @@ type AppInfoDTO struct { type SessionInfoDTO struct { Key string `json:"key"` DisplayName string `json:"displayName"` // friendly name, may be empty + IsDevice bool `json:"isDevice"` // device master session, not a process } // StatusDTO describes the live connection state for the settings GUI From 40a32edbfc95de5c205996f9f4aa120cdb34c6ce Mon Sep 17 00:00:00 2001 From: Nikita Date: Mon, 13 Jul 2026 16:53:26 +0300 Subject: [PATCH 13/47] show raw target name when no matching session is running --- frontend/src/lib/targets.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/lib/targets.ts b/frontend/src/lib/targets.ts index 686fc8db..79a73b95 100644 --- a/frontend/src/lib/targets.ts +++ b/frontend/src/lib/targets.ts @@ -26,7 +26,7 @@ export function specialTargetDescription(target: string): string | null { // targetLabel resolves a slider target to a human-friendly display name: // localized names for special targets, the input name for OBS targets, the // session's display name (e.g. exe file description) when it's running, and -// a prettified process name otherwise +// the target as written in the config otherwise export function targetLabel(target: string): string { const special = specialTargetLabel(target); if (special !== null) { @@ -39,11 +39,11 @@ export function targetLabel(target: string): string { } const session = app.sessions.find((s) => s.key === lower); - if (session?.displayName) { - return session.displayName; + if (session) { + return session.displayName || prettifyProcessName(target); } - return prettifyProcessName(target); + return target; } export function prettifyProcessName(name: string): string { From bf3f7b583661901befb41883ee032742c5788748 Mon Sep 17 00:00:00 2001 From: Nikita Date: Mon, 13 Jul 2026 19:54:40 +0300 Subject: [PATCH 14/47] build with the Task system and add a cgo-free headless linux build --- .github/workflows/main.yml | 14 ++++++ Taskfile.linux.yml | 44 +++++++++++++++++ Taskfile.windows.yml | 39 +++++++++++++++ Taskfile.yml | 59 ++++++++++++++++++++++ pkg/deej/tray.go | 2 + pkg/deej/tray_headless.go | 25 ++++++++++ scripts/linux/build.sh | 72 +++++---------------------- scripts/windows/build.bat | 78 +++++++----------------------- scripts/windows/make-installer.bat | 27 +++++------ 9 files changed, 226 insertions(+), 134 deletions(-) create mode 100644 Taskfile.linux.yml create mode 100644 Taskfile.windows.yml create mode 100644 Taskfile.yml create mode 100644 pkg/deej/tray_headless.go diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index fab39c90..e667b48a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -54,6 +54,13 @@ jobs: cache: npm cache-dependency-path: frontend/package-lock.json + # the build scripts delegate to the Task build system (Taskfile.yml) + - name: Install Task + uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + - name: Build deej (amd64) run: scripts/windows/build-all.bat shell: cmd @@ -119,6 +126,13 @@ jobs: sudo apt-get update sudo apt-get install -y libgtk-4-dev libwebkitgtk-6.0-dev + # the build scripts delegate to the Task build system (Taskfile.yml) + - name: Install Task + uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + - name: Build deej run: scripts/linux/build-all.sh diff --git a/Taskfile.linux.yml b/Taskfile.linux.yml new file mode 100644 index 00000000..a1d1d273 --- /dev/null +++ b/Taskfile.linux.yml @@ -0,0 +1,44 @@ +version: "3" + +# Linux build tasks. Included by the root Taskfile.yml, which passes BIN_DIR, +# LDFLAGS and VERSION_TAG. All tasks run from the repo root (dir: {{.ROOT_DIR}}). +# +# Two flavours of build exist: +# build - the full GUI app; links the Wails GTK4/WebKitGTK backend via +# cgo, so it needs a C compiler and the GTK/WebKit -dev packages +# (or Docker) and cannot be cross-compiled with CGO_ENABLED=0. +# build:headless - a GUI-less daemon built with -tags headless. Compiles Wails +# out entirely, so it is pure Go (CGO_ENABLED=0) and +# cross-compilable to any GOOS/GOARCH. Controlled by config.yaml +# only (no tray, no settings window). + +tasks: + frontend: + internal: true + dir: frontend + cmds: + - cmd: test -d node_modules || npm ci + - npm run build + + build: + desc: Build the full GUI app (cgo, GTK4/WebKitGTK) -> deej-release + deps: [frontend] + env: + CGO_ENABLED: "1" + cmds: + - go build -o {{.BIN_DIR}}/deej-release -ldflags "-s -w {{.LDFLAGS}} -X main.buildType=release" ./pkg/deej/cmd + + build:dev: + desc: Build the full GUI app with debug symbols -> deej-dev + deps: [frontend] + env: + CGO_ENABLED: "1" + cmds: + - go build -o {{.BIN_DIR}}/deej-dev -ldflags "{{.LDFLAGS}} -X main.buildType=dev" ./pkg/deej/cmd + + build:headless: + desc: Build a GUI-less, cgo-free daemon (cross-compilable) -> deej-headless + env: + CGO_ENABLED: "0" + cmds: + - go build -tags headless -o {{.BIN_DIR}}/deej-headless -ldflags "-s -w {{.LDFLAGS}} -X main.buildType=release" ./pkg/deej/cmd diff --git a/Taskfile.windows.yml b/Taskfile.windows.yml new file mode 100644 index 00000000..6c091d0a --- /dev/null +++ b/Taskfile.windows.yml @@ -0,0 +1,39 @@ +version: "3" + +# Windows build tasks. Included by the root Taskfile.yml, which passes BIN_DIR, +# LDFLAGS and VERSION_TAG. All tasks run from the repo root (dir: {{.ROOT_DIR}}). + +tasks: + syso: + desc: Generate winres icon/manifest .syso files (idempotent) + internal: true + cmds: + - go generate ./... + status: + - test -f pkg/deej/cmd/rsrc_windows_amd64.syso + - test -f pkg/deej/cmd/rsrc_windows_arm64.syso + + frontend: + internal: true + dir: frontend + cmds: + - cmd: test -d node_modules || npm ci + - npm run build + + build: + desc: Build deej-release.exe (optimized, no console window) + deps: [syso, frontend] + cmds: + - go build -o {{.BIN_DIR}}/deej-release.exe -ldflags "-H=windowsgui -s -w {{.LDFLAGS}} -X main.buildType=release" ./pkg/deej/cmd + + build:dev: + desc: Build deej-dev.exe (debug symbols, console window) + deps: [syso, frontend] + cmds: + - go build -o {{.BIN_DIR}}/deej-dev.exe -gcflags=all="-N -l" -ldflags "{{.LDFLAGS}} -X main.buildType=dev" ./pkg/deej/cmd + + package: + desc: Build the release exe and the Inno Setup installer + deps: [build] + cmds: + - ISCC /O{{.BIN_DIR}} "/DAppVersion={{.VERSION_TAG}}" /Qp scripts/windows/installer.iss diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 00000000..73e03651 --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,59 @@ +version: "3" + +# deej build orchestration. This is the Task (https://taskfile.dev) build system +# that also underpins `wails3 build`/`wails3 task`. It keeps deej's own bits that +# don't fit the default wails3 layout: the Inno Setup installer +# (scripts/windows/installer.iss), winres dual-arch resources, and the custom +# version ldflags. Invoke with `task ` or `wails3 task `. + +includes: + windows: + taskfile: ./Taskfile.windows.yml + dir: "{{.ROOT_DIR}}" + vars: &platformvars + BIN_DIR: "{{.BIN_DIR}}" + LDFLAGS: "{{.LDFLAGS}}" + VERSION_TAG: "{{.VERSION_TAG}}" + linux: + taskfile: ./Taskfile.linux.yml + dir: "{{.ROOT_DIR}}" + vars: *platformvars + +vars: + APP_NAME: deej + BIN_DIR: build + # git-derived build metadata, injected into the binary via ldflags + GIT_COMMIT: + sh: git rev-list -1 --abbrev-commit HEAD + VERSION_TAG: + sh: git describe --tags --always + LDFLAGS: -X main.gitCommit={{.GIT_COMMIT}} -X main.versionTag={{.VERSION_TAG}} + +tasks: + default: + desc: List the available tasks + cmds: + - cmd: echo "Run 'task --list' (or 'wails3 task --list') to see available tasks." + silent: true + + frontend:build: + desc: Build the embedded Svelte settings UI (frontend/dist) + dir: frontend + cmds: + - cmd: test -d node_modules || npm ci + - npm run build + + generate:bindings: + desc: Regenerate the Wails Go->TS bindings (requires the wails3 CLI) + cmds: + - wails3 generate bindings -silent + + build: + desc: Build deej for the current OS (release) + cmds: + - task: "{{OS}}:build" + + test: + desc: Run the Go test suite + cmds: + - go test ./... diff --git a/pkg/deej/tray.go b/pkg/deej/tray.go index c92dbc67..37174122 100644 --- a/pkg/deej/tray.go +++ b/pkg/deej/tray.go @@ -1,3 +1,5 @@ +//go:build !headless + package deej import ( diff --git a/pkg/deej/tray_headless.go b/pkg/deej/tray_headless.go new file mode 100644 index 00000000..9c123f13 --- /dev/null +++ b/pkg/deej/tray_headless.go @@ -0,0 +1,25 @@ +//go:build headless + +package deej + +// This file provides the no-GUI implementation of the tray surface, selected by +// the "headless" build tag. It contains no Wails import, so a headless build +// links none of the Wails GTK4/WebKitGTK backend and can be built with +// CGO_ENABLED=0 (and cross-compiled). deej then runs as a config-file-driven +// daemon: the serial->audio loop with fsnotify hot-reload, but no tray icon or +// settings window. + +// trayState is an empty placeholder in headless builds; the GUI build backs it +// with the Wails application (see tray.go). +type trayState struct{} + +// initializeTray runs the main loop directly instead of hosting a tray, mirroring +// the existing DEEJ_NO_TRAY_ICON runtime path in Initialize. onDone blocks until +// deej stops. +func (d *Deej) initializeTray(onDone func()) { + d.logger.Debug("Running headless (no tray, built with -tags headless)") + onDone() +} + +// stopTray is a no-op in headless builds; there is no tray to tear down. +func (d *Deej) stopTray() {} diff --git a/scripts/linux/build.sh b/scripts/linux/build.sh index da31cb46..aa260940 100755 --- a/scripts/linux/build.sh +++ b/scripts/linux/build.sh @@ -1,66 +1,20 @@ #!/bin/sh -# Check if mode parameter is provided -if [ -z "$1" ]; then - echo "Usage: $0 [dev|release]" - exit 1 -fi - -MODE=$1 - -# Validate the mode parameter -if [ "$MODE" != "dev" ] && [ "$MODE" != "release" ]; then - echo "Invalid mode: $MODE" - echo "Use 'dev' or 'release'." - exit 1 -fi - -echo "Building deej ($MODE)..." +# Thin wrapper around the Task build system (see Taskfile.yml). Kept so the +# existing entrypoints and CI keep working. Prefers the standalone `task` CLI +# and falls back to the copy bundled with the wails3 CLI. -# GOOS/GOARCH are honored for cross-compilation; Windows binaries need -# an .exe suffix and (in release mode) the windowsgui flag to avoid -# opening a console window -EXT="" -GUIFLAG="" -if [ "$(go env GOOS)" = "windows" ]; then - EXT=".exe" - GUIFLAG="-H=windowsgui " -fi +case "$1" in + dev) TASK_TARGET=linux:build:dev ;; + release) TASK_TARGET=linux:build ;; + *) echo "Usage: $0 [dev|release]"; exit 1 ;; +esac -# Build the settings GUI frontend (embedded into the binary) -echo "Building frontend..." -( - cd frontend || exit 1 - if [ ! -d node_modules ]; then - npm ci || exit 1 - fi - npm run build || exit 1 -) || { - echo 'Error: frontend build failed.' - exit 1 -} - -# Get git commit and version tag -GIT_COMMIT=$(git rev-list -1 --abbrev-commit HEAD) -VERSION_TAG=$(git describe --tags --always) -BUILD_TYPE=$MODE - -echo 'Embedding build-time parameters:' -echo "- gitCommit $GIT_COMMIT" -echo "- versionTag $VERSION_TAG" -echo "- buildType $BUILD_TYPE" - -# Build based on mode -if [ "$MODE" = "dev" ]; then - go build -o "build/deej-dev$EXT" -ldflags "-X main.gitCommit=$GIT_COMMIT -X main.versionTag=$VERSION_TAG -X main.buildType=$BUILD_TYPE" ./pkg/deej/cmd -else - go build -o "build/deej-release$EXT" -ldflags "$GUIFLAG-s -w -X main.gitCommit=$GIT_COMMIT -X main.versionTag=$VERSION_TAG -X main.buildType=$BUILD_TYPE" ./pkg/deej/cmd -fi +DEEJ_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) +cd "$DEEJ_ROOT" || exit 1 -# Check if build succeeded -if [ $? -eq 0 ]; then - echo "Done." +if command -v task >/dev/null 2>&1; then + exec task "$TASK_TARGET" else - echo 'Error: "go build" exited with a non-zero code. Are you running this script from the root deej directory?' - exit 1 + exec wails3 task "$TASK_TARGET" fi diff --git a/scripts/windows/build.bat b/scripts/windows/build.bat index b4659660..3de60dc2 100644 --- a/scripts/windows/build.bat +++ b/scripts/windows/build.bat @@ -1,72 +1,28 @@ @ECHO OFF -REM check if mode parameter is provided -IF "%1"=="" ( +REM Thin wrapper around the Task build system (see Taskfile.yml). Kept so the +REM existing entrypoints and CI keep working. Prefers the standalone `task` CLI +REM and falls back to the copy bundled with the wails3 CLI. + +IF "%1"=="dev" ( + SET "TASK_TARGET=windows:build:dev" +) ELSE IF "%1"=="release" ( + SET "TASK_TARGET=windows:build" +) ELSE ( ECHO Usage: build.bat [dev^|release] EXIT /B 1 ) -SET MODE=%1 - -REM validate the mode parameter -IF NOT "%MODE%"=="dev" IF NOT "%MODE%"=="release" ( - ECHO Invalid mode: %MODE% - ECHO Use "dev" or "release". - EXIT /B 1 -) - -ECHO Building deej (%MODE%)... - -REM set repo root in relation to script path to avoid cwd dependency SET "DEEJ_ROOT=%~dp0..\.." +PUSHD "%DEEJ_ROOT%" -REM get git commit and version tag -FOR /f "delims=" %%a IN ('git rev-list -1 --abbrev-commit HEAD') DO @SET GIT_COMMIT=%%a -FOR /f "delims=" %%a IN ('git describe --tags --always') DO @SET VERSION_TAG=%%a -SET BUILD_TYPE=%MODE% - -IF NOT EXIST %DEEJ_ROOT%/pkg/deej/cmd/rsrc_windows_amd64.syso SET NEED_RSRC=1 -IF NOT EXIST %DEEJ_ROOT%/pkg/deej/cmd/rsrc_windows_arm64.syso SET NEED_RSRC=1 -IF DEFINED NEED_RSRC ( - ECHO Generating resource files... - go generate %DEEJ_ROOT%/... -) - -ECHO Building frontend... -PUSHD "%DEEJ_ROOT%\frontend" -IF NOT EXIST node_modules ( - CALL npm ci - IF ERRORLEVEL 1 ( - POPD - GOTO BUILDERROR - ) -) -CALL npm run build -IF ERRORLEVEL 1 ( - POPD - GOTO BUILDERROR -) -POPD - -ECHO Embedding build-time parameters: -ECHO - gitCommit %GIT_COMMIT% -ECHO - versionTag %VERSION_TAG% -ECHO - buildType %BUILD_TYPE% - -REM build based on mode -IF "%MODE%"=="dev" ( - go build -o "%DEEJ_ROOT%\build\deej-dev.exe" -gcflags=all="-N -l" -ldflags "-X main.gitCommit=%GIT_COMMIT% -X main.versionTag=%VERSION_TAG% -X main.buildType=%BUILD_TYPE%" "%DEEJ_ROOT%\pkg\deej\cmd" +WHERE task >NUL 2>&1 +IF %ERRORLEVEL%==0 ( + task %TASK_TARGET% ) ELSE ( - go build -o "%DEEJ_ROOT%\build\deej-release.exe" -ldflags "-H=windowsgui -s -w -X main.gitCommit=%GIT_COMMIT% -X main.versionTag=%VERSION_TAG% -X main.buildType=%BUILD_TYPE%" "%DEEJ_ROOT%\pkg\deej\cmd" + wails3 task %TASK_TARGET% ) -IF %ERRORLEVEL% NEQ 0 GOTO BUILDERROR - -ECHO Done. -GOTO DONE - -:BUILDERROR -ECHO Failed to build deej in %MODE% mode! See above output for details. -EXIT /B 1 - -:DONE \ No newline at end of file +SET "BUILD_ERR=%ERRORLEVEL%" +POPD +EXIT /B %BUILD_ERR% diff --git a/scripts/windows/make-installer.bat b/scripts/windows/make-installer.bat index afc48097..f2d3b037 100644 --- a/scripts/windows/make-installer.bat +++ b/scripts/windows/make-installer.bat @@ -1,20 +1,19 @@ @ECHO OFF +REM Thin wrapper around the Task build system (see Taskfile.yml). Builds the +REM release exe and the Inno Setup installer via `windows:package`. Prefers the +REM standalone `task` CLI and falls back to the copy bundled with wails3. + SET "DEEJ_ROOT=%~dp0..\.." +PUSHD "%DEEJ_ROOT%" -IF NOT EXIST "%DEEJ_ROOT%\build\deej-release.exe" ( - ECHO build\deej-release.exe not found! Run scripts\windows\build-release.bat first. - EXIT /B 1 +WHERE task >NUL 2>&1 +IF %ERRORLEVEL%==0 ( + task windows:package +) ELSE ( + wails3 task windows:package ) -FOR /f "delims=" %%a IN ('git rev-list -1 --abbrev-commit HEAD') DO @SET GIT_COMMIT=%%a -FOR /f "delims=" %%a IN ('git describe --tags --always') DO @SET VERSION_TAG=%%a - -SET VERSION=%GIT_COMMIT%-%VERSION_TAG% - -ECHO Building installer -ECHO - gitCommit %GIT_COMMIT% -ECHO - versionTag %VERSION_TAG% - -ISCC /O"%DEEJ_ROOT%\build" "/DAppVersion=%VERSION_TAG%" /Qp "%DEEJ_ROOT%\scripts\windows\installer.iss" -ECHO Installer successfully built +SET "BUILD_ERR=%ERRORLEVEL%" +POPD +EXIT /B %BUILD_ERR% From de1d5952c983a14fe7465506182d01135d243a85 Mon Sep 17 00:00:00 2001 From: Nikita Date: Mon, 13 Jul 2026 19:54:54 +0300 Subject: [PATCH 15/47] warn when the webview2 runtime is missing during install --- scripts/windows/installer.iss | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/scripts/windows/installer.iss b/scripts/windows/installer.iss index 4df60a1e..ac5166ec 100644 --- a/scripts/windows/installer.iss +++ b/scripts/windows/installer.iss @@ -45,6 +45,8 @@ Name: "russian"; MessagesFile: "compiler:Languages\Russian.isl" [CustomMessages] english.EditConfig=Edit config file russian.EditConfig=Редактировать конфигурацию +english.WV2Prompt=deej needs the Microsoft Edge WebView2 runtime for its settings window, but it was not found on this PC. Open the download page now? +russian.WV2Prompt=Для окна настроек deej требуется среда выполнения Microsoft Edge WebView2, но она не найдена на этом компьютере. Открыть страницу загрузки? [Tasks] Name: "autostart"; Description: "{cm:AutoStartProgram,{#AppName}}" @@ -77,3 +79,32 @@ Type: filesandordirs; Name: "{app}/logs" [UninstallRun] ; kill deej on uninstall Filename: {sys}\taskkill.exe; Parameters: "/f /im {#AppExeName}"; Flags: skipifdoesntexist runhidden; RunOnceId: "KillProc" + +[Code] +// deej's settings window is rendered by Edge WebView2. Recent Windows 10/11 ship +// the runtime in-box, but older/LTSC/Server images may not. We don't bundle the +// runtime; instead we detect it and, if missing, offer to open the download page. +// The runtime registers under a fixed GUID: system-wide installs land under +// HKLM\...\WOW6432Node (even on 64-bit), per-user installs under HKCU. +function WebView2Missing(): Boolean; +var + Version: string; +begin + Result := not ( + RegQueryStringValue(HKLM, 'SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}', 'pv', Version) + or RegQueryStringValue(HKCU, 'Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}', 'pv', Version)); + // treat an empty or 0.0.0.0 version as "not installed" + if not Result and ((Version = '') or (Version = '0.0.0.0')) then + Result := True; +end; + +procedure CurStepChanged(CurStep: TSetupStep); +var + ResultCode: Integer; +begin + if (CurStep = ssPostInstall) and WebView2Missing() then + begin + if MsgBox(ExpandConstant('{cm:WV2Prompt}'), mbConfirmation, MB_YESNO) = IDYES then + ShellExec('open', 'https://developer.microsoft.com/microsoft-edge/webview2/', '', '', SW_SHOW, ewNoWait, ResultCode); + end; +end; From 887df8caace8ccf4e78d12c3324354830b26c2dc Mon Sep 17 00:00:00 2001 From: Nikita Date: Mon, 13 Jul 2026 20:02:08 +0300 Subject: [PATCH 16/47] build headless linux binaries in the release workflow --- .github/workflows/main.yml | 41 +++++++++++++++++++++++++++++++++++++- Taskfile.linux.yml | 1 + 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e667b48a..83d01890 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -147,10 +147,45 @@ jobs: name: deej-linux-${{ matrix.arch }} path: build/ + # the headless build compiles Wails out (-tags headless), so it is cgo-free and + # a single runner cross-compiles every arch without GTK/WebKit or Node + build-linux-headless: + name: Build (linux headless) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: "1.25.7" + + - name: Install Task + uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Build deej (headless, amd64 + arm64) + run: | + GOARCH=amd64 task linux:build:headless + mv build/deej-headless build/deej-linux-amd64-headless + GOARCH=arm64 task linux:build:headless + mv build/deej-headless build/deej-linux-arm64-headless + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: deej-linux-headless + path: build/ + release: name: Create release draft if: startsWith(github.ref, 'refs/tags/') - needs: [build-windows, build-linux] + needs: [build-windows, build-linux, build-linux-headless] runs-on: ubuntu-latest permissions: contents: write @@ -176,9 +211,13 @@ jobs: | Windows portable | arm64 | [deej-windows-arm64.exe](https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/deej-windows-arm64.exe) | | Linux | amd64 | [deej-linux-amd64](https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/deej-linux-amd64) | | Linux | arm64 | [deej-linux-arm64](https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/deej-linux-arm64) | + | Linux (headless, no GUI) | amd64 | [deej-linux-amd64-headless](https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/deej-linux-amd64-headless) | + | Linux (headless, no GUI) | arm64 | [deej-linux-arm64-headless](https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/deej-linux-arm64-headless) | files: | artifacts/deej-windows-amd64.exe artifacts/deej-windows-arm64.exe artifacts/deej-linux-amd64 artifacts/deej-linux-arm64 + artifacts/deej-linux-amd64-headless + artifacts/deej-linux-arm64-headless artifacts/deej_setup.exe diff --git a/Taskfile.linux.yml b/Taskfile.linux.yml index a1d1d273..0391789c 100644 --- a/Taskfile.linux.yml +++ b/Taskfile.linux.yml @@ -39,6 +39,7 @@ tasks: build:headless: desc: Build a GUI-less, cgo-free daemon (cross-compilable) -> deej-headless env: + GOOS: linux CGO_ENABLED: "0" cmds: - go build -tags headless -o {{.BIN_DIR}}/deej-headless -ldflags "-s -w {{.LDFLAGS}} -X main.buildType=release" ./pkg/deej/cmd From 1288a0f34d55d9eba6a07cf697a681fb3aeba803 Mon Sep 17 00:00:00 2001 From: Nikita Date: Tue, 14 Jul 2026 00:27:57 +0300 Subject: [PATCH 17/47] replace hand-rolled frontend i18n with paraglide-js --- .gitignore | 4 +- frontend/{src/lib => messages}/en.json | 1 + frontend/{src/lib => messages}/ru.json | 1 + frontend/package-lock.json | 245 +++++++++++++++++- frontend/package.json | 1 + frontend/project.inlang/settings.json | 12 + .../src/components/BehaviorSection.svelte | 18 +- .../src/components/ConnectionSection.svelte | 18 +- .../src/components/LanguageSection.svelte | 10 +- frontend/src/components/Mixer.svelte | 4 +- frontend/src/components/ObsSection.svelte | 14 +- frontend/src/components/SettingsDialog.svelte | 20 +- frontend/src/components/SliderColumn.svelte | 8 +- frontend/src/components/TargetDialog.svelte | 38 +-- frontend/src/components/Titlebar.svelte | 18 +- frontend/src/lib/i18n.ts | 19 -- frontend/src/lib/targets.ts | 26 +- frontend/src/main.ts | 7 +- frontend/tsconfig.json | 6 +- frontend/vite.config.ts | 16 +- 20 files changed, 372 insertions(+), 114 deletions(-) rename frontend/{src/lib => messages}/en.json (97%) rename frontend/{src/lib => messages}/ru.json (98%) create mode 100644 frontend/project.inlang/settings.json delete mode 100644 frontend/src/lib/i18n.ts diff --git a/.gitignore b/.gitignore index 803f6a3f..cb8fb809 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,6 @@ preferences.yaml config.yaml frontend/node_modules/ frontend/dist/* -!frontend/dist/.gitkeep \ No newline at end of file +!frontend/dist/.gitkeep +# paraglide-js generated output (regenerated by the vite plugin on build) +frontend/src/paraglide/ \ No newline at end of file diff --git a/frontend/src/lib/en.json b/frontend/messages/en.json similarity index 97% rename from frontend/src/lib/en.json rename to frontend/messages/en.json index f21180bf..0f4c4533 100644 --- a/frontend/src/lib/en.json +++ b/frontend/messages/en.json @@ -1,4 +1,5 @@ { + "$schema": "https://inlang.com/schema/inlang-message-format", "title": "deej settings", "connection": "Connection", "comPort": "COM port", diff --git a/frontend/src/lib/ru.json b/frontend/messages/ru.json similarity index 98% rename from frontend/src/lib/ru.json rename to frontend/messages/ru.json index 1291b62b..d83d5f23 100644 --- a/frontend/src/lib/ru.json +++ b/frontend/messages/ru.json @@ -1,4 +1,5 @@ { + "$schema": "https://inlang.com/schema/inlang-message-format", "title": "Настройки deej", "connection": "Подключение", "comPort": "COM-порт", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8934d78d..0303c4d1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "deej-frontend", "version": "0.0.0", "dependencies": { + "@inlang/paraglide-js": "^2.21.0", "@lucide/svelte": "^1.24.0", "@wailsio/runtime": "latest", "bits-ui": "^2.18.1" @@ -83,6 +84,57 @@ "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", "license": "MIT" }, + "node_modules/@inlang/paraglide-js": { + "version": "2.21.0", + "resolved": "https://registry.npmjs.org/@inlang/paraglide-js/-/paraglide-js-2.21.0.tgz", + "integrity": "sha512-t+OOui1i5p73zQ2Dk28yFzu6rZhh7hsDOCHjkXDmSwFFsUsS5HyUD4CvPJ+wf02qgmlCMu2z1qffJ5aWwgx1CA==", + "license": "MIT", + "dependencies": { + "@inlang/recommend-sherlock": "^0.2.1", + "@inlang/sdk": "^2.10.0", + "commander": "11.1.0", + "consola": "3.4.0", + "json5": "2.2.3", + "unplugin": "^2.1.2", + "urlpattern-polyfill": "^10.0.0" + }, + "bin": { + "paraglide-js": "bin/run.js" + }, + "peerDependencies": { + "typescript": ">=5.6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@inlang/recommend-sherlock": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@inlang/recommend-sherlock/-/recommend-sherlock-0.2.1.tgz", + "integrity": "sha512-ckv8HvHy/iTqaVAEKrr+gnl+p3XFNwe5D2+6w6wJk2ORV2XkcRkKOJ/XsTUJbPSiyi4PI+p+T3bqbmNx/rDUlg==", + "license": "MIT", + "dependencies": { + "comment-json": "^4.2.3" + } + }, + "node_modules/@inlang/sdk": { + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/@inlang/sdk/-/sdk-2.10.2.tgz", + "integrity": "sha512-O1ki72SNK6LPagaGrvlioBb1mWKvump7cO7P85hfGZjdFTmDdn3icI0A6MvaBsB3P9KQHAjzyubnN1OslGufTw==", + "license": "MIT", + "dependencies": { + "@lix-js/sdk": "0.4.10", + "@sinclair/typebox": "^0.31.17", + "kysely": "^0.28.12", + "sqlite-wasm-kysely": "0.3.0", + "uuid": "^14.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@internationalized/date": { "version": "3.12.2", "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.2.tgz", @@ -138,6 +190,30 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lix-js/sdk": { + "version": "0.4.10", + "resolved": "https://registry.npmjs.org/@lix-js/sdk/-/sdk-0.4.10.tgz", + "integrity": "sha512-0dMInAJK/67guTG5rRZaCEhvzC5cCXENOjaePA5AqMXrCE97kaY7SRor9e2vnoGsFIiGqXKlT0MCIoZj36G0gg==", + "license": "Apache-2.0", + "dependencies": { + "@lix-js/server-protocol-schema": "0.1.1", + "dedent": "1.5.1", + "human-id": "^4.1.1", + "js-sha256": "^0.11.0", + "kysely": "^0.28.12", + "sqlite-wasm-kysely": "0.3.0", + "uuid": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@lix-js/server-protocol-schema": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@lix-js/server-protocol-schema/-/server-protocol-schema-0.1.1.tgz", + "integrity": "sha512-jBeALB6prAbtr5q4vTuxnRZZv1M2rKe8iNqRQhFJ4Tv7150unEa0vKyz0hs8Gl3fUGsWaNJBh3J8++fpbrpRBQ==", + "license": "Apache-2.0" + }, "node_modules/@lucide/svelte": { "version": "1.24.0", "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.24.0.tgz", @@ -440,6 +516,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@sinclair/typebox": { + "version": "0.31.30", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.31.30.tgz", + "integrity": "sha512-MGsM7bVmHg3sUKCphlu3SGQ+T+5JTbygZWYArfKQCW5anJeACHeqLhcnf+R7XlCWZ+QR3C8JTBx3kCKJTN69ow==", + "license": "MIT" + }, + "node_modules/@sqlite.org/sqlite-wasm": { + "version": "3.48.0-build4", + "resolved": "https://registry.npmjs.org/@sqlite.org/sqlite-wasm/-/sqlite-wasm-3.48.0-build4.tgz", + "integrity": "sha512-hI6twvUkzOmyGZhQMza1gpfqErZxXRw6JEsiVjUbo7tFanVD+8Oil0Ih3l2nGzHdxPI41zFmfUQG7GHqhciKZQ==", + "license": "Apache-2.0", + "bin": { + "sqlite-wasm": "bin/index.js" + } + }, "node_modules/@sveltejs/acorn-typescript": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz", @@ -818,6 +909,12 @@ "node": ">= 0.4" } }, + "node_modules/array-timsort": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", + "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==", + "license": "MIT" + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -876,6 +973,51 @@ "node": ">=6" } }, + "node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/comment-json": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.6.2.tgz", + "integrity": "sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w==", + "license": "MIT", + "dependencies": { + "array-timsort": "^1.0.3", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/consola": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.0.tgz", + "integrity": "sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA==", + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/dedent": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz", + "integrity": "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==", + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -931,6 +1073,19 @@ "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", "license": "MIT" }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/esrap": { "version": "2.2.13", "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.13.tgz", @@ -988,6 +1143,15 @@ "dev": true, "license": "ISC" }, + "node_modules/human-id": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/human-id/-/human-id-4.2.0.tgz", + "integrity": "sha512-K3GbkIWqyvvlpfhBPlbEvD97TtqBpAYA4kt+cn2lD2x2HuohzZCibcA2nOlnJT6exqvJLggoB5nv2dNf192nEA==", + "license": "MIT", + "bin": { + "human-id": "dist/cli.js" + } + }, "node_modules/inline-style-parser": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", @@ -1013,6 +1177,33 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-sha256": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.11.1.tgz", + "integrity": "sha512-o6WSo/LUvY2uC4j7mO50a2ms7E/EAdbP0swigLV+nzHKTTaYnaLIWJ02VdXrsJX0vGedDESQnLsOekr94ryfjg==", + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kysely": { + "version": "0.28.17", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.17.tgz", + "integrity": "sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -1352,7 +1543,6 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -1485,6 +1675,17 @@ "node": ">=0.10.0" } }, + "node_modules/sqlite-wasm-kysely": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/sqlite-wasm-kysely/-/sqlite-wasm-kysely-0.3.0.tgz", + "integrity": "sha512-TzjBNv7KwRw6E3pdKdlRyZiTmUIE0UttT/Sl56MVwVARl/u5gp978KepazCJZewFUnlWHz9i3NQd4kOtP/Afdg==", + "dependencies": { + "@sqlite.org/sqlite-wasm": "^3.48.0-build2" + }, + "peerDependencies": { + "kysely": "*" + } + }, "node_modules/style-to-object": { "version": "1.0.14", "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", @@ -1620,7 +1821,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -1630,6 +1831,40 @@ "node": ">=14.17" } }, + "node_modules/unplugin": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", + "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "acorn": "^8.15.0", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/urlpattern-polyfill": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.1.0.tgz", + "integrity": "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==", + "license": "MIT" + }, + "node_modules/uuid": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz", + "integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/vite": { "version": "8.1.4", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", @@ -1728,6 +1963,12 @@ } } }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "license": "MIT" + }, "node_modules/zimmerframe": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index d8f54e36..f8f0a8a0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,7 @@ "check": "svelte-check --tsconfig ./tsconfig.json" }, "dependencies": { + "@inlang/paraglide-js": "^2.21.0", "@lucide/svelte": "^1.24.0", "@wailsio/runtime": "latest", "bits-ui": "^2.18.1" diff --git a/frontend/project.inlang/settings.json b/frontend/project.inlang/settings.json new file mode 100644 index 00000000..6e5d27f5 --- /dev/null +++ b/frontend/project.inlang/settings.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://inlang.com/schema/project-settings", + "baseLocale": "en", + "locales": ["en", "ru"], + "modules": [ + "https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js", + "https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2/dist/index.js" + ], + "plugin.inlang.messageFormat": { + "pathPattern": "./messages/{locale}.json" + } +} diff --git a/frontend/src/components/BehaviorSection.svelte b/frontend/src/components/BehaviorSection.svelte index 83130510..41384e59 100644 --- a/frontend/src/components/BehaviorSection.svelte +++ b/frontend/src/components/BehaviorSection.svelte @@ -1,33 +1,33 @@
-

{t("behavior")}

+

{m.behavior()}

- +
diff --git a/frontend/src/components/ConnectionSection.svelte b/frontend/src/components/ConnectionSection.svelte index 353d4ca8..6ad4d486 100644 --- a/frontend/src/components/ConnectionSection.svelte +++ b/frontend/src/components/ConnectionSection.svelte @@ -2,7 +2,7 @@ import { onMount } from "svelte"; import RefreshCw from "@lucide/svelte/icons/refresh-cw"; import { SettingsDTO, SerialPortDTO, SettingsService } from "../../bindings/github.com/nik9play/deej/pkg/deej"; - import { t } from "../lib/i18n"; + import { m } from "../paraglide/messages"; import FieldCombobox from "./ui/FieldCombobox.svelte"; let { settings }: { settings: SettingsDTO } = $props(); @@ -20,7 +20,7 @@ } const portItems = $derived([ - { value: "auto", hint: t("comPortAuto") }, + { value: "auto", hint: m.comPortAuto() }, ...ports.map((port) => ({ value: port.name, hint: port.product || (port.isUsb ? `USB ${port.vid}:${port.pid}` : ""), @@ -37,23 +37,23 @@
-

{t("connection")}

+

{m.connection()}

- +
-
- +
- +
- +
-
{t("vidPidHint")}
+
{m.vidPidHint()}
{/if}
diff --git a/frontend/src/components/LanguageSection.svelte b/frontend/src/components/LanguageSection.svelte index 68a2fa06..4a778887 100644 --- a/frontend/src/components/LanguageSection.svelte +++ b/frontend/src/components/LanguageSection.svelte @@ -1,22 +1,22 @@
-

{t("language")}

+

{m.language()}

- -
{t("languageHint")}
+ +
{m.languageHint()}
diff --git a/frontend/src/components/Mixer.svelte b/frontend/src/components/Mixer.svelte index 8ffc78dc..5340fc82 100644 --- a/frontend/src/components/Mixer.svelte +++ b/frontend/src/components/Mixer.svelte @@ -1,6 +1,6 @@
-

{t("obs")}

+

{m.obs()}

- -
{t("obsHint")}
+ +
{m.obsHint()}
{#if settings.obsEnabled}
- +
- +
- +
diff --git a/frontend/src/components/SettingsDialog.svelte b/frontend/src/components/SettingsDialog.svelte index c9280d40..8cdc8bca 100644 --- a/frontend/src/components/SettingsDialog.svelte +++ b/frontend/src/components/SettingsDialog.svelte @@ -2,7 +2,7 @@ import { Dialog } from "bits-ui"; import X from "@lucide/svelte/icons/x"; import { AppInfoDTO, SettingsDTO, SettingsService } from "../../bindings/github.com/nik9play/deej/pkg/deej"; - import { t } from "../lib/i18n"; + import { m } from "../paraglide/messages"; import ConnectionSection from "./ConnectionSection.svelte"; import BehaviorSection from "./BehaviorSection.svelte"; import LanguageSection from "./LanguageSection.svelte"; @@ -35,7 +35,7 @@ settings = JSON.parse(JSON.stringify(loaded)); originalJson = JSON.stringify(settings); } catch (err) { - showStatus(`${t("loadError")}: ${err}`, "error"); + showStatus(`${m.loadError()}: ${err}`, "error"); } } @@ -54,9 +54,9 @@ try { await SettingsService.SaveSettings(settings); originalJson = JSON.stringify(settings); - showStatus(t("saved"), "ok"); + showStatus(m.saved(), "ok"); } catch (err) { - showStatus(`${t("saveError")}: ${err}`, "error"); + showStatus(`${m.saveError()}: ${err}`, "error"); } finally { saving = false; } @@ -76,10 +76,10 @@ class="fixed top-1/2 left-1/2 z-50 flex max-h-[88dvh] w-[min(560px,92vw)] -translate-x-1/2 -translate-y-1/2 flex-col rounded-lg border border-edge bg-surface shadow-2xl" >
- {t("settings")} + {m.settings()} @@ -93,15 +93,15 @@
- {#if appInfo?.version}{t("version")}: {appInfo.version} ·{/if} - {t("configPath")}: {appInfo?.configPath} + {#if appInfo?.version}{m.version()}: {appInfo.version} ·{/if} + {m.configPath()}: {appInfo?.configPath}
{/if}
- - + + {#if statusText} {statusText} {/if} diff --git a/frontend/src/components/SliderColumn.svelte b/frontend/src/components/SliderColumn.svelte index 4c94051e..ee2846e3 100644 --- a/frontend/src/components/SliderColumn.svelte +++ b/frontend/src/components/SliderColumn.svelte @@ -1,5 +1,5 @@ -
-

{m.behavior()}

- +
-
-

{m.connection()}

- -
-
- -
-
- -
- +
+
+ +
+
+
+
+
-
- - String(settings.baudRate || ""), (v) => (settings.baudRate = parseInt(v, 10) || 0)} - /> -
+
+ + String(settings.baudRate || ""), (v) => (settings.baudRate = parseInt(v, 10) || 0)} + />
{#if settings.comPort === "auto"} diff --git a/frontend/src/components/LanguageSection.svelte b/frontend/src/components/LanguageSection.svelte index 4a778887..70cb60a5 100644 --- a/frontend/src/components/LanguageSection.svelte +++ b/frontend/src/components/LanguageSection.svelte @@ -12,9 +12,7 @@ ]); -
-

{m.language()}

- +
{m.languageHint()}
diff --git a/frontend/src/components/Mixer.svelte b/frontend/src/components/Mixer.svelte index 5340fc82..3be87861 100644 --- a/frontend/src/components/Mixer.svelte +++ b/frontend/src/components/Mixer.svelte @@ -26,7 +26,7 @@ {/if}
diff --git a/frontend/src/components/ObsSection.svelte b/frontend/src/components/ObsSection.svelte index 9945fe75..69c0ce88 100644 --- a/frontend/src/components/ObsSection.svelte +++ b/frontend/src/components/ObsSection.svelte @@ -6,9 +6,7 @@ let { settings }: { settings: SettingsDTO } = $props(); -
-

{m.obs()}

- +
{m.obsHint()}
diff --git a/frontend/src/components/SettingsDialog.svelte b/frontend/src/components/SettingsDialog.svelte index 8cdc8bca..3a466775 100644 --- a/frontend/src/components/SettingsDialog.svelte +++ b/frontend/src/components/SettingsDialog.svelte @@ -1,6 +1,10 @@ -
+
{percent}%
- + +
diff --git a/frontend/src/components/TargetDialog.svelte b/frontend/src/components/TargetDialog.svelte index bb08602f..7c39a9f9 100644 --- a/frontend/src/components/TargetDialog.svelte +++ b/frontend/src/components/TargetDialog.svelte @@ -144,7 +144,7 @@ {#snippet itemRow(target: string, label: string, hint: string)}
- -
+ +
{#each deviceItems as session (session.key)} {@render itemRow(session.key, session.displayName || session.key, "")} {:else} @@ -259,8 +259,8 @@
- -
+ +
{#each specialTargets as target (target)} {@render itemRow(target, specialTargetLabel(target) ?? target, specialTargetDescription(target) ?? "")} {/each} @@ -294,7 +294,7 @@
-
+
{#if obsError}
{m.obsNotConnected()}
{:else} diff --git a/frontend/src/components/Titlebar.svelte b/frontend/src/components/Titlebar.svelte index b01c10de..c1d67d37 100644 --- a/frontend/src/components/Titlebar.svelte +++ b/frontend/src/components/Titlebar.svelte @@ -79,7 +79,7 @@
diff --git a/frontend/src/components/ui/FieldCombobox.svelte b/frontend/src/components/ui/FieldCombobox.svelte index 643acc61..95845d9a 100644 --- a/frontend/src/components/ui/FieldCombobox.svelte +++ b/frontend/src/components/ui/FieldCombobox.svelte @@ -58,20 +58,20 @@ search = e.currentTarget.value; }} /> - +
{#each filtered as item (item.value)} {#snippet children({ selected })} {item.value} diff --git a/frontend/src/components/ui/FieldSelect.svelte b/frontend/src/components/ui/FieldSelect.svelte index 18b09dcf..023fcdb9 100644 --- a/frontend/src/components/ui/FieldSelect.svelte +++ b/frontend/src/components/ui/FieldSelect.svelte @@ -16,13 +16,13 @@ - + {selectedLabel} @@ -30,7 +30,7 @@ {#snippet children({ selected })} {item.label} diff --git a/pkg/deej/config_settings.go b/pkg/deej/config_settings.go index 6c789644..9b2516d5 100644 --- a/pkg/deej/config_settings.go +++ b/pkg/deej/config_settings.go @@ -52,11 +52,13 @@ func (dto *SettingsDTO) Validate() error { return fmt.Errorf("baud rate must be a positive number") } - if _, err := parseHexWord(dto.ComVID); err != nil { + // an empty VID/PID means "use the built-in default", so only validate + // non-empty values + if _, err := parseHexWordOrDefault(dto.ComVID, defaultVID); err != nil { return fmt.Errorf("com vid: %w", err) } - if _, err := parseHexWord(dto.ComPID); err != nil { + if _, err := parseHexWordOrDefault(dto.ComPID, defaultPID); err != nil { return fmt.Errorf("com pid: %w", err) } @@ -97,6 +99,18 @@ func parseHexWord(value string) (uint64, error) { return parsed, nil } +// parseHexWordOrDefault behaves like parseHexWord, but returns the given +// default for an empty (or whitespace-only) value. This lets the GUI leave the +// VID/PID fields blank to fall back to the built-in defaults, mirroring a +// config file that omits (or comments out) the com_vid/com_pid keys +func parseHexWordOrDefault(value string, def uint64) (uint64, error) { + if strings.TrimSpace(value) == "" { + return def, nil + } + + return parseHexWord(value) +} + // UserSettings returns the current contents of the user config file as a DTO. // It reads the user config only, so mappings merged from the internal config // never leak into the user's config file on save @@ -146,8 +160,8 @@ func (cc *CanonicalConfig) SaveUserSettings(dto SettingsDTO, localizer *i18n.Loc return err } - vid, _ := parseHexWord(dto.ComVID) - pid, _ := parseHexWord(dto.ComPID) + vid, _ := parseHexWordOrDefault(dto.ComVID, defaultVID) + pid, _ := parseHexWordOrDefault(dto.ComPID, defaultPID) data, err := os.ReadFile(cc.configPath) if err != nil { diff --git a/pkg/deej/config_settings_test.go b/pkg/deej/config_settings_test.go index 26ce41af..a96d8a1c 100644 --- a/pkg/deej/config_settings_test.go +++ b/pkg/deej/config_settings_test.go @@ -185,6 +185,14 @@ func TestSettingsDTOValidate(t *testing.T) { t.Errorf("valid DTO rejected: %v", err) } + // empty VID/PID mean "use the built-in default" and must be accepted + emptyVIDPID := valid + emptyVIDPID.ComVID = "" + emptyVIDPID.ComPID = "" + if err := emptyVIDPID.Validate(); err != nil { + t.Errorf("empty VID/PID rejected: %v", err) + } + cases := []struct { name string mutate func(*SettingsDTO) diff --git a/pkg/deej/tray.go b/pkg/deej/tray.go index 37174122..243b79b3 100644 --- a/pkg/deej/tray.go +++ b/pkg/deej/tray.go @@ -360,10 +360,10 @@ func (d *Deej) openSettingsWindow() { d.tray.app.Window.NewWithOptions(application.WebviewWindowOptions{ Name: settingsWindowName, Title: "deej - " + settingsTitle, - Width: 640, - Height: 480, - MinWidth: 460, - MinHeight: 380, + Width: 880, + Height: 620, + MinWidth: 800, + MinHeight: 600, Frameless: true, URL: "/", }) From 2dae68d88b91e53c979a5fc1847456a5e4f81878 Mon Sep 17 00:00:00 2001 From: Nikita Date: Thu, 16 Jul 2026 01:03:07 +0300 Subject: [PATCH 19/47] implement wails3 dev-mode support --- .gitignore | 3 ++- Taskfile.linux.yml | 14 ++++++++++++++ Taskfile.windows.yml | 13 +++++++++++++ Taskfile.yml | 25 ++++++++++++++++++++++++ build/config.yml | 46 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 build/config.yml diff --git a/.gitignore b/.gitignore index cb8fb809..85bd1d2a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,8 @@ venv/ *.exe deej-dev deej-release -build/ +build/* +!build/config.yml releases/ *.bin logs/ diff --git a/Taskfile.linux.yml b/Taskfile.linux.yml index 0391789c..c0be105e 100644 --- a/Taskfile.linux.yml +++ b/Taskfile.linux.yml @@ -36,6 +36,20 @@ tasks: cmds: - go build -o {{.BIN_DIR}}/deej-dev -ldflags "{{.LDFLAGS}} -X main.buildType=dev" ./pkg/deej/cmd + build:dev:live: + desc: Build deej-dev without rebuilding the embedded frontend (for `wails3 dev`) + # No `frontend` dep: in dev mode assets are served by Vite via + # FRONTEND_DEVSERVER_URL, and the //go:embed only needs dist/.gitkeep to exist. + env: + CGO_ENABLED: "1" + cmds: + - go build -o {{.BIN_DIR}}/deej-dev -ldflags "{{.LDFLAGS}} -X main.buildType=dev" ./pkg/deej/cmd + + run:dev: + desc: Run the already-built dev binary (inherits FRONTEND_DEVSERVER_URL under `wails3 dev`) + cmds: + - "{{.BIN_DIR}}/deej-dev" + build:headless: desc: Build a GUI-less, cgo-free daemon (cross-compilable) -> deej-headless env: diff --git a/Taskfile.windows.yml b/Taskfile.windows.yml index 6c091d0a..0d46b211 100644 --- a/Taskfile.windows.yml +++ b/Taskfile.windows.yml @@ -32,6 +32,19 @@ tasks: cmds: - go build -o {{.BIN_DIR}}/deej-dev.exe -gcflags=all="-N -l" -ldflags "{{.LDFLAGS}} -X main.buildType=dev" ./pkg/deej/cmd + build:dev:live: + desc: Build deej-dev.exe without rebuilding the embedded frontend (for `wails3 dev`) + # No `frontend` dep: in dev mode assets are served by Vite via + # FRONTEND_DEVSERVER_URL, and the //go:embed only needs dist/.gitkeep to exist. + deps: [syso] + cmds: + - go build -o {{.BIN_DIR}}/deej-dev.exe -gcflags=all="-N -l" -ldflags "{{.LDFLAGS}} -X main.buildType=dev" ./pkg/deej/cmd + + run:dev: + desc: Run the already-built dev exe (inherits FRONTEND_DEVSERVER_URL under `wails3 dev`) + cmds: + - "{{.BIN_DIR}}/deej-dev.exe" + package: desc: Build the release exe and the Inno Setup installer deps: [build] diff --git a/Taskfile.yml b/Taskfile.yml index 73e03651..1a88f3ce 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -57,3 +57,28 @@ tasks: desc: Run the Go test suite cmds: - go test ./... + + dev: + desc: Run deej in Wails dev mode (Go rebuild-on-change + Vite HMR for the settings UI) + cmds: + # reads build/config.yml; sets FRONTEND_DEVSERVER_URL + WAILS_VITE_PORT and + # drives the dev:* tasks below. Needs the wails3 CLI on PATH. + - wails3 dev + + dev:frontend: + desc: Start the Vite dev server for the settings UI (invoked by `wails3 dev`) + dir: frontend + cmds: + - cmd: test -d node_modules || npm ci + # honours WAILS_VITE_PORT (set by `wails3 dev`); vite.config.ts reads it + - npm run dev + + dev:app:build: + desc: Build the dev binary WITHOUT re-embedding the frontend (invoked by `wails3 dev`) + cmds: + - task: "{{OS}}:build:dev:live" + + dev:app:run: + desc: Run the dev binary; inherits FRONTEND_DEVSERVER_URL so assets load from Vite (invoked by `wails3 dev`) + cmds: + - task: "{{OS}}:run:dev" diff --git a/build/config.yml b/build/config.yml new file mode 100644 index 00000000..8cc64c80 --- /dev/null +++ b/build/config.yml @@ -0,0 +1,46 @@ +# Wails v3 dev-mode config, read by `wails3 dev` (default path: ./build/config.yml). +# +# `wails3 dev` picks a Vite port (--port flag > $WAILS_VITE_PORT > 9245), exports +# WAILS_VITE_PORT and FRONTEND_DEVSERVER_URL, then runs the `executes` below and +# watches the tree. The deej binary's AssetFileServerFS automatically serves from +# FRONTEND_DEVSERVER_URL instead of the embedded dist when that var is set, so the +# settings UI hot-reloads from Vite while Go rebuilds on .go changes. +# +# NOTE: build/ is otherwise git-ignored; this one file is un-ignored (see .gitignore). +version: '3' + +dev_mode: + root_path: . + log_level: warn + debounce: 1000 + ignore: + dir: + - .git + - node_modules + - frontend # frontend changes are handled by Vite, not the Go watcher + - build + - releases + file: + - .DS_Store + - .gitignore + - .gitkeep + - "*_test.go" + watched_extension: + - "*.go" + git_ignore: true + executes: + # 1. start Vite FIRST so its (slow, ~5s) first-run dependency pre-bundle runs + # while the Go build below is compiling. `background` runs once on startup + # and is skipped on reloads, so this head start costs nothing after run #1. + # delay_next holds ~2s before the build starts, guaranteeing deej doesn't + # begin polling until Vite has a head start; Wails' preRun only waits 5s for + # FRONTEND_DEVSERVER_URL before it FATALs, and a cold Vite can just exceed it. + - cmd: wails3 task dev:frontend + type: background + delay_next: 2000 + # 2. build the dev binary (skips the embedded frontend build) + - cmd: wails3 task dev:app:build + type: blocking + # 3. run deej; inherits FRONTEND_DEVSERVER_URL so the UI loads from Vite + - cmd: wails3 task dev:app:run + type: primary From ecf05e4f1cbf895cda488f4c84051556c4184809 Mon Sep 17 00:00:00 2001 From: Nikita Date: Thu, 16 Jul 2026 21:07:16 +0300 Subject: [PATCH 20/47] fix binding generation --- Taskfile.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Taskfile.yml b/Taskfile.yml index 1a88f3ce..9504752d 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -46,7 +46,7 @@ tasks: generate:bindings: desc: Regenerate the Wails Go->TS bindings (requires the wails3 CLI) cmds: - - wails3 generate bindings -silent + - wails3 generate bindings -ts -silent ./pkg/... build: desc: Build deej for the current OS (release) From 62978f27bbd905f569772ea6d1f254402648e850 Mon Sep 17 00:00:00 2001 From: Nikita Date: Fri, 17 Jul 2026 16:34:43 +0300 Subject: [PATCH 21/47] rewrite app config handling for better control --- config_examples/config.example.ru.yaml | 15 +- config_examples/config.example.yaml | 12 +- .../nik9play/deej/pkg/deej/index.ts | 9 +- .../nik9play/deej/pkg/deej/models.ts | 183 ++++-- .../nik9play/deej/pkg/deej/settingsservice.ts | 8 +- .../src/components/BehaviorSection.svelte | 4 +- .../src/components/ConnectionSection.svelte | 14 +- .../src/components/LanguageSection.svelte | 4 +- frontend/src/components/ObsSection.svelte | 14 +- frontend/src/components/SettingsDialog.svelte | 4 +- frontend/src/components/TargetDialog.svelte | 8 +- frontend/src/lib/state.svelte.ts | 4 +- go.mod | 9 +- go.sum | 24 +- pkg/deej/config.go | 432 ++++++-------- pkg/deej/config_settings.go | 545 ++++++++++++------ pkg/deej/config_settings_test.go | 354 +++++++++--- pkg/deej/obs.go | 10 +- pkg/deej/serial.go | 49 +- pkg/deej/serial_test.go | 7 +- pkg/deej/session_map.go | 24 +- pkg/deej/settings_service.go | 6 +- pkg/deej/slider_map.go | 34 +- pkg/deej/tray.go | 4 +- 24 files changed, 1062 insertions(+), 715 deletions(-) diff --git a/config_examples/config.example.ru.yaml b/config_examples/config.example.ru.yaml index a8bbe4f0..2fc53430 100644 --- a/config_examples/config.example.ru.yaml +++ b/config_examples/config.example.ru.yaml @@ -17,14 +17,15 @@ slider_mapping: # Инвертирование значений ползунков микшера. (1023 - 0, 0 - 1023) invert_sliders: false -# Настройки COM-порта. Впишите auto для автоопределения порта по USB PID/VID. -com_port: auto -baud_rate: 9600 +# Настройки COM-порта. Впишите в port значение auto для автоопределения порта по USB PID/VID. +com: + port: auto + baud_rate: 9600 -# Настройки VID и PID для автоматического поиска COM-порта. -# Измените, если используете ваш микшер использует другой COM-конвертер. -# com_vid: 0x1A86 -# com_pid: 0x7523 + # Настройки VID и PID для автоматического поиска COM-порта. + # Измените, если ваш микшер использует другой COM-чип. + # vid: 0x1A86 + # pid: 0x7523 # Степень подавления шумов значений с микшера. # Значения: low, default, high, none diff --git a/config_examples/config.example.yaml b/config_examples/config.example.yaml index 49736b84..da0b282c 100644 --- a/config_examples/config.example.yaml +++ b/config_examples/config.example.yaml @@ -19,12 +19,14 @@ slider_mapping: invert_sliders: false # settings for connecting to the arduino board -com_port: auto -baud_rate: 9600 +# set port to auto to detect it by USB VID/PID +com: + port: auto + baud_rate: 9600 -# change this if your mixer uses a different serial port chip so that automatic COM port detection will work -# com_vid: 0x1A86 -# com_pid: 0x7523 + # change these if your mixer uses a different serial port chip so that automatic COM port detection will work + # vid: 0x1A86 + # pid: 0x7523 # adjust the amount of signal noise reduction depending on your hardware quality # supported values are "low" (excellent hardware), "default" (regular hardware), "high" (bad, noisy hardware) diff --git a/frontend/bindings/github.com/nik9play/deej/pkg/deej/index.ts b/frontend/bindings/github.com/nik9play/deej/pkg/deej/index.ts index c7718783..5dce056b 100644 --- a/frontend/bindings/github.com/nik9play/deej/pkg/deej/index.ts +++ b/frontend/bindings/github.com/nik9play/deej/pkg/deej/index.ts @@ -8,9 +8,16 @@ export { export { AppInfoDTO, + COMSettings, + OBSSettings, SerialPortDTO, SessionInfoDTO, - SettingsDTO, + Settings, SliderMappingEntry, StatusDTO } from "./models.js"; + +export type { + HexWord, + SliderMappings +} from "./models.js"; diff --git a/frontend/bindings/github.com/nik9play/deej/pkg/deej/models.ts b/frontend/bindings/github.com/nik9play/deej/pkg/deej/models.ts index 7c2e8673..46030da8 100644 --- a/frontend/bindings/github.com/nik9play/deej/pkg/deej/models.ts +++ b/frontend/bindings/github.com/nik9play/deej/pkg/deej/models.ts @@ -45,6 +45,85 @@ export class AppInfoDTO { } } +/** + * COMSettings describes the Arduino serial connection parameters + */ +export class COMSettings { + "port": string; + "baudRate": number; + "vid": HexWord; + "pid": HexWord; + + /** Creates a new COMSettings instance. */ + constructor($$source: Partial = {}) { + if (!("port" in $$source)) { + this["port"] = ""; + } + if (!("baudRate" in $$source)) { + this["baudRate"] = 0; + } + if (!("vid" in $$source)) { + this["vid"] = ""; + } + if (!("pid" in $$source)) { + this["pid"] = ""; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new COMSettings instance from a string or object. + */ + static createFrom($$source: any = {}): COMSettings { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new COMSettings($$parsedSource as Partial); + } +} + +/** + * HexWord is a 16-bit value carried as a hex string (e.g. "1A86"), the form + * both the config file and the GUI use. An empty value means "use the + * built-in default" + */ +export type HexWord = string; + +/** + * OBSSettings describes the OBS websocket connection parameters + */ +export class OBSSettings { + "enabled": boolean; + "host": string; + "port": number; + "password": string; + + /** Creates a new OBSSettings instance. */ + constructor($$source: Partial = {}) { + if (!("enabled" in $$source)) { + this["enabled"] = false; + } + if (!("host" in $$source)) { + this["host"] = ""; + } + if (!("port" in $$source)) { + this["port"] = 0; + } + if (!("password" in $$source)) { + this["password"] = ""; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new OBSSettings instance from a string or object. + */ + static createFrom($$source: any = {}): OBSSettings { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new OBSSettings($$parsedSource as Partial); + } +} + /** * SerialPortDTO describes an available serial port */ @@ -126,83 +205,67 @@ export class SessionInfoDTO { } /** - * SettingsDTO is a JSON-friendly mirror of the user config file, used by the settings GUI + * Settings is the single source of truth for deej's user-facing + * configuration. The yaml tags define the config file keys, the json tags + * the GUI wire format: a new setting only needs a field here (plus a default + * in defaultSettings and, if needed, a rule in Validate/sanitize) to reach + * the file, the runtime snapshot and the settings GUI */ -export class SettingsDTO { - "comPort": string; - "baudRate": number; - - /** - * 16-bit hex string, e.g. "1A86" - */ - "comVid": string; - "comPid": string; +export class Settings { + "sliderMapping": SliderMappings; "invertSliders": boolean; + "com": COMSettings; "noiseReduction": string; "language": string; - "obsEnabled": boolean; - "obsHost": string; - "obsPort": number; - "obsPassword": string; - "sliderMapping": SliderMappingEntry[]; - - /** Creates a new SettingsDTO instance. */ - constructor($$source: Partial = {}) { - if (!("comPort" in $$source)) { - this["comPort"] = ""; - } - if (!("baudRate" in $$source)) { - this["baudRate"] = 0; - } - if (!("comVid" in $$source)) { - this["comVid"] = ""; - } - if (!("comPid" in $$source)) { - this["comPid"] = ""; + "obs": OBSSettings; + + /** Creates a new Settings instance. */ + constructor($$source: Partial = {}) { + if (!("sliderMapping" in $$source)) { + this["sliderMapping"] = []; } if (!("invertSliders" in $$source)) { this["invertSliders"] = false; } + if (!("com" in $$source)) { + this["com"] = (new COMSettings()); + } if (!("noiseReduction" in $$source)) { this["noiseReduction"] = ""; } if (!("language" in $$source)) { this["language"] = ""; } - if (!("obsEnabled" in $$source)) { - this["obsEnabled"] = false; - } - if (!("obsHost" in $$source)) { - this["obsHost"] = ""; - } - if (!("obsPort" in $$source)) { - this["obsPort"] = 0; - } - if (!("obsPassword" in $$source)) { - this["obsPassword"] = ""; - } - if (!("sliderMapping" in $$source)) { - this["sliderMapping"] = []; + if (!("obs" in $$source)) { + this["obs"] = (new OBSSettings()); } Object.assign(this, $$source); } /** - * Creates a new SettingsDTO instance from a string or object. + * Creates a new Settings instance from a string or object. */ - static createFrom($$source: any = {}): SettingsDTO { - const $$createField11_0 = $$createType2; + static createFrom($$source: any = {}): Settings { + const $$createField0_0 = $$createType1; + const $$createField2_0 = $$createType4; + const $$createField5_0 = $$createType5; let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; if ("sliderMapping" in $$parsedSource) { - $$parsedSource["sliderMapping"] = $$createField11_0($$parsedSource["sliderMapping"]); + $$parsedSource["sliderMapping"] = $$createField0_0($$parsedSource["sliderMapping"]); + } + if ("com" in $$parsedSource) { + $$parsedSource["com"] = $$createField2_0($$parsedSource["com"]); + } + if ("obs" in $$parsedSource) { + $$parsedSource["obs"] = $$createField5_0($$parsedSource["obs"]); } - return new SettingsDTO($$parsedSource as Partial); + return new Settings($$parsedSource as Partial); } } /** - * SliderMappingEntry is a JSON-friendly representation of a single slider's targets + * SliderMappingEntry is one slider's targets, JSON-friendly for the GUI */ export class SliderMappingEntry { "slider": number; @@ -233,6 +296,12 @@ export class SliderMappingEntry { } } +/** + * SliderMappings is the slider_mapping config key. In the file it's a YAML + * mapping of slider index to a single target or a list of targets + */ +export type SliderMappings = SliderMappingEntry[]; + /** * StatusDTO describes the live connection state for the settings GUI */ @@ -264,7 +333,7 @@ export class StatusDTO { * Creates a new StatusDTO instance from a string or object. */ static createFrom($$source: any = {}): StatusDTO { - const $$createField2_0 = $$createType3; + const $$createField2_0 = $$createType6; let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; if ("sliderValues" in $$parsedSource) { $$parsedSource["sliderValues"] = $$createField2_0($$parsedSource["sliderValues"]); @@ -275,6 +344,14 @@ export class StatusDTO { // Private type creation functions const $$createType0 = $Create.Array($Create.Any); -const $$createType1 = SliderMappingEntry.createFrom; -const $$createType2 = $Create.Array($$createType1); -const $$createType3 = $Create.Array($Create.Any); +var $$createType1 = (function $$initCreateType1(...args: any[]): any { + if ($$createType1 === $$initCreateType1) { + $$createType1 = $$createType3; + } + return $$createType1(...args); +}); +const $$createType2 = SliderMappingEntry.createFrom; +const $$createType3 = $Create.Array($$createType2); +const $$createType4 = COMSettings.createFrom; +const $$createType5 = OBSSettings.createFrom; +const $$createType6 = $Create.Array($Create.Any); diff --git a/frontend/bindings/github.com/nik9play/deej/pkg/deej/settingsservice.ts b/frontend/bindings/github.com/nik9play/deej/pkg/deej/settingsservice.ts index 12d0aaf2..842e996c 100644 --- a/frontend/bindings/github.com/nik9play/deej/pkg/deej/settingsservice.ts +++ b/frontend/bindings/github.com/nik9play/deej/pkg/deej/settingsservice.ts @@ -47,7 +47,7 @@ export function GetSessions(): $CancellablePromise<$models.SessionInfoDTO[]> { /** * GetSettings returns the current contents of the user config file */ -export function GetSettings(): $CancellablePromise<$models.SettingsDTO> { +export function GetSettings(): $CancellablePromise<$models.Settings> { return $Call.ByID(1935164325).then(($result: any) => { return $$createType4($result); }); @@ -76,8 +76,8 @@ export function ListSerialPorts(): $CancellablePromise<$models.SerialPortDTO[]> * SaveSettings validates and writes the given settings to the user config * file, applying them immediately */ -export function SaveSettings(dto: $models.SettingsDTO): $CancellablePromise { - return $Call.ByID(523154580, dto); +export function SaveSettings(settings: $models.Settings): $CancellablePromise { + return $Call.ByID(523154580, settings); } // Private type creation functions @@ -85,7 +85,7 @@ const $$createType0 = $models.AppInfoDTO.createFrom; const $$createType1 = $Create.Array($Create.Any); const $$createType2 = $models.SessionInfoDTO.createFrom; const $$createType3 = $Create.Array($$createType2); -const $$createType4 = $models.SettingsDTO.createFrom; +const $$createType4 = $models.Settings.createFrom; const $$createType5 = $models.StatusDTO.createFrom; const $$createType6 = $models.SerialPortDTO.createFrom; const $$createType7 = $Create.Array($$createType6); diff --git a/frontend/src/components/BehaviorSection.svelte b/frontend/src/components/BehaviorSection.svelte index e020b74f..8e7bccab 100644 --- a/frontend/src/components/BehaviorSection.svelte +++ b/frontend/src/components/BehaviorSection.svelte @@ -1,10 +1,10 @@
- +
{m.obsHint()}
- {#if settings.obsEnabled} + {#if settings.obs.enabled}
- +
- +
- +
{/if} diff --git a/frontend/src/components/SettingsDialog.svelte b/frontend/src/components/SettingsDialog.svelte index 3a466775..9a4b27ea 100644 --- a/frontend/src/components/SettingsDialog.svelte +++ b/frontend/src/components/SettingsDialog.svelte @@ -5,7 +5,7 @@ import SlidersHorizontal from "@lucide/svelte/icons/sliders-horizontal"; import Languages from "@lucide/svelte/icons/languages"; import Video from "@lucide/svelte/icons/video"; - import { AppInfoDTO, SettingsDTO, SettingsService } from "../../bindings/github.com/nik9play/deej/pkg/deej"; + import { AppInfoDTO, Settings, SettingsService } from "../../bindings/github.com/nik9play/deej/pkg/deej"; import { m } from "../paraglide/messages"; import ConnectionSection from "./ConnectionSection.svelte"; import BehaviorSection from "./BehaviorSection.svelte"; @@ -16,7 +16,7 @@ let { open = $bindable(false), appInfo }: { open?: boolean; appInfo: AppInfoDTO | null } = $props(); - let settings: SettingsDTO | null = $state(null); + let settings: Settings | null = $state(null); let originalJson = $state(""); let statusText = $state(""); let statusKind: "ok" | "error" = $state("ok"); diff --git a/frontend/src/components/TargetDialog.svelte b/frontend/src/components/TargetDialog.svelte index 7c39a9f9..0a304950 100644 --- a/frontend/src/components/TargetDialog.svelte +++ b/frontend/src/components/TargetDialog.svelte @@ -7,7 +7,7 @@ import Speaker from "@lucide/svelte/icons/speaker"; import Video from "@lucide/svelte/icons/video"; import X from "@lucide/svelte/icons/x"; - import { AppInfoDTO, SettingsDTO, SettingsService } from "../../bindings/github.com/nik9play/deej/pkg/deej"; + import { AppInfoDTO, Settings, SettingsService } from "../../bindings/github.com/nik9play/deej/pkg/deej"; import { app, refreshSessions } from "../lib/state.svelte"; import { m } from "../paraglide/messages"; import { OBS_PREFIX, prettifyProcessName, specialTargetDescription, specialTargetLabel, targetLabel } from "../lib/targets"; @@ -44,7 +44,7 @@ // fetch OBS inputs lazily, whenever the OBS tab is shown $effect(() => { - if (open && tab === "obs" && app.settings?.obsEnabled) { + if (open && tab === "obs" && app.settings?.obs.enabled) { void loadObsInputs(); } }); @@ -124,7 +124,7 @@ saving = true; errorText = ""; try { - const dto: SettingsDTO = JSON.parse(JSON.stringify(app.settings)); + const dto: Settings = JSON.parse(JSON.stringify(app.settings)); dto.sliderMapping = dto.sliderMapping.filter((entry) => entry.slider !== slider); if (targets.length > 0) { dto.sliderMapping.push({ slider, targets: [...targets] }); @@ -268,7 +268,7 @@ - {#if !app.settings?.obsEnabled} + {#if !app.settings?.obs.enabled}
{m.obsDisabled()}
{:else}
diff --git a/frontend/src/lib/state.svelte.ts b/frontend/src/lib/state.svelte.ts index 73210cde..fca68fe3 100644 --- a/frontend/src/lib/state.svelte.ts +++ b/frontend/src/lib/state.svelte.ts @@ -1,12 +1,12 @@ import { Events } from "@wailsio/runtime"; -import { SessionInfoDTO, SettingsDTO, SettingsService } from "../../bindings/github.com/nik9play/deej/pkg/deej"; +import { SessionInfoDTO, Settings, SettingsService } from "../../bindings/github.com/nik9play/deej/pkg/deej"; // live application state, fed by wails events from the Go side export const app = $state({ connected: false, comPort: "", values: [] as number[], // 0..1 per slider, as sessions receive them - settings: null as SettingsDTO | null, + settings: null as Settings | null, sessions: [] as SessionInfoDTO[], // running audio sessions with friendly names }); diff --git a/go.mod b/go.mod index 71ce5f4f..63d4c82a 100644 --- a/go.mod +++ b/go.mod @@ -15,8 +15,6 @@ require ( github.com/moutend/go-wca v0.3.0 github.com/nicksnyder/go-i18n/v2 v2.6.1 github.com/pelletier/go-toml/v2 v2.2.4 - github.com/spf13/viper v1.21.0 - github.com/thoas/go-funk v0.9.3 github.com/wailsapp/wails/v3 v3.0.0-alpha2.117 go.bug.st/serial v1.6.4 go.uber.org/zap v1.27.1 @@ -30,20 +28,15 @@ require ( github.com/buger/jsonparser v1.1.1 // indirect github.com/coder/websocket v1.8.14 // indirect github.com/creack/goselect v0.1.3 // indirect - github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/hashicorp/logutils v1.0.0 // indirect github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect + github.com/kr/pretty v0.3.1 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mmcloughlin/profile v0.1.1 // indirect github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d // indirect - github.com/sagikazarmark/locafero v0.12.0 // indirect - github.com/spf13/afero v1.15.0 // indirect - github.com/spf13/cast v1.10.0 // indirect - github.com/spf13/pflag v1.0.10 // indirect - github.com/subosito/gotenv v1.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect ) diff --git a/go.sum b/go.sum index f293d592..142712c3 100644 --- a/go.sum +++ b/go.sum @@ -12,11 +12,9 @@ github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9 github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/creack/goselect v0.1.3 h1:MaGNMclRo7P2Jl21hBpR1Cn33ITSbKP6E49RtfblLKc= github.com/creack/goselect v0.1.3/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 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/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= -github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU= @@ -24,8 +22,6 @@ github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1: github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= -github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= -github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 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/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -66,28 +62,13 @@ github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/ github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= -github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= -github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= -github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= -github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= -github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= -github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= -github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= -github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= -github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/thoas/go-funk v0.9.3 h1:7+nAEx3kn5ZJcnDm2Bh23N2yOtweO14bi//dvRtgLpw= -github.com/thoas/go-funk v0.9.3/go.mod h1:+IWnUfUmFO1+WVYQWQtIJHeRRdaIyyYglZN7xzUPe4Q= github.com/wailsapp/wails/v3 v3.0.0-alpha2.117 h1:udyjqPG3AIgkod5QDR/WblCkpV8R86BFPSrsWxSyt5Y= github.com/wailsapp/wails/v3 v3.0.0-alpha2.117/go.mod h1:74WH2FScMsgucZvHHvv7eOefDXCm/CjuIxqhhZgPhKg= go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A= @@ -111,6 +92,5 @@ golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/deej/config.go b/pkg/deej/config.go index d37fa81b..d1d0ca5a 100644 --- a/pkg/deej/config.go +++ b/pkg/deej/config.go @@ -1,6 +1,8 @@ package deej import ( + "crypto/sha256" + "errors" "fmt" "os" "path/filepath" @@ -11,49 +13,22 @@ import ( "github.com/fsnotify/fsnotify" "github.com/nicksnyder/go-i18n/v2/i18n" - "github.com/spf13/viper" "go.uber.org/zap" + "go.yaml.in/yaml/v3" "github.com/nik9play/deej/pkg/deej/util" "github.com/nik9play/deej/pkg/notify" ) -type VIDPID struct { - VID uint64 - PID uint64 -} - -// ConnectionInfo describes the serial connection parameters -type ConnectionInfo struct { - COMPort string - BaudRate int -} - -// OBSConfig describes the OBS websocket connection parameters -type OBSConfig struct { - Enabled bool - Host string - Port int - Password string -} - // ConfigValues holds a single immutable generation of deej's configuration. // A fresh instance is published atomically on every (re)load, so concurrent // readers must grab a snapshot with Values and must not mutate it type ConfigValues struct { - SliderMapping *sliderMap - - ConnectionInfo ConnectionInfo - - InvertSliders bool - - NoiseReductionLevel string - - Language string - - AutoSearchVIDPID VIDPID + // Settings is the sanitized contents of the user config file + Settings - OBSConfig OBSConfig + // SliderMapping is the runtime slider->targets map built from Mapping + SliderMapping *sliderMap } // CanonicalConfig provides application-wide access to configuration fields, @@ -61,22 +36,20 @@ type ConfigValues struct { type CanonicalConfig struct { current atomic.Pointer[ConfigValues] - logger *zap.SugaredLogger - notifier notify.Notifier - stopWatcherChannel chan bool - watcherStopped atomic.Bool + logger *zap.SugaredLogger + notifier notify.Notifier - reloadConsumers []chan bool + stopWatcher chan struct{} - userConfig *viper.Viper - internalConfig *viper.Viper + consumersLock sync.Mutex + reloadConsumers []chan bool - // guards viper access between the file watcher and the settings GUI - viperLock sync.Mutex + // serializes the read-modify-write cycles of Load and SaveUserSettings + lock sync.Mutex - // unix-nano timestamp of the last GUI-initiated config write, used to - // suppress the file watcher event caused by our own save - lastSelfWrite atomic.Int64 + // hash of the last config content written by the GUI, so the file + // watcher can tell our own writes apart from hand edits + lastSelfWrite atomic.Value // string configPath string } @@ -88,48 +61,11 @@ func (cc *CanonicalConfig) Values() *ConfigValues { return cc.current.Load() } -const ( - internalConfigName = "preferences" - - configType = "yaml" - - configKeySliderMapping = "slider_mapping" - configKeyInvertSliders = "invert_sliders" - configKeyCOMPort = "com_port" - configKeyBaudRate = "baud_rate" - configKeyNoiseReductionLevel = "noise_reduction" - configKeyLanguage = "language" - configKeyComVID = "com_vid" - configKeyComPID = "com_pid" - configKeyOBSEnabled = "obs.enabled" - configKeyOBSHost = "obs.host" - configKeyOBSPort = "obs.port" - configKeyOBSPassword = "obs.password" - - defaultCOMPort = "COM4" - defaultBaudRate = 9600 - defaultLanguage = "auto" - - // ch340 chip - defaultVID uint64 = 0x1A86 - defaultPID uint64 = 0x7523 - - defaultOBSEnabled = false - defaultOBSHost = "localhost" - defaultOBSPort = 4455 - defaultOBSPassword = "" -) - -// has to be defined as a non-constant because we're using path.Join - -var defaultSliderMapping = func() *sliderMap { - emptyMap := newSliderMap() - emptyMap.set(0, []string{masterSessionName}) +// how long after the last file event to wait before reloading, so editors +// that write multiple times (or write partial content) settle first +const watchDebounceDelay = 250 * time.Millisecond - return emptyMap -}() - -// NewConfig creates a config instance for the deej object and sets up viper instances for deej's config files +// NewConfig creates a config instance for the deej object func NewConfig(logger *zap.SugaredLogger, notifier notify.Notifier, configPath string) (*CanonicalConfig, error) { logger = logger.Named("config") @@ -143,54 +79,28 @@ func NewConfig(logger *zap.SugaredLogger, notifier notify.Notifier, configPath s configPath = filepath.Join(filepath.Dir(ex), "config.yaml") } - userConfigName := filepath.Base(configPath) - configDir := filepath.Dir(configPath) - internalConfigDir := filepath.Join(filepath.Dir(ex), "logs") - cc := &CanonicalConfig{ - logger: logger, - notifier: notifier, - reloadConsumers: []chan bool{}, - stopWatcherChannel: make(chan bool), - configPath: configPath, + logger: logger, + notifier: notifier, + reloadConsumers: []chan bool{}, + stopWatcher: make(chan struct{}), + configPath: configPath, } - // distinguish between the user-provided config (config.yaml) and the internal config (logs/preferences.yaml) - userConfig := viper.New() - userConfig.SetConfigName(userConfigName) - userConfig.SetConfigType(configType) - userConfig.AddConfigPath(configDir) - - userConfig.SetDefault(configKeySliderMapping, map[string][]string{}) - userConfig.SetDefault(configKeyInvertSliders, false) - userConfig.SetDefault(configKeyCOMPort, defaultCOMPort) - userConfig.SetDefault(configKeyBaudRate, defaultBaudRate) - userConfig.SetDefault(configKeyLanguage, defaultLanguage) - userConfig.SetDefault(configKeyComVID, defaultVID) - userConfig.SetDefault(configKeyComPID, defaultPID) - userConfig.SetDefault(configKeyOBSEnabled, defaultOBSEnabled) - userConfig.SetDefault(configKeyOBSHost, defaultOBSHost) - userConfig.SetDefault(configKeyOBSPort, defaultOBSPort) - userConfig.SetDefault(configKeyOBSPassword, defaultOBSPassword) - - internalConfig := viper.New() - internalConfig.SetConfigName(internalConfigName) - internalConfig.SetConfigType(configType) - internalConfig.AddConfigPath(internalConfigDir) - - cc.userConfig = userConfig - cc.internalConfig = internalConfig - logger.Debug("Created config instance") return cc, nil } -// Load reads deej's config files from disk and tries to parse them +// Load reads deej's config file from disk and tries to parse it func (cc *CanonicalConfig) Load(localizer *i18n.Localizer) error { - cc.viperLock.Lock() - defer cc.viperLock.Unlock() + cc.lock.Lock() + defer cc.lock.Unlock() + return cc.loadLocked(localizer) +} + +func (cc *CanonicalConfig) loadLocked(localizer *i18n.Localizer) error { cc.logger.Debugw("Loading config", "path", cc.configPath) // make sure it exists @@ -217,12 +127,39 @@ func (cc *CanonicalConfig) Load(localizer *i18n.Localizer) error { return fmt.Errorf("config file doesn't exist: %s", cc.configPath) } - // load the user config - if err := cc.userConfig.ReadInConfig(); err != nil { - cc.logger.Warnw("Viper failed to read user config", "error", err) + data, err := os.ReadFile(cc.configPath) + if err != nil { + cc.logger.Warnw("Failed to read user config", "error", err) + + configErrorTitle := localizer.MustLocalize(&i18n.LocalizeConfig{ + DefaultMessage: &i18n.Message{ + ID: "ConfigErrorTitle", + Other: "Error loading configuration!", + }, + }) + configErrorDescription := localizer.MustLocalize(&i18n.LocalizeConfig{ + DefaultMessage: &i18n.Message{ + ID: "ConfigErrorDescription", + Other: "Please check deej's logs for more details.", + }, + }) + cc.notifier.Notify(configErrorTitle, configErrorDescription) + + return fmt.Errorf("read user config: %w", err) + } + + // missing keys keep the defaults they were initialized with + settings := defaultSettings() + if err := yaml.Unmarshal(data, &settings); err != nil { + + // a *yaml.TypeError means the file parsed, but some values have the + // wrong type; those fields keep their defaults, so we can keep going + var typeErr *yaml.TypeError + if errors.As(err, &typeErr) { + cc.logger.Warnw("Config file has values of unexpected types, using defaults for them", "error", err) + } else { + cc.logger.Warnw("Failed to parse user config", "error", err) - // if the error is yaml-format-related, show a sensible error. otherwise, show 'em to the logs - if strings.Contains(err.Error(), "yaml:") { configInvalidTitle := localizer.MustLocalize(&i18n.LocalizeConfig{ DefaultMessage: &i18n.Message{ ID: "ConfigInvalidTitle", @@ -239,49 +176,37 @@ func (cc *CanonicalConfig) Load(localizer *i18n.Localizer) error { }, }) cc.notifier.Notify(configInvalidTitle, configInvalidDescription) - } else { - configErrorTitle := localizer.MustLocalize(&i18n.LocalizeConfig{ - DefaultMessage: &i18n.Message{ - ID: "ConfigErrorTitle", - Other: "Error loading configuration!", - }, - }) - configErrorDescription := localizer.MustLocalize(&i18n.LocalizeConfig{ - DefaultMessage: &i18n.Message{ - ID: "ConfigErrorDescription", - Other: "Please check deej's logs for more details.", - }, - }) - cc.notifier.Notify(configErrorTitle, configErrorDescription) - } - return fmt.Errorf("read user config: %w", err) + return fmt.Errorf("parse user config: %w", err) + } } - // load the internal config - this doesn't have to exist, so it can error - if err := cc.internalConfig.ReadInConfig(); err != nil { - cc.logger.Debugw("Viper failed to read internal config", "error", err, "reminder", "this is fine") - } + settings.sanitize(cc.logger) - // canonize the configuration with viper's helpers - if err := cc.populateFromVipers(); err != nil { - cc.logger.Warnw("Failed to populate config fields", "error", err) - return fmt.Errorf("populate config fields: %w", err) + values := &ConfigValues{ + Settings: settings, + SliderMapping: sliderMapFromSettings(settings.Mapping), } + cc.current.Store(values) - values := cc.Values() cc.logger.Info("Loaded config successfully") cc.logger.Infow("Config values", "sliderMapping", values.SliderMapping, - "connectionInfo", values.ConnectionInfo, + "comPort", values.COM.Port, + "baudRate", values.COM.BaudRate, "invertSliders", values.InvertSliders) return nil } -// SubscribeToChanges allows external components to receive updates when the config is reloaded +// SubscribeToChanges returns a channel that receives a signal whenever the +// config is (re)applied. Signals are coalesced - consumers should re-read +// Values() rather than count events func (cc *CanonicalConfig) SubscribeToChanges() chan bool { - c := make(chan bool) + cc.consumersLock.Lock() + defer cc.consumersLock.Unlock() + + c := make(chan bool, 1) cc.reloadConsumers = append(cc.reloadConsumers, c) return c @@ -292,135 +217,124 @@ func (cc *CanonicalConfig) SubscribeToChanges() chan bool { func (cc *CanonicalConfig) WatchConfigFileChanges(localizer *i18n.Localizer) { cc.logger.Debugw("Starting to watch user config file for changes", "path", cc.configPath) - const ( - minTimeBetweenReloadAttempts = time.Millisecond * 500 - delayBetweenEventAndReload = time.Millisecond * 50 - ) - - lastAttemptedReload := time.Now() - - // establish watch using viper as opposed to doing it ourselves, though our internal cooldown is still required. - // the callback must be registered before WatchConfig starts viper's watch - // goroutine, since viper stores it in an unsynchronized field - cc.userConfig.OnConfigChange(func(event fsnotify.Event) { - - // viper offers no way to unregister the callback safely, so once we're - // stopped just ignore any further events - if cc.watcherStopped.Load() { - return - } - - // ignore events caused by a GUI save; it already loaded and applied - // the new config synchronously, and shows its own confirmation - if time.Since(time.Unix(0, cc.lastSelfWrite.Load())) < selfWriteSuppressWindow { - cc.logger.Debug("Ignoring config file event caused by GUI save") - return - } - - // when we get a write event... - if event.Op&fsnotify.Write == fsnotify.Write { - - now := time.Now() - - // ... check if it's not a duplicate (many editors will write to a file twice) - if lastAttemptedReload.Add(minTimeBetweenReloadAttempts).Before(now) { + watcher, err := fsnotify.NewWatcher() + if err != nil { + cc.logger.Warnw("Failed to create filesystem watcher", "error", err) + return + } + defer watcher.Close() + + // watch the directory rather than the file itself, so atomic + // (write-temp-then-rename) saves and editors that replace the file + // don't break the watch + if err := watcher.Add(filepath.Dir(cc.configPath)); err != nil { + cc.logger.Warnw("Failed to watch config directory", "error", err) + return + } - // and attempt reload if appropriate - cc.logger.Debugw("Config file modified, attempting reload", "event", event) + // trailing-edge debounce timer, armed on every relevant event + debounce := time.NewTimer(time.Hour) + if !debounce.Stop() { + <-debounce.C + } - // wait a bit to let the editor actually flush the new file contents to disk - time.Sleep(delayBetweenEventAndReload) + for { + select { + case event, ok := <-watcher.Events: + if !ok { + return + } - if err := cc.Load(localizer); err != nil { - cc.logger.Warnw("Failed to reload config file", "error", err) - } else { - cc.logger.Info("Reloaded config successfully") + if !strings.EqualFold(filepath.Clean(event.Name), filepath.Clean(cc.configPath)) { + continue + } - configReloadTitle := localizer.MustLocalize(&i18n.LocalizeConfig{ - DefaultMessage: &i18n.Message{ - ID: "ConfigReloadTitle", - Other: "Configuration reloaded!", - }, - }) - configReloadDescription := localizer.MustLocalize(&i18n.LocalizeConfig{ - DefaultMessage: &i18n.Message{ - ID: "ConfigReloadDescription", - Other: "Your changes have been applied.", - }, - }) - cc.notifier.Notify(configReloadTitle, configReloadDescription) + if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Rename) == 0 { + continue + } - cc.onConfigReloaded() + if !debounce.Stop() { + select { + case <-debounce.C: + default: } + } + debounce.Reset(watchDebounceDelay) - // don't forget to update the time - lastAttemptedReload = now + case err, ok := <-watcher.Errors: + if !ok { + return } - } - }) - cc.userConfig.WatchConfig() + cc.logger.Warnw("Config file watcher error", "error", err) - // wait till they stop us - <-cc.stopWatcherChannel - cc.logger.Debug("Stopping user config file watcher") -} + case <-debounce.C: + cc.handleConfigFileChange(localizer) -// StopWatchingConfigFile signals our filesystem watcher to stop -func (cc *CanonicalConfig) StopWatchingConfigFile() { - cc.watcherStopped.Store(true) - cc.stopWatcherChannel <- true + case <-cc.stopWatcher: + cc.logger.Debug("Stopping user config file watcher") + return + } + } } -func (cc *CanonicalConfig) populateFromVipers() error { +func (cc *CanonicalConfig) handleConfigFileChange(localizer *i18n.Localizer) { - values := &ConfigValues{} - - // merge the slider mappings from the user and internal configs - values.SliderMapping = sliderMapFromConfigs( - cc.userConfig.GetStringMapStringSlice(configKeySliderMapping), - cc.internalConfig.GetStringMapStringSlice(configKeySliderMapping), - ) - - // get the rest of the config fields - viper saves us a lot of effort here - values.ConnectionInfo.COMPort = cc.userConfig.GetString(configKeyCOMPort) - - values.ConnectionInfo.BaudRate = cc.userConfig.GetInt(configKeyBaudRate) - if values.ConnectionInfo.BaudRate <= 0 { - cc.logger.Warnw("Invalid baud rate specified, using default value", - "key", configKeyBaudRate, - "invalidValue", values.ConnectionInfo.BaudRate, - "defaultValue", defaultBaudRate) - - values.ConnectionInfo.BaudRate = defaultBaudRate + // ignore events caused by a GUI save: it already loaded and applied the + // new config synchronously, and shows its own confirmation + if data, err := os.ReadFile(cc.configPath); err == nil { + if hash, ok := cc.lastSelfWrite.Load().(string); ok && hash == contentHash(data) { + cc.logger.Debug("Ignoring config file event caused by GUI save") + return + } } - values.InvertSliders = cc.userConfig.GetBool(configKeyInvertSliders) - values.NoiseReductionLevel = cc.userConfig.GetString(configKeyNoiseReductionLevel) - values.Language = cc.userConfig.GetString(configKeyLanguage) + cc.logger.Debug("Config file modified, attempting reload") - userConfigVID := cc.userConfig.GetUint64(configKeyComVID) - userConfigPID := cc.userConfig.GetUint64(configKeyComPID) + if err := cc.Load(localizer); err != nil { + cc.logger.Warnw("Failed to reload config file", "error", err) + return + } - values.AutoSearchVIDPID = VIDPID{VID: userConfigVID, PID: userConfigPID} + cc.logger.Info("Reloaded config successfully") - values.OBSConfig.Enabled = cc.userConfig.GetBool(configKeyOBSEnabled) - values.OBSConfig.Host = cc.userConfig.GetString(configKeyOBSHost) - values.OBSConfig.Port = cc.userConfig.GetInt(configKeyOBSPort) - values.OBSConfig.Password = cc.userConfig.GetString(configKeyOBSPassword) + configReloadTitle := localizer.MustLocalize(&i18n.LocalizeConfig{ + DefaultMessage: &i18n.Message{ + ID: "ConfigReloadTitle", + Other: "Configuration reloaded!", + }, + }) + configReloadDescription := localizer.MustLocalize(&i18n.LocalizeConfig{ + DefaultMessage: &i18n.Message{ + ID: "ConfigReloadDescription", + Other: "Your changes have been applied.", + }, + }) + cc.notifier.Notify(configReloadTitle, configReloadDescription) - cc.current.Store(values) + cc.onConfigReloaded() +} - cc.logger.Debugw("AutoSearchVIDPID", "val", values.AutoSearchVIDPID) - cc.logger.Debugw("OBSConfig", "enabled", values.OBSConfig.Enabled, "host", values.OBSConfig.Host, "port", values.OBSConfig.Port) - cc.logger.Debugw("Populated config fields from vipers") +// StopWatchingConfigFile signals our filesystem watcher to stop +func (cc *CanonicalConfig) StopWatchingConfigFile() { + close(cc.stopWatcher) +} - return nil +func contentHash(data []byte) string { + return fmt.Sprintf("%x", sha256.Sum256(data)) } func (cc *CanonicalConfig) onConfigReloaded() { cc.logger.Debug("Notifying consumers about configuration reload") + cc.consumersLock.Lock() + defer cc.consumersLock.Unlock() + for _, consumer := range cc.reloadConsumers { - consumer <- true + // non-blocking send: a signal already pending in the buffer tells the + // consumer everything it needs (re-read Values), so never block on it + select { + case consumer <- true: + default: + } } } diff --git a/pkg/deej/config_settings.go b/pkg/deej/config_settings.go index 9b2516d5..cad5b3c6 100644 --- a/pkg/deej/config_settings.go +++ b/pkg/deej/config_settings.go @@ -1,81 +1,157 @@ package deej import ( + "bytes" + "errors" "fmt" "os" + "path/filepath" + "slices" "sort" "strconv" "strings" - "time" "github.com/nicksnyder/go-i18n/v2/i18n" - "github.com/thoas/go-funk" + "go.uber.org/zap" "go.yaml.in/yaml/v3" ) -// SliderMappingEntry is a JSON-friendly representation of a single slider's targets -type SliderMappingEntry struct { - Slider int `json:"slider"` - Targets []string `json:"targets"` +// Settings is the single source of truth for deej's user-facing +// configuration. The yaml tags define the config file keys, the json tags +// the GUI wire format: a new setting only needs a field here (plus a default +// in defaultSettings and, if needed, a rule in Validate/sanitize) to reach +// the file, the runtime snapshot and the settings GUI +type Settings struct { + Mapping SliderMappings `yaml:"slider_mapping" json:"sliderMapping"` + InvertSliders bool `yaml:"invert_sliders" json:"invertSliders"` + COM COMSettings `yaml:"com" json:"com"` + NoiseReduction string `yaml:"noise_reduction,omitempty" json:"noiseReduction"` + Language string `yaml:"language" json:"language"` + OBS OBSSettings `yaml:"obs" json:"obs"` +} + +// COMSettings describes the Arduino serial connection parameters +type COMSettings struct { + Port string `yaml:"port" json:"port"` + BaudRate int `yaml:"baud_rate" json:"baudRate"` + VID HexWord `yaml:"vid" json:"vid"` + PID HexWord `yaml:"pid" json:"pid"` +} + +// UnmarshalYAML reads the com section, plus the legacy flat keys (com_port, +// baud_rate, com_vid, com_pid) that older config files use. Keys in the com +// section win over their legacy counterparts; saves only ever write the +// section +func (s *Settings) UnmarshalYAML(node *yaml.Node) error { + var legacy struct { + Port *string `yaml:"com_port"` + BaudRate *int `yaml:"baud_rate"` + VID *HexWord `yaml:"com_vid"` + PID *HexWord `yaml:"com_pid"` + } + legacyErr := node.Decode(&legacy) + if legacyErr != nil && !isYAMLTypeError(legacyErr) { + return legacyErr + } + + if legacy.Port != nil { + s.COM.Port = *legacy.Port + } + if legacy.BaudRate != nil { + s.COM.BaudRate = *legacy.BaudRate + } + if legacy.VID != nil { + s.COM.VID = *legacy.VID + } + if legacy.PID != nil { + s.COM.PID = *legacy.PID + } + + // the alias type drops Settings' methods, so this can't recurse; keys the + // document does specify overwrite the legacy values applied above + type settingsAlias Settings + aliasErr := node.Decode((*settingsAlias)(s)) + if aliasErr != nil && !isYAMLTypeError(aliasErr) { + return aliasErr + } + + // surviving errors are type errors only; the caller treats those as + // non-fatal (the affected fields keep their previous values) + return errors.Join(legacyErr, aliasErr) } -// SettingsDTO is a JSON-friendly mirror of the user config file, used by the settings GUI -type SettingsDTO struct { - ComPort string `json:"comPort"` - BaudRate int `json:"baudRate"` - ComVID string `json:"comVid"` // 16-bit hex string, e.g. "1A86" - ComPID string `json:"comPid"` - InvertSliders bool `json:"invertSliders"` - NoiseReduction string `json:"noiseReduction"` - Language string `json:"language"` - OBSEnabled bool `json:"obsEnabled"` - OBSHost string `json:"obsHost"` - OBSPort int `json:"obsPort"` - OBSPassword string `json:"obsPassword"` - SliderMapping []SliderMappingEntry `json:"sliderMapping"` +func isYAMLTypeError(err error) bool { + var typeErr *yaml.TypeError + return errors.As(err, &typeErr) } -// how long after a GUI-initiated config write to ignore the resulting -// filesystem event, since the save already loads and applies the config itself -const selfWriteSuppressWindow = 2 * time.Second +// OBSSettings describes the OBS websocket connection parameters +type OBSSettings struct { + Enabled bool `yaml:"enabled" json:"enabled"` + Host string `yaml:"host" json:"host"` + Port int `yaml:"port" json:"port"` + Password string `yaml:"password" json:"password"` +} + +func defaultSettings() Settings { + return Settings{ + Mapping: SliderMappings{}, + Language: "auto", + + COM: COMSettings{ + Port: "COM4", + BaudRate: 9600, + + // ch340 chip + VID: "1A86", + PID: "7523", + }, + + OBS: OBSSettings{ + Host: "localhost", + Port: 4455, + }, + } +} var validNoiseReductionLevels = []string{"", "low", "default", "high", "none"} var validLanguages = []string{"auto", "en", "ru"} -// Validate checks the DTO for values that would produce a broken config file -func (dto *SettingsDTO) Validate() error { - if dto.ComPort == "" { +// Validate checks the settings for values that would produce a broken +// config file; used for GUI saves, which must be rejected rather than fixed +func (s *Settings) Validate() error { + if s.COM.Port == "" { return fmt.Errorf("com port must not be empty") } - if dto.BaudRate <= 0 { + if s.COM.BaudRate <= 0 { return fmt.Errorf("baud rate must be a positive number") } // an empty VID/PID means "use the built-in default", so only validate // non-empty values - if _, err := parseHexWordOrDefault(dto.ComVID, defaultVID); err != nil { + if _, err := s.COM.VID.parse(); s.COM.VID != "" && err != nil { return fmt.Errorf("com vid: %w", err) } - if _, err := parseHexWordOrDefault(dto.ComPID, defaultPID); err != nil { + if _, err := s.COM.PID.parse(); s.COM.PID != "" && err != nil { return fmt.Errorf("com pid: %w", err) } - if !funk.ContainsString(validNoiseReductionLevels, dto.NoiseReduction) { - return fmt.Errorf("invalid noise reduction level: %s", dto.NoiseReduction) + if !slices.Contains(validNoiseReductionLevels, s.NoiseReduction) { + return fmt.Errorf("invalid noise reduction level: %s", s.NoiseReduction) } - if !funk.ContainsString(validLanguages, dto.Language) { - return fmt.Errorf("invalid language: %s", dto.Language) + if !slices.Contains(validLanguages, s.Language) { + return fmt.Errorf("invalid language: %s", s.Language) } - if dto.OBSPort < 1 || dto.OBSPort > 65535 { + if s.OBS.Port < 1 || s.OBS.Port > 65535 { return fmt.Errorf("obs port must be between 1 and 65535") } seenSliders := map[int]bool{} - for _, entry := range dto.SliderMapping { + for _, entry := range s.Mapping { if entry.Slider < 0 { return fmt.Errorf("slider index must not be negative: %d", entry.Slider) } @@ -88,172 +164,196 @@ func (dto *SettingsDTO) Validate() error { return nil } -// parseHexWord parses a 16-bit hex string like "1A86" or "0x1A86" -func parseHexWord(value string) (uint64, error) { - trimmed := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(value)), "0x") - parsed, err := strconv.ParseUint(trimmed, 16, 16) - if err != nil { - return 0, fmt.Errorf("must be a 16-bit hex value: %s", value) - } +// sanitize brings settings to a canonical, valid state, falling back to the +// default (with a warning) for any value a hand-edited file got wrong. GUI +// saves pass through it too, after Validate, for canonicalization only +func (s *Settings) sanitize(logger *zap.SugaredLogger) { + defaults := defaultSettings() - return parsed, nil -} - -// parseHexWordOrDefault behaves like parseHexWord, but returns the given -// default for an empty (or whitespace-only) value. This lets the GUI leave the -// VID/PID fields blank to fall back to the built-in defaults, mirroring a -// config file that omits (or comments out) the com_vid/com_pid keys -func parseHexWordOrDefault(value string, def uint64) (uint64, error) { - if strings.TrimSpace(value) == "" { - return def, nil + if s.COM.Port == "" { + logger.Warnw("Empty com port, using default value", "defaultValue", defaults.COM.Port) + s.COM.Port = defaults.COM.Port } - return parseHexWord(value) -} + if s.COM.BaudRate <= 0 { + logger.Warnw("Invalid baud rate specified, using default value", + "invalidValue", s.COM.BaudRate, + "defaultValue", defaults.COM.BaudRate) + s.COM.BaudRate = defaults.COM.BaudRate + } -// UserSettings returns the current contents of the user config file as a DTO. -// It reads the user config only, so mappings merged from the internal config -// never leak into the user's config file on save -func (cc *CanonicalConfig) UserSettings() SettingsDTO { - cc.viperLock.Lock() - defer cc.viperLock.Unlock() - - dto := SettingsDTO{ - ComPort: cc.userConfig.GetString(configKeyCOMPort), - BaudRate: cc.userConfig.GetInt(configKeyBaudRate), - ComVID: fmt.Sprintf("%04X", cc.userConfig.GetUint64(configKeyComVID)), - ComPID: fmt.Sprintf("%04X", cc.userConfig.GetUint64(configKeyComPID)), - InvertSliders: cc.userConfig.GetBool(configKeyInvertSliders), - NoiseReduction: cc.userConfig.GetString(configKeyNoiseReductionLevel), - Language: cc.userConfig.GetString(configKeyLanguage), - OBSEnabled: cc.userConfig.GetBool(configKeyOBSEnabled), - OBSHost: cc.userConfig.GetString(configKeyOBSHost), - OBSPort: cc.userConfig.GetInt(configKeyOBSPort), - OBSPassword: cc.userConfig.GetString(configKeyOBSPassword), - SliderMapping: []SliderMappingEntry{}, - } - - for sliderIdxString, targets := range cc.userConfig.GetStringMapStringSlice(configKeySliderMapping) { - sliderIdx, err := strconv.Atoi(sliderIdxString) - if err != nil { - continue - } + s.COM.VID = s.COM.VID.canonicalOrDefault(logger, "com.vid", defaults.COM.VID) + s.COM.PID = s.COM.PID.canonicalOrDefault(logger, "com.pid", defaults.COM.PID) - dto.SliderMapping = append(dto.SliderMapping, SliderMappingEntry{ - Slider: sliderIdx, - Targets: funk.FilterString(targets, func(s string) bool { return s != "" }), - }) + if !slices.Contains(validNoiseReductionLevels, s.NoiseReduction) { + logger.Warnw("Invalid noise reduction level, using default", "invalidValue", s.NoiseReduction) + s.NoiseReduction = defaults.NoiseReduction } - sort.Slice(dto.SliderMapping, func(i, j int) bool { - return dto.SliderMapping[i].Slider < dto.SliderMapping[j].Slider - }) + if !slices.Contains(validLanguages, s.Language) { + logger.Warnw("Invalid language, using default", "invalidValue", s.Language, "defaultValue", defaults.Language) + s.Language = defaults.Language + } - return dto -} + if s.OBS.Port < 1 || s.OBS.Port > 65535 { + logger.Warnw("Invalid obs port, using default value", + "invalidValue", s.OBS.Port, + "defaultValue", defaults.OBS.Port) + s.OBS.Port = defaults.OBS.Port + } -// SaveUserSettings validates the DTO, patches the user config file on disk -// (preserving comments, key order and unknown keys), and applies the new -// config immediately -func (cc *CanonicalConfig) SaveUserSettings(dto SettingsDTO, localizer *i18n.Localizer) error { - if err := dto.Validate(); err != nil { - return err + if s.OBS.Host == "" { + s.OBS.Host = defaults.OBS.Host } - vid, _ := parseHexWordOrDefault(dto.ComVID, defaultVID) - pid, _ := parseHexWordOrDefault(dto.ComPID, defaultPID) + // canonicalize the mapping: drop negative or duplicate sliders and empty + // targets, and keep it sorted by slider index + mapping := SliderMappings{} + seenSliders := map[int]bool{} + for _, entry := range s.Mapping { + if entry.Slider < 0 || seenSliders[entry.Slider] { + logger.Warnw("Ignoring invalid slider mapping entry", "slider", entry.Slider) + continue + } + seenSliders[entry.Slider] = true - data, err := os.ReadFile(cc.configPath) - if err != nil { - cc.logger.Warnw("Failed to read config file for saving", "error", err) - return fmt.Errorf("read config for save: %w", err) + targets := slices.DeleteFunc(slices.Clone(entry.Targets), func(t string) bool { return t == "" }) + mapping = append(mapping, SliderMappingEntry{Slider: entry.Slider, Targets: targets}) } + sort.Slice(mapping, func(i, j int) bool { return mapping[i].Slider < mapping[j].Slider }) + s.Mapping = mapping +} - var doc yaml.Node - if err := yaml.Unmarshal(data, &doc); err != nil { - cc.logger.Warnw("Failed to parse config file for saving", "error", err) - return fmt.Errorf("parse config for save: %w", err) - } +// HexWord is a 16-bit value carried as a hex string (e.g. "1A86"), the form +// both the config file and the GUI use. An empty value means "use the +// built-in default" +type HexWord string - root := yamlEnsureDocumentMapping(&doc) +// Value returns the numeric form; ok is false for empty or malformed values +func (h HexWord) Value() (uint16, bool) { + parsed, err := h.parse() + if err != nil { + return 0, false + } - yamlSetKey(root, configKeySliderMapping, sliderMappingNode(dto.SliderMapping)) - yamlSetKey(root, configKeyInvertSliders, yamlBoolNode(dto.InvertSliders)) - yamlSetKey(root, configKeyCOMPort, yamlStringNode(dto.ComPort)) - yamlSetKey(root, configKeyBaudRate, yamlIntNode(dto.BaudRate)) - yamlSetKey(root, "com_vid", yamlHexNode(vid)) - yamlSetKey(root, "com_pid", yamlHexNode(pid)) - yamlSetKey(root, configKeyLanguage, yamlStringNode(dto.Language)) + return parsed, true +} - // don't add an empty noise_reduction key to configs that never had one - if dto.NoiseReduction != "" || yamlFindValue(root, configKeyNoiseReductionLevel) != nil { - yamlSetKey(root, configKeyNoiseReductionLevel, yamlStringNode(dto.NoiseReduction)) +func (h HexWord) parse() (uint16, error) { + trimmed := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(string(h))), "0x") + parsed, err := strconv.ParseUint(trimmed, 16, 16) + if err != nil { + return 0, fmt.Errorf("must be a 16-bit hex value: %s", string(h)) } - obs := yamlFindValue(root, "obs") - if obs == nil || obs.Kind != yaml.MappingNode { - obs = &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} - yamlSetKey(root, "obs", obs) + return uint16(parsed), nil +} + +func (h HexWord) canonicalOrDefault(logger *zap.SugaredLogger, key string, def HexWord) HexWord { + if strings.TrimSpace(string(h)) == "" { + return def } - yamlSetKey(obs, "enabled", yamlBoolNode(dto.OBSEnabled)) - yamlSetKey(obs, "host", yamlStringNode(dto.OBSHost)) - yamlSetKey(obs, "port", yamlIntNode(dto.OBSPort)) - yamlSetKey(obs, "password", yamlStringNode(dto.OBSPassword)) - out, err := yaml.Marshal(&doc) + parsed, err := h.parse() if err != nil { - return fmt.Errorf("marshal config for save: %w", err) + logger.Warnw("Invalid hex value, using default", "key", key, "invalidValue", string(h), "defaultValue", string(def)) + return def } - // the write below must happen in place (truncate and write): the file - // watcher only reacts to write events, and reacting to it twice is - // prevented by suppressing events for the duration of the window - cc.lastSelfWrite.Store(time.Now().UnixNano()) + return HexWord(fmt.Sprintf("%04X", parsed)) +} - if err := os.WriteFile(cc.configPath, out, 0o644); err != nil { - cc.logger.Warnw("Failed to write config file", "error", err) - return fmt.Errorf("write config for save: %w", err) - } +// UnmarshalYAML accepts both YAML integers (0x1A86, 6790) and hex strings +// ("1A86", "0x1A86") +func (h *HexWord) UnmarshalYAML(node *yaml.Node) error { + var number uint64 + if err := node.Decode(&number); err == nil { + if number > 0xFFFF { + return fmt.Errorf("must be a 16-bit value: %s", node.Value) + } - cc.logger.Infow("Saved user settings to config file", "path", cc.configPath) + *h = HexWord(fmt.Sprintf("%04X", number)) + return nil + } - // apply immediately instead of relying on the watcher's debounce timing - if err := cc.Load(localizer); err != nil { - return fmt.Errorf("load config after save: %w", err) + var str string + if err := node.Decode(&str); err != nil { + return err } - cc.onConfigReloaded() + *h = HexWord(str) return nil } -func yamlStringNode(value string) *yaml.Node { - return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value} -} +// MarshalYAML writes the value as a 0x-prefixed YAML integer +func (h HexWord) MarshalYAML() (any, error) { + value, ok := h.Value() + if !ok { + // sanitized settings never hit this; keep whatever the value is + return string(h), nil + } -func yamlIntNode(value int) *yaml.Node { - return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!int", Value: strconv.Itoa(value)} + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!int", Value: fmt.Sprintf("0x%04X", value)}, nil } -func yamlHexNode(value uint64) *yaml.Node { - return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!int", Value: fmt.Sprintf("0x%04X", value)} +// SliderMappingEntry is one slider's targets, JSON-friendly for the GUI +type SliderMappingEntry struct { + Slider int `json:"slider"` + Targets []string `json:"targets"` } -func yamlBoolNode(value bool) *yaml.Node { - return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!bool", Value: strconv.FormatBool(value)} +// SliderMappings is the slider_mapping config key. In the file it's a YAML +// mapping of slider index to a single target or a list of targets +type SliderMappings []SliderMappingEntry + +// UnmarshalYAML is deliberately lenient: hand-edited entries it can't make +// sense of are skipped rather than failing the whole config load. Strictness +// for GUI saves comes from Settings.Validate instead +func (sm *SliderMappings) UnmarshalYAML(node *yaml.Node) error { + entries := SliderMappings{} + + if node.Kind == yaml.MappingNode { + for i := 0; i+1 < len(node.Content); i += 2 { + keyNode, valueNode := node.Content[i], node.Content[i+1] + + slider, err := strconv.Atoi(keyNode.Value) + if err != nil { + continue + } + + targets := []string{} + switch valueNode.Kind { + case yaml.ScalarNode: + if valueNode.Tag != "!!null" && valueNode.Value != "" { + targets = append(targets, valueNode.Value) + } + case yaml.SequenceNode: + for _, item := range valueNode.Content { + if item.Kind == yaml.ScalarNode && item.Tag != "!!null" { + targets = append(targets, item.Value) + } + } + } + + entries = append(entries, SliderMappingEntry{Slider: slider, Targets: targets}) + } + } + + *sm = entries + return nil } -// sliderMappingNode builds the slider_mapping value: single targets are -// written as plain scalars, multiple targets as sequences, matching the -// style of the example config -func sliderMappingNode(entries []SliderMappingEntry) *yaml.Node { - sorted := make([]SliderMappingEntry, len(entries)) - copy(sorted, entries) +// MarshalYAML builds the slider_mapping value: single targets are written +// as plain scalars, multiple targets as sequences, matching the style of +// the example config +func (sm SliderMappings) MarshalYAML() (any, error) { + sorted := sm.clone() sort.Slice(sorted, func(i, j int) bool { return sorted[i].Slider < sorted[j].Slider }) mapping := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} for _, entry := range sorted { - targets := funk.FilterString(entry.Targets, func(s string) bool { return s != "" }) + targets := slices.DeleteFunc(entry.Targets, func(t string) bool { return t == "" }) if len(targets) == 0 { continue } @@ -271,50 +371,117 @@ func sliderMappingNode(entries []SliderMappingEntry) *yaml.Node { mapping.Content = append(mapping.Content, yamlIntNode(entry.Slider), value) } - return mapping + return mapping, nil } -// yamlEnsureDocumentMapping returns the document's root mapping node, -// creating the structure if the file was empty -func yamlEnsureDocumentMapping(doc *yaml.Node) *yaml.Node { - if doc.Kind == yaml.DocumentNode && len(doc.Content) > 0 && doc.Content[0].Kind == yaml.MappingNode { - return doc.Content[0] +func (sm SliderMappings) clone() SliderMappings { + out := make(SliderMappings, len(sm)) + for i, entry := range sm { + out[i] = SliderMappingEntry{Slider: entry.Slider, Targets: slices.Clone(entry.Targets)} } - root := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} - doc.Kind = yaml.DocumentNode - doc.Tag = "" - doc.Value = "" - doc.Content = []*yaml.Node{root} + return out +} + +// UserSettings returns the current contents of the user config file +func (cc *CanonicalConfig) UserSettings() Settings { + settings := cc.Values().Settings + settings.Mapping = settings.Mapping.clone() - return root + return settings } -// yamlFindValue returns the value node for a key in a mapping node, or nil -func yamlFindValue(mapping *yaml.Node, key string) *yaml.Node { - for i := 0; i+1 < len(mapping.Content); i += 2 { - if mapping.Content[i].Value == key { - return mapping.Content[i+1] - } +// SaveUserSettings validates the settings, rewrites the user config file on +// disk and applies the new config immediately. The file is fully regenerated: +// comments, key order and unknown keys are not preserved +func (cc *CanonicalConfig) SaveUserSettings(settings Settings, localizer *i18n.Localizer) error { + if err := settings.Validate(); err != nil { + return err + } + + // canonicalize (blank VID/PID -> defaults, mapping sorted and filtered) + settings.sanitize(cc.logger) + + if err := cc.saveAndReload(settings, localizer); err != nil { + return err } + cc.onConfigReloaded() + return nil } -// yamlSetKey replaces the value of a key in a mapping node, keeping the key -// node (and any comments attached to it) intact; missing keys are appended -func yamlSetKey(mapping *yaml.Node, key string, value *yaml.Node) { - for i := 0; i+1 < len(mapping.Content); i += 2 { - if mapping.Content[i].Value == key { - // keep comments that were attached to the old value node - value.HeadComment = mapping.Content[i+1].HeadComment - value.LineComment = mapping.Content[i+1].LineComment - value.FootComment = mapping.Content[i+1].FootComment - mapping.Content[i+1] = value - return - } +func (cc *CanonicalConfig) saveAndReload(settings Settings, localizer *i18n.Localizer) error { + cc.lock.Lock() + defer cc.lock.Unlock() + + var buf bytes.Buffer + encoder := yaml.NewEncoder(&buf) + encoder.SetIndent(2) + if err := encoder.Encode(&settings); err != nil { + return fmt.Errorf("marshal config for save: %w", err) + } + if err := encoder.Close(); err != nil { + return fmt.Errorf("marshal config for save: %w", err) + } + out := buf.Bytes() + + // remember the exact content we wrote, so the file watcher can tell this + // write apart from a hand edit and skip the redundant reload + cc.lastSelfWrite.Store(contentHash(out)) + + if err := writeFileAtomic(cc.configPath, out); err != nil { + cc.logger.Warnw("Failed to write config file", "error", err) + return fmt.Errorf("write config for save: %w", err) } - mapping.Content = append(mapping.Content, - &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}, value) + cc.logger.Infow("Saved user settings to config file", "path", cc.configPath) + + // apply immediately instead of relying on the watcher's debounce timing + if err := cc.loadLocked(localizer); err != nil { + return fmt.Errorf("load config after save: %w", err) + } + + return nil +} + +// writeFileAtomic writes data to a temp file in the target's directory and +// renames it over the target, so a crash mid-write can't corrupt the config +func writeFileAtomic(path string, data []byte) error { + tmp, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".tmp-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + _ = os.Remove(tmpPath) + return err + } + + if err := tmp.Close(); err != nil { + _ = os.Remove(tmpPath) + return err + } + + if err := os.Chmod(tmpPath, 0o644); err != nil { + _ = os.Remove(tmpPath) + return err + } + + if err := os.Rename(tmpPath, path); err != nil { + _ = os.Remove(tmpPath) + return err + } + + return nil +} + +func yamlStringNode(value string) *yaml.Node { + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value} +} + +func yamlIntNode(value int) *yaml.Node { + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!int", Value: strconv.Itoa(value)} } diff --git a/pkg/deej/config_settings_test.go b/pkg/deej/config_settings_test.go index a96d8a1c..14d34b82 100644 --- a/pkg/deej/config_settings_test.go +++ b/pkg/deej/config_settings_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/nicksnyder/go-i18n/v2/i18n" "go.uber.org/zap" @@ -41,19 +42,15 @@ func newTestConfig(t *testing.T, contents string) *CanonicalConfig { return cc } -const testConfigContents = `# top comment about mappings -slider_mapping: +const testConfigContents = `slider_mapping: 0: master 1: [chrome.exe, firefox.exe] -# whether sliders are inverted invert_sliders: false -com_port: COM4 -baud_rate: 9600 - -# some unknown key deej doesn't manage -my_custom_key: hello +com: + port: COM4 + baud_rate: 9600 obs: enabled: false @@ -62,20 +59,20 @@ obs: password: "" ` -func TestSaveUserSettingsPreservesCommentsAndUnknownKeys(t *testing.T) { +func TestSaveUserSettingsWritesAndApplies(t *testing.T) { cc := newTestConfig(t, testConfigContents) - dto := cc.UserSettings() - dto.ComPort = "COM7" - dto.InvertSliders = true - dto.NoiseReduction = "high" - dto.OBSEnabled = true - dto.SliderMapping = []SliderMappingEntry{ + settings := cc.UserSettings() + settings.COM.Port = "COM7" + settings.InvertSliders = true + settings.NoiseReduction = "high" + settings.OBS.Enabled = true + settings.Mapping = SliderMappings{ {Slider: 0, Targets: []string{"master"}}, {Slider: 2, Targets: []string{"discord.exe", "spotify.exe"}}, } - if err := cc.SaveUserSettings(dto, newTestLocalizer()); err != nil { + if err := cc.SaveUserSettings(settings, newTestLocalizer()); err != nil { t.Fatalf("save settings: %v", err) } @@ -86,11 +83,7 @@ func TestSaveUserSettingsPreservesCommentsAndUnknownKeys(t *testing.T) { saved := string(data) for _, want := range []string{ - "# top comment about mappings", - "# whether sliders are inverted", - "# some unknown key deej doesn't manage", - "my_custom_key: hello", - "com_port: COM7", + "port: COM7", "invert_sliders: true", "noise_reduction: high", "discord.exe", @@ -102,13 +95,13 @@ func TestSaveUserSettingsPreservesCommentsAndUnknownKeys(t *testing.T) { // the save must have applied the new values immediately values := cc.Values() - if values.ConnectionInfo.COMPort != "COM7" { - t.Errorf("com port not applied, got %q", values.ConnectionInfo.COMPort) + if values.COM.Port != "COM7" { + t.Errorf("com port not applied, got %q", values.COM.Port) } if !values.InvertSliders { t.Error("invert_sliders not applied") } - if !values.OBSConfig.Enabled { + if !values.OBS.Enabled { t.Error("obs.enabled not applied") } targets, ok := values.SliderMapping.get(2) @@ -120,31 +113,31 @@ func TestSaveUserSettingsPreservesCommentsAndUnknownKeys(t *testing.T) { func TestSaveUserSettingsRoundTrip(t *testing.T) { cc := newTestConfig(t, testConfigContents) - dto := cc.UserSettings() - dto.BaudRate = 115200 - dto.ComVID = "2341" - dto.ComPID = "0043" - dto.Language = "ru" - dto.OBSHost = "192.168.1.5" - dto.OBSPort = 4456 - dto.OBSPassword = "secret" + settings := cc.UserSettings() + settings.COM.BaudRate = 115200 + settings.COM.VID = "2341" + settings.COM.PID = "0043" + settings.Language = "ru" + settings.OBS.Host = "192.168.1.5" + settings.OBS.Port = 4456 + settings.OBS.Password = "secret" - if err := cc.SaveUserSettings(dto, newTestLocalizer()); err != nil { + if err := cc.SaveUserSettings(settings, newTestLocalizer()); err != nil { t.Fatalf("save settings: %v", err) } got := cc.UserSettings() - if got.BaudRate != 115200 || got.ComVID != "2341" || got.ComPID != "0043" || - got.Language != "ru" || got.OBSHost != "192.168.1.5" || - got.OBSPort != 4456 || got.OBSPassword != "secret" { + if got.COM.BaudRate != 115200 || got.COM.VID != "2341" || got.COM.PID != "0043" || + got.Language != "ru" || got.OBS.Host != "192.168.1.5" || + got.OBS.Port != 4456 || got.OBS.Password != "secret" { t.Errorf("round trip mismatch: %+v", got) } - if len(got.SliderMapping) != 2 { - t.Fatalf("expected 2 mapping entries, got %v", got.SliderMapping) + if len(got.Mapping) != 2 { + t.Fatalf("expected 2 mapping entries, got %v", got.Mapping) } - if got.SliderMapping[1].Slider != 1 || len(got.SliderMapping[1].Targets) != 2 { - t.Errorf("multi-target mapping mismatch: %+v", got.SliderMapping[1]) + if got.Mapping[1].Slider != 1 || len(got.Mapping[1].Targets) != 2 { + t.Errorf("multi-target mapping mismatch: %+v", got.Mapping[1]) } } @@ -157,8 +150,8 @@ func TestSaveUserSettingsNotifiesConsumers(t *testing.T) { done <- <-reloaded }() - dto := cc.UserSettings() - if err := cc.SaveUserSettings(dto, newTestLocalizer()); err != nil { + settings := cc.UserSettings() + if err := cc.SaveUserSettings(settings, newTestLocalizer()); err != nil { t.Fatalf("save settings: %v", err) } @@ -167,48 +160,198 @@ func TestSaveUserSettingsNotifiesConsumers(t *testing.T) { } } -func TestSettingsDTOValidate(t *testing.T) { - valid := SettingsDTO{ - ComPort: "auto", - BaudRate: 9600, - ComVID: "1A86", - ComPID: "7523", +func TestLoadParsesHexWordFormats(t *testing.T) { + // decimal 6790 == 0x1A86; quoted strings and 0x-prefixed ints must all work + cc := newTestConfig(t, "com:\n vid: 6790\n pid: \"0x7523\"\n") + + values := cc.Values() + if values.COM.VID != "1A86" { + t.Errorf("COM.VID = %q, expected 1A86", values.COM.VID) + } + if values.COM.PID != "7523" { + t.Errorf("COM.PID = %q, expected 7523", values.COM.PID) + } + + vid, ok := values.COM.VID.Value() + if !ok || vid != 0x1A86 { + t.Errorf("COM.VID.Value() = %v, %v; expected 0x1A86", vid, ok) + } +} + +func TestLoadReadsLegacyComKeys(t *testing.T) { + cc := newTestConfig(t, ` +com_port: COM9 +baud_rate: 115200 +com_vid: "2341" +com_pid: "0043" +`) + + values := cc.Values() + if values.COM.Port != "COM9" { + t.Errorf("COM.Port = %q, expected COM9", values.COM.Port) + } + if values.COM.BaudRate != 115200 { + t.Errorf("COM.BaudRate = %d, expected 115200", values.COM.BaudRate) + } + if values.COM.VID != "2341" || values.COM.PID != "0043" { + t.Errorf("COM VID/PID = %q/%q, expected 2341/0043", values.COM.VID, values.COM.PID) + } +} + +func TestComSectionWinsOverLegacyKeys(t *testing.T) { + cc := newTestConfig(t, ` +com_port: COM3 +baud_rate: 19200 +com: + port: COM9 +`) + + values := cc.Values() + + // the section's key wins over its legacy counterpart... + if values.COM.Port != "COM9" { + t.Errorf("COM.Port = %q, expected COM9", values.COM.Port) + } + + // ...but legacy keys the section doesn't override still apply + if values.COM.BaudRate != 19200 { + t.Errorf("COM.BaudRate = %d, expected 19200", values.COM.BaudRate) + } +} + +func TestSaveMigratesLegacyKeysToComSection(t *testing.T) { + cc := newTestConfig(t, "com_port: COM9\nbaud_rate: 115200\n") + + if err := cc.SaveUserSettings(cc.UserSettings(), newTestLocalizer()); err != nil { + t.Fatalf("save settings: %v", err) + } + + data, err := os.ReadFile(cc.configPath) + if err != nil { + t.Fatalf("read saved config: %v", err) + } + saved := string(data) + + // legacy keys live at the top level, so they'd start a line unindented + // (the com section's own baud_rate is indented) + for _, line := range strings.Split(saved, "\n") { + for _, stale := range []string{"com_port:", "baud_rate:", "com_vid:", "com_pid:"} { + if strings.HasPrefix(line, stale) { + t.Errorf("saved config still contains legacy key %q:\n%s", stale, saved) + } + } + } + if !strings.Contains(saved, "com:") || !strings.Contains(saved, " port: COM9") || + !strings.Contains(saved, " baud_rate: 115200") { + t.Errorf("saved config missing com section values:\n%s", saved) + } +} + +func TestLoadFallsBackOnInvalidValues(t *testing.T) { + cc := newTestConfig(t, ` +com: + port: "" + baud_rate: -5 + vid: zzzz +language: de +noise_reduction: extreme +obs: + port: 99999 +`) + + values := cc.Values() + defaults := defaultSettings() + + if values.COM.Port != defaults.COM.Port { + t.Errorf("COM.Port = %q, expected default %q", values.COM.Port, defaults.COM.Port) + } + if values.COM.BaudRate != defaults.COM.BaudRate { + t.Errorf("COM.BaudRate = %d, expected default %d", values.COM.BaudRate, defaults.COM.BaudRate) + } + if values.Language != defaults.Language { + t.Errorf("Language = %q, expected default %q", values.Language, defaults.Language) + } + if values.NoiseReduction != defaults.NoiseReduction { + t.Errorf("NoiseReduction = %q, expected default %q", values.NoiseReduction, defaults.NoiseReduction) + } + if values.COM.VID != defaults.COM.VID { + t.Errorf("COM.VID = %q, expected default %q", values.COM.VID, defaults.COM.VID) + } + if values.OBS.Port != defaults.OBS.Port { + t.Errorf("OBS.Port = %d, expected default %d", values.OBS.Port, defaults.OBS.Port) + } +} + +func TestLoadToleratesWrongValueTypes(t *testing.T) { + // baud_rate has a wrong type; the rest of the file must still apply + cc := newTestConfig(t, "com:\n baud_rate: [what]\n port: COM9\n") + + values := cc.Values() + if values.COM.BaudRate != defaultSettings().COM.BaudRate { + t.Errorf("COM.BaudRate = %d, expected default", values.COM.BaudRate) + } + if values.COM.Port != "COM9" { + t.Errorf("COM.Port = %q, expected COM9", values.COM.Port) + } +} + +func TestLoadToleratesWrongLegacyValueTypes(t *testing.T) { + // same, with the legacy flat keys + cc := newTestConfig(t, "baud_rate: [what]\ncom_port: COM9\n") + + values := cc.Values() + if values.COM.BaudRate != defaultSettings().COM.BaudRate { + t.Errorf("COM.BaudRate = %d, expected default", values.COM.BaudRate) + } + if values.COM.Port != "COM9" { + t.Errorf("COM.Port = %q, expected COM9", values.COM.Port) + } +} + +func TestSettingsValidate(t *testing.T) { + valid := Settings{ + COM: COMSettings{ + Port: "auto", + BaudRate: 9600, + VID: "1A86", + PID: "7523", + }, NoiseReduction: "default", Language: "auto", - OBSPort: 4455, - SliderMapping: []SliderMappingEntry{ + OBS: OBSSettings{Host: "localhost", Port: 4455}, + Mapping: SliderMappings{ {Slider: 0, Targets: []string{"master"}}, }, } if err := valid.Validate(); err != nil { - t.Errorf("valid DTO rejected: %v", err) + t.Errorf("valid settings rejected: %v", err) } // empty VID/PID mean "use the built-in default" and must be accepted emptyVIDPID := valid - emptyVIDPID.ComVID = "" - emptyVIDPID.ComPID = "" + emptyVIDPID.COM.VID = "" + emptyVIDPID.COM.PID = "" if err := emptyVIDPID.Validate(); err != nil { t.Errorf("empty VID/PID rejected: %v", err) } cases := []struct { name string - mutate func(*SettingsDTO) + mutate func(*Settings) }{ - {"empty com port", func(d *SettingsDTO) { d.ComPort = "" }}, - {"zero baud rate", func(d *SettingsDTO) { d.BaudRate = 0 }}, - {"bad vid", func(d *SettingsDTO) { d.ComVID = "xyz" }}, - {"vid too large", func(d *SettingsDTO) { d.ComVID = "12345" }}, - {"bad noise level", func(d *SettingsDTO) { d.NoiseReduction = "extreme" }}, - {"bad language", func(d *SettingsDTO) { d.Language = "de" }}, - {"obs port too large", func(d *SettingsDTO) { d.OBSPort = 70000 }}, - {"negative slider", func(d *SettingsDTO) { - d.SliderMapping = []SliderMappingEntry{{Slider: -1, Targets: []string{"a"}}} + {"empty com port", func(s *Settings) { s.COM.Port = "" }}, + {"zero baud rate", func(s *Settings) { s.COM.BaudRate = 0 }}, + {"bad vid", func(s *Settings) { s.COM.VID = "xyz" }}, + {"vid too large", func(s *Settings) { s.COM.VID = "12345" }}, + {"bad noise level", func(s *Settings) { s.NoiseReduction = "extreme" }}, + {"bad language", func(s *Settings) { s.Language = "de" }}, + {"obs port too large", func(s *Settings) { s.OBS.Port = 70000 }}, + {"negative slider", func(s *Settings) { + s.Mapping = SliderMappings{{Slider: -1, Targets: []string{"a"}}} }}, - {"duplicate slider", func(d *SettingsDTO) { - d.SliderMapping = []SliderMappingEntry{ + {"duplicate slider", func(s *Settings) { + s.Mapping = SliderMappings{ {Slider: 1, Targets: []string{"a"}}, {Slider: 1, Targets: []string{"b"}}, } @@ -217,9 +360,9 @@ func TestSettingsDTOValidate(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - dto := valid - tc.mutate(&dto) - if err := dto.Validate(); err == nil { + settings := valid + tc.mutate(&settings) + if err := settings.Validate(); err == nil { t.Error("expected validation error") } }) @@ -229,10 +372,10 @@ func TestSettingsDTOValidate(t *testing.T) { func TestSaveUserSettingsRejectsInvalid(t *testing.T) { cc := newTestConfig(t, testConfigContents) - dto := cc.UserSettings() - dto.BaudRate = -1 + settings := cc.UserSettings() + settings.COM.BaudRate = -1 - if err := cc.SaveUserSettings(dto, newTestLocalizer()); err == nil { + if err := cc.SaveUserSettings(settings, newTestLocalizer()); err == nil { t.Error("expected save to fail validation") } @@ -242,3 +385,72 @@ func TestSaveUserSettingsRejectsInvalid(t *testing.T) { t.Error("config file was modified by a rejected save") } } + +// startTestWatcher runs the config file watcher and makes sure it is torn +// down before the test's temp dir is removed +func startTestWatcher(t *testing.T, cc *CanonicalConfig) { + t.Helper() + + done := make(chan struct{}) + go func() { + cc.WatchConfigFileChanges(newTestLocalizer()) + close(done) + }() + t.Cleanup(func() { + cc.StopWatchingConfigFile() + <-done + }) + + // give the watcher a moment to establish its directory watch + time.Sleep(100 * time.Millisecond) +} + +func TestWatcherReloadsOnHandEdit(t *testing.T) { + cc := newTestConfig(t, testConfigContents) + reloaded := cc.SubscribeToChanges() + + startTestWatcher(t, cc) + + edited := strings.Replace(testConfigContents, "port: COM4", "port: COM9", 1) + if err := os.WriteFile(cc.configPath, []byte(edited), 0o644); err != nil { + t.Fatalf("edit config: %v", err) + } + + select { + case <-reloaded: + case <-time.After(5 * time.Second): + t.Fatal("no reload notification after a hand edit") + } + + if cc.Values().COM.Port != "COM9" { + t.Errorf("COM.Port = %q after reload, expected COM9", cc.Values().COM.Port) + } +} + +func TestWatcherIgnoresGUISave(t *testing.T) { + cc := newTestConfig(t, testConfigContents) + reloaded := cc.SubscribeToChanges() + + startTestWatcher(t, cc) + + settings := cc.UserSettings() + settings.COM.Port = "COM7" + if err := cc.SaveUserSettings(settings, newTestLocalizer()); err != nil { + t.Fatalf("save settings: %v", err) + } + + // the save itself notifies once, synchronously + select { + case <-reloaded: + case <-time.After(time.Second): + t.Fatal("no notification from the save itself") + } + + // the watcher must recognize the file event as our own write and stay + // quiet; wait well past the debounce window + select { + case <-reloaded: + t.Fatal("watcher re-applied a GUI save") + case <-time.After(watchDebounceDelay*4 + 500*time.Millisecond): + } +} diff --git a/pkg/deej/obs.go b/pkg/deej/obs.go index f9f31cd5..0ffae61b 100644 --- a/pkg/deej/obs.go +++ b/pkg/deej/obs.go @@ -19,7 +19,7 @@ const obsRetryDelay = 5 * time.Second // was dialed with so config reloads can detect parameter changes type obsConn struct { client *goobs.Client - cfg OBSConfig + cfg OBSSettings } type OBSClient struct { @@ -39,7 +39,7 @@ func NewOBSClient(deej *Deej, logger *zap.SugaredLogger) *OBSClient { o.reconnector = reconnect.New(reconnect.Options[obsConn]{ Logger: logger, - Enabled: func() bool { return o.deej.config.Values().OBSConfig.Enabled }, + Enabled: func() bool { return o.deej.config.Values().OBS.Enabled }, Dial: o.dial, Watch: o.watch, Close: o.close, @@ -124,7 +124,7 @@ func (o *OBSClient) ListInputs() ([]string, error) { func (o *OBSClient) onUp(conn obsConn) { // re-check the snapshot now that the connection is adopted - if o.deej.config.Values().OBSConfig != conn.cfg { + if o.deej.config.Values().OBS != conn.cfg { o.logger.Debug("OBS config changed while connecting, triggering reconnect") o.reconnector.Reconnect(errors.New("config changed during dial")) return @@ -136,7 +136,7 @@ func (o *OBSClient) onUp(conn obsConn) { // dial connects to OBS using the current config without touching any client // state - the reconnector decides whether to adopt the returned connection func (o *OBSClient) dial() (obsConn, error) { - cfg := o.deej.config.Values().OBSConfig + cfg := o.deej.config.Values().OBS address := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) o.logger.Debugw("Attempting OBS connection", "address", address) @@ -186,7 +186,7 @@ func (o *OBSClient) setupOnConfigReload() { continue } - cfg := o.deej.config.Values().OBSConfig + cfg := o.deej.config.Values().OBS if cfg != conn.cfg { o.logger.Debug("OBS config changed, triggering reconnect") diff --git a/pkg/deej/serial.go b/pkg/deej/serial.go index bbef19d2..5faa972c 100644 --- a/pkg/deej/serial.go +++ b/pkg/deej/serial.go @@ -26,7 +26,7 @@ import ( type serialConn struct { port serial.Port comPort string - connInfo ConnectionInfo + connInfo COMSettings } // SerialIO provides a deej-aware abstraction layer to managing serial I/O @@ -37,7 +37,6 @@ type SerialIO struct { reconnector *reconnect.Reconnector[serialConn] stateLock sync.Mutex - comPortToUse string lastKnownNumSliders int currentSliderValues []int @@ -48,8 +47,6 @@ type SerialIO struct { var ErrNoSerialPorts = errors.New("no serial ports found") var ErrAutoPortNotFound = errors.New("can't autodetect com port") -// var allowedVIDPIDs = []VIDPID{{0x1A86, 0x7523}} - // SliderMoveEvent represents a single slider move captured by deej type SliderMoveEvent struct { SliderID int @@ -92,8 +89,9 @@ func NewSerialIO(deej *Deej, logger *zap.SugaredLogger) (*SerialIO, error) { func (sio *SerialIO) dial() (serialConn, error) { config := sio.deej.config.Values() - comPort := config.ConnectionInfo.COMPort - allowedVIDPID := config.AutoSearchVIDPID + comPort := config.COM.Port + wantVID, _ := config.COM.VID.Value() + wantPID, _ := config.COM.PID.Value() if comPort == "auto" { sio.logger.Debugw("Trying to autodetect serial port") @@ -116,7 +114,7 @@ func (sio *SerialIO) dial() (serialConn, error) { vid, _ := strconv.ParseUint(port.VID, 16, 16) pid, _ := strconv.ParseUint(port.PID, 16, 16) - if vid == allowedVIDPID.VID && pid == allowedVIDPID.PID { + if uint16(vid) == wantVID && uint16(pid) == wantPID { sio.logger.Debugw("Found COM port", "com", port.Name, "vid", port.VID, "pid", port.PID) comPort = port.Name @@ -133,7 +131,7 @@ func (sio *SerialIO) dial() (serialConn, error) { } mode := serial.Mode{ - BaudRate: config.ConnectionInfo.BaudRate, + BaudRate: config.COM.BaudRate, DataBits: 8, StopBits: serial.OneStopBit, } @@ -169,16 +167,12 @@ func (sio *SerialIO) dial() (serialConn, error) { return serialConn{ port: port, comPort: comPort, - connInfo: config.ConnectionInfo, + connInfo: config.COM, }, nil } func (sio *SerialIO) onUp(conn serialConn) { - sio.stateLock.Lock() - sio.comPortToUse = conn.comPort - sio.stateLock.Unlock() - - if sio.deej.config.Values().ConnectionInfo != conn.connInfo { + if sio.deej.config.Values().COM != conn.connInfo { sio.logger.Info("Connection parameters changed while connecting, renewing connection") sio.reconnector.Reconnect(errors.New("connection parameters changed during dial")) return @@ -242,11 +236,14 @@ func (sio *SerialIO) GetState() bool { return sio.reconnector.Connected() } +// CurrentComPort returns the port the live connection resolved to, or the +// configured port (verbatim, including "auto") while disconnected func (sio *SerialIO) CurrentComPort() string { - sio.stateLock.Lock() - defer sio.stateLock.Unlock() + if conn, ok := sio.reconnector.Current(); ok { + return conn.comPort + } - return sio.comPortToUse + return sio.deej.config.Values().COM.Port } func (sio *SerialIO) CurrentSliderValues() []int { @@ -286,9 +283,9 @@ func (sio *SerialIO) CurrentSliderPercentValues() []float32 { func (sio *SerialIO) Start() { config := sio.deej.config.Values() sio.logger.Infow("Trying serial connection", - "port", config.ConnectionInfo.COMPort, - "vid", fmt.Sprintf("%X", config.AutoSearchVIDPID.VID), - "pid", fmt.Sprintf("%X", config.AutoSearchVIDPID.PID), + "port", config.COM.Port, + "vid", string(config.COM.VID), + "pid", string(config.COM.PID), ) sio.reconnector.Start() @@ -334,17 +331,17 @@ func (sio *SerialIO) setupOnConfigReload() { sio.lastKnownNumSliders = 0 sio.stateLock.Unlock() + config := sio.deej.config.Values() + // if connection params have changed, ask the reconnector to - // renew the connection. when disconnected there's nothing to do: - // every dial attempt reads the current config anyway + // renew the connection. when disconnected every dial attempt + // reads the current config anyway conn, ok := sio.reconnector.Current() if !ok { continue } - config := sio.deej.config.Values() - - if config.ConnectionInfo != conn.connInfo { + if config.COM != conn.connInfo { sio.logger.Info("Detected change in connection parameters, attempting to renew connection") sio.reconnector.Reconnect(errors.New("connection parameters changed")) } @@ -435,7 +432,7 @@ func (sio *SerialIO) handleLine(logger *zap.SugaredLogger, line string) { } // check if it changes the desired state (could just be a jumpy raw slider value) - if util.SignificantlyDifferent(sio.currentSliderValues[sliderIdx], number, config.NoiseReductionLevel) { + if util.SignificantlyDifferent(sio.currentSliderValues[sliderIdx], number, config.NoiseReduction) { // if it does, update the saved value and create a move event sio.currentSliderValues[sliderIdx] = number diff --git a/pkg/deej/serial_test.go b/pkg/deej/serial_test.go index 61a6603f..622ab32a 100644 --- a/pkg/deej/serial_test.go +++ b/pkg/deej/serial_test.go @@ -11,8 +11,10 @@ import ( func newTestSerialIO(invertSliders bool, noiseReductionLevel string) (*SerialIO, chan SliderMoveEvent) { config := &CanonicalConfig{} config.current.Store(&ConfigValues{ - InvertSliders: invertSliders, - NoiseReductionLevel: noiseReductionLevel, + Settings: Settings{ + InvertSliders: invertSliders, + NoiseReduction: noiseReductionLevel, + }, }) sio := &SerialIO{ @@ -167,3 +169,4 @@ func TestHandleLineSliderCountChange(t *testing.T) { t.Errorf("lastKnownNumSliders = %d, expected 2", sio.lastKnownNumSliders) } } + diff --git a/pkg/deej/session_map.go b/pkg/deej/session_map.go index 3a378038..6fbff7fd 100644 --- a/pkg/deej/session_map.go +++ b/pkg/deej/session_map.go @@ -2,12 +2,12 @@ package deej import ( "fmt" + "slices" "sort" "strings" "sync" "github.com/nik9play/deej/pkg/deej/util" - "github.com/thoas/go-funk" "go.uber.org/zap" ) @@ -231,13 +231,29 @@ func (m *sessionMap) removeSessionLocked(session Session) { } } +// uniqueStrings returns the input without duplicates, preserving order +func uniqueStrings(in []string) []string { + seen := make(map[string]struct{}, len(in)) + out := make([]string, 0, len(in)) + + for _, s := range in { + if _, ok := seen[s]; ok { + continue + } + seen[s] = struct{}{} + out = append(out, s) + } + + return out +} + // returns true if a session is not currently mapped to any slider, false otherwise // special sessions (master, system, mic) and device-specific sessions always count as mapped, // even when absent from the config. this makes sense for every current feature that uses "unmapped sessions" func (m *sessionMap) sessionMapped(session Session) bool { // count master/system/mic as mapped - if funk.ContainsString([]string{masterSessionName, systemSessionName, inputSessionName}, session.Key()) { + if slices.Contains([]string{masterSessionName, systemSessionName, inputSessionName}, session.Key()) { return true } @@ -389,8 +405,8 @@ func (m *sessionMap) applyTargetTransform(specialTargetName string) []string { currentWindowProcessNames[targetIdx] = strings.ToLower(target) } - // remove dupes - return funk.UniqString(currentWindowProcessNames) + // remove dupes, preserving order + return uniqueStrings(currentWindowProcessNames) // get currently unmapped sessions case specialTargetAllUnmapped: diff --git a/pkg/deej/settings_service.go b/pkg/deej/settings_service.go index 2cb9c004..cb96b4e6 100644 --- a/pkg/deej/settings_service.go +++ b/pkg/deej/settings_service.go @@ -48,14 +48,14 @@ type StatusDTO struct { } // GetSettings returns the current contents of the user config file -func (s *SettingsService) GetSettings() SettingsDTO { +func (s *SettingsService) GetSettings() Settings { return s.d.config.UserSettings() } // SaveSettings validates and writes the given settings to the user config // file, applying them immediately -func (s *SettingsService) SaveSettings(dto SettingsDTO) error { - return s.d.config.SaveUserSettings(dto, s.d.localizer) +func (s *SettingsService) SaveSettings(settings Settings) error { + return s.d.config.SaveUserSettings(settings, s.d.localizer) } // GetAppInfo returns version and localization info along with the list of diff --git a/pkg/deej/slider_map.go b/pkg/deej/slider_map.go index 3238a5c2..7d311323 100644 --- a/pkg/deej/slider_map.go +++ b/pkg/deej/slider_map.go @@ -2,10 +2,8 @@ package deej import ( "fmt" - "strconv" + "slices" "sync" - - "github.com/thoas/go-funk" ) type sliderMap struct { @@ -20,33 +18,13 @@ func newSliderMap() *sliderMap { } } -func sliderMapFromConfigs(userMapping map[string][]string, internalMapping map[string][]string) *sliderMap { +func sliderMapFromSettings(mapping SliderMappings) *sliderMap { resultMap := newSliderMap() - // copy targets from user config, ignoring empty values - for sliderIdxString, targets := range userMapping { - sliderIdx, _ := strconv.Atoi(sliderIdxString) - - resultMap.set(sliderIdx, funk.FilterString(targets, func(s string) bool { - return s != "" - })) - } - - // add targets from internal configs, ignoring duplicate or empty values - for sliderIdxString, targets := range internalMapping { - sliderIdx, _ := strconv.Atoi(sliderIdxString) - - existingTargets, ok := resultMap.get(sliderIdx) - if !ok { - existingTargets = []string{} - } - - filteredTargets := funk.FilterString(targets, func(s string) bool { - return (!funk.ContainsString(existingTargets, s)) && s != "" - }) - - existingTargets = append(existingTargets, filteredTargets...) - resultMap.set(sliderIdx, existingTargets) + // copy targets from the config, ignoring empty values + for _, entry := range mapping { + targets := slices.DeleteFunc(slices.Clone(entry.Targets), func(s string) bool { return s == "" }) + resultMap.set(entry.Slider, targets) } return resultMap diff --git a/pkg/deej/tray.go b/pkg/deej/tray.go index 243b79b3..da3559a4 100644 --- a/pkg/deej/tray.go +++ b/pkg/deej/tray.go @@ -323,10 +323,10 @@ func (d *Deej) initializeTray(onDone func()) { }) app.Event.Emit(eventSessions) - // config applied (GUI save or manual edit); this case must always - // be drained, since onConfigReloaded blocks on every consumer + // config applied (GUI save or manual edit) case <-configReloadedChannel: app.Event.Emit(eventConfig) + emitState() app.Event.Emit(eventSliders, d.serial.CurrentSliderPercentValues()) } } From c974b734f033f20e896dab72a2d131f42587da3c Mon Sep 17 00:00:00 2001 From: Nikita Date: Sat, 18 Jul 2026 17:51:32 +0300 Subject: [PATCH 22/47] ui/ux improvements --- .../nik9play/deej/pkg/deej/models.ts | 4 + .../nik9play/deej/pkg/deej/settingsservice.ts | 15 ++ frontend/messages/en.json | 33 +++-- frontend/messages/ru.json | 41 +++--- frontend/src/App.svelte | 20 ++- frontend/src/app.css | 30 +++- frontend/src/components/GeneralSection.svelte | 63 ++++++++ .../src/components/LanguageSection.svelte | 20 --- frontend/src/components/Mixer.svelte | 14 +- frontend/src/components/ObsSection.svelte | 1 + frontend/src/components/SettingsDialog.svelte | 32 +++-- frontend/src/components/SliderColumn.svelte | 44 ++++-- frontend/src/components/TargetDialog.svelte | 134 ++++++++++++------ frontend/src/components/Titlebar.svelte | 22 ++- .../src/components/ui/FieldCheckbox.svelte | 9 +- .../src/components/ui/FieldCombobox.svelte | 5 + frontend/src/lib/state.svelte.ts | 5 +- frontend/src/lib/tick.ts | 62 ++++++++ pkg/deej/settings_service.go | 24 +++- 19 files changed, 434 insertions(+), 144 deletions(-) create mode 100644 frontend/src/components/GeneralSection.svelte delete mode 100644 frontend/src/components/LanguageSection.svelte create mode 100644 frontend/src/lib/tick.ts diff --git a/frontend/bindings/github.com/nik9play/deej/pkg/deej/models.ts b/frontend/bindings/github.com/nik9play/deej/pkg/deej/models.ts index 46030da8..b605ee66 100644 --- a/frontend/bindings/github.com/nik9play/deej/pkg/deej/models.ts +++ b/frontend/bindings/github.com/nik9play/deej/pkg/deej/models.ts @@ -13,6 +13,7 @@ export class AppInfoDTO { "configPath": string; "resolvedLanguage": string; "specialTargets": string[]; + "autostartAvailable": boolean; /** Creates a new AppInfoDTO instance. */ constructor($$source: Partial = {}) { @@ -28,6 +29,9 @@ export class AppInfoDTO { if (!("specialTargets" in $$source)) { this["specialTargets"] = []; } + if (!("autostartAvailable" in $$source)) { + this["autostartAvailable"] = false; + } Object.assign(this, $$source); } diff --git a/frontend/bindings/github.com/nik9play/deej/pkg/deej/settingsservice.ts b/frontend/bindings/github.com/nik9play/deej/pkg/deej/settingsservice.ts index 842e996c..4b822b30 100644 --- a/frontend/bindings/github.com/nik9play/deej/pkg/deej/settingsservice.ts +++ b/frontend/bindings/github.com/nik9play/deej/pkg/deej/settingsservice.ts @@ -24,6 +24,13 @@ export function GetAppInfo(): $CancellablePromise<$models.AppInfoDTO> { }); } +/** + * GetAutostart reports whether deej is set to run at system startup + */ +export function GetAutostart(): $CancellablePromise { + return $Call.ByID(3740953349); +} + /** * GetOBSInputs returns the input names of the connected OBS instance, for * slider mapping suggestions @@ -80,6 +87,14 @@ export function SaveSettings(settings: $models.Settings): $CancellablePromise { + return $Call.ByID(470775857, state); +} + // Private type creation functions const $$createType0 = $models.AppInfoDTO.createFrom; const $$createType1 = $Create.Array($Create.Any); diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 0f4c4533..ac8e2d58 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1,6 +1,8 @@ { "$schema": "https://inlang.com/schema/inlang-message-format", - "title": "deej settings", + "general": "General", + "autostart": "Run at startup", + "autostartHint": "deej will launch at startup", "connection": "Connection", "comPort": "COM port", "comPortAuto": "auto (detect by USB VID/PID)", @@ -18,25 +20,24 @@ "noiseHigh": "high (noisy hardware)", "noiseNone": "none (done on the hardware)", "language": "Language", - "languageAuto": "auto (system language)", - "languageHint": "Applied to notifications and the tray menu after restarting deej.", + "languageAuto": "System language", + "languageHint": "Applied after restarting deej.", "obs": "OBS integration", - "obsEnabled": "Enable OBS WebSocket integration", - "obsHint": "Control OBS audio sources using deej.obs: targets.", - "obsHost": "Host", + "obsEnabled": "Enable OBS Studio integration via WebSocket", + "obsHint": "Control audio sources in OBS Studio.", + "obsWebsocketHint": "Enable the WebSocket server in OBS first: Tools → WebSocket Server Settings.", + "obsHost": "Address", "obsPort": "Port", "obsPassword": "Password", - "sliderMapping": "Slider mapping", - "sliderMappingHint": "Slider indexes start at 0, regardless of which analog pins you're using.", "slider": "Slider", - "addSlider": "Add slider", - "removeSlider": "Remove slider", "removeTarget": "Remove target", "noTargets": "No targets - this slider does nothing", "settings": "Settings", "waitingForDevice": "Waiting for device...", + "connected": "Connected", + "disconnected": "Disconnected", "unmapped": "unmapped", - "targetsFor": "Targets for slider", + "targetsFor": "Targets for slider {number}", "addTarget": "Add target", "tabApps": "Apps", "tabDevices": "Devices", @@ -44,20 +45,22 @@ "tabObs": "OBS", "noDevices": "No audio devices found", "searchApps": "Search apps or type a process name...", + "searchDevices": "Search devices...", + "searchObsInputs": "Search OBS inputs or type a name...", "noSessions": "No running audio sessions", - "obsDisabled": "OBS integration is disabled. Enable it in settings first.", + "obsDisabled": "OBS integration is disabled.", + "openSettings": "Open settings", "obsNotConnected": "Not connected to OBS. You can still add an input name manually.", - "obsInputPlaceholder": "OBS input name...", "noObsInputs": "No inputs found in OBS", "targetMaster": "Master volume", "targetMasterDesc": "The default output device volume", "targetSystem": "System sounds", "targetSystemDesc": "System notification sounds", "targetMic": "Microphone", - "targetMicDesc": "The default input device level", + "targetMicDesc": "The default input device volume", "targetCurrent": "Current app", "targetCurrentDesc": "Whichever app is currently in focus", - "targetCurrentFullscreen": "Current app (fullscreen)", + "targetCurrentFullscreen": "Current fullscreen app", "targetCurrentFullscreenDesc": "The focused app, only while it's fullscreen", "targetUnmapped": "Unmapped apps", "targetUnmappedDesc": "All apps not assigned to any other slider", diff --git a/frontend/messages/ru.json b/frontend/messages/ru.json index d83d5f23..69dba8a3 100644 --- a/frontend/messages/ru.json +++ b/frontend/messages/ru.json @@ -1,6 +1,8 @@ { "$schema": "https://inlang.com/schema/inlang-message-format", - "title": "Настройки deej", + "general": "Общие", + "autostart": "Запускать вместе с системой", + "autostartHint": "Запускать deej вместе с системой", "connection": "Подключение", "comPort": "COM-порт", "comPortAuto": "auto (поиск по USB VID/PID)", @@ -13,30 +15,29 @@ "invertSliders": "Инвертировать слайдеры", "invertSlidersHint": "Верх - 0%, низ - 100%", "noiseReduction": "Шумоподавление", - "noiseDefault": "default (обычное железо)", - "noiseLow": "low (отличное железо)", - "noiseHigh": "high (шумное железо)", - "noiseNone": "none (выполняется на устройстве)", + "noiseDefault": "обычное (обычное железо)", + "noiseLow": "слабое (отличное железо)", + "noiseHigh": "сильное (шумное железо)", + "noiseNone": "нет (выполняется на устройстве)", "language": "Язык", - "languageAuto": "auto (язык системы)", - "languageHint": "Применяется к уведомлениям и меню в трее после перезапуска deej.", + "languageAuto": "Язык системы", + "languageHint": "Применяется после перезапуска deej.", "obs": "Интеграция с OBS", - "obsEnabled": "Включить интеграцию OBS WebSocket", - "obsHint": "Управляйте источниками звука OBS с помощью целей deej.obs:<имя источника>.", - "obsHost": "Хост", + "obsEnabled": "Включить интеграцию с OBS Studio через WebSocket", + "obsHint": "Управляйте источниками звука в OBS Studio.", + "obsWebsocketHint": "Сначала включите сервер WebSocket в OBS: Сервис → Настройки сервера WebSocket.", + "obsHost": "Адрес", "obsPort": "Порт", "obsPassword": "Пароль", - "sliderMapping": "Назначение слайдеров", - "sliderMappingHint": "Индексы слайдеров начинаются с 0, независимо от используемых аналоговых пинов.", "slider": "Слайдер", - "addSlider": "Добавить слайдер", - "removeSlider": "Удалить слайдер", "removeTarget": "Удалить цель", "noTargets": "Нет целей - этот слайдер ничего не делает", "settings": "Настройки", "waitingForDevice": "Ожидание устройства...", + "connected": "Подключено", + "disconnected": "Нет подключения", "unmapped": "не назначен", - "targetsFor": "Цели для слайдера", + "targetsFor": "Цели для слайдера {number}", "addTarget": "Добавить цель", "tabApps": "Приложения", "tabDevices": "Устройства", @@ -44,20 +45,22 @@ "tabObs": "OBS", "noDevices": "Аудиоустройства не найдены", "searchApps": "Поиск приложений или имя процесса...", + "searchDevices": "Поиск устройств...", + "searchObsInputs": "Поиск источников OBS или имя источника...", "noSessions": "Нет активных аудиосессий", - "obsDisabled": "Интеграция с OBS отключена. Сначала включите её в настройках.", + "obsDisabled": "Интеграция с OBS отключена.", + "openSettings": "Открыть настройки", "obsNotConnected": "Нет подключения к OBS. Имя источника можно добавить вручную.", - "obsInputPlaceholder": "Имя источника OBS...", "noObsInputs": "В OBS не найдено источников", "targetMaster": "Общая громкость", "targetMasterDesc": "Громкость устройства вывода по умолчанию", "targetSystem": "Системные звуки", "targetSystemDesc": "Звуки системных уведомлений", "targetMic": "Микрофон", - "targetMicDesc": "Уровень устройства ввода по умолчанию", + "targetMicDesc": "Громкость устройства ввода по умолчанию", "targetCurrent": "Текущее приложение", "targetCurrentDesc": "Приложение, которое сейчас в фокусе", - "targetCurrentFullscreen": "Текущее приложение (полный экран)", + "targetCurrentFullscreen": "Текущее полноэкранное приложение", "targetCurrentFullscreenDesc": "Приложение в фокусе, только в полноэкранном режиме", "targetUnmapped": "Неназначенные приложения", "targetUnmappedDesc": "Все приложения, не назначенные другим слайдерам", diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 10a7cdc4..a4b18a4c 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -10,6 +10,7 @@ let { appInfo }: { appInfo: AppInfoDTO | null } = $props(); let settingsOpen = $state(false); + let settingsTab = $state("general"); let targetDialogOpen = $state(false); let targetSlider = $state(0); @@ -19,13 +20,26 @@ targetSlider = slider; targetDialogOpen = true; } + + function openSettings(tab = "general") { + settingsTab = tab; + settingsOpen = true; + } - (settingsOpen = true)} /> + openSettings()} />
- - + + { + targetDialogOpen = false; + openSettings("obs"); + }} +/> diff --git a/frontend/src/app.css b/frontend/src/app.css index 97214504..c4b2cd0e 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -97,7 +97,7 @@ @layer components { .input { - @apply w-full rounded-md border border-edge bg-field px-2.5 py-1.5 text-sm text-body focus-visible:outline-2 focus-visible:outline-accent; + @apply w-full rounded-md border border-edge bg-field px-2.5 py-1.5 text-sm text-body focus-visible:outline-2 focus-visible:outline-accent/80; } .label { @@ -122,18 +122,34 @@ dark:enabled:active:from-white/5 dark:enabled:active:to-white/5; } - /* blue-violet gradient accent, identical in light and dark */ + /* monochrome accent: near-black in light mode, near-white in dark mode */ .btn-primary { - @apply bg-linear-to-b from-violet-500 to-indigo-600 font-semibold text-white - shadow-[inset_0_1px_0_rgb(255_255_255/0.35),0_1px_2px_rgb(60_50_160/0.4)] - enabled:hover:from-violet-400 enabled:hover:to-indigo-500 - enabled:active:from-violet-600 enabled:active:to-indigo-700 - enabled:active:shadow-[inset_0_1px_3px_rgb(38_30_120/0.45)]; + @apply bg-linear-to-b from-neutral-700 to-neutral-900 font-semibold text-on-accent + shadow-[inset_0_1px_0_rgb(255_255_255/0.25),0_1px_2px_rgb(0_0_0/0.35)] + enabled:hover:from-neutral-600 enabled:hover:to-neutral-800 + enabled:active:from-neutral-900 enabled:active:to-neutral-900 + enabled:active:shadow-[inset_0_1px_3px_rgb(0_0_0/0.6)] + dark:from-white dark:to-neutral-300 + dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.9),0_1px_2px_rgb(0_0_0/0.45)] + dark:enabled:hover:from-white dark:enabled:hover:to-neutral-200 + dark:enabled:active:from-neutral-300 dark:enabled:active:to-neutral-400 + dark:enabled:active:shadow-[inset_0_1px_3px_rgb(0_0_0/0.25)]; } .card { @apply rounded-lg border border-edge bg-card p-4; } + + /* dialog panel with the same bright top highlight as the buttons */ + .dialog { + @apply rounded-lg border border-edge bg-surface; + } + + /* horizontal tick marks every 10%, drawn upward from the bottom edge in + currentColor; layered over both the filled and unfilled slider zones */ + .slider-ticks { + background-image: repeating-linear-gradient(to top, currentColor 0 1px, transparent 1px 10%); + } } /* Fluent-style enter/exit motion. bits-ui delays unmount until these finish, diff --git a/frontend/src/components/GeneralSection.svelte b/frontend/src/components/GeneralSection.svelte new file mode 100644 index 00000000..01b30166 --- /dev/null +++ b/frontend/src/components/GeneralSection.svelte @@ -0,0 +1,63 @@ + + +
+
+ + +
{m.languageHint()}
+
+ + {#if appInfo?.autostartAvailable} +
+ void applyAutostart(checked)} + label={m.autostart()} + /> + {#if autostartError} +
{autostartError}
+ {:else} +
{m.autostartHint()}
+ {/if} +
+ {/if} +
diff --git a/frontend/src/components/LanguageSection.svelte b/frontend/src/components/LanguageSection.svelte deleted file mode 100644 index e8f10dc1..00000000 --- a/frontend/src/components/LanguageSection.svelte +++ /dev/null @@ -1,20 +0,0 @@ - - -
-
- -
{m.languageHint()}
-
-
diff --git a/frontend/src/components/Mixer.svelte b/frontend/src/components/Mixer.svelte index 3be87861..8904963a 100644 --- a/frontend/src/components/Mixer.svelte +++ b/frontend/src/components/Mixer.svelte @@ -5,6 +5,17 @@ let { onEditTargets }: { onEditTargets: (slider: number) => void } = $props(); + // translate vertical wheel movement into horizontal scrolling so the slider + // row can be panned left/right with a regular mouse wheel + function onWheel(event: WheelEvent) { + const el = event.currentTarget as HTMLElement; + if (el.scrollWidth <= el.clientWidth) return; + // let horizontal-capable input (trackpads, tilt wheels) through untouched + if (event.deltaX !== 0) return; + el.scrollLeft += event.deltaY; + event.preventDefault(); + } + const columns = $derived.by(() => { const mapping = app.settings?.sliderMapping ?? []; const maxMapped = mapping.reduce((max, entry) => Math.max(max, entry.slider + 1), 0); @@ -26,7 +37,8 @@ {/if}
diff --git a/frontend/src/components/ObsSection.svelte b/frontend/src/components/ObsSection.svelte index 4ff8fb0a..ab49b2c5 100644 --- a/frontend/src/components/ObsSection.svelte +++ b/frontend/src/components/ObsSection.svelte @@ -11,6 +11,7 @@
{m.obsHint()}
{#if settings.obs.enabled} +
{m.obsWebsocketHint()}
diff --git a/frontend/src/components/SettingsDialog.svelte b/frontend/src/components/SettingsDialog.svelte index 9a4b27ea..55574016 100644 --- a/frontend/src/components/SettingsDialog.svelte +++ b/frontend/src/components/SettingsDialog.svelte @@ -2,19 +2,23 @@ import { Dialog, Tabs } from "bits-ui"; import X from "@lucide/svelte/icons/x"; import Cable from "@lucide/svelte/icons/cable"; + import Cog from "@lucide/svelte/icons/cog"; import SlidersHorizontal from "@lucide/svelte/icons/sliders-horizontal"; - import Languages from "@lucide/svelte/icons/languages"; import Video from "@lucide/svelte/icons/video"; import { AppInfoDTO, Settings, SettingsService } from "../../bindings/github.com/nik9play/deej/pkg/deej"; import { m } from "../paraglide/messages"; import ConnectionSection from "./ConnectionSection.svelte"; import BehaviorSection from "./BehaviorSection.svelte"; - import LanguageSection from "./LanguageSection.svelte"; + import GeneralSection from "./GeneralSection.svelte"; import ObsSection from "./ObsSection.svelte"; - let tab = $state("connection"); + let tab = $state("general"); - let { open = $bindable(false), appInfo }: { open?: boolean; appInfo: AppInfoDTO | null } = $props(); + let { + open = $bindable(false), + initialTab = "general", + appInfo, + }: { open?: boolean; initialTab?: string; appInfo: AppInfoDTO | null } = $props(); let settings: Settings | null = $state(null); let originalJson = $state(""); @@ -31,7 +35,7 @@ if (open) { settings = null; statusText = ""; - tab = "connection"; + tab = initialTab; loadSettings(); } }); @@ -80,7 +84,7 @@
{m.settings()} @@ -94,8 +98,8 @@ {#if settings} - - {#each [{ value: "connection", label: m.connection(), Icon: Cable }, { value: "behavior", label: m.behavior(), Icon: SlidersHorizontal }, { value: "language", label: m.language(), Icon: Languages }, { value: "obs", label: m.obs(), Icon: Video }] as tabItem (tabItem.value)} + + {#each [{ value: "general", label: m.general(), Icon: Cog }, { value: "connection", label: m.connection(), Icon: Cable }, { value: "behavior", label: m.behavior(), Icon: SlidersHorizontal }, { value: "obs", label: m.obs(), Icon: Video }] as tabItem (tabItem.value)} -
+
+ -
@@ -120,12 +124,12 @@
{/if} -
- - +
{#if statusText} - {statusText} + {statusText} {/if} + +
diff --git a/frontend/src/components/SliderColumn.svelte b/frontend/src/components/SliderColumn.svelte index f4fa48da..8fc87ace 100644 --- a/frontend/src/components/SliderColumn.svelte +++ b/frontend/src/components/SliderColumn.svelte @@ -11,33 +11,55 @@ }: { slider: number; value: number; targets: string[]; onEdit: () => void } = $props(); const percent = $derived(Math.round(value * 100)); + + // controlled so the tooltip can be dismissed when the click opens the dialog + let tooltipOpen = $state(false); -
+
{percent}%
-
+
+
+ +
+ +
+ +
+ +
+
+
- + { + tooltipOpen = false; + onEdit(); + }} > {#if targets.length === 0} {m.unmapped()} diff --git a/frontend/src/components/TargetDialog.svelte b/frontend/src/components/TargetDialog.svelte index 0a304950..7a357b6c 100644 --- a/frontend/src/components/TargetDialog.svelte +++ b/frontend/src/components/TargetDialog.svelte @@ -16,12 +16,19 @@ open = $bindable(false), slider, appInfo, - }: { open?: boolean; slider: number; appInfo: AppInfoDTO | null } = $props(); + onOpenObsSettings, + }: { + open?: boolean; + slider: number; + appInfo: AppInfoDTO | null; + onOpenObsSettings?: () => void; + } = $props(); let targets: string[] = $state([]); let tab = $state("apps"); let appSearch = $state(""); - let obsInputName = $state(""); + let deviceSearch = $state(""); + let obsSearch = $state(""); let obsInputs: string[] = $state([]); let obsError = $state(false); let saving = $state(false); @@ -36,7 +43,8 @@ targets = [...(app.settings?.sliderMapping.find((entry) => entry.slider === slider)?.targets ?? [])]; tab = "apps"; appSearch = ""; - obsInputName = ""; + deviceSearch = ""; + obsSearch = ""; errorText = ""; void refreshSessions(); } @@ -73,7 +81,30 @@ ); }); - const deviceItems = $derived(app.sessions.filter((session) => session.isDevice)); + const deviceItems = $derived.by(() => { + const query = deviceSearch.trim().toLowerCase(); + return app.sessions + .filter((session) => session.isDevice) + .filter( + (session) => + query === "" || + session.key.includes(query) || + (session.displayName ?? "").toLowerCase().includes(query), + ); + }); + + const obsItems = $derived.by(() => { + const query = obsSearch.trim().toLowerCase(); + return obsInputs.filter((inputName) => query === "" || inputName.toLowerCase().includes(query)); + }); + + // OBS input names are arbitrary too - offer to add the typed text when it + // doesn't exactly match a known input + const obsFreeText = $derived.by(() => { + const value = obsSearch.trim(); + if (value === "") return ""; + return obsInputs.includes(value) ? "" : value; + }); // arbitrary process names are allowed - offer to add the typed text when it // doesn't exactly match a running session @@ -109,14 +140,13 @@ appSearch = ""; } - function addObsInput() { - const value = obsInputName.trim(); - if (value === "") return; - const target = OBS_PREFIX + value; + function addObsFreeText() { + if (obsFreeText === "") return; + const target = OBS_PREFIX + obsFreeText; if (!isSelected(target)) { targets = [...targets, target]; } - obsInputName = ""; + obsSearch = ""; } async function save() { @@ -141,6 +171,16 @@ } +{#snippet obsSettingsLink()} + +{/snippet} + {#snippet itemRow(target: string, label: string, hint: string)} +
+ {m.obsDisabled()} + {@render obsSettingsLink()}
+ {:else} + { + if (e.key === "Enter") { + e.preventDefault(); + addObsFreeText(); + } + }} + />
+ {#if obsFreeText !== ""} + + {/if} {#if obsError} -
{m.obsNotConnected()}
+
+ {m.obsNotConnected()} + {@render obsSettingsLink()} +
{:else} - {#each obsInputs as inputName (inputName)} + {#each obsItems as inputName (inputName)} {@render itemRow(OBS_PREFIX + inputName, inputName, "")} {:else} -
{m.noObsInputs()}
+ {#if obsFreeText === ""} +
{m.noObsInputs()}
+ {/if} {/each} {/if}
@@ -310,12 +358,12 @@
-
- - +
{#if errorText} - {errorText} + {errorText} {/if} + +
diff --git a/frontend/src/components/Titlebar.svelte b/frontend/src/components/Titlebar.svelte index c1d67d37..3c92a6bf 100644 --- a/frontend/src/components/Titlebar.svelte +++ b/frontend/src/components/Titlebar.svelte @@ -7,8 +7,9 @@ import Copy from "@lucide/svelte/icons/copy"; import X from "@lucide/svelte/icons/x"; import { m } from "../paraglide/messages"; + import { app } from "../lib/state.svelte"; - let { version = "", onOpenSettings }: { version?: string; onOpenSettings: () => void } = $props(); + let { onOpenSettings }: { onOpenSettings: () => void } = $props(); let maximised = $state(false); @@ -38,11 +39,22 @@ ondblclick={toggleMaximise} > deej - {#if version} - {version} - {/if} -
+ + + {#if app.connected} + {app.comPort || m.connected()} + {:else} + {m.disconnected()} + {/if} + + +
{/snippet} -{#snippet itemRow(target: string, label: string, hint: string)} +{#snippet itemRow(target: string, label: string, hint: string, dimmed: boolean = false)}
diff --git a/pkg/deej/settings_service.go b/pkg/deej/settings_service.go index c7182f78..3f0f5815 100644 --- a/pkg/deej/settings_service.go +++ b/pkg/deej/settings_service.go @@ -4,7 +4,9 @@ import ( "errors" "runtime" "sort" + "strings" + ps "github.com/mitchellh/go-ps" "go.bug.st/serial/enumerator" "github.com/nik9play/deej/pkg/deej/util" @@ -108,6 +110,37 @@ func (s *SettingsService) GetSessions() []SessionInfoDTO { return s.d.sessions.sessionInfos() } +// GetProcesses returns the deduplicated, sorted executable names of all +// running processes, so the target picker can suggest apps that aren't +// currently playing audio +func (s *SettingsService) GetProcesses() ([]string, error) { + processes, err := ps.Processes() + if err != nil { + return nil, err + } + + seen := make(map[string]struct{}, len(processes)) + names := make([]string, 0, len(processes)) + for _, process := range processes { + name := strings.ToLower(process.Executable()) + + // skip pseudo-processes like the Windows "[System Process]" (pid 0) + if name == "" || strings.HasPrefix(name, "[") { + continue + } + if _, ok := seen[name]; ok { + continue + } + + seen[name] = struct{}{} + names = append(names, name) + } + + sort.Strings(names) + + return names, nil +} + // GetOBSInputs returns the input names of the connected OBS instance, for // slider mapping suggestions func (s *SettingsService) GetOBSInputs() ([]string, error) { From 7103ab54ada3ab199aa4dd331bb73145cc36e611 Mon Sep 17 00:00:00 2001 From: Nikita Date: Wed, 22 Jul 2026 18:05:06 +0300 Subject: [PATCH 26/47] add icons to process (only Windows for now) and device lists --- .../nik9play/deej/pkg/deej/models.ts | 8 + .../nik9play/deej/pkg/deej/settingsservice.ts | 34 ++-- frontend/src/components/TargetDialog.svelte | 66 ++++++-- pkg/deej/session.go | 9 + pkg/deej/session_finder_windows.go | 14 +- pkg/deej/session_linux.go | 1 + pkg/deej/session_map.go | 3 + pkg/deej/session_windows.go | 2 + pkg/deej/settings_service.go | 119 +++++++++++-- pkg/deej/util/procicon_linux.go | 15 ++ pkg/deej/util/procicon_windows.go | 111 ++++++++++++ pkg/deej/util/procinfo_windows.go | 13 +- pkg/win/icon_windows.go | 160 ++++++++++++++++++ 13 files changed, 511 insertions(+), 44 deletions(-) create mode 100644 pkg/deej/util/procicon_linux.go create mode 100644 pkg/deej/util/procicon_windows.go create mode 100644 pkg/win/icon_windows.go diff --git a/frontend/bindings/github.com/nik9play/deej/pkg/deej/models.ts b/frontend/bindings/github.com/nik9play/deej/pkg/deej/models.ts index b605ee66..800ffaa8 100644 --- a/frontend/bindings/github.com/nik9play/deej/pkg/deej/models.ts +++ b/frontend/bindings/github.com/nik9play/deej/pkg/deej/models.ts @@ -184,6 +184,11 @@ export class SessionInfoDTO { */ "isDevice": boolean; + /** + * capture-side session (microphone) + */ + "isInput": boolean; + /** Creates a new SessionInfoDTO instance. */ constructor($$source: Partial = {}) { if (!("key" in $$source)) { @@ -195,6 +200,9 @@ export class SessionInfoDTO { if (!("isDevice" in $$source)) { this["isDevice"] = false; } + if (!("isInput" in $$source)) { + this["isInput"] = false; + } Object.assign(this, $$source); } diff --git a/frontend/bindings/github.com/nik9play/deej/pkg/deej/settingsservice.ts b/frontend/bindings/github.com/nik9play/deej/pkg/deej/settingsservice.ts index f5c21990..51bb1fc7 100644 --- a/frontend/bindings/github.com/nik9play/deej/pkg/deej/settingsservice.ts +++ b/frontend/bindings/github.com/nik9play/deej/pkg/deej/settingsservice.ts @@ -41,10 +41,19 @@ export function GetOBSInputs(): $CancellablePromise { }); } +/** + * GetProcessIcons returns PNG data URIs for the icons of the given process + * names, keyed by the names exactly as passed. Icons are cached across calls + */ +export function GetProcessIcons(names: string[]): $CancellablePromise<{ [_ in string]?: string }> { + return $Call.ByID(3155775785, names).then(($result: any) => { + return $$createType2($result); + }); +} + /** * GetProcesses returns the deduplicated, sorted executable names of all - * running processes, so the target picker can suggest apps that aren't - * currently playing audio + * running processes */ export function GetProcesses(): $CancellablePromise { return $Call.ByID(3688174489).then(($result: any) => { @@ -58,7 +67,7 @@ export function GetProcesses(): $CancellablePromise { */ export function GetSessions(): $CancellablePromise<$models.SessionInfoDTO[]> { return $Call.ByID(1202968053).then(($result: any) => { - return $$createType3($result); + return $$createType4($result); }); } @@ -67,7 +76,7 @@ export function GetSessions(): $CancellablePromise<$models.SessionInfoDTO[]> { */ export function GetSettings(): $CancellablePromise<$models.Settings> { return $Call.ByID(1935164325).then(($result: any) => { - return $$createType4($result); + return $$createType5($result); }); } @@ -77,7 +86,7 @@ export function GetSettings(): $CancellablePromise<$models.Settings> { */ export function GetStatus(): $CancellablePromise<$models.StatusDTO> { return $Call.ByID(4223089242).then(($result: any) => { - return $$createType5($result); + return $$createType6($result); }); } @@ -86,7 +95,7 @@ export function GetStatus(): $CancellablePromise<$models.StatusDTO> { */ export function ListSerialPorts(): $CancellablePromise<$models.SerialPortDTO[]> { return $Call.ByID(102515344).then(($result: any) => { - return $$createType7($result); + return $$createType8($result); }); } @@ -109,9 +118,10 @@ export function SetAutostart(state: boolean): $CancellablePromise { // Private type creation functions const $$createType0 = $models.AppInfoDTO.createFrom; const $$createType1 = $Create.Array($Create.Any); -const $$createType2 = $models.SessionInfoDTO.createFrom; -const $$createType3 = $Create.Array($$createType2); -const $$createType4 = $models.Settings.createFrom; -const $$createType5 = $models.StatusDTO.createFrom; -const $$createType6 = $models.SerialPortDTO.createFrom; -const $$createType7 = $Create.Array($$createType6); +const $$createType2 = $Create.Map($Create.Any, $Create.Any); +const $$createType3 = $models.SessionInfoDTO.createFrom; +const $$createType4 = $Create.Array($$createType3); +const $$createType5 = $models.Settings.createFrom; +const $$createType6 = $models.StatusDTO.createFrom; +const $$createType7 = $models.SerialPortDTO.createFrom; +const $$createType8 = $Create.Array($$createType7); diff --git a/frontend/src/components/TargetDialog.svelte b/frontend/src/components/TargetDialog.svelte index 377912c1..cc386239 100644 --- a/frontend/src/components/TargetDialog.svelte +++ b/frontend/src/components/TargetDialog.svelte @@ -2,6 +2,7 @@ import { Dialog, Tabs } from "bits-ui"; import AppWindow from "@lucide/svelte/icons/app-window"; import Check from "@lucide/svelte/icons/check"; + import Mic from "@lucide/svelte/icons/mic"; import Plus from "@lucide/svelte/icons/plus"; import Sparkles from "@lucide/svelte/icons/sparkles"; import Speaker from "@lucide/svelte/icons/speaker"; @@ -32,12 +33,23 @@ let obsInputs: string[] = $state([]); let obsError = $state(false); let processes: string[] = $state([]); + let icons: Record = $state({}); let saving = $state(false); let errorText = $state(""); + // names already sent to GetProcessIcons, to avoid re-requesting on every + // sessions refresh + const requestedIcons = new Set(); + // covered by the "special" tab, so hidden from the apps list const specialSessionKeys = ["master", "system", "mic"]; + // sessions shown on the apps tab (devices and special targets have their + // own tabs); also the set of sessions that get process icons + const appSessions = $derived( + app.sessions.filter((session) => !session.isDevice && !specialSessionKeys.includes(session.key)), + ); + // take a fresh copy of the slider's targets every time the dialog opens $effect(() => { if (open) { @@ -67,6 +79,26 @@ } } + // fetch icons + $effect(() => { + if (!open) return; + void loadIcons([...appSessions.map((session) => session.key), ...processes]); + }); + + async function loadIcons(names: string[]) { + const wanted = names.filter((name) => !requestedIcons.has(name)); + if (wanted.length === 0) return; + wanted.forEach((name) => requestedIcons.add(name)); + try { + const result = await SettingsService.GetProcessIcons(wanted); + for (const [name, icon] of Object.entries(result ?? {})) { + if (icon) icons[name] = icon; + } + } catch { + // no icons + } + } + async function loadObsInputs() { obsError = false; try { @@ -81,14 +113,12 @@ const appItems = $derived.by(() => { const query = appSearch.trim().toLowerCase(); - return app.sessions - .filter((session) => !session.isDevice && !specialSessionKeys.includes(session.key)) - .filter( - (session) => - query === "" || - session.key.includes(query) || - (session.displayName ?? "").toLowerCase().includes(query), - ); + return appSessions.filter( + (session) => + query === "" || + session.key.includes(query) || + (session.displayName ?? "").toLowerCase().includes(query), + ); }); // running processes without an audio session, offered as dimmed @@ -203,12 +233,18 @@ {/snippet} -{#snippet itemRow(target: string, label: string, hint: string, dimmed: boolean = false)} +{#snippet itemRow(target: string, label: string, hint: string, dimmed: boolean = false, icon: string | typeof Speaker | undefined = undefined)}
{#each processItems as name (name)} - {@render itemRow(name, prettifyProcessName(name), name.toLowerCase().endsWith(".exe") ? name : "", true)} + {@render itemRow( + name, + prettifyProcessName(name), + name.toLowerCase().endsWith(".exe") ? name : "", + true, + icons[name] ?? AppWindow, + )} {/each} {/if}
@@ -323,7 +367,7 @@
{#each deviceItems as session (session.key)} - {@render itemRow(session.key, session.displayName || session.key, "")} + {@render itemRow(session.key, session.displayName || session.key, "", false, session.isInput ? Mic : Speaker)} {:else}
{m.noDevices()}
{/each} diff --git a/pkg/deej/session.go b/pkg/deej/session.go index ecfe869b..1cd47518 100644 --- a/pkg/deej/session.go +++ b/pkg/deej/session.go @@ -26,6 +26,10 @@ type Session interface { // master/mic session) IsDevice() bool + // IsInput returns true for capture-side sessions (microphones and other + // input devices) + IsInput() bool + Release() } @@ -44,6 +48,7 @@ type baseSession struct { system bool master bool device bool + input bool // used by Key(), needs to be set by child name string @@ -63,6 +68,10 @@ func (s *baseSession) IsDevice() bool { return s.device } +func (s *baseSession) IsInput() bool { + return s.input +} + func (s *baseSession) Key() string { if s.system { return systemSessionName diff --git a/pkg/deej/session_finder_windows.go b/pkg/deej/session_finder_windows.go index 8582b1b3..41192b70 100644 --- a/pkg/deej/session_finder_windows.go +++ b/pkg/deej/session_finder_windows.go @@ -347,7 +347,7 @@ func (sf *wcaSessionFinder) createDeviceManager(device *wca.IMMDevice) error { } // Create device master session - deviceMasterSession, err := sf.createDeviceMasterSession(device) + deviceMasterSession, err := sf.createDeviceMasterSession(device, isOutput) if err != nil { sf.logger.Warnw("Failed to create device master session", "deviceID", deviceIDStr, "error", err) } else { @@ -632,7 +632,7 @@ func (sf *wcaSessionFinder) cleanup() { } } -func (sf *wcaSessionFinder) createDeviceMasterSession(device *wca.IMMDevice) (*masterSession, error) { +func (sf *wcaSessionFinder) createDeviceMasterSession(device *wca.IMMDevice, isOutput bool) (*masterSession, error) { // Get device properties for friendly name var propertyStore *wca.IPropertyStore if err := device.OpenPropertyStore(wca.STGM_READ, &propertyStore); err != nil { @@ -651,7 +651,7 @@ func (sf *wcaSessionFinder) createDeviceMasterSession(device *wca.IMMDevice) (*m } endpointFriendlyName := value.String() - master, err := sf.getMasterSession(device, endpointFriendlyName, fmt.Sprintf(deviceSessionFormat, endpointDescription)) + master, err := sf.getMasterSession(device, endpointFriendlyName, fmt.Sprintf(deviceSessionFormat, endpointDescription), isOutput) if err != nil { return nil, err } @@ -660,14 +660,14 @@ func (sf *wcaSessionFinder) createDeviceMasterSession(device *wca.IMMDevice) (*m return master, nil } -func (sf *wcaSessionFinder) getMasterSession(mmDevice *wca.IMMDevice, key string, loggerKey string) (*masterSession, error) { +func (sf *wcaSessionFinder) getMasterSession(mmDevice *wca.IMMDevice, key string, loggerKey string, isOutput bool) (*masterSession, error) { var audioEndpointVolume *wca.IAudioEndpointVolume if err := mmdActivateWorkaround(mmDevice, wca.IID_IAudioEndpointVolume, wca.CLSCTX_ALL, nil, &audioEndpointVolume); err != nil { return nil, fmt.Errorf("activate AudioEndpointVolume: %w", err) } - master, err := newMasterSession(sf.sessionLogger, audioEndpointVolume, sf.eventCtx, key, loggerKey) + master, err := newMasterSession(sf.sessionLogger, audioEndpointVolume, sf.eventCtx, key, loggerKey, isOutput) if err != nil { audioEndpointVolume.Release() return nil, fmt.Errorf("create master session: %w", err) @@ -757,7 +757,7 @@ func (sf *wcaSessionFinder) refreshMasterOutput() { defer mmOutDevice.Release() // Create new master output session - masterOut, err := sf.getMasterSession(mmOutDevice, masterSessionName, masterSessionName) + masterOut, err := sf.getMasterSession(mmOutDevice, masterSessionName, masterSessionName, true) if err != nil { sf.logger.Warnw("Failed to create new master output session", "error", err) return @@ -796,7 +796,7 @@ func (sf *wcaSessionFinder) refreshMasterInput() { defer mmInDevice.Release() // Create new master input session - masterIn, err := sf.getMasterSession(mmInDevice, inputSessionName, inputSessionName) + masterIn, err := sf.getMasterSession(mmInDevice, inputSessionName, inputSessionName, false) if err != nil { sf.logger.Warnw("Failed to create new master input session", "error", err) return diff --git a/pkg/deej/session_linux.go b/pkg/deej/session_linux.go index 731e05cd..6237dea4 100644 --- a/pkg/deej/session_linux.go +++ b/pkg/deej/session_linux.go @@ -93,6 +93,7 @@ func newNamedMasterSession( s.logger = logger.Named(name) s.master = true + s.input = !isOutput s.name = name s.humanReadableDesc = name // keeps the original casing (Key() lowercases), shown for device sessions diff --git a/pkg/deej/session_map.go b/pkg/deej/session_map.go index 6fbff7fd..031785af 100644 --- a/pkg/deej/session_map.go +++ b/pkg/deej/session_map.go @@ -477,6 +477,9 @@ func (m *sessionMap) sessionInfos() []SessionInfoDTO { if session.IsDevice() { info.IsDevice = true } + if session.IsInput() { + info.IsInput = true + } if info.DisplayName == "" { info.DisplayName = session.DisplayName() } diff --git a/pkg/deej/session_windows.go b/pkg/deej/session_windows.go index 695a0c54..35688cea 100644 --- a/pkg/deej/session_windows.go +++ b/pkg/deej/session_windows.go @@ -97,6 +97,7 @@ func newMasterSession( eventCtx *ole.GUID, key string, loggerKey string, + isOutput bool, ) (*masterSession, error) { s := &masterSession{ @@ -106,6 +107,7 @@ func newMasterSession( s.logger = logger.Named(loggerKey) s.master = true + s.input = !isOutput s.name = key s.humanReadableDesc = key // keeps the original casing (Key() lowercases), shown for device sessions diff --git a/pkg/deej/settings_service.go b/pkg/deej/settings_service.go index 3f0f5815..cb4adca0 100644 --- a/pkg/deej/settings_service.go +++ b/pkg/deej/settings_service.go @@ -1,10 +1,13 @@ package deej import ( + "encoding/base64" "errors" + "maps" "runtime" "sort" "strings" + "sync" ps "github.com/mitchellh/go-ps" "go.bug.st/serial/enumerator" @@ -15,10 +18,13 @@ import ( // SettingsService exposes configuration APIs to the settings GUI frontend type SettingsService struct { d *Deej + + iconMu sync.Mutex + iconCache map[string]string } func newSettingsService(d *Deej) *SettingsService { - return &SettingsService{d: d} + return &SettingsService{d: d, iconCache: make(map[string]string)} } // SerialPortDTO describes an available serial port @@ -44,6 +50,7 @@ type SessionInfoDTO struct { Key string `json:"key"` DisplayName string `json:"displayName"` // friendly name, may be empty IsDevice bool `json:"isDevice"` // device master session, not a process + IsInput bool `json:"isInput"` // capture-side session (microphone) } // StatusDTO describes the live connection state for the settings GUI @@ -110,29 +117,38 @@ func (s *SettingsService) GetSessions() []SessionInfoDTO { return s.d.sessions.sessionInfos() } -// GetProcesses returns the deduplicated, sorted executable names of all -// running processes, so the target picker can suggest apps that aren't -// currently playing audio -func (s *SettingsService) GetProcesses() ([]string, error) { +// processIDsByName enumerates running processes and groups their PIDs by +// lowercased executable name, skipping pseudo-processes like the Windows +// "[System Process]" (pid 0) +func processIDsByName() (map[string][]int, error) { processes, err := ps.Processes() if err != nil { return nil, err } - seen := make(map[string]struct{}, len(processes)) - names := make([]string, 0, len(processes)) + pidsByName := make(map[string][]int, len(processes)) for _, process := range processes { name := strings.ToLower(process.Executable()) - - // skip pseudo-processes like the Windows "[System Process]" (pid 0) if name == "" || strings.HasPrefix(name, "[") { continue } - if _, ok := seen[name]; ok { - continue - } - seen[name] = struct{}{} + pidsByName[name] = append(pidsByName[name], process.Pid()) + } + + return pidsByName, nil +} + +// GetProcesses returns the deduplicated, sorted executable names of all +// running processes +func (s *SettingsService) GetProcesses() ([]string, error) { + pidsByName, err := processIDsByName() + if err != nil { + return nil, err + } + + names := make([]string, 0, len(pidsByName)) + for name := range pidsByName { names = append(names, name) } @@ -141,6 +157,83 @@ func (s *SettingsService) GetProcesses() ([]string, error) { return names, nil } +// processIconDataURI extracts the icon shared by the processes with the +// given PIDs, as a PNG data URI. +func processIconDataURI(pids []int) (icon string, conclusive bool) { + triedPaths := make(map[string]struct{}) + + for _, pid := range pids { + path, err := util.GetProcessImagePath(uint32(pid)) + if err != nil { + continue + } + if _, tried := triedPaths[path]; tried { + continue + } + triedPaths[path] = struct{}{} + + if pngBytes, err := util.GetFileIconPNG(path); err == nil { + return "data:image/png;base64," + base64.StdEncoding.EncodeToString(pngBytes), true + } + } + + return "", len(triedPaths) > 0 +} + +// GetProcessIcons returns PNG data URIs for the icons of the given process +// names, keyed by the names exactly as passed. Icons are cached across calls +func (s *SettingsService) GetProcessIcons(names []string) map[string]string { + result := make(map[string]string) + + if !util.ProcessIconsSupported { + return result + } + + missing := make(map[string]struct{}) + + s.iconMu.Lock() + for _, name := range names { + key := strings.ToLower(name) + if icon, ok := s.iconCache[key]; ok { + if icon != "" { + result[name] = icon + } + } else { + missing[key] = struct{}{} + } + } + s.iconMu.Unlock() + + if len(missing) == 0 { + return result + } + + pidsByName, err := processIDsByName() + if err != nil { + return result + } + + extracted := make(map[string]string, len(missing)) + for key := range missing { + icon, conclusive := processIconDataURI(pidsByName[key]) + if conclusive { + extracted[key] = icon + } + } + + s.iconMu.Lock() + maps.Copy(s.iconCache, extracted) + s.iconMu.Unlock() + + for _, name := range names { + if icon := extracted[strings.ToLower(name)]; icon != "" { + result[name] = icon + } + } + + return result +} + // GetOBSInputs returns the input names of the connected OBS instance, for // slider mapping suggestions func (s *SettingsService) GetOBSInputs() ([]string, error) { diff --git a/pkg/deej/util/procicon_linux.go b/pkg/deej/util/procicon_linux.go new file mode 100644 index 00000000..0b70c646 --- /dev/null +++ b/pkg/deej/util/procicon_linux.go @@ -0,0 +1,15 @@ +package util + +import "errors" + +const ProcessIconsSupported = false + +var errProcessIconsUnsupported = errors.New("process icons are not supported on this platform") + +func GetProcessImagePath(_ uint32) (string, error) { + return "", errProcessIconsUnsupported +} + +func GetFileIconPNG(_ string) ([]byte, error) { + return nil, errProcessIconsUnsupported +} diff --git a/pkg/deej/util/procicon_windows.go b/pkg/deej/util/procicon_windows.go new file mode 100644 index 00000000..b54c22ce --- /dev/null +++ b/pkg/deej/util/procicon_windows.go @@ -0,0 +1,111 @@ +package util + +import ( + "bytes" + "errors" + "fmt" + "image" + "image/png" + + "golang.org/x/sys/windows" + + "github.com/nik9play/deej/pkg/win" +) + +const processIconSize = 48 + +const ProcessIconsSupported = true + +// GetFileIconPNG returns the icon of the given executable, encoded as a PNG +func GetFileIconPNG(path string) ([]byte, error) { + icon, err := win.SHDefExtractIcon(path, processIconSize) + if err != nil { + return nil, fmt.Errorf("extract icon: %w", err) + } + defer win.DestroyIcon(icon) + + img, err := iconToImage(icon) + if err != nil { + return nil, err + } + + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + return nil, fmt.Errorf("encode png: %w", err) + } + + return buf.Bytes(), nil +} + +func iconToImage(icon windows.Handle) (image.Image, error) { + info, err := win.GetIconInfo(icon) + if err != nil { + return nil, fmt.Errorf("get icon info: %w", err) + } + defer func() { + if info.HbmColor != 0 { + win.DeleteObject(info.HbmColor) + } + if info.HbmMask != 0 { + win.DeleteObject(info.HbmMask) + } + }() + + if info.HbmColor == 0 { + return nil, errors.New("monochrome icon") + } + + bm, err := win.GetBitmapInfo(info.HbmColor) + if err != nil { + return nil, fmt.Errorf("get bitmap info: %w", err) + } + + width, height := int(bm.Width), int(bm.Height) + if width <= 0 || height <= 0 || width > 256 || height > 256 { + return nil, fmt.Errorf("unexpected icon dimensions %dx%d", width, height) + } + + hdc, err := win.GetDC(0) + if err != nil { + return nil, fmt.Errorf("get dc: %w", err) + } + defer win.ReleaseDC(0, hdc) + + pixels, err := win.GetDIBits32(hdc, info.HbmColor, width, height) + if err != nil { + return nil, fmt.Errorf("get color bits: %w", err) + } + + // old-style icons have no alpha channel; their transparency lives in the + // separate AND mask instead + hasAlpha := false + for i := 3; i < len(pixels); i += 4 { + if pixels[i] != 0 { + hasAlpha = true + break + } + } + + var mask []byte + if !hasAlpha && info.HbmMask != 0 { + mask, _ = win.GetDIBits32(hdc, info.HbmMask, width, height) + } + + img := image.NewNRGBA(image.Rect(0, 0, width, height)) + for i := 0; i < width*height; i++ { + // DIB pixel order is BGRA + img.Pix[i*4+0] = pixels[i*4+2] + img.Pix[i*4+1] = pixels[i*4+1] + img.Pix[i*4+2] = pixels[i*4+0] + switch { + case hasAlpha: + img.Pix[i*4+3] = pixels[i*4+3] + case mask != nil && mask[i*4] != 0: // white mask pixel = transparent + img.Pix[i*4+3] = 0 + default: + img.Pix[i*4+3] = 0xff + } + } + + return img, nil +} diff --git a/pkg/deej/util/procinfo_windows.go b/pkg/deej/util/procinfo_windows.go index a70d83af..61734366 100644 --- a/pkg/deej/util/procinfo_windows.go +++ b/pkg/deej/util/procinfo_windows.go @@ -12,6 +12,17 @@ import ( // of the executable behind the given process (e.g. "Google Chrome" for // chrome.exe), for use as a friendly display name func GetProcessFileDescription(pid uint32) (string, error) { + path, err := GetProcessImagePath(pid) + if err != nil { + return "", err + } + + return getFileDescription(path) +} + +// GetProcessImagePath returns the full path of the executable behind the +// given process +func GetProcessImagePath(pid uint32) (string, error) { handle, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid) if err != nil { return "", fmt.Errorf("open process: %w", err) @@ -26,7 +37,7 @@ func GetProcessFileDescription(pid uint32) (string, error) { return "", fmt.Errorf("query process image name: %w", err) } - return getFileDescription(windows.UTF16ToString(buf[:size])) + return windows.UTF16ToString(buf[:size]), nil } func getFileDescription(path string) (string, error) { diff --git a/pkg/win/icon_windows.go b/pkg/win/icon_windows.go new file mode 100644 index 00000000..bb6bf605 --- /dev/null +++ b/pkg/win/icon_windows.go @@ -0,0 +1,160 @@ +package win + +import ( + "errors" + "fmt" + "unsafe" + + "golang.org/x/sys/windows" +) + +var ( + modgdi32 = windows.NewLazySystemDLL("gdi32.dll") + + procSHDefExtractIcon = modshell32.NewProc("SHDefExtractIconW") + procGetIconInfo = moduser32.NewProc("GetIconInfo") + procDestroyIcon = moduser32.NewProc("DestroyIcon") + procGetDC = moduser32.NewProc("GetDC") + procReleaseDC = moduser32.NewProc("ReleaseDC") + procGetObject = modgdi32.NewProc("GetObjectW") + procGetDIBits = modgdi32.NewProc("GetDIBits") + procDeleteObject = modgdi32.NewProc("DeleteObject") +) + +type ICONINFO struct { + FIcon int32 + XHotspot uint32 + YHotspot uint32 + HbmMask windows.Handle + HbmColor windows.Handle +} + +type BITMAP struct { + Type int32 + Width int32 + Height int32 + WidthBytes int32 + Planes uint16 + BitsPixel uint16 + Bits uintptr +} + +type BITMAPINFOHEADER struct { + Size uint32 + Width int32 + Height int32 + Planes uint16 + BitCount uint16 + Compression uint32 + SizeImage uint32 + XPelsPerMeter int32 + YPelsPerMeter int32 + ClrUsed uint32 + ClrImportant uint32 +} + +// bitmapInfo reserves room after the header for the three color masks +// GetDIBits may write for BI_BITFIELDS formats +type bitmapInfo struct { + Header BITMAPINFOHEADER + colors [3]uint32 //nolint:unused +} + +// SHDefExtractIcon extracts the first icon of an executable or icon file, +// rendered at the requested pixel size +func SHDefExtractIcon(path string, size int) (icon windows.Handle, err error) { + pathPtr, err := windows.UTF16PtrFromString(path) + if err != nil { + return 0, err + } + + hr, _, _ := procSHDefExtractIcon.Call( + uintptr(unsafe.Pointer(pathPtr)), + 0, + 0, + uintptr(unsafe.Pointer(&icon)), + 0, + uintptr(uint32(size)), + ) + if hr != 0 { + return 0, fmt.Errorf("SHDefExtractIcon: hr=0x%x", hr) + } + if icon == 0 { + return 0, errors.New("no icon in file") + } + + return icon, nil +} + +func GetIconInfo(icon windows.Handle) (info ICONINFO, err error) { + r1, _, lastErr := procGetIconInfo.Call(uintptr(icon), uintptr(unsafe.Pointer(&info))) + + if r1 == 0 { + err = lastErr + } + + return +} + +func DestroyIcon(icon windows.Handle) { + _, _, _ = procDestroyIcon.Call(uintptr(icon)) +} + +func DeleteObject(object windows.Handle) { + _, _, _ = procDeleteObject.Call(uintptr(object)) +} + +func GetDC(hwnd windows.HWND) (hdc windows.Handle, err error) { + r1, _, _ := procGetDC.Call(uintptr(hwnd)) + if r1 == 0 { + return 0, errors.New("GetDC failed") + } + + return windows.Handle(r1), nil +} + +func ReleaseDC(hwnd windows.HWND, hdc windows.Handle) { + _, _, _ = procReleaseDC.Call(uintptr(hwnd), uintptr(hdc)) +} + +// GetBitmapInfo returns the dimensions and format of a GDI bitmap handle +func GetBitmapInfo(bitmap windows.Handle) (bm BITMAP, err error) { + r1, _, lastErr := procGetObject.Call( + uintptr(bitmap), + unsafe.Sizeof(bm), + uintptr(unsafe.Pointer(&bm)), + ) + + if r1 == 0 { + err = lastErr + } + + return +} + +// GetDIBits32 reads the pixels of a GDI bitmap as top-down 32bpp BGRA data +func GetDIBits32(hdc windows.Handle, bitmap windows.Handle, width int, height int) ([]byte, error) { + info := bitmapInfo{Header: BITMAPINFOHEADER{ + Size: uint32(unsafe.Sizeof(BITMAPINFOHEADER{})), + Width: int32(width), + Height: -int32(height), // negative height = top-down rows + Planes: 1, + BitCount: 32, + }} + + buf := make([]byte, width*height*4) + r1, _, lastErr := procGetDIBits.Call( + uintptr(hdc), + uintptr(bitmap), + 0, + uintptr(uint32(height)), + uintptr(unsafe.Pointer(&buf[0])), + uintptr(unsafe.Pointer(&info)), + 0, // DIB_RGB_COLORS + ) + if r1 == 0 { + return nil, lastErr + } + + return buf, nil +} From 181880ac7173a49e0f6320060f03a98a66fd2ac3 Mon Sep 17 00:00:00 2001 From: Nikita Date: Sat, 25 Jul 2026 20:09:23 +0300 Subject: [PATCH 27/47] unify config validation in normalize and drop the ConfigValues wrapper --- pkg/deej/config.go | 176 +++++-- pkg/deej/config_model.go | 369 +++++++++++++ ..._settings_test.go => config_model_test.go} | 162 +++--- pkg/deej/config_settings.go | 487 ------------------ pkg/deej/serial_test.go | 8 +- pkg/deej/session_map.go | 13 +- pkg/deej/slider_map.go | 70 --- 7 files changed, 583 insertions(+), 702 deletions(-) create mode 100644 pkg/deej/config_model.go rename pkg/deej/{config_settings_test.go => config_model_test.go} (73%) delete mode 100644 pkg/deej/config_settings.go delete mode 100644 pkg/deej/slider_map.go diff --git a/pkg/deej/config.go b/pkg/deej/config.go index d1d0ca5a..5f2b1f0e 100644 --- a/pkg/deej/config.go +++ b/pkg/deej/config.go @@ -1,11 +1,12 @@ package deej import ( - "crypto/sha256" + "bytes" "errors" "fmt" "os" "path/filepath" + "reflect" "strings" "sync" "sync/atomic" @@ -20,21 +21,10 @@ import ( "github.com/nik9play/deej/pkg/notify" ) -// ConfigValues holds a single immutable generation of deej's configuration. -// A fresh instance is published atomically on every (re)load, so concurrent -// readers must grab a snapshot with Values and must not mutate it -type ConfigValues struct { - // Settings is the sanitized contents of the user config file - Settings - - // SliderMapping is the runtime slider->targets map built from Mapping - SliderMapping *sliderMap -} - // CanonicalConfig provides application-wide access to configuration fields, // as well as loading/file watching logic for deej's configuration file type CanonicalConfig struct { - current atomic.Pointer[ConfigValues] + current atomic.Pointer[Settings] logger *zap.SugaredLogger notifier notify.Notifier @@ -47,17 +37,14 @@ type CanonicalConfig struct { // serializes the read-modify-write cycles of Load and SaveUserSettings lock sync.Mutex - // hash of the last config content written by the GUI, so the file - // watcher can tell our own writes apart from hand edits - lastSelfWrite atomic.Value // string - configPath string } // Values returns the current immutable snapshot of the configuration. // Callers that read multiple fields should grab one snapshot and use it -// throughout, so all values belong to the same config generation -func (cc *CanonicalConfig) Values() *ConfigValues { +// throughout, so all values belong to the same config generation. The +// returned snapshot must not be mutated +func (cc *CanonicalConfig) Values() *Settings { return cc.current.Load() } @@ -148,8 +135,10 @@ func (cc *CanonicalConfig) loadLocked(localizer *i18n.Localizer) error { return fmt.Errorf("read user config: %w", err) } - // missing keys keep the defaults they were initialized with + // missing keys keep the defaults they were initialized with. Legacy flat + // com keys are applied first so an explicit com: section overrides them settings := defaultSettings() + applyLegacyKeys(data, &settings) if err := yaml.Unmarshal(data, &settings); err != nil { // a *yaml.TypeError means the file parsed, but some values have the @@ -181,20 +170,18 @@ func (cc *CanonicalConfig) loadLocked(localizer *i18n.Localizer) error { } } - settings.sanitize(cc.logger) - - values := &ConfigValues{ - Settings: settings, - SliderMapping: sliderMapFromSettings(settings.Mapping), + if problems := settings.normalize(); len(problems) > 0 { + cc.logger.Warnw("Config had invalid values, replaced with defaults", "problems", problems) } - cc.current.Store(values) + + cc.current.Store(&settings) cc.logger.Info("Loaded config successfully") cc.logger.Infow("Config values", - "sliderMapping", values.SliderMapping, - "comPort", values.COM.Port, - "baudRate", values.COM.BaudRate, - "invertSliders", values.InvertSliders) + "sliderMapping", settings.SliderMapping, + "comPort", settings.COM.Port, + "baudRate", settings.COM.BaudRate, + "invertSliders", settings.InvertSliders) return nil } @@ -232,11 +219,11 @@ func (cc *CanonicalConfig) WatchConfigFileChanges(localizer *i18n.Localizer) { return } - // trailing-edge debounce timer, armed on every relevant event + // trailing-edge debounce timer, armed on every relevant event. Go 1.23+ + // timer channels are unbuffered, so Stop/Reset never leave a stale tick + // behind and the usual drain dance isn't needed debounce := time.NewTimer(time.Hour) - if !debounce.Stop() { - <-debounce.C - } + debounce.Stop() for { select { @@ -253,12 +240,6 @@ func (cc *CanonicalConfig) WatchConfigFileChanges(localizer *i18n.Localizer) { continue } - if !debounce.Stop() { - select { - case <-debounce.C: - default: - } - } debounce.Reset(watchDebounceDelay) case err, ok := <-watcher.Errors: @@ -278,23 +259,25 @@ func (cc *CanonicalConfig) WatchConfigFileChanges(localizer *i18n.Localizer) { } func (cc *CanonicalConfig) handleConfigFileChange(localizer *i18n.Localizer) { - - // ignore events caused by a GUI save: it already loaded and applied the - // new config synchronously, and shows its own confirmation - if data, err := os.ReadFile(cc.configPath); err == nil { - if hash, ok := cc.lastSelfWrite.Load().(string); ok && hash == contentHash(data) { - cc.logger.Debug("Ignoring config file event caused by GUI save") - return - } - } - cc.logger.Debug("Config file modified, attempting reload") + previous := cc.current.Load() + if err := cc.Load(localizer); err != nil { cc.logger.Warnw("Failed to reload config file", "error", err) return } + // a GUI save already loaded, applied and notified synchronously; the file + // event it triggers just reloads identical content. Skip the redundant + // toast and consumer notification whenever the config didn't actually + // change - this also covers a hand edit that only touched comments or + // whitespace + if previous != nil && reflect.DeepEqual(previous, cc.current.Load()) { + cc.logger.Debug("Config unchanged after reload, skipping notification") + return + } + cc.logger.Info("Reloaded config successfully") configReloadTitle := localizer.MustLocalize(&i18n.LocalizeConfig{ @@ -319,10 +302,6 @@ func (cc *CanonicalConfig) StopWatchingConfigFile() { close(cc.stopWatcher) } -func contentHash(data []byte) string { - return fmt.Sprintf("%x", sha256.Sum256(data)) -} - func (cc *CanonicalConfig) onConfigReloaded() { cc.logger.Debug("Notifying consumers about configuration reload") @@ -338,3 +317,92 @@ func (cc *CanonicalConfig) onConfigReloaded() { } } } + +// UserSettings returns the current contents of the user config file +func (cc *CanonicalConfig) UserSettings() Settings { + settings := *cc.Values() + settings.SliderMapping = settings.SliderMapping.clone() + + return settings +} + +// SaveUserSettings validates the settings, rewrites the user config file on +// disk and applies the new config immediately. The file is fully regenerated: +// comments, key order and unknown keys are not preserved +func (cc *CanonicalConfig) SaveUserSettings(settings Settings, localizer *i18n.Localizer) error { + // normalize both canonicalizes (blank VID/PID -> defaults, mapping sorted + // and filtered) and reports invalid values; a GUI save must be rejected + // rather than silently corrected + if problems := settings.normalize(); len(problems) > 0 { + return fmt.Errorf("invalid settings: %s", strings.Join(problems, "; ")) + } + + if err := cc.saveAndReload(settings, localizer); err != nil { + return err + } + + cc.onConfigReloaded() + + return nil +} + +func (cc *CanonicalConfig) saveAndReload(settings Settings, localizer *i18n.Localizer) error { + cc.lock.Lock() + defer cc.lock.Unlock() + + var buf bytes.Buffer + encoder := yaml.NewEncoder(&buf) + encoder.SetIndent(2) + if err := encoder.Encode(&settings); err != nil { + return fmt.Errorf("marshal config for save: %w", err) + } + if err := encoder.Close(); err != nil { + return fmt.Errorf("marshal config for save: %w", err) + } + out := buf.Bytes() + + if err := writeFileAtomic(cc.configPath, out); err != nil { + cc.logger.Warnw("Failed to write config file", "error", err) + return fmt.Errorf("write config for save: %w", err) + } + + cc.logger.Infow("Saved user settings to config file", "path", cc.configPath) + + // apply immediately instead of relying on the watcher's debounce timing + if err := cc.loadLocked(localizer); err != nil { + return fmt.Errorf("load config after save: %w", err) + } + + return nil +} + +// writeFileAtomic writes data to a temp file in the target's directory and +// renames it over the target, so a crash mid-write can't corrupt the config. +// The config holds the OBS websocket password, so it stays owner-only: the +// mode comes from os.CreateTemp, which creates at 0600, and rename preserves +// it - don't widen it +func writeFileAtomic(path string, data []byte) error { + tmp, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".tmp-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + _ = os.Remove(tmpPath) + return err + } + + if err := tmp.Close(); err != nil { + _ = os.Remove(tmpPath) + return err + } + + if err := os.Rename(tmpPath, path); err != nil { + _ = os.Remove(tmpPath) + return err + } + + return nil +} diff --git a/pkg/deej/config_model.go b/pkg/deej/config_model.go new file mode 100644 index 00000000..327c6aee --- /dev/null +++ b/pkg/deej/config_model.go @@ -0,0 +1,369 @@ +package deej + +import ( + "cmp" + "errors" + "fmt" + "slices" + "strconv" + "strings" + + "go.yaml.in/yaml/v3" +) + +// Settings is the single source of truth for deej's user-facing +// configuration. The yaml tags define the config file keys, the json tags +// the GUI wire format: a new setting only needs a field here (plus a default +// in defaultSettings and, if needed, a rule in normalize) to reach the file, +// the runtime snapshot and the settings GUI +type Settings struct { + SliderMapping SliderMappings `yaml:"slider_mapping" json:"sliderMapping"` + InvertSliders bool `yaml:"invert_sliders" json:"invertSliders"` + COM COMSettings `yaml:"com" json:"com"` + NoiseReduction string `yaml:"noise_reduction,omitempty" json:"noiseReduction"` + Language string `yaml:"language" json:"language"` + OBS OBSSettings `yaml:"obs" json:"obs"` +} + +// COMSettings describes the Arduino serial connection parameters +type COMSettings struct { + Port string `yaml:"port" json:"port"` + BaudRate int `yaml:"baud_rate" json:"baudRate"` + VID HexWord `yaml:"vid" json:"vid"` + PID HexWord `yaml:"pid" json:"pid"` +} + +// applyLegacyKeys fills COM settings from the legacy flat keys (com_port, +// baud_rate, com_vid, com_pid) that older config files used. Loads call it +// before the main decode, so an explicit com: section overrides these. Saves +// only ever write the com: section, so this is read-only compatibility +func applyLegacyKeys(data []byte, s *Settings) { + var legacy struct { + Port *string `yaml:"com_port"` + BaudRate *int `yaml:"baud_rate"` + VID *HexWord `yaml:"com_vid"` + PID *HexWord `yaml:"com_pid"` + } + + // a type error still decodes the well-typed keys; only a structural parse + // error (which the main decode reports too) means nothing usable came back + if err := yaml.Unmarshal(data, &legacy); err != nil && !isYAMLTypeError(err) { + return + } + + if legacy.Port != nil { + s.COM.Port = *legacy.Port + } + if legacy.BaudRate != nil { + s.COM.BaudRate = *legacy.BaudRate + } + if legacy.VID != nil { + s.COM.VID = *legacy.VID + } + if legacy.PID != nil { + s.COM.PID = *legacy.PID + } +} + +func isYAMLTypeError(err error) bool { + var typeErr *yaml.TypeError + return errors.As(err, &typeErr) +} + +// OBSSettings describes the OBS websocket connection parameters +type OBSSettings struct { + Enabled bool `yaml:"enabled" json:"enabled"` + Host string `yaml:"host" json:"host"` + Port int `yaml:"port" json:"port"` + Password string `yaml:"password" json:"password"` +} + +func defaultSettings() Settings { + return Settings{ + SliderMapping: SliderMappings{}, + Language: "auto", + + COM: COMSettings{ + Port: "auto", + BaudRate: 9600, + + // ch340 chip + VID: "1A86", + PID: "7523", + }, + + OBS: OBSSettings{ + Host: "localhost", + Port: 4455, + }, + } +} + +var validNoiseReductionLevels = []string{"", "low", "default", "high", "none"} +var validLanguages = []string{"auto", "en", "ru"} + +// normalize canonicalizes the settings in place and returns the list of +// values that were invalid and had to be replaced with a default (or +// dropped). It is the single rule set behind both config policies: GUI saves +// reject when the returned list is non-empty; config-file loads apply the +// fixes and log the list. Silent canonicalizations (blank VID/PID -> default, +// empty OBS host -> default, dropping empty targets, sorting the mapping) are +// not reported +func (s *Settings) normalize() []string { + var problems []string + defaults := defaultSettings() + + if s.COM.Port == "" { + problems = append(problems, "com port must not be empty") + s.COM.Port = defaults.COM.Port + } + + if s.COM.BaudRate <= 0 { + problems = append(problems, fmt.Sprintf("baud rate must be positive, got %d", s.COM.BaudRate)) + s.COM.BaudRate = defaults.COM.BaudRate + } + + // a blank VID/PID means "use the built-in default" and is not a problem; + // only a non-blank value that isn't valid hex is + if vid, ok := s.COM.VID.canonical(defaults.COM.VID); ok { + s.COM.VID = vid + } else { + problems = append(problems, fmt.Sprintf("com vid %q is not a 16-bit hex value", string(s.COM.VID))) + s.COM.VID = vid + } + + if pid, ok := s.COM.PID.canonical(defaults.COM.PID); ok { + s.COM.PID = pid + } else { + problems = append(problems, fmt.Sprintf("com pid %q is not a 16-bit hex value", string(s.COM.PID))) + s.COM.PID = pid + } + + if !slices.Contains(validNoiseReductionLevels, s.NoiseReduction) { + problems = append(problems, fmt.Sprintf("invalid noise reduction level: %q", s.NoiseReduction)) + s.NoiseReduction = defaults.NoiseReduction + } + + if !slices.Contains(validLanguages, s.Language) { + problems = append(problems, fmt.Sprintf("invalid language: %q", s.Language)) + s.Language = defaults.Language + } + + if s.OBS.Port < 1 || s.OBS.Port > 65535 { + problems = append(problems, fmt.Sprintf("obs port must be between 1 and 65535, got %d", s.OBS.Port)) + s.OBS.Port = defaults.OBS.Port + } + + if s.OBS.Host == "" { + s.OBS.Host = defaults.OBS.Host + } + + // canonicalize the mapping: report and drop negative/duplicate sliders, + // silently drop empty targets (and entries left with none, so the snapshot + // matches what a save writes), keep it sorted by slider index + mapping := SliderMappings{} + seenSliders := map[int]bool{} + for _, entry := range s.SliderMapping { + if entry.Slider < 0 { + problems = append(problems, fmt.Sprintf("slider index must not be negative: %d", entry.Slider)) + continue + } + if seenSliders[entry.Slider] { + problems = append(problems, fmt.Sprintf("duplicate slider index: %d", entry.Slider)) + continue + } + seenSliders[entry.Slider] = true + + targets := slices.DeleteFunc(slices.Clone(entry.Targets), func(t string) bool { return t == "" }) + if len(targets) == 0 { + continue + } + + mapping = append(mapping, SliderMappingEntry{Slider: entry.Slider, Targets: targets}) + } + slices.SortFunc(mapping, func(a, b SliderMappingEntry) int { return cmp.Compare(a.Slider, b.Slider) }) + s.SliderMapping = mapping + + return problems +} + +// HexWord is a 16-bit value carried as a hex string (e.g. "1A86"), the form +// both the config file and the GUI use. An empty value means "use the +// built-in default" +type HexWord string + +// Value returns the numeric form; ok is false for empty or malformed values +func (h HexWord) Value() (uint16, bool) { + trimmed := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(string(h))), "0x") + + parsed, err := strconv.ParseUint(trimmed, 16, 16) + if err != nil { + return 0, false + } + + return uint16(parsed), true +} + +// canonical returns the canonical 4-digit hex form. A blank value maps to the +// default (blank means "use the built-in default") with ok=true. ok is false +// only for a non-blank value that isn't valid hex; the default is returned in +// that case too, so callers always get a usable value +func (h HexWord) canonical(def HexWord) (HexWord, bool) { + if strings.TrimSpace(string(h)) == "" { + return def, true + } + + parsed, ok := h.Value() + if !ok { + return def, false + } + + return HexWord(fmt.Sprintf("%04X", parsed)), true +} + +// UnmarshalYAML accepts both YAML integers (0x1A86, 6790) and hex strings +// ("1A86", "0x1A86") +func (h *HexWord) UnmarshalYAML(node *yaml.Node) error { + var number uint64 + if err := node.Decode(&number); err == nil { + if number > 0xFFFF { + return fmt.Errorf("must be a 16-bit value: %s", node.Value) + } + + *h = HexWord(fmt.Sprintf("%04X", number)) + return nil + } + + var str string + if err := node.Decode(&str); err != nil { + return err + } + + *h = HexWord(str) + return nil +} + +// MarshalYAML writes the value as a 0x-prefixed YAML integer +func (h HexWord) MarshalYAML() (any, error) { + value, ok := h.Value() + if !ok { + // sanitized settings never hit this; keep whatever the value is + return string(h), nil + } + + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!int", Value: fmt.Sprintf("0x%04X", value)}, nil +} + +// SliderMappingEntry is one slider's targets, JSON-friendly for the GUI +type SliderMappingEntry struct { + Slider int `json:"slider"` + Targets []string `json:"targets"` +} + +// SliderMappings is the slider_mapping config key. In the file it's a YAML +// mapping of slider index to a single target or a list of targets +type SliderMappings []SliderMappingEntry + +// UnmarshalYAML is deliberately lenient: hand-edited entries it can't make +// sense of are skipped rather than failing the whole config load. Strictness +// for GUI saves comes from Settings.normalize instead +func (sm *SliderMappings) UnmarshalYAML(node *yaml.Node) error { + entries := SliderMappings{} + + if node.Kind == yaml.MappingNode { + for i := 0; i+1 < len(node.Content); i += 2 { + keyNode, valueNode := node.Content[i], node.Content[i+1] + + slider, err := strconv.Atoi(keyNode.Value) + if err != nil { + continue + } + + targets := []string{} + switch valueNode.Kind { + case yaml.ScalarNode: + if valueNode.Tag != "!!null" && valueNode.Value != "" { + targets = append(targets, valueNode.Value) + } + case yaml.SequenceNode: + for _, item := range valueNode.Content { + if item.Kind == yaml.ScalarNode && item.Tag != "!!null" { + targets = append(targets, item.Value) + } + } + } + + entries = append(entries, SliderMappingEntry{Slider: slider, Targets: targets}) + } + } + + *sm = entries + return nil +} + +// MarshalYAML builds the slider_mapping value: single targets are written +// as plain scalars, multiple targets as sequences, matching the style of +// the example config. Every save runs Settings.normalize first, so the +// entries are already sorted, deduped and free of empty targets +func (sm SliderMappings) MarshalYAML() (any, error) { + mapping := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} + + for _, entry := range sm { + if len(entry.Targets) == 0 { + continue + } + + var value *yaml.Node + if len(entry.Targets) == 1 { + value = yamlStringNode(entry.Targets[0]) + } else { + value = &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"} + for _, target := range entry.Targets { + value.Content = append(value.Content, yamlStringNode(target)) + } + } + + mapping.Content = append(mapping.Content, yamlIntNode(entry.Slider), value) + } + + return mapping, nil +} + +// get returns the targets mapped to a slider index. The mapping is small +// (one entry per physical slider) and sanitized, so a linear scan is fine +func (sm SliderMappings) get(slider int) ([]string, bool) { + for _, entry := range sm { + if entry.Slider == slider { + return entry.Targets, true + } + } + + return nil, false +} + +// String is a compact form for logs +func (sm SliderMappings) String() string { + targets := 0 + for _, entry := range sm { + targets += len(entry.Targets) + } + + return fmt.Sprintf("<%d sliders mapped to %d targets>", len(sm), targets) +} + +func (sm SliderMappings) clone() SliderMappings { + out := make(SliderMappings, len(sm)) + for i, entry := range sm { + out[i] = SliderMappingEntry{Slider: entry.Slider, Targets: slices.Clone(entry.Targets)} + } + + return out +} + +func yamlStringNode(value string) *yaml.Node { + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value} +} + +func yamlIntNode(value int) *yaml.Node { + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!int", Value: strconv.Itoa(value)} +} diff --git a/pkg/deej/config_settings_test.go b/pkg/deej/config_model_test.go similarity index 73% rename from pkg/deej/config_settings_test.go rename to pkg/deej/config_model_test.go index 14d34b82..5b38a10a 100644 --- a/pkg/deej/config_settings_test.go +++ b/pkg/deej/config_model_test.go @@ -59,15 +59,24 @@ obs: password: "" ` -func TestSaveUserSettingsWritesAndApplies(t *testing.T) { +// TestSaveUserSettingsRoundTrip touches every field of Settings, so a save +// that drops or mangles one shows up here +func TestSaveUserSettingsRoundTrip(t *testing.T) { cc := newTestConfig(t, testConfigContents) settings := cc.UserSettings() settings.COM.Port = "COM7" + settings.COM.BaudRate = 115200 + settings.COM.VID = "2341" + settings.COM.PID = "0043" settings.InvertSliders = true settings.NoiseReduction = "high" + settings.Language = "ru" settings.OBS.Enabled = true - settings.Mapping = SliderMappings{ + settings.OBS.Host = "192.168.1.5" + settings.OBS.Port = 4456 + settings.OBS.Password = "secret" + settings.SliderMapping = SliderMappings{ {Slider: 0, Targets: []string{"master"}}, {Slider: 2, Targets: []string{"discord.exe", "spotify.exe"}}, } @@ -76,6 +85,7 @@ func TestSaveUserSettingsWritesAndApplies(t *testing.T) { t.Fatalf("save settings: %v", err) } + // the file must carry the config's own key names, not just the values data, err := os.ReadFile(cc.configPath) if err != nil { t.Fatalf("read saved config: %v", err) @@ -93,69 +103,39 @@ func TestSaveUserSettingsWritesAndApplies(t *testing.T) { } } - // the save must have applied the new values immediately - values := cc.Values() - if values.COM.Port != "COM7" { - t.Errorf("com port not applied, got %q", values.COM.Port) - } - if !values.InvertSliders { - t.Error("invert_sliders not applied") - } - if !values.OBS.Enabled { - t.Error("obs.enabled not applied") - } - targets, ok := values.SliderMapping.get(2) - if !ok || len(targets) != 2 { - t.Errorf("slider mapping not applied, got %v", targets) - } -} - -func TestSaveUserSettingsRoundTrip(t *testing.T) { - cc := newTestConfig(t, testConfigContents) - - settings := cc.UserSettings() - settings.COM.BaudRate = 115200 - settings.COM.VID = "2341" - settings.COM.PID = "0043" - settings.Language = "ru" - settings.OBS.Host = "192.168.1.5" - settings.OBS.Port = 4456 - settings.OBS.Password = "secret" - - if err := cc.SaveUserSettings(settings, newTestLocalizer()); err != nil { - t.Fatalf("save settings: %v", err) - } - + // every value must survive the write -> read cycle, applied immediately got := cc.UserSettings() - if got.COM.BaudRate != 115200 || got.COM.VID != "2341" || got.COM.PID != "0043" || - got.Language != "ru" || got.OBS.Host != "192.168.1.5" || + if got.COM.Port != "COM7" || got.COM.BaudRate != 115200 || + got.COM.VID != "2341" || got.COM.PID != "0043" || + !got.InvertSliders || got.NoiseReduction != "high" || got.Language != "ru" || + !got.OBS.Enabled || got.OBS.Host != "192.168.1.5" || got.OBS.Port != 4456 || got.OBS.Password != "secret" { t.Errorf("round trip mismatch: %+v", got) } - if len(got.Mapping) != 2 { - t.Fatalf("expected 2 mapping entries, got %v", got.Mapping) + if len(got.SliderMapping) != 2 { + t.Fatalf("expected 2 mapping entries, got %v", got.SliderMapping) } - if got.Mapping[1].Slider != 1 || len(got.Mapping[1].Targets) != 2 { - t.Errorf("multi-target mapping mismatch: %+v", got.Mapping[1]) + targets, ok := got.SliderMapping.get(2) + if !ok || len(targets) != 2 { + t.Errorf("multi-target mapping not applied, got %v", targets) } } func TestSaveUserSettingsNotifiesConsumers(t *testing.T) { cc := newTestConfig(t, testConfigContents) + // the channel is buffered and the save notifies synchronously, so the + // signal is already waiting by the time SaveUserSettings returns reloaded := cc.SubscribeToChanges() - done := make(chan bool) - go func() { - done <- <-reloaded - }() - settings := cc.UserSettings() - if err := cc.SaveUserSettings(settings, newTestLocalizer()); err != nil { + if err := cc.SaveUserSettings(cc.UserSettings(), newTestLocalizer()); err != nil { t.Fatalf("save settings: %v", err) } - if !<-done { + select { + case <-reloaded: + default: t.Error("expected reload notification") } } @@ -234,7 +214,7 @@ func TestSaveMigratesLegacyKeysToComSection(t *testing.T) { // legacy keys live at the top level, so they'd start a line unindented // (the com section's own baud_rate is indented) - for _, line := range strings.Split(saved, "\n") { + for line := range strings.SplitSeq(saved, "\n") { for _, stale := range []string{"com_port:", "baud_rate:", "com_vid:", "com_pid:"} { if strings.HasPrefix(line, stale) { t.Errorf("saved config still contains legacy key %q:\n%s", stale, saved) @@ -283,32 +263,38 @@ obs: } func TestLoadToleratesWrongValueTypes(t *testing.T) { - // baud_rate has a wrong type; the rest of the file must still apply - cc := newTestConfig(t, "com:\n baud_rate: [what]\n port: COM9\n") + // baud_rate has a wrong type and falls back to its default; the rest of + // the file must still apply, through the com section and the legacy keys + // alike (the two take separate decode paths) + for _, tc := range []struct { + name string + contents string + }{ + {"com section", "com:\n baud_rate: [what]\n port: COM9\n"}, + {"legacy keys", "baud_rate: [what]\ncom_port: COM9\n"}, + } { + t.Run(tc.name, func(t *testing.T) { + values := newTestConfig(t, tc.contents).Values() - values := cc.Values() - if values.COM.BaudRate != defaultSettings().COM.BaudRate { - t.Errorf("COM.BaudRate = %d, expected default", values.COM.BaudRate) - } - if values.COM.Port != "COM9" { - t.Errorf("COM.Port = %q, expected COM9", values.COM.Port) + if values.COM.BaudRate != defaultSettings().COM.BaudRate { + t.Errorf("COM.BaudRate = %d, expected default", values.COM.BaudRate) + } + if values.COM.Port != "COM9" { + t.Errorf("COM.Port = %q, expected COM9", values.COM.Port) + } + }) } } -func TestLoadToleratesWrongLegacyValueTypes(t *testing.T) { - // same, with the legacy flat keys - cc := newTestConfig(t, "baud_rate: [what]\ncom_port: COM9\n") - - values := cc.Values() - if values.COM.BaudRate != defaultSettings().COM.BaudRate { - t.Errorf("COM.BaudRate = %d, expected default", values.COM.BaudRate) - } - if values.COM.Port != "COM9" { - t.Errorf("COM.Port = %q, expected COM9", values.COM.Port) +func TestDefaultComPortIsAuto(t *testing.T) { + // autodetection by VID/PID is the shipped default; a hardcoded port name + // would only ever be right on the machine it was picked on + if port := defaultSettings().COM.Port; port != "auto" { + t.Errorf("default com port = %q, expected auto", port) } } -func TestSettingsValidate(t *testing.T) { +func TestSettingsNormalize(t *testing.T) { valid := Settings{ COM: COMSettings{ Port: "auto", @@ -319,21 +305,41 @@ func TestSettingsValidate(t *testing.T) { NoiseReduction: "default", Language: "auto", OBS: OBSSettings{Host: "localhost", Port: 4455}, - Mapping: SliderMappings{ + SliderMapping: SliderMappings{ {Slider: 0, Targets: []string{"master"}}, }, } - if err := valid.Validate(); err != nil { - t.Errorf("valid settings rejected: %v", err) + v := valid + if problems := v.normalize(); len(problems) != 0 { + t.Errorf("valid settings reported problems: %v", problems) } - // empty VID/PID mean "use the built-in default" and must be accepted + // empty VID/PID mean "use the built-in default": no problem, and they + // canonicalize to the default value emptyVIDPID := valid emptyVIDPID.COM.VID = "" emptyVIDPID.COM.PID = "" - if err := emptyVIDPID.Validate(); err != nil { - t.Errorf("empty VID/PID rejected: %v", err) + if problems := emptyVIDPID.normalize(); len(problems) != 0 { + t.Errorf("empty VID/PID reported problems: %v", problems) + } + if emptyVIDPID.COM.VID != defaultSettings().COM.VID || emptyVIDPID.COM.PID != defaultSettings().COM.PID { + t.Errorf("empty VID/PID not defaulted: %q/%q", emptyVIDPID.COM.VID, emptyVIDPID.COM.PID) + } + + // an entry whose targets are all empty is dropped, not reported: the + // snapshot has to match what a save writes to the file + emptyTargets := valid + emptyTargets.SliderMapping = SliderMappings{ + {Slider: 0, Targets: []string{"master"}}, + {Slider: 1, Targets: []string{""}}, + {Slider: 2, Targets: nil}, + } + if problems := emptyTargets.normalize(); len(problems) != 0 { + t.Errorf("empty targets reported problems: %v", problems) + } + if len(emptyTargets.SliderMapping) != 1 || emptyTargets.SliderMapping[0].Slider != 0 { + t.Errorf("target-less entries not dropped: %+v", emptyTargets.SliderMapping) } cases := []struct { @@ -348,10 +354,10 @@ func TestSettingsValidate(t *testing.T) { {"bad language", func(s *Settings) { s.Language = "de" }}, {"obs port too large", func(s *Settings) { s.OBS.Port = 70000 }}, {"negative slider", func(s *Settings) { - s.Mapping = SliderMappings{{Slider: -1, Targets: []string{"a"}}} + s.SliderMapping = SliderMappings{{Slider: -1, Targets: []string{"a"}}} }}, {"duplicate slider", func(s *Settings) { - s.Mapping = SliderMappings{ + s.SliderMapping = SliderMappings{ {Slider: 1, Targets: []string{"a"}}, {Slider: 1, Targets: []string{"b"}}, } @@ -362,8 +368,8 @@ func TestSettingsValidate(t *testing.T) { t.Run(tc.name, func(t *testing.T) { settings := valid tc.mutate(&settings) - if err := settings.Validate(); err == nil { - t.Error("expected validation error") + if problems := settings.normalize(); len(problems) == 0 { + t.Error("expected normalize to report a problem") } }) } diff --git a/pkg/deej/config_settings.go b/pkg/deej/config_settings.go deleted file mode 100644 index cad5b3c6..00000000 --- a/pkg/deej/config_settings.go +++ /dev/null @@ -1,487 +0,0 @@ -package deej - -import ( - "bytes" - "errors" - "fmt" - "os" - "path/filepath" - "slices" - "sort" - "strconv" - "strings" - - "github.com/nicksnyder/go-i18n/v2/i18n" - "go.uber.org/zap" - "go.yaml.in/yaml/v3" -) - -// Settings is the single source of truth for deej's user-facing -// configuration. The yaml tags define the config file keys, the json tags -// the GUI wire format: a new setting only needs a field here (plus a default -// in defaultSettings and, if needed, a rule in Validate/sanitize) to reach -// the file, the runtime snapshot and the settings GUI -type Settings struct { - Mapping SliderMappings `yaml:"slider_mapping" json:"sliderMapping"` - InvertSliders bool `yaml:"invert_sliders" json:"invertSliders"` - COM COMSettings `yaml:"com" json:"com"` - NoiseReduction string `yaml:"noise_reduction,omitempty" json:"noiseReduction"` - Language string `yaml:"language" json:"language"` - OBS OBSSettings `yaml:"obs" json:"obs"` -} - -// COMSettings describes the Arduino serial connection parameters -type COMSettings struct { - Port string `yaml:"port" json:"port"` - BaudRate int `yaml:"baud_rate" json:"baudRate"` - VID HexWord `yaml:"vid" json:"vid"` - PID HexWord `yaml:"pid" json:"pid"` -} - -// UnmarshalYAML reads the com section, plus the legacy flat keys (com_port, -// baud_rate, com_vid, com_pid) that older config files use. Keys in the com -// section win over their legacy counterparts; saves only ever write the -// section -func (s *Settings) UnmarshalYAML(node *yaml.Node) error { - var legacy struct { - Port *string `yaml:"com_port"` - BaudRate *int `yaml:"baud_rate"` - VID *HexWord `yaml:"com_vid"` - PID *HexWord `yaml:"com_pid"` - } - legacyErr := node.Decode(&legacy) - if legacyErr != nil && !isYAMLTypeError(legacyErr) { - return legacyErr - } - - if legacy.Port != nil { - s.COM.Port = *legacy.Port - } - if legacy.BaudRate != nil { - s.COM.BaudRate = *legacy.BaudRate - } - if legacy.VID != nil { - s.COM.VID = *legacy.VID - } - if legacy.PID != nil { - s.COM.PID = *legacy.PID - } - - // the alias type drops Settings' methods, so this can't recurse; keys the - // document does specify overwrite the legacy values applied above - type settingsAlias Settings - aliasErr := node.Decode((*settingsAlias)(s)) - if aliasErr != nil && !isYAMLTypeError(aliasErr) { - return aliasErr - } - - // surviving errors are type errors only; the caller treats those as - // non-fatal (the affected fields keep their previous values) - return errors.Join(legacyErr, aliasErr) -} - -func isYAMLTypeError(err error) bool { - var typeErr *yaml.TypeError - return errors.As(err, &typeErr) -} - -// OBSSettings describes the OBS websocket connection parameters -type OBSSettings struct { - Enabled bool `yaml:"enabled" json:"enabled"` - Host string `yaml:"host" json:"host"` - Port int `yaml:"port" json:"port"` - Password string `yaml:"password" json:"password"` -} - -func defaultSettings() Settings { - return Settings{ - Mapping: SliderMappings{}, - Language: "auto", - - COM: COMSettings{ - Port: "COM4", - BaudRate: 9600, - - // ch340 chip - VID: "1A86", - PID: "7523", - }, - - OBS: OBSSettings{ - Host: "localhost", - Port: 4455, - }, - } -} - -var validNoiseReductionLevels = []string{"", "low", "default", "high", "none"} -var validLanguages = []string{"auto", "en", "ru"} - -// Validate checks the settings for values that would produce a broken -// config file; used for GUI saves, which must be rejected rather than fixed -func (s *Settings) Validate() error { - if s.COM.Port == "" { - return fmt.Errorf("com port must not be empty") - } - - if s.COM.BaudRate <= 0 { - return fmt.Errorf("baud rate must be a positive number") - } - - // an empty VID/PID means "use the built-in default", so only validate - // non-empty values - if _, err := s.COM.VID.parse(); s.COM.VID != "" && err != nil { - return fmt.Errorf("com vid: %w", err) - } - - if _, err := s.COM.PID.parse(); s.COM.PID != "" && err != nil { - return fmt.Errorf("com pid: %w", err) - } - - if !slices.Contains(validNoiseReductionLevels, s.NoiseReduction) { - return fmt.Errorf("invalid noise reduction level: %s", s.NoiseReduction) - } - - if !slices.Contains(validLanguages, s.Language) { - return fmt.Errorf("invalid language: %s", s.Language) - } - - if s.OBS.Port < 1 || s.OBS.Port > 65535 { - return fmt.Errorf("obs port must be between 1 and 65535") - } - - seenSliders := map[int]bool{} - for _, entry := range s.Mapping { - if entry.Slider < 0 { - return fmt.Errorf("slider index must not be negative: %d", entry.Slider) - } - if seenSliders[entry.Slider] { - return fmt.Errorf("duplicate slider index: %d", entry.Slider) - } - seenSliders[entry.Slider] = true - } - - return nil -} - -// sanitize brings settings to a canonical, valid state, falling back to the -// default (with a warning) for any value a hand-edited file got wrong. GUI -// saves pass through it too, after Validate, for canonicalization only -func (s *Settings) sanitize(logger *zap.SugaredLogger) { - defaults := defaultSettings() - - if s.COM.Port == "" { - logger.Warnw("Empty com port, using default value", "defaultValue", defaults.COM.Port) - s.COM.Port = defaults.COM.Port - } - - if s.COM.BaudRate <= 0 { - logger.Warnw("Invalid baud rate specified, using default value", - "invalidValue", s.COM.BaudRate, - "defaultValue", defaults.COM.BaudRate) - s.COM.BaudRate = defaults.COM.BaudRate - } - - s.COM.VID = s.COM.VID.canonicalOrDefault(logger, "com.vid", defaults.COM.VID) - s.COM.PID = s.COM.PID.canonicalOrDefault(logger, "com.pid", defaults.COM.PID) - - if !slices.Contains(validNoiseReductionLevels, s.NoiseReduction) { - logger.Warnw("Invalid noise reduction level, using default", "invalidValue", s.NoiseReduction) - s.NoiseReduction = defaults.NoiseReduction - } - - if !slices.Contains(validLanguages, s.Language) { - logger.Warnw("Invalid language, using default", "invalidValue", s.Language, "defaultValue", defaults.Language) - s.Language = defaults.Language - } - - if s.OBS.Port < 1 || s.OBS.Port > 65535 { - logger.Warnw("Invalid obs port, using default value", - "invalidValue", s.OBS.Port, - "defaultValue", defaults.OBS.Port) - s.OBS.Port = defaults.OBS.Port - } - - if s.OBS.Host == "" { - s.OBS.Host = defaults.OBS.Host - } - - // canonicalize the mapping: drop negative or duplicate sliders and empty - // targets, and keep it sorted by slider index - mapping := SliderMappings{} - seenSliders := map[int]bool{} - for _, entry := range s.Mapping { - if entry.Slider < 0 || seenSliders[entry.Slider] { - logger.Warnw("Ignoring invalid slider mapping entry", "slider", entry.Slider) - continue - } - seenSliders[entry.Slider] = true - - targets := slices.DeleteFunc(slices.Clone(entry.Targets), func(t string) bool { return t == "" }) - mapping = append(mapping, SliderMappingEntry{Slider: entry.Slider, Targets: targets}) - } - sort.Slice(mapping, func(i, j int) bool { return mapping[i].Slider < mapping[j].Slider }) - s.Mapping = mapping -} - -// HexWord is a 16-bit value carried as a hex string (e.g. "1A86"), the form -// both the config file and the GUI use. An empty value means "use the -// built-in default" -type HexWord string - -// Value returns the numeric form; ok is false for empty or malformed values -func (h HexWord) Value() (uint16, bool) { - parsed, err := h.parse() - if err != nil { - return 0, false - } - - return parsed, true -} - -func (h HexWord) parse() (uint16, error) { - trimmed := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(string(h))), "0x") - parsed, err := strconv.ParseUint(trimmed, 16, 16) - if err != nil { - return 0, fmt.Errorf("must be a 16-bit hex value: %s", string(h)) - } - - return uint16(parsed), nil -} - -func (h HexWord) canonicalOrDefault(logger *zap.SugaredLogger, key string, def HexWord) HexWord { - if strings.TrimSpace(string(h)) == "" { - return def - } - - parsed, err := h.parse() - if err != nil { - logger.Warnw("Invalid hex value, using default", "key", key, "invalidValue", string(h), "defaultValue", string(def)) - return def - } - - return HexWord(fmt.Sprintf("%04X", parsed)) -} - -// UnmarshalYAML accepts both YAML integers (0x1A86, 6790) and hex strings -// ("1A86", "0x1A86") -func (h *HexWord) UnmarshalYAML(node *yaml.Node) error { - var number uint64 - if err := node.Decode(&number); err == nil { - if number > 0xFFFF { - return fmt.Errorf("must be a 16-bit value: %s", node.Value) - } - - *h = HexWord(fmt.Sprintf("%04X", number)) - return nil - } - - var str string - if err := node.Decode(&str); err != nil { - return err - } - - *h = HexWord(str) - return nil -} - -// MarshalYAML writes the value as a 0x-prefixed YAML integer -func (h HexWord) MarshalYAML() (any, error) { - value, ok := h.Value() - if !ok { - // sanitized settings never hit this; keep whatever the value is - return string(h), nil - } - - return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!int", Value: fmt.Sprintf("0x%04X", value)}, nil -} - -// SliderMappingEntry is one slider's targets, JSON-friendly for the GUI -type SliderMappingEntry struct { - Slider int `json:"slider"` - Targets []string `json:"targets"` -} - -// SliderMappings is the slider_mapping config key. In the file it's a YAML -// mapping of slider index to a single target or a list of targets -type SliderMappings []SliderMappingEntry - -// UnmarshalYAML is deliberately lenient: hand-edited entries it can't make -// sense of are skipped rather than failing the whole config load. Strictness -// for GUI saves comes from Settings.Validate instead -func (sm *SliderMappings) UnmarshalYAML(node *yaml.Node) error { - entries := SliderMappings{} - - if node.Kind == yaml.MappingNode { - for i := 0; i+1 < len(node.Content); i += 2 { - keyNode, valueNode := node.Content[i], node.Content[i+1] - - slider, err := strconv.Atoi(keyNode.Value) - if err != nil { - continue - } - - targets := []string{} - switch valueNode.Kind { - case yaml.ScalarNode: - if valueNode.Tag != "!!null" && valueNode.Value != "" { - targets = append(targets, valueNode.Value) - } - case yaml.SequenceNode: - for _, item := range valueNode.Content { - if item.Kind == yaml.ScalarNode && item.Tag != "!!null" { - targets = append(targets, item.Value) - } - } - } - - entries = append(entries, SliderMappingEntry{Slider: slider, Targets: targets}) - } - } - - *sm = entries - return nil -} - -// MarshalYAML builds the slider_mapping value: single targets are written -// as plain scalars, multiple targets as sequences, matching the style of -// the example config -func (sm SliderMappings) MarshalYAML() (any, error) { - sorted := sm.clone() - sort.Slice(sorted, func(i, j int) bool { return sorted[i].Slider < sorted[j].Slider }) - - mapping := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} - - for _, entry := range sorted { - targets := slices.DeleteFunc(entry.Targets, func(t string) bool { return t == "" }) - if len(targets) == 0 { - continue - } - - var value *yaml.Node - if len(targets) == 1 { - value = yamlStringNode(targets[0]) - } else { - value = &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"} - for _, target := range targets { - value.Content = append(value.Content, yamlStringNode(target)) - } - } - - mapping.Content = append(mapping.Content, yamlIntNode(entry.Slider), value) - } - - return mapping, nil -} - -func (sm SliderMappings) clone() SliderMappings { - out := make(SliderMappings, len(sm)) - for i, entry := range sm { - out[i] = SliderMappingEntry{Slider: entry.Slider, Targets: slices.Clone(entry.Targets)} - } - - return out -} - -// UserSettings returns the current contents of the user config file -func (cc *CanonicalConfig) UserSettings() Settings { - settings := cc.Values().Settings - settings.Mapping = settings.Mapping.clone() - - return settings -} - -// SaveUserSettings validates the settings, rewrites the user config file on -// disk and applies the new config immediately. The file is fully regenerated: -// comments, key order and unknown keys are not preserved -func (cc *CanonicalConfig) SaveUserSettings(settings Settings, localizer *i18n.Localizer) error { - if err := settings.Validate(); err != nil { - return err - } - - // canonicalize (blank VID/PID -> defaults, mapping sorted and filtered) - settings.sanitize(cc.logger) - - if err := cc.saveAndReload(settings, localizer); err != nil { - return err - } - - cc.onConfigReloaded() - - return nil -} - -func (cc *CanonicalConfig) saveAndReload(settings Settings, localizer *i18n.Localizer) error { - cc.lock.Lock() - defer cc.lock.Unlock() - - var buf bytes.Buffer - encoder := yaml.NewEncoder(&buf) - encoder.SetIndent(2) - if err := encoder.Encode(&settings); err != nil { - return fmt.Errorf("marshal config for save: %w", err) - } - if err := encoder.Close(); err != nil { - return fmt.Errorf("marshal config for save: %w", err) - } - out := buf.Bytes() - - // remember the exact content we wrote, so the file watcher can tell this - // write apart from a hand edit and skip the redundant reload - cc.lastSelfWrite.Store(contentHash(out)) - - if err := writeFileAtomic(cc.configPath, out); err != nil { - cc.logger.Warnw("Failed to write config file", "error", err) - return fmt.Errorf("write config for save: %w", err) - } - - cc.logger.Infow("Saved user settings to config file", "path", cc.configPath) - - // apply immediately instead of relying on the watcher's debounce timing - if err := cc.loadLocked(localizer); err != nil { - return fmt.Errorf("load config after save: %w", err) - } - - return nil -} - -// writeFileAtomic writes data to a temp file in the target's directory and -// renames it over the target, so a crash mid-write can't corrupt the config -func writeFileAtomic(path string, data []byte) error { - tmp, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".tmp-*") - if err != nil { - return err - } - tmpPath := tmp.Name() - - if _, err := tmp.Write(data); err != nil { - _ = tmp.Close() - _ = os.Remove(tmpPath) - return err - } - - if err := tmp.Close(); err != nil { - _ = os.Remove(tmpPath) - return err - } - - if err := os.Chmod(tmpPath, 0o644); err != nil { - _ = os.Remove(tmpPath) - return err - } - - if err := os.Rename(tmpPath, path); err != nil { - _ = os.Remove(tmpPath) - return err - } - - return nil -} - -func yamlStringNode(value string) *yaml.Node { - return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value} -} - -func yamlIntNode(value int) *yaml.Node { - return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!int", Value: strconv.Itoa(value)} -} diff --git a/pkg/deej/serial_test.go b/pkg/deej/serial_test.go index 622ab32a..a1f8e0d2 100644 --- a/pkg/deej/serial_test.go +++ b/pkg/deej/serial_test.go @@ -10,11 +10,9 @@ import ( // plus a buffered channel that captures emitted slider move events. func newTestSerialIO(invertSliders bool, noiseReductionLevel string) (*SerialIO, chan SliderMoveEvent) { config := &CanonicalConfig{} - config.current.Store(&ConfigValues{ - Settings: Settings{ - InvertSliders: invertSliders, - NoiseReduction: noiseReductionLevel, - }, + config.current.Store(&Settings{ + InvertSliders: invertSliders, + NoiseReduction: noiseReductionLevel, }) sio := &SerialIO{ diff --git a/pkg/deej/session_map.go b/pkg/deej/session_map.go index 031785af..e64b9664 100644 --- a/pkg/deej/session_map.go +++ b/pkg/deej/session_map.go @@ -262,11 +262,9 @@ func (m *sessionMap) sessionMapped(session Session) bool { return true } - matchFound := false - // look through the actual mappings - m.deej.config.Values().SliderMapping.iterate(func(_ int, targets []string) { - for _, target := range targets { + for _, entry := range m.deej.config.Values().SliderMapping { + for _, target := range entry.Targets { // ignore special transforms if m.targetHasSpecialTransform(target) { @@ -277,13 +275,12 @@ func (m *sessionMap) sessionMapped(session Session) bool { target = m.resolveTarget(target)[0] if target == session.Key() { - matchFound = true - return + return true } } - }) + } - return matchFound + return false } func (m *sessionMap) handleSliderMoveEvent(event SliderMoveEvent) { diff --git a/pkg/deej/slider_map.go b/pkg/deej/slider_map.go deleted file mode 100644 index 7d311323..00000000 --- a/pkg/deej/slider_map.go +++ /dev/null @@ -1,70 +0,0 @@ -package deej - -import ( - "fmt" - "slices" - "sync" -) - -type sliderMap struct { - m map[int][]string - lock sync.Locker -} - -func newSliderMap() *sliderMap { - return &sliderMap{ - m: make(map[int][]string), - lock: &sync.Mutex{}, - } -} - -func sliderMapFromSettings(mapping SliderMappings) *sliderMap { - resultMap := newSliderMap() - - // copy targets from the config, ignoring empty values - for _, entry := range mapping { - targets := slices.DeleteFunc(slices.Clone(entry.Targets), func(s string) bool { return s == "" }) - resultMap.set(entry.Slider, targets) - } - - return resultMap -} - -func (m *sliderMap) iterate(f func(int, []string)) { - m.lock.Lock() - defer m.lock.Unlock() - - for key, value := range m.m { - f(key, value) - } -} - -func (m *sliderMap) get(key int) ([]string, bool) { - m.lock.Lock() - defer m.lock.Unlock() - - value, ok := m.m[key] - return value, ok -} - -func (m *sliderMap) set(key int, value []string) { - m.lock.Lock() - defer m.lock.Unlock() - - m.m[key] = value -} - -func (m *sliderMap) String() string { - m.lock.Lock() - defer m.lock.Unlock() - - sliderCount := 0 - targetCount := 0 - - for _, value := range m.m { - sliderCount++ - targetCount += len(value) - } - - return fmt.Sprintf("<%d sliders mapped to %d targets>", sliderCount, targetCount) -} From 0dd8e4751e58c4b7880f800f7c1353fef36f5545 Mon Sep 17 00:00:00 2001 From: Nikita Date: Sat, 25 Jul 2026 20:09:40 +0300 Subject: [PATCH 28/47] save connection and obs settings per section and add an about tab --- frontend/messages/en.json | 4 +- frontend/messages/ru.json | 4 +- frontend/src/components/AboutSection.svelte | 20 +++ .../src/components/BehaviorSection.svelte | 12 +- .../src/components/ConnectionSection.svelte | 59 ++++++- frontend/src/components/GeneralSection.svelte | 19 ++- frontend/src/components/ObsSection.svelte | 43 ++++- frontend/src/components/SettingsDialog.svelte | 155 ++++++++++++------ frontend/src/components/TargetDialog.svelte | 2 +- .../src/components/ui/FieldCombobox.svelte | 13 +- .../src/components/ui/SectionActions.svelte | 17 ++ frontend/src/lib/state.svelte.ts | 6 +- 12 files changed, 275 insertions(+), 79 deletions(-) create mode 100644 frontend/src/components/AboutSection.svelte create mode 100644 frontend/src/components/ui/SectionActions.svelte diff --git a/frontend/messages/en.json b/frontend/messages/en.json index ab759d79..28de4b2e 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -74,8 +74,10 @@ "save": "Save", "revert": "Revert", "saved": "Settings saved", + "unsavedChanges": "Unsaved changes", "saveError": "Failed to save settings", - "loadError": "Failed to load settings", + "about": "About", "configPath": "Configuration file", + "configPathHint": "Can be edited by hand; changes are applied automatically.", "version": "Version" } diff --git a/frontend/messages/ru.json b/frontend/messages/ru.json index 7f402e7f..ebbee3ab 100644 --- a/frontend/messages/ru.json +++ b/frontend/messages/ru.json @@ -74,8 +74,10 @@ "save": "Сохранить", "revert": "Отменить", "saved": "Настройки сохранены", + "unsavedChanges": "Несохранённые изменения", "saveError": "Не удалось сохранить настройки", - "loadError": "Не удалось загрузить настройки", + "about": "О программе", "configPath": "Файл конфигурации", + "configPathHint": "Можно править вручную — изменения применяются автоматически.", "version": "Версия" } diff --git a/frontend/src/components/AboutSection.svelte b/frontend/src/components/AboutSection.svelte new file mode 100644 index 00000000..c47f619e --- /dev/null +++ b/frontend/src/components/AboutSection.svelte @@ -0,0 +1,20 @@ + + +
+
+ {m.version()} + {appInfo?.version || "—"} +
+ +
+ {m.configPath()} + + {appInfo?.configPath || "—"} + {m.configPathHint()} +
+
diff --git a/frontend/src/components/BehaviorSection.svelte b/frontend/src/components/BehaviorSection.svelte index 8e7bccab..4d625c88 100644 --- a/frontend/src/components/BehaviorSection.svelte +++ b/frontend/src/components/BehaviorSection.svelte @@ -4,7 +4,9 @@ import FieldCheckbox from "./ui/FieldCheckbox.svelte"; import FieldSelect from "./ui/FieldSelect.svelte"; - let { settings }: { settings: Settings } = $props(); + // these settings are cheap to apply, so they're written as soon as they + // change instead of behind a save button + let { settings, onsave }: { settings: Settings; onsave: (patch: Partial) => void } = $props(); const noiseItems = $derived([ ...(settings.noiseReduction === "" ? [{ value: "", label: m.noiseDefault() }] : []), @@ -19,13 +21,17 @@
settings.invertSliders, (invertSliders) => onsave({ invertSliders })} />
- + settings.noiseReduction, (noiseReduction) => onsave({ noiseReduction })} + />
diff --git a/frontend/src/components/ConnectionSection.svelte b/frontend/src/components/ConnectionSection.svelte index d7c30f16..276d1f4b 100644 --- a/frontend/src/components/ConnectionSection.svelte +++ b/frontend/src/components/ConnectionSection.svelte @@ -1,11 +1,27 @@ -
+ +
{ + event.preventDefault(); + onsave(); + }} +>
- +
- {#if settings.com.port === "auto"} + {#if draft.port === "auto"}
- +
- +
{m.vidPidHint()}
{/if} -
+ + + diff --git a/frontend/src/components/GeneralSection.svelte b/frontend/src/components/GeneralSection.svelte index 01b30166..4adc5076 100644 --- a/frontend/src/components/GeneralSection.svelte +++ b/frontend/src/components/GeneralSection.svelte @@ -5,7 +5,17 @@ import FieldCheckbox from "./ui/FieldCheckbox.svelte"; import FieldSelect from "./ui/FieldSelect.svelte"; - let { settings, appInfo }: { settings: Settings; appInfo: AppInfoDTO | null } = $props(); + // these settings are cheap to apply, so they're written as soon as they + // change instead of behind a save button + let { + settings, + appInfo, + onsave, + }: { + settings: Settings; + appInfo: AppInfoDTO | null; + onsave: (patch: Partial) => void; + } = $props(); const languageItems = $derived([ { value: "auto", label: m.languageAuto() }, @@ -41,7 +51,12 @@
- + settings.language, (language) => onsave({ language })} + />
{m.languageHint()}
diff --git a/frontend/src/components/ObsSection.svelte b/frontend/src/components/ObsSection.svelte index ab49b2c5..3e96246f 100644 --- a/frontend/src/components/ObsSection.svelte +++ b/frontend/src/components/ObsSection.svelte @@ -1,30 +1,55 @@ -
- + +
{ + event.preventDefault(); + onsave(); + }} +> +
{m.obsHint()}
- {#if settings.obs.enabled} + {#if draft.enabled}
{m.obsWebsocketHint()}
- +
- +
- +
{/if} -
+ + + diff --git a/frontend/src/components/SettingsDialog.svelte b/frontend/src/components/SettingsDialog.svelte index 55574016..ac7ef37e 100644 --- a/frontend/src/components/SettingsDialog.svelte +++ b/frontend/src/components/SettingsDialog.svelte @@ -1,12 +1,24 @@ @@ -96,41 +131,63 @@
- {#if settings} + {#if settings && comDraft && obsDraft} - {#each [{ value: "general", label: m.general(), Icon: Cog }, { value: "connection", label: m.connection(), Icon: Cable }, { value: "behavior", label: m.behavior(), Icon: SlidersHorizontal }, { value: "obs", label: m.obs(), Icon: Video }] as tabItem (tabItem.value)} + {#each tabItems as tabItem (tabItem.value)} {tabItem.label} + {#if tabItem.dirty} + + {/if} {/each}
- - - - + + + + + save({ com: $state.snapshot(comDraft!) })} + onrevert={() => (comDraft = $state.snapshot(settings!.com))} + /> + + + + save({ obs: $state.snapshot(obsDraft!) })} + onrevert={() => (obsDraft = $state.snapshot(settings!.obs))} + /> + +
+ {/if} -
- {#if appInfo?.version}{m.version()}: {appInfo.version} ·{/if} - {m.configPath()}: {appInfo?.configPath} + {#if statusText} +
+ {statusText}
{/if} - -
- {#if statusText} - {statusText} - {/if} - - -
diff --git a/frontend/src/components/TargetDialog.svelte b/frontend/src/components/TargetDialog.svelte index cc386239..645af8f5 100644 --- a/frontend/src/components/TargetDialog.svelte +++ b/frontend/src/components/TargetDialog.svelte @@ -206,7 +206,7 @@ saving = true; errorText = ""; try { - const dto: Settings = JSON.parse(JSON.stringify(app.settings)); + const dto: Settings = $state.snapshot(app.settings); dto.sliderMapping = dto.sliderMapping.filter((entry) => entry.slider !== slider); if (targets.length > 0) { dto.sliderMapping.push({ slider, targets: [...targets] }); diff --git a/frontend/src/components/ui/FieldCombobox.svelte b/frontend/src/components/ui/FieldCombobox.svelte index a426e497..41660bdc 100644 --- a/frontend/src/components/ui/FieldCombobox.svelte +++ b/frontend/src/components/ui/FieldCombobox.svelte @@ -12,7 +12,16 @@ items, id, placeholder = "", - }: { value?: string; items: Item[]; id?: string; placeholder?: string } = $props(); + required = false, + pattern, + }: { + value?: string; + items: Item[]; + id?: string; + placeholder?: string; + required?: boolean; + pattern?: string; + } = $props(); let search = $state(""); let open = $state(false); @@ -55,6 +64,8 @@ {id} class="input pr-8" {placeholder} + {required} + {pattern} oninput={(e) => { value = e.currentTarget.value; search = e.currentTarget.value; diff --git a/frontend/src/components/ui/SectionActions.svelte b/frontend/src/components/ui/SectionActions.svelte new file mode 100644 index 00000000..7eb42f8a --- /dev/null +++ b/frontend/src/components/ui/SectionActions.svelte @@ -0,0 +1,17 @@ + + +
+ + +
diff --git a/frontend/src/lib/state.svelte.ts b/frontend/src/lib/state.svelte.ts index f20b5368..6c36ea31 100644 --- a/frontend/src/lib/state.svelte.ts +++ b/frontend/src/lib/state.svelte.ts @@ -13,9 +13,9 @@ export const app = $state({ export async function refreshSettings(): Promise { try { - const loaded = await SettingsService.GetSettings(); - // clone into a plain object so svelte's deep reactivity applies - app.settings = JSON.parse(JSON.stringify(loaded)); + // snapshot into a plain object so svelte's deep reactivity applies (the + // wails bridge hands back class instances, which aren't made reactive) + app.settings = $state.snapshot(await SettingsService.GetSettings()); } catch (err) { console.error("failed to load settings", err); } From a30436560cc5ce8e82d66ebcb559ea5eaecee5e2 Mon Sep 17 00:00:00 2001 From: Nikita Date: Mon, 27 Jul 2026 14:38:12 +0300 Subject: [PATCH 29/47] add slider mapping profiles switchable by tray, gui and global hotkey --- config_examples/config.example.ru.yaml | 15 ++ config_examples/config.example.yaml | 15 ++ .../nik9play/deej/pkg/deej/index.ts | 1 + .../nik9play/deej/pkg/deej/models.ts | 77 ++++-- .../nik9play/deej/pkg/deej/settingsservice.ts | 8 + frontend/messages/en.json | 16 ++ frontend/messages/ru.json | 16 ++ frontend/src/App.svelte | 2 +- frontend/src/components/Mixer.svelte | 3 +- .../src/components/ProfilesSection.svelte | 122 +++++++++ frontend/src/components/SettingsDialog.svelte | 6 + frontend/src/components/TargetDialog.svelte | 12 +- frontend/src/components/Titlebar.svelte | 89 ++++++- frontend/src/components/ui/HotkeyInput.svelte | 98 +++++++ frontend/src/lib/profiles.ts | 14 + pkg/deej/config.go | 36 ++- pkg/deej/config_model.go | 249 +++++++++++++++--- pkg/deej/config_model_test.go | 200 +++++++++++++- pkg/deej/lang/active.en.toml | 4 + pkg/deej/lang/active.ru.toml | 12 + pkg/deej/session_map.go | 6 +- pkg/deej/settings_service.go | 6 + pkg/deej/tray.go | 125 +++++++++ 23 files changed, 1054 insertions(+), 78 deletions(-) create mode 100644 frontend/src/components/ProfilesSection.svelte create mode 100644 frontend/src/components/ui/HotkeyInput.svelte create mode 100644 frontend/src/lib/profiles.ts diff --git a/config_examples/config.example.ru.yaml b/config_examples/config.example.ru.yaml index b79f7999..f0696c48 100644 --- a/config_examples/config.example.ru.yaml +++ b/config_examples/config.example.ru.yaml @@ -15,6 +15,21 @@ slider_mapping: 3: discord.exe 4: chrome.exe +# Если нужно несколько раскладок, замените slider_mapping выше на список профилей: +# у каждого своя раскладка и необязательное сочетание клавиш, которое переключает +# на него из любого приложения. Остальные настройки файла общие для всех профилей +# active_profile: default +# profiles: +# - name: default +# slider_mapping: +# 0: master +# 1: discord.exe +# - name: gaming +# hotkey: Ctrl+Alt+2 +# slider_mapping: +# 0: master +# 1: [game.exe, discord.exe] + # Инвертирование значений ползунков микшера. (1023 - 0, 0 - 1023) invert_sliders: false diff --git a/config_examples/config.example.yaml b/config_examples/config.example.yaml index 846b1c41..6a410ca0 100644 --- a/config_examples/config.example.yaml +++ b/config_examples/config.example.yaml @@ -16,6 +16,21 @@ slider_mapping: 3: master 4: mic +# for more than one layout, replace slider_mapping above with a list of profiles: +# each has its own mapping, and an optional hotkey that switches to it from any +# application. everything else in this file stays shared between them +# active_profile: default +# profiles: +# - name: default +# slider_mapping: +# 0: master +# 1: discord.exe +# - name: gaming +# hotkey: Ctrl+Alt+2 +# slider_mapping: +# 0: master +# 1: [game.exe, discord.exe] + # set this to true if you want the controls inverted (i.e. top is 0%, bottom is 100%) invert_sliders: false diff --git a/frontend/bindings/github.com/nik9play/deej/pkg/deej/index.ts b/frontend/bindings/github.com/nik9play/deej/pkg/deej/index.ts index 5dce056b..56fd864f 100644 --- a/frontend/bindings/github.com/nik9play/deej/pkg/deej/index.ts +++ b/frontend/bindings/github.com/nik9play/deej/pkg/deej/index.ts @@ -10,6 +10,7 @@ export { AppInfoDTO, COMSettings, OBSSettings, + Profile, SerialPortDTO, SessionInfoDTO, Settings, diff --git a/frontend/bindings/github.com/nik9play/deej/pkg/deej/models.ts b/frontend/bindings/github.com/nik9play/deej/pkg/deej/models.ts index 800ffaa8..0704add1 100644 --- a/frontend/bindings/github.com/nik9play/deej/pkg/deej/models.ts +++ b/frontend/bindings/github.com/nik9play/deej/pkg/deej/models.ts @@ -128,6 +128,45 @@ export class OBSSettings { } } +/** + * Profile is one named slider layout. Only the mapping and its hotkey are + * per-profile; everything else (com, obs, language, ...) stays global. + * normalize guarantees at least one profile exists, so consumers can always + * resolve a mapping + */ +export class Profile { + "name": string; + "hotkey": string; + "sliderMapping": SliderMappings; + + /** Creates a new Profile instance. */ + constructor($$source: Partial = {}) { + if (!("name" in $$source)) { + this["name"] = ""; + } + if (!("hotkey" in $$source)) { + this["hotkey"] = ""; + } + if (!("sliderMapping" in $$source)) { + this["sliderMapping"] = []; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new Profile instance from a string or object. + */ + static createFrom($$source: any = {}): Profile { + const $$createField2_0 = $$createType1; + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + if ("sliderMapping" in $$parsedSource) { + $$parsedSource["sliderMapping"] = $$createField2_0($$parsedSource["sliderMapping"]); + } + return new Profile($$parsedSource as Partial); + } +} + /** * SerialPortDTO describes an available serial port */ @@ -220,11 +259,12 @@ export class SessionInfoDTO { * Settings is the single source of truth for deej's user-facing * configuration. The yaml tags define the config file keys, the json tags * the GUI wire format: a new setting only needs a field here (plus a default - * in defaultSettings and, if needed, a rule in Validate/sanitize) to reach - * the file, the runtime snapshot and the settings GUI + * in defaultSettings and, if needed, a rule in normalize) to reach the file, + * the runtime snapshot and the settings GUI */ export class Settings { - "sliderMapping": SliderMappings; + "activeProfile": string; + "profiles": Profile[]; "invertSliders": boolean; "com": COMSettings; "noiseReduction": string; @@ -233,8 +273,11 @@ export class Settings { /** Creates a new Settings instance. */ constructor($$source: Partial = {}) { - if (!("sliderMapping" in $$source)) { - this["sliderMapping"] = []; + if (!("activeProfile" in $$source)) { + this["activeProfile"] = ""; + } + if (!("profiles" in $$source)) { + this["profiles"] = []; } if (!("invertSliders" in $$source)) { this["invertSliders"] = false; @@ -259,18 +302,18 @@ export class Settings { * Creates a new Settings instance from a string or object. */ static createFrom($$source: any = {}): Settings { - const $$createField0_0 = $$createType1; - const $$createField2_0 = $$createType4; - const $$createField5_0 = $$createType5; + const $$createField1_0 = $$createType5; + const $$createField3_0 = $$createType6; + const $$createField6_0 = $$createType7; let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; - if ("sliderMapping" in $$parsedSource) { - $$parsedSource["sliderMapping"] = $$createField0_0($$parsedSource["sliderMapping"]); + if ("profiles" in $$parsedSource) { + $$parsedSource["profiles"] = $$createField1_0($$parsedSource["profiles"]); } if ("com" in $$parsedSource) { - $$parsedSource["com"] = $$createField2_0($$parsedSource["com"]); + $$parsedSource["com"] = $$createField3_0($$parsedSource["com"]); } if ("obs" in $$parsedSource) { - $$parsedSource["obs"] = $$createField5_0($$parsedSource["obs"]); + $$parsedSource["obs"] = $$createField6_0($$parsedSource["obs"]); } return new Settings($$parsedSource as Partial); } @@ -345,7 +388,7 @@ export class StatusDTO { * Creates a new StatusDTO instance from a string or object. */ static createFrom($$source: any = {}): StatusDTO { - const $$createField2_0 = $$createType6; + const $$createField2_0 = $$createType8; let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; if ("sliderValues" in $$parsedSource) { $$parsedSource["sliderValues"] = $$createField2_0($$parsedSource["sliderValues"]); @@ -364,6 +407,8 @@ var $$createType1 = (function $$initCreateType1(...args: any[]): any { }); const $$createType2 = SliderMappingEntry.createFrom; const $$createType3 = $Create.Array($$createType2); -const $$createType4 = COMSettings.createFrom; -const $$createType5 = OBSSettings.createFrom; -const $$createType6 = $Create.Array($Create.Any); +const $$createType4 = Profile.createFrom; +const $$createType5 = $Create.Array($$createType4); +const $$createType6 = COMSettings.createFrom; +const $$createType7 = OBSSettings.createFrom; +const $$createType8 = $Create.Array($Create.Any); diff --git a/frontend/bindings/github.com/nik9play/deej/pkg/deej/settingsservice.ts b/frontend/bindings/github.com/nik9play/deej/pkg/deej/settingsservice.ts index 51bb1fc7..331c0d57 100644 --- a/frontend/bindings/github.com/nik9play/deej/pkg/deej/settingsservice.ts +++ b/frontend/bindings/github.com/nik9play/deej/pkg/deej/settingsservice.ts @@ -107,6 +107,14 @@ export function SaveSettings(settings: $models.Settings): $CancellablePromise { + return $Call.ByID(1627894027, name); +} + /** * SetAutostart enables or disables running deej at system startup, applying * the change immediately diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 28de4b2e..ff185b15 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -11,6 +11,22 @@ "vid": "USB VID", "pid": "USB PID", "vidPidHint": "Used to find the mixer when the COM port is set to \"auto\". Hex values, e.g. 1A86.", + "profiles": "Profiles", + "profilesHint": "Each profile has its own slider mapping.", + "profileName": "Name", + "profileHotkey": "Hotkey", + "profileActive": "Active profile", + "setActiveProfile": "Make this profile active", + "switchProfile": "Switch profile", + "manageProfiles": "Manage profiles...", + "addProfile": "Add profile", + "duplicateProfile": "Duplicate profile", + "deleteProfile": "Delete profile", + "newProfileName": "profile {number}", + "hotkeyNone": "Not set", + "hotkeyPressKeys": "Press a key combination...", + "hotkeyClear": "Clear hotkey", + "hotkeyHint": "Click, then press a combination with Ctrl, Alt or Shift.", "behavior": "Behavior", "invertSliders": "Invert sliders", "invertSlidersHint": "Top is 0%, bottom is 100%", diff --git a/frontend/messages/ru.json b/frontend/messages/ru.json index ebbee3ab..105134ec 100644 --- a/frontend/messages/ru.json +++ b/frontend/messages/ru.json @@ -11,6 +11,22 @@ "vid": "USB VID", "pid": "USB PID", "vidPidHint": "Используется для поиска микшера, когда COM-порт установлен в \"auto\". Шестнадцатеричные значения, например 1A86.", + "profiles": "Профили", + "profilesHint": "У каждого профиля своя раскладка слайдеров.", + "profileName": "Название", + "profileHotkey": "Сочетание клавиш", + "profileActive": "Активный профиль", + "setActiveProfile": "Сделать профиль активным", + "switchProfile": "Переключить профиль", + "manageProfiles": "Управление профилями...", + "addProfile": "Добавить профиль", + "duplicateProfile": "Дублировать профиль", + "deleteProfile": "Удалить профиль", + "newProfileName": "профиль {number}", + "hotkeyNone": "Не задано", + "hotkeyPressKeys": "Нажмите сочетание клавиш...", + "hotkeyClear": "Очистить сочетание клавиш", + "hotkeyHint": "Нажмите на поле, затем сочетание с Ctrl, Alt или Shift.", "behavior": "Поведение", "invertSliders": "Инвертировать слайдеры", "invertSlidersHint": "Верх - 0%, низ - 100%", diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index a4b18a4c..8e6f7cb3 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -27,7 +27,7 @@ } - openSettings()} /> + openSettings()} onOpenProfiles={() => openSettings("profiles")} />
diff --git a/frontend/src/components/Mixer.svelte b/frontend/src/components/Mixer.svelte index 8904963a..b5eef30f 100644 --- a/frontend/src/components/Mixer.svelte +++ b/frontend/src/components/Mixer.svelte @@ -1,5 +1,6 @@ + +
+

{m.profilesHint()}

+ +
+ {#each settings.profiles as profile, index (index)} +
+
+ + update(index, { name: event.currentTarget.value })} + /> + + +
+ +
+ {m.profileHotkey()} +
+ profile.hotkey, (hotkey) => update(index, { hotkey })} + /> +
+
+
+ {/each} +
+ + +
diff --git a/frontend/src/components/SettingsDialog.svelte b/frontend/src/components/SettingsDialog.svelte index ac7ef37e..8f6a2ff9 100644 --- a/frontend/src/components/SettingsDialog.svelte +++ b/frontend/src/components/SettingsDialog.svelte @@ -7,6 +7,7 @@ import Cable from "@lucide/svelte/icons/cable"; import Cog from "@lucide/svelte/icons/cog"; import Info from "@lucide/svelte/icons/info"; + import Layers from "@lucide/svelte/icons/layers"; import SlidersHorizontal from "@lucide/svelte/icons/sliders-horizontal"; import Video from "@lucide/svelte/icons/video"; import { @@ -23,6 +24,7 @@ import BehaviorSection from "./BehaviorSection.svelte"; import GeneralSection from "./GeneralSection.svelte"; import ObsSection from "./ObsSection.svelte"; + import ProfilesSection from "./ProfilesSection.svelte"; let tab = $state("general"); @@ -59,6 +61,7 @@ const tabItems = $derived([ { value: "general", label: m.general(), Icon: Cog, dirty: false }, + { value: "profiles", label: m.profiles(), Icon: Layers, dirty: false }, { value: "connection", label: m.connection(), Icon: Cable, dirty: comDirty }, { value: "behavior", label: m.behavior(), Icon: SlidersHorizontal, dirty: false }, { value: "obs", label: m.obs(), Icon: Video, dirty: obsDirty }, @@ -156,6 +159,9 @@ + + + { if (open) { - targets = [...(app.settings?.sliderMapping.find((entry) => entry.slider === slider)?.targets ?? [])]; + targets = [...(activeMapping(app.settings).find((entry) => entry.slider === slider)?.targets ?? [])]; tab = "apps"; appSearch = ""; deviceSearch = ""; @@ -207,10 +208,13 @@ errorText = ""; try { const dto: Settings = $state.snapshot(app.settings); - dto.sliderMapping = dto.sliderMapping.filter((entry) => entry.slider !== slider); + // targets belong to the active profile; the others are left alone + const profile = activeProfile(dto); + if (!profile) return; + profile.sliderMapping = profile.sliderMapping.filter((entry) => entry.slider !== slider); if (targets.length > 0) { - dto.sliderMapping.push({ slider, targets: [...targets] }); - dto.sliderMapping.sort((a, b) => a.slider - b.slider); + profile.sliderMapping.push({ slider, targets: [...targets] }); + profile.sliderMapping.sort((a, b) => a.slider - b.slider); } await SettingsService.SaveSettings(dto); // local state refreshes via the deej:config event round-trip diff --git a/frontend/src/components/Titlebar.svelte b/frontend/src/components/Titlebar.svelte index 3c92a6bf..a4cd9210 100644 --- a/frontend/src/components/Titlebar.svelte +++ b/frontend/src/components/Titlebar.svelte @@ -1,17 +1,40 @@ + +
+ + (capturing = true)} + onblur={() => (capturing = false)} + {onkeydown} + /> + +
diff --git a/frontend/src/lib/profiles.ts b/frontend/src/lib/profiles.ts new file mode 100644 index 00000000..9215e089 --- /dev/null +++ b/frontend/src/lib/profiles.ts @@ -0,0 +1,14 @@ +import type { Profile, Settings, SliderMappings } from "../../bindings/github.com/nik9play/deej/pkg/deej"; + +// mirrors Settings.ActiveMapping on the go side: normalized settings always +// resolve by name, the first-profile fallback only covers the brief moment +// before the first config arrives +export function activeProfile(settings: Settings | null): Profile | null { + if (!settings) return null; + + return settings.profiles.find((profile) => profile.name === settings.activeProfile) ?? settings.profiles[0] ?? null; +} + +export function activeMapping(settings: Settings | null): SliderMappings { + return activeProfile(settings)?.sliderMapping ?? []; +} diff --git a/pkg/deej/config.go b/pkg/deej/config.go index 5f2b1f0e..cbb4133e 100644 --- a/pkg/deej/config.go +++ b/pkg/deej/config.go @@ -178,7 +178,9 @@ func (cc *CanonicalConfig) loadLocked(localizer *i18n.Localizer) error { cc.logger.Info("Loaded config successfully") cc.logger.Infow("Config values", - "sliderMapping", settings.SliderMapping, + "activeProfile", settings.ActiveProfile, + "profiles", len(settings.Profiles), + "sliderMapping", settings.ActiveMapping(), "comPort", settings.COM.Port, "baudRate", settings.COM.BaudRate, "invertSliders", settings.InvertSliders) @@ -320,10 +322,36 @@ func (cc *CanonicalConfig) onConfigReloaded() { // UserSettings returns the current contents of the user config file func (cc *CanonicalConfig) UserSettings() Settings { - settings := *cc.Values() - settings.SliderMapping = settings.SliderMapping.clone() + return cc.Values().clone() +} + +// SetActiveProfile switches to the named profile and persists the choice, so +// it survives a restart. It's the one-field write behind the profile hotkeys, +// the tray menu and the titlebar dropdown; everything else goes through +// SaveUserSettings +func (cc *CanonicalConfig) SetActiveProfile(name string, localizer *i18n.Localizer) error { + settings := cc.Values().clone() + + resolved, ok := findProfileName(settings.Profiles, name) + if !ok { + return fmt.Errorf("unknown profile: %q", name) + } + + // a hotkey can be pressed repeatedly; don't rewrite the config file (and + // wake every consumer) when nothing would change + if settings.ActiveProfile == resolved { + return nil + } + + settings.ActiveProfile = resolved - return settings + if err := cc.saveAndReload(settings, localizer); err != nil { + return err + } + + cc.onConfigReloaded() + + return nil } // SaveUserSettings validates the settings, rewrites the user config file on diff --git a/pkg/deej/config_model.go b/pkg/deej/config_model.go index 327c6aee..ec0e8ce5 100644 --- a/pkg/deej/config_model.go +++ b/pkg/deej/config_model.go @@ -17,12 +17,72 @@ import ( // in defaultSettings and, if needed, a rule in normalize) to reach the file, // the runtime snapshot and the settings GUI type Settings struct { - SliderMapping SliderMappings `yaml:"slider_mapping" json:"sliderMapping"` - InvertSliders bool `yaml:"invert_sliders" json:"invertSliders"` - COM COMSettings `yaml:"com" json:"com"` - NoiseReduction string `yaml:"noise_reduction,omitempty" json:"noiseReduction"` - Language string `yaml:"language" json:"language"` - OBS OBSSettings `yaml:"obs" json:"obs"` + ActiveProfile string `yaml:"active_profile" json:"activeProfile"` + Profiles []Profile `yaml:"profiles" json:"profiles"` + InvertSliders bool `yaml:"invert_sliders" json:"invertSliders"` + COM COMSettings `yaml:"com" json:"com"` + NoiseReduction string `yaml:"noise_reduction,omitempty" json:"noiseReduction"` + Language string `yaml:"language" json:"language"` + OBS OBSSettings `yaml:"obs" json:"obs"` +} + +// defaultProfileName is the profile a config without any gets, and the one a +// legacy top-level slider_mapping is migrated into +const defaultProfileName = "default" + +// Profile is one named slider layout. Only the mapping and its hotkey are +// per-profile; everything else (com, obs, language, ...) stays global. +// normalize guarantees at least one profile exists, so consumers can always +// resolve a mapping +type Profile struct { + Name string `yaml:"name" json:"name"` + Hotkey string `yaml:"hotkey,omitempty" json:"hotkey"` + SliderMapping SliderMappings `yaml:"slider_mapping" json:"sliderMapping"` +} + +// ActiveMapping returns the slider mapping deej is currently driving. It's the +// only mapping lookup on the hot path, so it stays a plain scan over the (very +// short) profile list +func (s *Settings) ActiveMapping() SliderMappings { + for i := range s.Profiles { + if s.Profiles[i].Name == s.ActiveProfile { + return s.Profiles[i].SliderMapping + } + } + + // normalized settings always resolve above; fall back to the first profile + // so an unnormalized snapshot still does something sensible + if len(s.Profiles) > 0 { + return s.Profiles[0].SliderMapping + } + + return nil +} + +// clone returns a deep copy, safe to hand out and mutate without touching the +// live snapshot +func (s Settings) clone() Settings { + out := s + + out.Profiles = make([]Profile, len(s.Profiles)) + for i, profile := range s.Profiles { + profile.SliderMapping = profile.SliderMapping.clone() + out.Profiles[i] = profile + } + + return out +} + +// findProfileName resolves a profile name case-insensitively, returning it in +// the spelling the profile actually uses +func findProfileName(profiles []Profile, name string) (string, bool) { + for _, profile := range profiles { + if strings.EqualFold(profile.Name, name) { + return profile.Name, true + } + } + + return "", false } // COMSettings describes the Arduino serial connection parameters @@ -33,16 +93,18 @@ type COMSettings struct { PID HexWord `yaml:"pid" json:"pid"` } -// applyLegacyKeys fills COM settings from the legacy flat keys (com_port, -// baud_rate, com_vid, com_pid) that older config files used. Loads call it -// before the main decode, so an explicit com: section overrides these. Saves -// only ever write the com: section, so this is read-only compatibility +// applyLegacyKeys fills settings from the flat keys older config files used: +// the com ones (com_port, baud_rate, com_vid, com_pid) and the top-level +// slider_mapping that predates profiles. Loads call it before the main decode, +// so an explicit com:/profiles: section overrides these. Saves only ever write +// the new form, so this is read-only compatibility func applyLegacyKeys(data []byte, s *Settings) { var legacy struct { - Port *string `yaml:"com_port"` - BaudRate *int `yaml:"baud_rate"` - VID *HexWord `yaml:"com_vid"` - PID *HexWord `yaml:"com_pid"` + Port *string `yaml:"com_port"` + BaudRate *int `yaml:"baud_rate"` + VID *HexWord `yaml:"com_vid"` + PID *HexWord `yaml:"com_pid"` + SliderMapping *SliderMappings `yaml:"slider_mapping"` } // a type error still decodes the well-typed keys; only a structural parse @@ -63,6 +125,13 @@ func applyLegacyKeys(data []byte, s *Settings) { if legacy.PID != nil { s.COM.PID = *legacy.PID } + + // a pre-profiles config: read the single mapping as the default profile. + // yaml replaces a slice wholesale, so a profiles: section in the same file + // still wins when the main decode runs + if legacy.SliderMapping != nil { + s.Profiles = []Profile{{Name: defaultProfileName, SliderMapping: *legacy.SliderMapping}} + } } func isYAMLTypeError(err error) bool { @@ -80,8 +149,11 @@ type OBSSettings struct { func defaultSettings() Settings { return Settings{ - SliderMapping: SliderMappings{}, - Language: "auto", + // the active profile is left to normalize, which resolves an empty + // value to the first profile - so a config that only has profiles: + // (and no active_profile:) isn't reported as pointing at a missing one + Profiles: []Profile{{Name: defaultProfileName, SliderMapping: SliderMappings{}}}, + Language: "auto", COM: COMSettings{ Port: "auto", @@ -158,35 +230,109 @@ func (s *Settings) normalize() []string { s.OBS.Host = defaults.OBS.Host } - // canonicalize the mapping: report and drop negative/duplicate sliders, - // silently drop empty targets (and entries left with none, so the snapshot - // matches what a save writes), keep it sorted by slider index - mapping := SliderMappings{} - seenSliders := map[int]bool{} - for _, entry := range s.SliderMapping { - if entry.Slider < 0 { - problems = append(problems, fmt.Sprintf("slider index must not be negative: %d", entry.Slider)) - continue + problems = append(problems, s.normalizeProfiles()...) + + return problems +} + +// normalizeProfiles canonicalizes the profile list and the active profile. +// There is always at least one profile afterwards, and ActiveProfile always +// names one of them +func (s *Settings) normalizeProfiles() []string { + var problems []string + + profiles := make([]Profile, 0, len(s.Profiles)) + seenNames := map[string]bool{} + seenHotkeys := map[string]bool{} + + for i, profile := range s.Profiles { + profile.Name = strings.TrimSpace(profile.Name) + if profile.Name == "" { + generated := fmt.Sprintf("profile %d", i+1) + problems = append(problems, fmt.Sprintf("profile %d has no name, using %q", i+1, generated)) + profile.Name = generated } - if seenSliders[entry.Slider] { - problems = append(problems, fmt.Sprintf("duplicate slider index: %d", entry.Slider)) + + if seenNames[strings.ToLower(profile.Name)] { + problems = append(problems, fmt.Sprintf("duplicate profile name: %q", profile.Name)) continue } - seenSliders[entry.Slider] = true + seenNames[strings.ToLower(profile.Name)] = true + + profile.Hotkey = strings.TrimSpace(profile.Hotkey) + if profile.Hotkey != "" { + switch { + case !validHotkey(profile.Hotkey): + problems = append(problems, fmt.Sprintf("invalid hotkey for profile %q: %q", profile.Name, profile.Hotkey)) + profile.Hotkey = "" + + // the OS would reject the second registration anyway; dropping it + // here keeps the file honest about what's actually bound + case seenHotkeys[strings.ToLower(profile.Hotkey)]: + problems = append(problems, fmt.Sprintf("duplicate hotkey %q on profile %q", profile.Hotkey, profile.Name)) + profile.Hotkey = "" + + default: + seenHotkeys[strings.ToLower(profile.Hotkey)] = true + } + } - targets := slices.DeleteFunc(slices.Clone(entry.Targets), func(t string) bool { return t == "" }) - if len(targets) == 0 { - continue + mapping, mappingProblems := profile.SliderMapping.normalized() + for _, problem := range mappingProblems { + problems = append(problems, fmt.Sprintf("profile %q: %s", profile.Name, problem)) } + profile.SliderMapping = mapping - mapping = append(mapping, SliderMappingEntry{Slider: entry.Slider, Targets: targets}) + profiles = append(profiles, profile) + } + + // a config with no profiles at all (or one whose every profile was dropped + // as a duplicate) gets an empty default, so consumers never see none + if len(profiles) == 0 { + profiles = append(profiles, Profile{Name: defaultProfileName, SliderMapping: SliderMappings{}}) + } + s.Profiles = profiles + + s.ActiveProfile = strings.TrimSpace(s.ActiveProfile) + if resolved, ok := findProfileName(profiles, s.ActiveProfile); ok { + s.ActiveProfile = resolved + } else { + // an empty value just means "no preference", only a name that doesn't + // resolve is worth reporting + if s.ActiveProfile != "" { + problems = append(problems, fmt.Sprintf("unknown active profile: %q", s.ActiveProfile)) + } + s.ActiveProfile = profiles[0].Name } - slices.SortFunc(mapping, func(a, b SliderMappingEntry) int { return cmp.Compare(a.Slider, b.Slider) }) - s.SliderMapping = mapping return problems } +// validHotkeyModifiers mirrors the modifiers wails accepts in an accelerator +// (see application.modifierMap). The key itself is left to wails to validate, +// which reports an unsupported one when it registers the shortcut +var validHotkeyModifiers = []string{ + "cmdorctrl", "cmd", "command", "ctrl", "optionoralt", "alt", "option", "shift", "super", +} + +// validHotkey reports whether a profile hotkey looks like a wails accelerator +// with at least one modifier. The modifier is not optional: a bare key would +// be grabbed system-wide, so deej would swallow it in every other application +func validHotkey(hotkey string) bool { + components := strings.Split(hotkey, "+") + if len(components) < 2 { + return false + } + + for _, modifier := range components[:len(components)-1] { + if !slices.Contains(validHotkeyModifiers, strings.ToLower(strings.TrimSpace(modifier))) { + return false + } + } + + return strings.TrimSpace(components[len(components)-1]) != "" +} + // HexWord is a 16-bit value carried as a hex string (e.g. "1A86"), the form // both the config file and the GUI use. An empty value means "use the // built-in default" @@ -329,6 +475,41 @@ func (sm SliderMappings) MarshalYAML() (any, error) { return mapping, nil } +// normalized returns the canonical form of a mapping and the list of values +// that were invalid: negative and duplicate slider indexes are reported and +// dropped, empty targets (and entries left with none, so the snapshot matches +// what a save writes) are dropped silently, and the result is sorted by +// slider index +func (sm SliderMappings) normalized() (SliderMappings, []string) { + var problems []string + + mapping := SliderMappings{} + seenSliders := map[int]bool{} + + for _, entry := range sm { + if entry.Slider < 0 { + problems = append(problems, fmt.Sprintf("slider index must not be negative: %d", entry.Slider)) + continue + } + if seenSliders[entry.Slider] { + problems = append(problems, fmt.Sprintf("duplicate slider index: %d", entry.Slider)) + continue + } + seenSliders[entry.Slider] = true + + targets := slices.DeleteFunc(slices.Clone(entry.Targets), func(t string) bool { return t == "" }) + if len(targets) == 0 { + continue + } + + mapping = append(mapping, SliderMappingEntry{Slider: entry.Slider, Targets: targets}) + } + + slices.SortFunc(mapping, func(a, b SliderMappingEntry) int { return cmp.Compare(a.Slider, b.Slider) }) + + return mapping, problems +} + // get returns the targets mapped to a slider index. The mapping is small // (one entry per physical slider) and sanitized, so a linear scan is fine func (sm SliderMappings) get(slider int) ([]string, bool) { diff --git a/pkg/deej/config_model_test.go b/pkg/deej/config_model_test.go index 5b38a10a..b70f0d4c 100644 --- a/pkg/deej/config_model_test.go +++ b/pkg/deej/config_model_test.go @@ -76,10 +76,16 @@ func TestSaveUserSettingsRoundTrip(t *testing.T) { settings.OBS.Host = "192.168.1.5" settings.OBS.Port = 4456 settings.OBS.Password = "secret" - settings.SliderMapping = SliderMappings{ - {Slider: 0, Targets: []string{"master"}}, - {Slider: 2, Targets: []string{"discord.exe", "spotify.exe"}}, + settings.Profiles = []Profile{ + {Name: "default", SliderMapping: SliderMappings{ + {Slider: 0, Targets: []string{"master"}}, + {Slider: 2, Targets: []string{"discord.exe", "spotify.exe"}}, + }}, + {Name: "gaming", Hotkey: "Ctrl+Alt+2", SliderMapping: SliderMappings{ + {Slider: 0, Targets: []string{"game.exe"}}, + }}, } + settings.ActiveProfile = "default" if err := cc.SaveUserSettings(settings, newTestLocalizer()); err != nil { t.Fatalf("save settings: %v", err) @@ -97,6 +103,9 @@ func TestSaveUserSettingsRoundTrip(t *testing.T) { "invert_sliders: true", "noise_reduction: high", "discord.exe", + "active_profile: default", + "name: gaming", + "hotkey: Ctrl+Alt+2", } { if !strings.Contains(saved, want) { t.Errorf("saved config missing %q:\n%s", want, saved) @@ -113,15 +122,142 @@ func TestSaveUserSettingsRoundTrip(t *testing.T) { t.Errorf("round trip mismatch: %+v", got) } - if len(got.SliderMapping) != 2 { - t.Fatalf("expected 2 mapping entries, got %v", got.SliderMapping) + if len(got.Profiles) != 2 || got.Profiles[1].Name != "gaming" || got.Profiles[1].Hotkey != "Ctrl+Alt+2" { + t.Fatalf("profiles did not survive the round trip: %+v", got.Profiles) + } + + if len(got.ActiveMapping()) != 2 { + t.Fatalf("expected 2 mapping entries, got %v", got.ActiveMapping()) } - targets, ok := got.SliderMapping.get(2) + targets, ok := got.ActiveMapping().get(2) if !ok || len(targets) != 2 { t.Errorf("multi-target mapping not applied, got %v", targets) } } +func TestLoadReadsLegacySliderMapping(t *testing.T) { + // a pre-profiles config: the top-level mapping becomes the default profile + cc := newTestConfig(t, testConfigContents) + + values := cc.Values() + if len(values.Profiles) != 1 || values.Profiles[0].Name != defaultProfileName { + t.Fatalf("expected a single %q profile, got %+v", defaultProfileName, values.Profiles) + } + if values.ActiveProfile != defaultProfileName { + t.Errorf("ActiveProfile = %q, expected %q", values.ActiveProfile, defaultProfileName) + } + + targets, ok := values.ActiveMapping().get(1) + if !ok || len(targets) != 2 { + t.Errorf("legacy mapping not migrated, got %v", targets) + } +} + +func TestProfilesWinOverLegacySliderMapping(t *testing.T) { + cc := newTestConfig(t, ` +slider_mapping: + 0: master +profiles: + - name: gaming + slider_mapping: + 0: game.exe + - name: music + slider_mapping: + 0: spotify.exe +`) + + values := cc.Values() + if len(values.Profiles) != 2 { + t.Fatalf("expected the profiles section to replace the legacy mapping, got %+v", values.Profiles) + } + + // no active_profile in the file: the first profile wins, silently + if values.ActiveProfile != "gaming" { + t.Errorf("ActiveProfile = %q, expected gaming", values.ActiveProfile) + } + if targets, _ := values.ActiveMapping().get(0); len(targets) != 1 || targets[0] != "game.exe" { + t.Errorf("active mapping = %v, expected game.exe", targets) + } +} + +func TestSaveMigratesSliderMappingToProfiles(t *testing.T) { + cc := newTestConfig(t, testConfigContents) + + if err := cc.SaveUserSettings(cc.UserSettings(), newTestLocalizer()); err != nil { + t.Fatalf("save settings: %v", err) + } + + data, err := os.ReadFile(cc.configPath) + if err != nil { + t.Fatalf("read saved config: %v", err) + } + saved := string(data) + + // the legacy key lives at the top level, so it would start a line + // unindented (a profile's own slider_mapping is indented) + for line := range strings.SplitSeq(saved, "\n") { + if strings.HasPrefix(line, "slider_mapping:") { + t.Errorf("saved config still contains the legacy top-level mapping:\n%s", saved) + } + } + if !strings.Contains(saved, "profiles:") || !strings.Contains(saved, "name: default") { + t.Errorf("saved config missing the migrated profile:\n%s", saved) + } +} + +func TestSetActiveProfile(t *testing.T) { + cc := newTestConfig(t, ` +active_profile: default +profiles: + - name: default + slider_mapping: + 0: master + - name: gaming + slider_mapping: + 0: game.exe +`) + + if err := cc.SetActiveProfile("unknown", newTestLocalizer()); err == nil { + t.Error("expected an error for an unknown profile") + } + + reloaded := cc.SubscribeToChanges() + + // resolves case-insensitively, and stores the profile's own spelling + if err := cc.SetActiveProfile("GAMING", newTestLocalizer()); err != nil { + t.Fatalf("set active profile: %v", err) + } + if cc.Values().ActiveProfile != "gaming" { + t.Errorf("ActiveProfile = %q, expected gaming", cc.Values().ActiveProfile) + } + if targets, _ := cc.Values().ActiveMapping().get(0); len(targets) != 1 || targets[0] != "game.exe" { + t.Errorf("active mapping = %v, expected game.exe", targets) + } + + select { + case <-reloaded: + default: + t.Error("expected a reload notification") + } + + // a hotkey can be pressed repeatedly: switching to the active profile must + // not rewrite the file + before, err := os.Stat(cc.configPath) + if err != nil { + t.Fatalf("stat config: %v", err) + } + if err := cc.SetActiveProfile("gaming", newTestLocalizer()); err != nil { + t.Fatalf("re-set active profile: %v", err) + } + after, err := os.Stat(cc.configPath) + if err != nil { + t.Fatalf("stat config: %v", err) + } + if !before.ModTime().Equal(after.ModTime()) { + t.Error("switching to the already-active profile rewrote the config file") + } +} + func TestSaveUserSettingsNotifiesConsumers(t *testing.T) { cc := newTestConfig(t, testConfigContents) @@ -305,8 +441,11 @@ func TestSettingsNormalize(t *testing.T) { NoiseReduction: "default", Language: "auto", OBS: OBSSettings{Host: "localhost", Port: 4455}, - SliderMapping: SliderMappings{ - {Slider: 0, Targets: []string{"master"}}, + ActiveProfile: "default", + Profiles: []Profile{ + {Name: "default", Hotkey: "Ctrl+Alt+1", SliderMapping: SliderMappings{ + {Slider: 0, Targets: []string{"master"}}, + }}, }, } @@ -330,16 +469,27 @@ func TestSettingsNormalize(t *testing.T) { // an entry whose targets are all empty is dropped, not reported: the // snapshot has to match what a save writes to the file emptyTargets := valid - emptyTargets.SliderMapping = SliderMappings{ + emptyTargets.Profiles = []Profile{{Name: "default", SliderMapping: SliderMappings{ {Slider: 0, Targets: []string{"master"}}, {Slider: 1, Targets: []string{""}}, {Slider: 2, Targets: nil}, - } + }}} if problems := emptyTargets.normalize(); len(problems) != 0 { t.Errorf("empty targets reported problems: %v", problems) } - if len(emptyTargets.SliderMapping) != 1 || emptyTargets.SliderMapping[0].Slider != 0 { - t.Errorf("target-less entries not dropped: %+v", emptyTargets.SliderMapping) + if mapping := emptyTargets.ActiveMapping(); len(mapping) != 1 || mapping[0].Slider != 0 { + t.Errorf("target-less entries not dropped: %+v", mapping) + } + + // a config with no profiles at all gets an empty default, silently + noProfiles := valid + noProfiles.Profiles = nil + noProfiles.ActiveProfile = "" + if problems := noProfiles.normalize(); len(problems) != 0 { + t.Errorf("missing profiles reported problems: %v", problems) + } + if len(noProfiles.Profiles) != 1 || noProfiles.ActiveProfile != defaultProfileName { + t.Errorf("missing profiles not defaulted: %+v", noProfiles) } cases := []struct { @@ -354,12 +504,34 @@ func TestSettingsNormalize(t *testing.T) { {"bad language", func(s *Settings) { s.Language = "de" }}, {"obs port too large", func(s *Settings) { s.OBS.Port = 70000 }}, {"negative slider", func(s *Settings) { - s.SliderMapping = SliderMappings{{Slider: -1, Targets: []string{"a"}}} + s.Profiles = []Profile{{Name: "default", SliderMapping: SliderMappings{ + {Slider: -1, Targets: []string{"a"}}, + }}} }}, {"duplicate slider", func(s *Settings) { - s.SliderMapping = SliderMappings{ + s.Profiles = []Profile{{Name: "default", SliderMapping: SliderMappings{ {Slider: 1, Targets: []string{"a"}}, {Slider: 1, Targets: []string{"b"}}, + }}} + }}, + {"unnamed profile", func(s *Settings) { + s.Profiles = []Profile{{Name: " "}} + s.ActiveProfile = "" + }}, + {"duplicate profile name", func(s *Settings) { + s.Profiles = []Profile{{Name: "default"}, {Name: "DEFAULT"}} + }}, + {"unknown active profile", func(s *Settings) { s.ActiveProfile = "gaming" }}, + {"hotkey without a modifier", func(s *Settings) { + s.Profiles = []Profile{{Name: "default", Hotkey: "a"}} + }}, + {"hotkey with an unknown modifier", func(s *Settings) { + s.Profiles = []Profile{{Name: "default", Hotkey: "Hyper+F5"}} + }}, + {"duplicate hotkey", func(s *Settings) { + s.Profiles = []Profile{ + {Name: "default", Hotkey: "Ctrl+Alt+1"}, + {Name: "gaming", Hotkey: "ctrl+alt+1"}, } }}, } diff --git a/pkg/deej/lang/active.en.toml b/pkg/deej/lang/active.en.toml index 4d5811f1..9e41192e 100644 --- a/pkg/deej/lang/active.en.toml +++ b/pkg/deej/lang/active.en.toml @@ -16,6 +16,10 @@ EditConfigDescription = "Open config file with notepad" EditConfigTitle = "Edit configuration" OpenSettingsDescription = "Open the settings window" OpenSettingsTitle = "Open settings" +ProfileSwitchedDescription = "{{.Profile}} is now active." +ProfileSwitchedTitle = "Profile switched" +ProfilesDescription = "Switch the active slider mapping" +ProfilesTitle = "Profiles" QuitDescription = "Stop deej and quit" QuitTitle = "Quit" SettingsDescription = "Settings" diff --git a/pkg/deej/lang/active.ru.toml b/pkg/deej/lang/active.ru.toml index 3b232f0d..448704e5 100644 --- a/pkg/deej/lang/active.ru.toml +++ b/pkg/deej/lang/active.ru.toml @@ -75,6 +75,18 @@ other = "Открыть окно настроек" [OpenSettingsTitle] other = "Открыть настройки" +[ProfileSwitchedDescription] +other = "Профиль {{.Profile}} активен." + +[ProfileSwitchedTitle] +other = "Профиль переключён" + +[ProfilesDescription] +other = "Переключить активную раскладку слайдеров" + +[ProfilesTitle] +other = "Профили" + [QuitDescription] hash = "sha1-2683afe6d5eb3d1a51548bd95d2ea0af240e381f" other = "Остановить deej и выйти" diff --git a/pkg/deej/session_map.go b/pkg/deej/session_map.go index e64b9664..0c2f52ba 100644 --- a/pkg/deej/session_map.go +++ b/pkg/deej/session_map.go @@ -263,7 +263,7 @@ func (m *sessionMap) sessionMapped(session Session) bool { } // look through the actual mappings - for _, entry := range m.deej.config.Values().SliderMapping { + for _, entry := range m.deej.config.Values().ActiveMapping() { for _, target := range entry.Targets { // ignore special transforms @@ -285,8 +285,8 @@ func (m *sessionMap) sessionMapped(session Session) bool { func (m *sessionMap) handleSliderMoveEvent(event SliderMoveEvent) { - // get the targets mapped to this slider from the config - targets, ok := m.deej.config.Values().SliderMapping.get(event.SliderID) + // get the targets mapped to this slider from the active profile + targets, ok := m.deej.config.Values().ActiveMapping().get(event.SliderID) // if slider not found in config, silently ignore if !ok { diff --git a/pkg/deej/settings_service.go b/pkg/deej/settings_service.go index cb4adca0..d8cd9513 100644 --- a/pkg/deej/settings_service.go +++ b/pkg/deej/settings_service.go @@ -71,6 +71,12 @@ func (s *SettingsService) SaveSettings(settings Settings) error { return s.d.config.SaveUserSettings(settings, s.d.localizer) } +// SetActiveProfile switches to the named profile. It's a targeted write, so a +// switch can't clobber a hand edit that landed since the window was opened +func (s *SettingsService) SetActiveProfile(name string) error { + return s.d.config.SetActiveProfile(name, s.d.localizer) +} + // GetAppInfo returns version and localization info along with the list of // special slider targets deej supports func (s *SettingsService) GetAppInfo() AppInfoDTO { diff --git a/pkg/deej/tray.go b/pkg/deej/tray.go index da3559a4..0c3e1329 100644 --- a/pkg/deej/tray.go +++ b/pkg/deej/tray.go @@ -5,6 +5,7 @@ package deej import ( "io/fs" "log/slog" + "maps" "strconv" "strings" "sync" @@ -106,6 +107,43 @@ func getAutostartItemText(d *Deej) (string, string) { return configTitle, configDescription } +func getProfilesItemText(d *Deej) (string, string) { + profilesTitle := d.localizer.MustLocalize(&i18n.LocalizeConfig{ + DefaultMessage: &i18n.Message{ + ID: "ProfilesTitle", + Other: "Profiles", + }, + }) + profilesDescription := d.localizer.MustLocalize(&i18n.LocalizeConfig{ + DefaultMessage: &i18n.Message{ + ID: "ProfilesDescription", + Other: "Switch the active slider mapping", + }, + }) + + return profilesTitle, profilesDescription +} + +func getProfileSwitchedText(d *Deej, name string) (string, string) { + switchedTitle := d.localizer.MustLocalize(&i18n.LocalizeConfig{ + DefaultMessage: &i18n.Message{ + ID: "ProfileSwitchedTitle", + Other: "Profile switched", + }, + }) + switchedDescription := d.localizer.MustLocalize(&i18n.LocalizeConfig{ + DefaultMessage: &i18n.Message{ + ID: "ProfileSwitchedDescription", + Other: "{{.Profile}} is now active.", + }, + TemplateData: map[string]string{ + "Profile": name, + }, + }) + + return switchedTitle, switchedDescription +} + func getQuitItemText(d *Deej) (string, string) { quitTitle := d.localizer.MustLocalize(&i18n.LocalizeConfig{ DefaultMessage: &i18n.Message{ @@ -243,6 +281,91 @@ func (d *Deej) initializeTray(onDone func()) { }) } + profilesTitle, _ := getProfilesItemText(d) + profilesMenu := menu.AddSubmenu(profilesTitle) + + // the profile list follows the config, so the submenu is rebuilt from + // scratch on every reload. Menu mutations must run on the wails main thread + rebuildProfilesMenu := func() { + settings := d.config.Values() + + profilesMenu.Clear() + for _, profile := range settings.Profiles { + name := profile.Name + + // a tab right-aligns what follows it in a win32 menu, which is how + // native menus lay their shortcuts out. SetAccelerator would do the + // same, but it also reorders the modifiers and binds the shortcut a + // second time whenever a window has focus. On the linux tray the tab + // is just whitespace + label := name + if profile.Hotkey != "" { + label += "\t" + profile.Hotkey + } + + profilesMenu.AddRadio(label, name == settings.ActiveProfile).OnClick(func(*application.Context) { + logger.Infow("Profile menu item clicked, switching profile", "profile", name) + + if err := d.config.SetActiveProfile(name, d.localizer); err != nil { + logger.Warnw("Failed to switch profile", "profile", name, "error", err) + } + }) + } + + // the tray builds a native menu out of the item tree when it's handed + // one, so rebuilding the tree isn't enough - the menu has to be set + // again. SetMenu hops to the main thread itself, so this must not run + // on it (InvokeSync from the main thread would deadlock) + tray.SetMenu(menu) + } + rebuildProfilesMenu() + + // accelerator -> profile name, as last handed to the OS. Registering before + // Run is supported: wails defers the actual binding to application startup + profileHotkeys := map[string]string{} + + syncProfileHotkeys := func() { + next := map[string]string{} + for _, profile := range d.config.Values().Profiles { + if profile.Hotkey != "" { + next[profile.Hotkey] = profile.Name + } + } + + // switching profiles reloads the config too, and the hotkey set is + // unchanged then - don't churn the OS registrations over it + if maps.Equal(profileHotkeys, next) { + return + } + + if err := app.GlobalShortcut.UnregisterAll(); err != nil { + logger.Warnw("Failed to unregister profile hotkeys", "error", err) + } + + for hotkey, name := range next { + if err := app.GlobalShortcut.Register(hotkey, func() { + logger.Infow("Profile hotkey pressed, switching profile", "profile", name) + + if err := d.config.SetActiveProfile(name, d.localizer); err != nil { + logger.Warnw("Failed to switch profile", "profile", name, "error", err) + return + } + + // a global hotkey fires while deej is invisible, so the toast + // is the only feedback the user gets + d.notifier.Notify(getProfileSwitchedText(d, name)) + }); err != nil { + // usually another application already owns the combination. + // The entry stays in the set so an unrelated reload doesn't + // retry (and re-churn) it; editing the hotkey does + logger.Warnw("Failed to register profile hotkey", "hotkey", hotkey, "profile", name, "error", err) + } + } + + profileHotkeys = next + } + syncProfileHotkeys() + menu.AddSeparator() statusInfo := menu.Add(getStatusItemTitle(d)).SetEnabled(false) @@ -325,6 +448,8 @@ func (d *Deej) initializeTray(onDone func()) { // config applied (GUI save or manual edit) case <-configReloadedChannel: + rebuildProfilesMenu() + syncProfileHotkeys() app.Event.Emit(eventConfig) emitState() app.Event.Emit(eventSliders, d.serial.CurrentSliderPercentValues()) From e13af2ea55742c726fc26f354841498808831c22 Mon Sep 17 00:00:00 2001 From: Nikita Date: Sun, 9 Aug 2026 16:35:58 +0300 Subject: [PATCH 30/47] feat: add Discord integration --- CHANGELOG.md | 112 ++ CLAUDE.md | 137 ++ config_examples/config.example.ru.yaml | 14 + config_examples/config.example.yaml | 14 + .../nik9play/deej/pkg/deej/index.ts | 3 + .../nik9play/deej/pkg/deej/models.ts | 120 +- .../nik9play/deej/pkg/deej/settingsservice.ts | 69 +- frontend/messages/en.json | 27 + frontend/messages/ru.json | 27 + frontend/package-lock.json | 11 + frontend/package.json | 1 + frontend/src/App.svelte | 4 + frontend/src/components/DiscordSection.svelte | 202 +++ frontend/src/components/SettingsDialog.svelte | 40 +- frontend/src/components/TargetDialog.svelte | 145 +- frontend/src/components/ui/SimpleIcon.svelte | 10 + frontend/src/lib/state.svelte.ts | 21 +- frontend/src/lib/targets.ts | 9 + go.mod | 1 + go.sum | 2 + pkg/deej/config_model.go | 68 +- pkg/deej/deej.go | 4 + pkg/deej/discord.go | 1271 +++++++++++++++++ pkg/deej/discord_ipc_other.go | 31 + pkg/deej/discord_ipc_windows.go | 29 + pkg/deej/discord_test.go | 502 +++++++ pkg/deej/session_map.go | 28 +- pkg/deej/session_map_test.go | 23 + pkg/deej/settings_service.go | 55 + pkg/deej/tray.go | 6 + 30 files changed, 2943 insertions(+), 43 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 frontend/src/components/DiscordSection.svelte create mode 100644 frontend/src/components/ui/SimpleIcon.svelte create mode 100644 pkg/deej/discord.go create mode 100644 pkg/deej/discord_ipc_other.go create mode 100644 pkg/deej/discord_ipc_windows.go create mode 100644 pkg/deej/discord_test.go create mode 100644 pkg/deej/session_map_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..5f687157 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,112 @@ +# Changelog + +## v0.3.1 → current + +### New Features + +- **Profiles** — Keep several slider mappings and switch between them instantly. Each profile has its own `slider_mapping`; everything else (`com`, `obs`, `language`, ...) stays shared. Switch from the settings window's _Profiles_ tab, the profile dropdown in the title bar, the tray _Profiles_ menu, or a per-profile global hotkey that works even when deej isn't focused: + ```yaml + active_profile: default + profiles: + - name: default + slider_mapping: + 0: master + 1: discord.exe + - name: gaming + slider_mapping: + 0: master + 1: [game.exe, discord.exe] + hotkey: Ctrl+Alt+2 # activates this profile from anywhere + ``` + Existing configs with a top-level `slider_mapping` are read as before and migrated into a single `default` profile the next time settings are saved. +- **OBS integration** — Control OBS audio source volumes with your sliders. Enable WebSocket support in OBS via _Tools → WebSocket Server Settings_, then add to your config: + ```yaml + slider_mapping: + 0: deej.obs:Mic/Aux + 1: deej.obs:Desktop Audio + + obs: + enabled: true + host: localhost + port: 4455 + password: "" # set if you use a password in OBS + ``` +- **Discord integration** — Control the volume of individual people in a voice call, plus your own microphone and output levels inside Discord. Mapping the `discord.exe` process only ever moved all of Discord at once; this reaches inside it: + ```yaml + slider_mapping: + 0: deej.discord:Nikita # one person in your voice channel + 1: deej.discord.input # your own mic level in Discord + 2: deej.discord.output # Discord's own output level + + discord: + enabled: true + client_id: "" + client_secret: "" + ``` + Discord only allows this for an application you own, so deej can't ship its own credentials. Create one at [discord.com/developers](https://discord.com/developers/applications), add `http://localhost` as a redirect URI under _OAuth2_, paste its client ID and secret into the settings window's _Discord_ tab, and click _Link Discord account_ — Discord asks you to approve it once, and deej reconnects on its own from then on. The target picker lists whoever is in your voice channel, and a name that isn't there yet can be typed in by hand; it starts working once they join. +- **Named audio device support on Linux** — Sliders can now be mapped to specific audio devices by name on Linux, same as Windows. Input devices are suffixed with `(input)` (e.g. `hyperx cloud (input)`), so a card's input and output can be controlled separately even when they share the same device name. +- **Event-driven session tracking** — deej now listens for system audio events instead of polling, so sliders respond correctly as soon as an app opens or closes. + +### Improvements + +- Serial connection settings now live in a `com:` config section: + ```yaml + com: + port: auto + baud_rate: 9600 + vid: 0x1A86 + pid: 0x7523 + ``` + The old flat keys (`com_port`, `baud_rate`, `com_vid`, `com_pid`) are still read, but saving from the settings GUI writes the new format. +- Linux tray menu fixes. +- Updated Go version and dependencies. + +--- + +## v0.3.1 → текущая версия + +### Новые возможности + +- **Профили** — Храните несколько раскладок слайдеров и мгновенно переключайтесь между ними. У каждого профиля своя `slider_mapping`; всё остальное (`com`, `obs`, `language`, ...) общее. Переключайтесь на вкладке _Профили_ в окне настроек, через выпадающий список в заголовке окна, в меню трея _Профили_ или глобальной горячей клавишей профиля, которая работает, даже когда deej не в фокусе: + ```yaml + active_profile: default + profiles: + - name: default + slider_mapping: + 0: master + 1: discord.exe + - name: gaming + slider_mapping: + 0: master + 1: [game.exe, discord.exe] + hotkey: Ctrl+Alt+2 # активирует этот профиль откуда угодно + ``` + Существующие конфиги с `slider_mapping` на верхнем уровне читаются как раньше и переносятся в один профиль `default` при следующем сохранении настроек. +- **Интеграция с OBS** — Управляйте громкостью аудиоисточников OBS через слайдеры. Включите OBS WebSocket v5 в OBS через _Сервис → Настройки сервера WebSocket_, затем добавьте в конфиг: + ```yaml + slider_mapping: + 0: deej.obs:Mic/Aux + 1: deej.obs:Desktop Audio + + obs: + enabled: true + host: localhost + port: 4455 + password: "" # укажите, если в OBS задан пароль + ``` +- **Поддержка именованных устройств на Linux** — Слайдеры теперь можно привязать к конкретным аудиоустройствам по имени на Linux, как и на Windows. Устройства ввода получают суффикс `(input)` (например, `hyperx cloud (input)`), поэтому вход и выход одной карты управляются отдельно, даже если у них одинаковое имя. +- **Отслеживание сессий на основе событий** — deej теперь слушает системные аудиособытия вместо периодического опроса, поэтому слайдеры реагируют корректно сразу после открытия или закрытия приложения. + +### Улучшения + +- Настройки COM-порта теперь находятся в секции `com:` конфига: + ```yaml + com: + port: auto + baud_rate: 9600 + vid: 0x1A86 + pid: 0x7523 + ``` + Старые ключи (`com_port`, `baud_rate`, `com_vid`, `com_pid`) по-прежнему читаются, но сохранение из GUI настроек записывает новый формат. +- Исправления для меню в трее на Linux. +- Обновлена версия Go и зависимости. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..f37788d8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,137 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +deej is a hardware volume mixer client written in Go that pairs with an Arduino to provide physical volume control for Windows and Linux. It communicates with Arduino hardware via serial port and controls per-application audio volumes through OS-specific audio APIs. + +This is a fork of the original deej project (github.com/omriharel/deej) with automatic COM port reconnection, autorun support, updated dependencies, an OBS websocket integration, i18n support, and a Wails-based settings GUI. + +## Build Commands + +Builds are orchestrated by [Task](https://taskfile.dev) (`Taskfile.yml` plus `Taskfile.{windows,linux}.yml`) — the same build system that underpins `wails3 build`. Every build compiles the embedded Svelte frontend (`npm ci` / `npm run build`) before the Go binary, so Node.js and npm are required. Run tasks with `wails3 task ` — the wails3 CLI embeds the Task runner and is what's installed here (the standalone `task` binary also works if present, but don't assume it). Output goes to `build/`. + +```bash +wails3 task windows:build # release exe (optimized, -H=windowsgui, no console) +wails3 task windows:build:dev # dev exe (debug symbols, console window) +wails3 task windows:package # release exe + Inno Setup installer (needs ISCC) +wails3 task linux:build # release GUI binary (cgo: GTK4/WebKitGTK) +wails3 task linux:build:dev # dev GUI binary +wails3 task linux:build:headless # GUI-less daemon, CGO_ENABLED=0, cross-compilable +wails3 task generate:bindings # regenerate Wails Go->TS bindings +``` + +The Windows `build` task runs `go generate` for the winres icon/manifest `.syso` files when they're missing (idempotent `status:` check). + +The legacy scripts still work and are the entrypoints CI uses — they are now thin wrappers that delegate to Task: + +**Windows:** `scripts/windows/build-dev.bat`, `build-release.bat` (→ `windows:build[:dev]`), `make-installer.bat` (→ `windows:package`). +**Linux:** `scripts/linux/build-dev.sh`, `build-release.sh` (→ `linux:build[:dev]`). + +### Cross-compilation and cgo + +Only the **Windows** target and the **Linux headless** build are pure Go and cross-compile to any `GOOS`/`GOARCH` with `CGO_ENABLED=0`. The **Linux GUI** build (`linux:build`) links the Wails GTK4/WebKitGTK 6.0 backend via cgo, so it needs a C compiler and the `libgtk-4-dev` / `libwebkitgtk-6.0-dev` packages (or Docker) and builds natively per-arch in CI. + +deej also has a runtime headless switch (`DEEJ_NO_TRAY_ICON`) that skips the tray at startup, but that does **not** drop the compile-time Wails/cgo dependency — the `headless` build tag (see `tray.go` / `tray_headless.go`) is what compiles Wails out entirely. + +## Linting + +Only use this command to lint the project: + +```bash +golangci-lint run +``` + +Enabled linters: errcheck, govet, revive, staticcheck, unused + +## Architecture + +### Core Components (pkg/deej/) + +- **deej.go** - Main application orchestrator. Creates and manages all components (config, serial, sessions, OBS, tray), handles i18n setup, runs the main event loop +- **serial.go** - Arduino communication via serial port (`go.bug.st/serial`). Parses slider values (format: `val1|val2|val3\r\n`), auto-detects COM ports by VID/PID, handles reconnection via `pkg/reconnect` +- **session_map.go** - Maps slider IDs to audio sessions. Handles special targets like `master`, `system`, `mic`, `deej.current`, `deej.unmapped`, and OBS inputs (`deej.obs:`). Reads `config.Values().ActiveMapping()` fresh on every slider event and caches nothing, so switching profiles takes effect on the next slider move +- **config.go** - Config lifecycle: YAML loading (`go.yaml.in/yaml/v3`) with hot-reload via a self-managed fsnotify watcher (trailing-edge debounce), plus GUI save (`SaveUserSettings` → `normalize` → atomic write → reload). Publishes an immutable `*Settings` snapshot atomically on every (re)load (read via `Values()`); reload signals to consumers are buffered and coalesced. A reload that produces an unchanged snapshot skips the toast/notify (so a GUI save's own file event, and comment-only hand edits, are no-ops) +- **config_model.go** - The config schema. `Settings` is the single source of truth (yaml tags = file keys, json tags = GUI wire format). Adding a setting = one field here (+ default in `defaultSettings`, + a rule in `normalize` if it needs validation) + a frontend control. `normalize` is the single rule set behind both policies: it canonicalizes in place and returns the list of invalid values — GUI saves reject when that list is non-empty, file loads apply the fixes and log them. `applyLegacyKeys` reads the legacy flat keys — the com ones (`com_port`, `baud_rate`, `com_vid`, `com_pid`) and the pre-profiles top-level `slider_mapping` — before the main decode, so an explicit `com:`/`profiles:` section wins; saves only ever write the new form. Slider mappings live on `Profile`, one per named profile, with `Settings.ActiveMapping()` resolving the active one; `SliderMappings` owns `get`, `clone` and `normalized`. Saves fully regenerate the file (comments, key order and unknown keys are not preserved) +- **settings_service.go** - Wails service (`SettingsService`) exposing config, serial-port enumeration, live status, and audio-session info to the settings frontend +- **obs.go** - OBS websocket integration (`goobs`) for controlling OBS input volumes; reconnects via `pkg/reconnect` +- **discord.go** + **discord_ipc_{windows,other}.go** - Discord integration over the client's local RPC connection, for per-user voice volumes and the user's own input/output levels. The IPC frame codec (8-byte LE header + JSON) and the OAuth token exchange are hand-rolled on stdlib. The transport is the one platform split: on Windows the pipe **must** be opened with `winio.DialPipe` (overlapped), not `os.OpenFile` — a synchronous handle serializes I/O, so the read loop's blocking `ReadFile` deadlocks any concurrent write and every command hangs forever; elsewhere it's a plain unix socket. Reconnects via `pkg/reconnect`. Discord gates the `rpc.voice.*` scopes to an application's owner, so credentials are user-supplied (`discord.client_id`/`client_secret`) rather than shipped. The reconnect loop only ever does the silent path; the interactive `AUTHORIZE` lives in `Link()` behind a GUI button, so retries can't spawn consent popups. The resulting token is persisted in `discord_token.json` next to the config, deliberately not inside it. Volume commands are throttled (dedupe + 100ms flush) to stay under Discord's RPC rate limit during a slider sweep. The voice-channel roster is kept live by `VOICE_STATE_CREATE/UPDATE/DELETE` subscriptions (re-pointed on `VOICE_CHANNEL_SELECT`, since they're channel-scoped) with a 30s reconcile as the safety net for dropped events; a single `eventLoop` goroutine owns every roster write, and roster changes reach the GUI as a `deej:discord` event rather than by polling. Events are handed to that loop through a buffered channel and dropped when it's full — the read loop must never block, because the event handler issues requests that same loop has to answer +- **tray.go** - System tray icon and settings window, built on **Wails v3** (`wailsapp/wails/v3`). Hosts the frontend assets, registers the Wails service, and pushes live events to the settings window (`deej:sliders`, `deej:state`, `deej:config`, `deej:sessions`). Also owns the Profiles submenu (rebuilt on every config reload) and the per-profile global hotkeys (`app.GlobalShortcut`, re-registered only when the accelerator set actually changes). Lives only in the non-headless build, so headless daemons have no hotkeys + +### Settings GUI (frontend/) + +A **Svelte 5 + Vite + TypeScript** single-page app, styled with **Tailwind CSS v4** and **bits-ui** components (icons from `@lucide/svelte`). It renders a live mixer plus a settings dialog with a frameless titlebar. Built to `frontend/dist` and embedded into the Go binary via `frontend/embed.go`. Wails-generated Go↔TS bindings live in `frontend/bindings/`. Frontend i18n is separate from the Go i18n and uses **Paraglide JS** (`@inlang/paraglide-js`): message catalogs live in `frontend/messages/{en,ru}.json`, configured by `frontend/project.inlang/settings.json`, and the Vite plugin compiles them to typed message functions under `frontend/src/paraglide/` (git-ignored, regenerated on build). Components call `m.()` from `../paraglide/messages`; `main.ts` mirrors the Go-resolved language into Paraglide's in-memory locale via `setLocale(..., { reload: false })` (the `globalVariable` strategy). + +### Platform-Specific Code + +Audio session management is platform-specific: +- **session_finder_windows.go** - Uses Windows Core Audio API (go-wca) to enumerate and control audio sessions +- **session_finder_linux.go** - Uses PulseAudio (jfreymuth/pulse) +- **session_windows.go / session_linux.go** - Platform-specific session implementations + +Window detection for `deej.current` targets: +- **pkg/deej/util/util_windows.go** - Win32 API calls for foreground/fullscreen window detection +- **pkg/win/syscalls_windows.go** - Low-level Windows syscall definitions + +### Supporting Packages + +- **pkg/reconnect/** - Generic reconnection lifecycle manager shared by the serial, OBS, and PulseAudio connections (dial → watch → reconnect on failure). See the package doc for the callback contracts +- **pkg/notify/** - Toast notifications (Windows) and libnotify (Linux) +- **pkg/icon/** - Tray icon assets and loading (PNG on Windows — Wails cannot load ICO containers) + +### Internationalization + +Go-side i18n uses `nicksnyder/go-i18n` with TOML message catalogs embedded from `pkg/deej/lang/` (`active.en.toml`, `active.ru.toml`). Language resolves from config, falling back to auto-detection; the resolved language is exposed to the GUI. + +### Config Format + +`slider_mapping` maps slider indices (0-based) to targets, and lives inside a profile. Everything else is a flat top-level key: +```yaml +active_profile: default +profiles: + - name: default + slider_mapping: + 0: master # Master volume + 1: discord.exe # Process name (case-insensitive) + 2: deej.current # Currently active window + 3: [spotify.exe, chrome.exe] # Multiple targets + - name: gaming + hotkey: Ctrl+Alt+2 # global shortcut, switches to this profile from anywhere + slider_mapping: + 0: master +invert_sliders: false +language: auto # auto | en | ru +``` + +Only `slider_mapping` and `hotkey` are per-profile; `com`, `obs`, `discord`, `language`, `invert_sliders` and `noise_reduction` are shared. `normalize` guarantees at least one profile exists and that `active_profile` names one of them. A hotkey must have at least one modifier (a bare key would be grabbed system-wide). A config with a top-level `slider_mapping` and no `profiles:` still loads, as a single `default` profile, and is migrated on the next save. + +Special targets: `master`, `system`, `mic`, `deej.current`, `deej.current.fullscreen`, `deej.unmapped`, `deej.obs:` (OBS input volume), `deej.discord:` (one person's volume in the current voice channel, matched case-insensitively against their display name, or a raw user ID), `deej.discord.input` and `deej.discord.output` (the user's own levels inside Discord). + +Serial connection settings live in the `com:` section (OBS settings in an analogous `obs:` section): +```yaml +com: + port: auto # COM port name, or "auto" to detect by USB VID/PID + baud_rate: 9600 + vid: 0x1A86 # USB IDs for auto-detection (default: CH340 chip) + pid: 0x7523 +``` + +The legacy flat keys (`com_port`, `baud_rate`, `com_vid`, `com_pid`) are still read for backwards compatibility, but new configs and GUI saves use the `com:` section only. + +Discord settings live in a `discord:` section holding only `enabled`, `client_id` and `client_secret` — the OAuth token that linking produces is stored separately in `discord_token.json`, so backend writes never race the GUI's whole-document saves: +```yaml +discord: + enabled: false + client_id: "" # from an application you create at discord.com/developers + client_secret: "" +``` + +The config can be edited by hand (hot-reloaded) or through the settings GUI, which writes the same file. + +### Data Flow + +1. Arduino sends analog readings over serial as pipe-delimited values +2. `SerialIO.readLoop` parses lines matching `^\d{1,4}(\|\d{1,4})*\r\n$` +3. Slider move events are emitted to `sessionMap` (and to the tray/GUI as live events) +4. `sessionMap.handleSliderMoveEvent` resolves targets and adjusts volumes diff --git a/config_examples/config.example.ru.yaml b/config_examples/config.example.ru.yaml index f0696c48..b68231b7 100644 --- a/config_examples/config.example.ru.yaml +++ b/config_examples/config.example.ru.yaml @@ -8,6 +8,8 @@ # На Linux устройства ввода получают суффикс ' (input)', например "HyperX Cloud (input)", так как вход и выход устройства могут иметь одинаковое имя # Только Windows - Вы можете вписать 'system' для управления громкостью звуков Windows, таких как уведомления # Вы можете вписать 'deej.obs:<имя источника>' для управления аудиоисточниками OBS (требуется obs.enabled: true) +# Вы можете вписать 'deej.discord:<имя>' для управления громкостью собеседника в голосовом канале, а также +# 'deej.discord.input' / 'deej.discord.output' для своих уровней внутри Discord (требуется discord.enabled: true) slider_mapping: 0: master 1: deej.current @@ -59,3 +61,15 @@ obs: host: localhost port: 4455 password: "" + +# Интеграция с Discord (опционально) +# Управление громкостью отдельных собеседников через 'deej.discord:<имя>' в slider_mapping, +# а также своими уровнями внутри Discord через 'deej.discord.input' / 'deej.discord.output' +# Discord разрешает это только для вашего собственного приложения, поэтому создайте его на +# https://discord.com/developers/applications, добавьте http://localhost в Redirect URI +# в разделе OAuth2 и укажите client id и секрет здесь. Затем привяжите аккаунт в окне +# настроек deej - Discord один раз попросит подтвердить доступ +discord: + enabled: false + client_id: "" + client_secret: "" diff --git a/config_examples/config.example.yaml b/config_examples/config.example.yaml index 6a410ca0..ef06a0b3 100644 --- a/config_examples/config.example.yaml +++ b/config_examples/config.example.yaml @@ -8,6 +8,8 @@ # on linux, input devices get an ' (input)' suffix, i.e. "HyperX Cloud (input)", since a device's input and output can share the same name # windows only - you can use 'system' to control the "system sounds" volume # you can use 'deej.obs:' to control OBS audio sources (requires obs.enabled: true) +# you can use 'deej.discord:' to control one person's volume in your voice channel, and +# 'deej.discord.input' / 'deej.discord.output' for your own levels inside discord (requires discord.enabled: true) # important: slider indexes start at 0, regardless of which analog pins you're using! slider_mapping: 0: firefox.exe @@ -60,3 +62,15 @@ obs: host: localhost port: 4455 password: "" + +# Discord integration (optional) +# control the volume of individual people in a voice call with 'deej.discord:', +# and your own levels inside discord with 'deej.discord.input' / 'deej.discord.output' +# discord only allows this for an application you own, so create one at +# https://discord.com/developers/applications, add http://localhost as a redirect URI +# under OAuth2, and put its client id and secret here. then link the account from +# deej's settings window - discord will ask you to approve it once +discord: + enabled: false + client_id: "" + client_secret: "" diff --git a/frontend/bindings/github.com/nik9play/deej/pkg/deej/index.ts b/frontend/bindings/github.com/nik9play/deej/pkg/deej/index.ts index 56fd864f..30355b69 100644 --- a/frontend/bindings/github.com/nik9play/deej/pkg/deej/index.ts +++ b/frontend/bindings/github.com/nik9play/deej/pkg/deej/index.ts @@ -9,6 +9,9 @@ export { export { AppInfoDTO, COMSettings, + DiscordSettings, + DiscordStatusDTO, + DiscordUserDTO, OBSSettings, Profile, SerialPortDTO, diff --git a/frontend/bindings/github.com/nik9play/deej/pkg/deej/models.ts b/frontend/bindings/github.com/nik9play/deej/pkg/deej/models.ts index 0704add1..e095f22e 100644 --- a/frontend/bindings/github.com/nik9play/deej/pkg/deej/models.ts +++ b/frontend/bindings/github.com/nik9play/deej/pkg/deej/models.ts @@ -85,6 +85,113 @@ export class COMSettings { } } +/** + * DiscordSettings describes the Discord RPC integration. deej can't ship + * credentials of its own: Discord gates the rpc.voice.* scopes to an + * application's owner, so the user creates their own application and pastes + * its ID and secret here. The OAuth token that comes out of linking is not + * kept here - it lives in its own file next to the config (see discord.go) + */ +export class DiscordSettings { + "enabled": boolean; + "clientId": string; + "clientSecret": string; + + /** Creates a new DiscordSettings instance. */ + constructor($$source: Partial = {}) { + if (!("enabled" in $$source)) { + this["enabled"] = false; + } + if (!("clientId" in $$source)) { + this["clientId"] = ""; + } + if (!("clientSecret" in $$source)) { + this["clientSecret"] = ""; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new DiscordSettings instance from a string or object. + */ + static createFrom($$source: any = {}): DiscordSettings { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new DiscordSettings($$parsedSource as Partial); + } +} + +/** + * DiscordStatusDTO describes the Discord integration's state for the settings GUI + */ +export class DiscordStatusDTO { + /** + * an account has been authorized + */ + "linked": boolean; + + /** + * the RPC connection is up + */ + "connected": boolean; + + /** + * the voice channel the user is in, if any + */ + "channel": string; + + /** Creates a new DiscordStatusDTO instance. */ + constructor($$source: Partial = {}) { + if (!("linked" in $$source)) { + this["linked"] = false; + } + if (!("connected" in $$source)) { + this["connected"] = false; + } + if (!("channel" in $$source)) { + this["channel"] = ""; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new DiscordStatusDTO instance from a string or object. + */ + static createFrom($$source: any = {}): DiscordStatusDTO { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new DiscordStatusDTO($$parsedSource as Partial); + } +} + +/** + * DiscordUserDTO describes one member of the current voice channel + */ +export class DiscordUserDTO { + "id": string; + "name": string; + + /** Creates a new DiscordUserDTO instance. */ + constructor($$source: Partial = {}) { + if (!("id" in $$source)) { + this["id"] = ""; + } + if (!("name" in $$source)) { + this["name"] = ""; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new DiscordUserDTO instance from a string or object. + */ + static createFrom($$source: any = {}): DiscordUserDTO { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new DiscordUserDTO($$parsedSource as Partial); + } +} + /** * HexWord is a 16-bit value carried as a hex string (e.g. "1A86"), the form * both the config file and the GUI use. An empty value means "use the @@ -270,6 +377,7 @@ export class Settings { "noiseReduction": string; "language": string; "obs": OBSSettings; + "discord": DiscordSettings; /** Creates a new Settings instance. */ constructor($$source: Partial = {}) { @@ -294,6 +402,9 @@ export class Settings { if (!("obs" in $$source)) { this["obs"] = (new OBSSettings()); } + if (!("discord" in $$source)) { + this["discord"] = (new DiscordSettings()); + } Object.assign(this, $$source); } @@ -305,6 +416,7 @@ export class Settings { const $$createField1_0 = $$createType5; const $$createField3_0 = $$createType6; const $$createField6_0 = $$createType7; + const $$createField7_0 = $$createType8; let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; if ("profiles" in $$parsedSource) { $$parsedSource["profiles"] = $$createField1_0($$parsedSource["profiles"]); @@ -315,6 +427,9 @@ export class Settings { if ("obs" in $$parsedSource) { $$parsedSource["obs"] = $$createField6_0($$parsedSource["obs"]); } + if ("discord" in $$parsedSource) { + $$parsedSource["discord"] = $$createField7_0($$parsedSource["discord"]); + } return new Settings($$parsedSource as Partial); } } @@ -388,7 +503,7 @@ export class StatusDTO { * Creates a new StatusDTO instance from a string or object. */ static createFrom($$source: any = {}): StatusDTO { - const $$createField2_0 = $$createType8; + const $$createField2_0 = $$createType9; let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; if ("sliderValues" in $$parsedSource) { $$parsedSource["sliderValues"] = $$createField2_0($$parsedSource["sliderValues"]); @@ -411,4 +526,5 @@ const $$createType4 = Profile.createFrom; const $$createType5 = $Create.Array($$createType4); const $$createType6 = COMSettings.createFrom; const $$createType7 = OBSSettings.createFrom; -const $$createType8 = $Create.Array($Create.Any); +const $$createType8 = DiscordSettings.createFrom; +const $$createType9 = $Create.Array($Create.Any); diff --git a/frontend/bindings/github.com/nik9play/deej/pkg/deej/settingsservice.ts b/frontend/bindings/github.com/nik9play/deej/pkg/deej/settingsservice.ts index 331c0d57..fc6527a1 100644 --- a/frontend/bindings/github.com/nik9play/deej/pkg/deej/settingsservice.ts +++ b/frontend/bindings/github.com/nik9play/deej/pkg/deej/settingsservice.ts @@ -31,13 +31,34 @@ export function GetAutostart(): $CancellablePromise { return $Call.ByID(3740953349); } +/** + * GetDiscordStatus returns the Discord integration's live state + */ +export function GetDiscordStatus(): $CancellablePromise<$models.DiscordStatusDTO> { + return $Call.ByID(2917961514).then(($result: any) => { + return $$createType1($result); + }); +} + +/** + * GetDiscordUsers returns the members of the voice channel the user is + * currently in, for slider mapping suggestions. The roster is kept current by + * RPC events, so this serves it as-is; the GUI is told to re-read it by the + * deej:discord event rather than by polling + */ +export function GetDiscordUsers(): $CancellablePromise<$models.DiscordUserDTO[]> { + return $Call.ByID(96084360).then(($result: any) => { + return $$createType3($result); + }); +} + /** * GetOBSInputs returns the input names of the connected OBS instance, for * slider mapping suggestions */ export function GetOBSInputs(): $CancellablePromise { return $Call.ByID(1620740735).then(($result: any) => { - return $$createType1($result); + return $$createType4($result); }); } @@ -47,7 +68,7 @@ export function GetOBSInputs(): $CancellablePromise { */ export function GetProcessIcons(names: string[]): $CancellablePromise<{ [_ in string]?: string }> { return $Call.ByID(3155775785, names).then(($result: any) => { - return $$createType2($result); + return $$createType5($result); }); } @@ -57,7 +78,7 @@ export function GetProcessIcons(names: string[]): $CancellablePromise<{ [_ in st */ export function GetProcesses(): $CancellablePromise { return $Call.ByID(3688174489).then(($result: any) => { - return $$createType1($result); + return $$createType4($result); }); } @@ -67,7 +88,7 @@ export function GetProcesses(): $CancellablePromise { */ export function GetSessions(): $CancellablePromise<$models.SessionInfoDTO[]> { return $Call.ByID(1202968053).then(($result: any) => { - return $$createType4($result); + return $$createType7($result); }); } @@ -76,7 +97,7 @@ export function GetSessions(): $CancellablePromise<$models.SessionInfoDTO[]> { */ export function GetSettings(): $CancellablePromise<$models.Settings> { return $Call.ByID(1935164325).then(($result: any) => { - return $$createType5($result); + return $$createType8($result); }); } @@ -86,16 +107,24 @@ export function GetSettings(): $CancellablePromise<$models.Settings> { */ export function GetStatus(): $CancellablePromise<$models.StatusDTO> { return $Call.ByID(4223089242).then(($result: any) => { - return $$createType6($result); + return $$createType9($result); }); } +/** + * LinkDiscord runs the interactive authorization. Discord shows the user a + * consent dialog, so this blocks until they answer it (or time out) + */ +export function LinkDiscord(): $CancellablePromise { + return $Call.ByID(656445116); +} + /** * ListSerialPorts enumerates the serial ports available on this machine */ export function ListSerialPorts(): $CancellablePromise<$models.SerialPortDTO[]> { return $Call.ByID(102515344).then(($result: any) => { - return $$createType8($result); + return $$createType11($result); }); } @@ -123,13 +152,23 @@ export function SetAutostart(state: boolean): $CancellablePromise { return $Call.ByID(470775857, state); } +/** + * UnlinkDiscord forgets the stored authorization + */ +export function UnlinkDiscord(): $CancellablePromise { + return $Call.ByID(235581697); +} + // Private type creation functions const $$createType0 = $models.AppInfoDTO.createFrom; -const $$createType1 = $Create.Array($Create.Any); -const $$createType2 = $Create.Map($Create.Any, $Create.Any); -const $$createType3 = $models.SessionInfoDTO.createFrom; -const $$createType4 = $Create.Array($$createType3); -const $$createType5 = $models.Settings.createFrom; -const $$createType6 = $models.StatusDTO.createFrom; -const $$createType7 = $models.SerialPortDTO.createFrom; -const $$createType8 = $Create.Array($$createType7); +const $$createType1 = $models.DiscordStatusDTO.createFrom; +const $$createType2 = $models.DiscordUserDTO.createFrom; +const $$createType3 = $Create.Array($$createType2); +const $$createType4 = $Create.Array($Create.Any); +const $$createType5 = $Create.Map($Create.Any, $Create.Any); +const $$createType6 = $models.SessionInfoDTO.createFrom; +const $$createType7 = $Create.Array($$createType6); +const $$createType8 = $models.Settings.createFrom; +const $$createType9 = $models.StatusDTO.createFrom; +const $$createType10 = $models.SerialPortDTO.createFrom; +const $$createType11 = $Create.Array($$createType10); diff --git a/frontend/messages/en.json b/frontend/messages/en.json index ff185b15..020d7825 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -45,6 +45,23 @@ "obsHost": "Address", "obsPort": "Port", "obsPassword": "Password", + "discord": "Discord integration", + "discordEnabled": "Enable Discord integration", + "discordHint": "Control the volume of individual people in a voice call, and your own mic and output levels inside Discord.", + "discordSetupHint": "deej don't ship with application token. Create one in the {#developerPortal}Discord Developer Portal{/developerPortal}. Add {#localhost}http://localhost{/localhost} as a redirect URI under OAuth2, then paste its client ID and secret below.", + "copyLocalhost": "Copy http://localhost", + "discordClientId": "Client ID", + "discordClientSecret": "Client secret", + "discordLink": "Link Discord account", + "discordRelink": "Link again", + "discordUnlink": "Unlink", + "discordLinking": "Waiting for you to approve it in Discord...", + "discordLinked": "Account linked", + "discordNotLinked": "No account linked yet", + "discordConnected": "Connected to Discord", + "discordConnecting": "Waiting for Discord...", + "discordSaveFirst": "Save the client ID and secret first.", + "discordLinkError": "Failed to link the Discord account", "slider": "Slider", "removeTarget": "Remove target", "noTargets": "No targets - this slider does nothing", @@ -59,6 +76,7 @@ "tabDevices": "Devices", "tabSpecial": "Special", "tabObs": "OBS", + "tabDiscord": "Discord", "noDevices": "No audio devices found", "searchApps": "Search apps or type a process name...", "searchDevices": "Search devices...", @@ -81,6 +99,15 @@ "targetCurrentFullscreenDesc": "The focused app, only while it's fullscreen", "targetUnmapped": "Unmapped apps", "targetUnmappedDesc": "All apps not assigned to any other slider", + "targetDiscordInput": "Discord microphone", + "targetDiscordInputDesc": "Your own mic level inside Discord", + "targetDiscordOutput": "Discord output", + "targetDiscordOutputDesc": "Discord's own output level", + "discordDisabled": "Discord integration is disabled.", + "discordNotConnected": "Not connected to Discord. You can still add a name manually.", + "discordNoVoiceChannel": "Join a voice channel to see who's in it.", + "searchDiscordUsers": "Search people or type a name...", + "discordVoiceChannel": "In {channel}", "ok": "OK", "cancel": "Cancel", "minimize": "Minimize", diff --git a/frontend/messages/ru.json b/frontend/messages/ru.json index 105134ec..3a76ad7d 100644 --- a/frontend/messages/ru.json +++ b/frontend/messages/ru.json @@ -45,6 +45,23 @@ "obsHost": "Адрес", "obsPort": "Порт", "obsPassword": "Пароль", + "discord": "Интеграция с Discord", + "discordEnabled": "Включить интеграцию с Discord", + "discordHint": "Управление громкостью отдельных собеседников в голосовом канале, а также громкостью микрофона и динамика внутри Discord.", + "discordSetupHint": "deej не поставляется со своим токеном приложения. Создайте приложение в {#developerPortal}Discord Developer Portal{/developerPortal}. Добавьте {#localhost}http://localhost{/localhost} в Redirect URI в разделе OAuth2, затем вставьте сюда его Client ID и секрет.", + "copyLocalhost": "Скопировать http://localhost", + "discordClientId": "Client ID", + "discordClientSecret": "Client secret", + "discordLink": "Привязать аккаунт Discord", + "discordRelink": "Привязать заново", + "discordUnlink": "Отвязать", + "discordLinking": "Ожидание подтверждения в Discord...", + "discordLinked": "Аккаунт привязан", + "discordNotLinked": "Аккаунт ещё не привязан", + "discordConnected": "Подключено к Discord", + "discordConnecting": "Ожидание Discord...", + "discordSaveFirst": "Сначала сохраните Client ID и секрет.", + "discordLinkError": "Не удалось привязать аккаунт Discord", "slider": "Слайдер", "removeTarget": "Удалить цель", "noTargets": "Нет целей - этот слайдер ничего не делает", @@ -59,6 +76,7 @@ "tabDevices": "Устройства", "tabSpecial": "Специальные", "tabObs": "OBS", + "tabDiscord": "Discord", "noDevices": "Аудиоустройства не найдены", "searchApps": "Поиск приложений или имя процесса...", "searchDevices": "Поиск устройств...", @@ -81,6 +99,15 @@ "targetCurrentFullscreenDesc": "Приложение в фокусе, только в полноэкранном режиме", "targetUnmapped": "Неназначенные приложения", "targetUnmappedDesc": "Все приложения, не назначенные другим слайдерам", + "targetDiscordInput": "Микрофон Discord", + "targetDiscordInputDesc": "Уровень вашего микрофона внутри Discord", + "targetDiscordOutput": "Вывод Discord", + "targetDiscordOutputDesc": "Собственный уровень вывода Discord", + "discordDisabled": "Интеграция с Discord отключена.", + "discordNotConnected": "Нет подключения к Discord. Имя можно добавить вручную.", + "discordNoVoiceChannel": "Зайдите в голосовой канал, чтобы увидеть участников.", + "searchDiscordUsers": "Поиск участников или введите имя...", + "discordVoiceChannel": "В канале {channel}", "ok": "ОК", "cancel": "Отмена", "minimize": "Свернуть", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0303c4d1..562378d6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "@inlang/paraglide-js": "^2.21.0", + "@inlang/paraglide-js-svelte": "^1.0.4", "@lucide/svelte": "^1.24.0", "@wailsio/runtime": "latest", "bits-ui": "^2.18.1" @@ -110,6 +111,16 @@ } } }, + "node_modules/@inlang/paraglide-js-svelte": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@inlang/paraglide-js-svelte/-/paraglide-js-svelte-1.0.4.tgz", + "integrity": "sha512-dNxkRy/bdZRdSWWEUrg9JxE6bc4h2Kvht8LEpfYNQojHBYGlk44eIQrHU+kEiigQOCmlY2nqx2Wa3OQpLk+dnw==", + "license": "MIT", + "peerDependencies": { + "@inlang/paraglide-js": ">=2.11.0 <3", + "svelte": "^5.0.0" + } + }, "node_modules/@inlang/recommend-sherlock": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/@inlang/recommend-sherlock/-/recommend-sherlock-0.2.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index f8f0a8a0..d8a95359 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@inlang/paraglide-js": "^2.21.0", + "@inlang/paraglide-js-svelte": "^1.0.4", "@lucide/svelte": "^1.24.0", "@wailsio/runtime": "latest", "bits-ui": "^2.18.1" diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 8e6f7cb3..bcf9ca52 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -42,4 +42,8 @@ targetDialogOpen = false; openSettings("obs"); }} + onOpenDiscordSettings={() => { + targetDialogOpen = false; + openSettings("discord"); + }} /> diff --git a/frontend/src/components/DiscordSection.svelte b/frontend/src/components/DiscordSection.svelte new file mode 100644 index 00000000..4dbfc124 --- /dev/null +++ b/frontend/src/components/DiscordSection.svelte @@ -0,0 +1,202 @@ + + +
{ + event.preventDefault(); + onsave(); + }} +> + +
{m.discordHint()}
+ + {#if draft.enabled} +
+ + {#snippet developerPortal({ children })} + {@render children?.()} + {/snippet} + {#snippet localhost({ children })} + + {/snippet} + +
+
+
+ + +
+
+ + +
+
+ +
+ {#if status?.linked} + + + {:else} + + {/if} + + + {#if linking} + {m.discordLinking()} + {:else if !status?.linked} + + {m.discordNotLinked()} + {:else if status.connected} + + {status.channel ? m.discordVoiceChannel({ channel: status.channel }) : m.discordConnected()} + {:else} + + {m.discordConnecting()} + {/if} + +
+ + {#if dirty} +
{m.discordSaveFirst()}
+ {/if} + + {#if errorText} +
{errorText}
+ {/if} + {/if} + + + diff --git a/frontend/src/components/SettingsDialog.svelte b/frontend/src/components/SettingsDialog.svelte index 8f6a2ff9..f4daf524 100644 --- a/frontend/src/components/SettingsDialog.svelte +++ b/frontend/src/components/SettingsDialog.svelte @@ -8,11 +8,12 @@ import Cog from "@lucide/svelte/icons/cog"; import Info from "@lucide/svelte/icons/info"; import Layers from "@lucide/svelte/icons/layers"; + import MessageCircle from "@lucide/svelte/icons/message-circle"; import SlidersHorizontal from "@lucide/svelte/icons/sliders-horizontal"; - import Video from "@lucide/svelte/icons/video"; import { AppInfoDTO, COMSettings, + DiscordSettings, OBSSettings, Settings, SettingsService, @@ -22,9 +23,11 @@ import AboutSection from "./AboutSection.svelte"; import ConnectionSection from "./ConnectionSection.svelte"; import BehaviorSection from "./BehaviorSection.svelte"; + import DiscordSection from "./DiscordSection.svelte"; import GeneralSection from "./GeneralSection.svelte"; import ObsSection from "./ObsSection.svelte"; import ProfilesSection from "./ProfilesSection.svelte"; + import SimpleIcon from "./ui/SimpleIcon.svelte"; let tab = $state("general"); @@ -40,12 +43,13 @@ const settings = $derived(app.settings); const ready = $derived(settings !== null); - // connection and obs settings make deej reconnect, so they're edited as - // drafts and committed explicitly. the drafts live here rather than in the - // sections because bits-ui unmounts inactive tabs, which would otherwise - // discard in-progress edits on a tab switch + // connection, obs and discord settings make deej reconnect, so they're + // edited as drafts and committed explicitly. the drafts live here rather + // than in the sections because bits-ui unmounts inactive tabs, which would + // otherwise discard in-progress edits on a tab switch let comDraft: COMSettings | null = $state(null); let obsDraft: OBSSettings | null = $state(null); + let discordDraft: DiscordSettings | null = $state(null); let statusText = $state(""); let statusKind: "ok" | "error" = $state("ok"); @@ -58,13 +62,17 @@ const obsDirty = $derived( !!settings && !!obsDraft && JSON.stringify(obsDraft) !== JSON.stringify(settings.obs), ); + const discordDirty = $derived( + !!settings && !!discordDraft && JSON.stringify(discordDraft) !== JSON.stringify(settings.discord), + ); const tabItems = $derived([ { value: "general", label: m.general(), Icon: Cog, dirty: false }, { value: "profiles", label: m.profiles(), Icon: Layers, dirty: false }, { value: "connection", label: m.connection(), Icon: Cable, dirty: comDirty }, { value: "behavior", label: m.behavior(), Icon: SlidersHorizontal, dirty: false }, - { value: "obs", label: m.obs(), Icon: Video, dirty: obsDirty }, + { value: "obs", label: m.obs(), Icon: null, dirty: obsDirty }, + { value: "discord", label: m.discord(), Icon: null, dirty: discordDirty }, { value: "about", label: m.about(), Icon: Info, dirty: false }, ]); @@ -83,6 +91,7 @@ statusText = ""; comDraft = $state.snapshot(settings!.com); obsDraft = $state.snapshot(settings!.obs); + discordDraft = $state.snapshot(settings!.discord); }); }); @@ -134,7 +143,7 @@
- {#if settings && comDraft && obsDraft} + {#if settings && comDraft && obsDraft && discordDraft} {#each tabItems as tabItem (tabItem.value)} @@ -142,7 +151,13 @@ value={tabItem.value} class="flex items-center gap-2 rounded-md px-3 py-2 text-left text-sm text-muted transition-colors hover:bg-chip hover:text-body data-[state=active]:bg-chip data-[state=active]:text-body" > - + {#if tabItem.value === "obs"} + + {:else if tabItem.value === "discord"} + + {:else} + + {/if} {tabItem.label} {#if tabItem.dirty} (obsDraft = $state.snapshot(settings!.obs))} />
+ + save({ discord: $state.snapshot(discordDraft!) })} + onrevert={() => (discordDraft = $state.snapshot(settings!.discord))} + /> +
diff --git a/frontend/src/components/TargetDialog.svelte b/frontend/src/components/TargetDialog.svelte index 519f0377..babd64de 100644 --- a/frontend/src/components/TargetDialog.svelte +++ b/frontend/src/components/TargetDialog.svelte @@ -1,4 +1,5 @@ + + diff --git a/frontend/src/lib/state.svelte.ts b/frontend/src/lib/state.svelte.ts index 6c36ea31..2fdce894 100644 --- a/frontend/src/lib/state.svelte.ts +++ b/frontend/src/lib/state.svelte.ts @@ -1,5 +1,5 @@ import { Events } from "@wailsio/runtime"; -import { SessionInfoDTO, Settings, SettingsService } from "../../bindings/github.com/nik9play/deej/pkg/deej"; +import { DiscordUserDTO, SessionInfoDTO, Settings, SettingsService } from "../../bindings/github.com/nik9play/deej/pkg/deej"; import { tickOnChange } from "./tick"; // live application state, fed by wails events from the Go side @@ -9,6 +9,8 @@ export const app = $state({ values: [] as number[], // 0..1 per slider, as sessions receive them settings: null as Settings | null, sessions: [] as SessionInfoDTO[], // running audio sessions with friendly names + discordUsers: [] as DiscordUserDTO[], // current voice-channel roster + discordUserNames: {} as Record, // names retained after users leave }); export async function refreshSettings(): Promise { @@ -29,6 +31,19 @@ export async function refreshSessions(): Promise { } } +export async function refreshDiscordUsers(): Promise { + try { + app.discordUsers = (await SettingsService.GetDiscordUsers()) ?? []; + app.discordUserNames = Object.assign( + {}, + app.discordUserNames, + Object.fromEntries(app.discordUsers.map((user) => [user.id, user.name])), + ); + } catch { + app.discordUsers = []; + } +} + // init subscribes to live events (before fetching initial state, so nothing // is missed) and returns a cleanup function for onDestroy export function init(): () => void { @@ -49,6 +64,9 @@ export function init(): () => void { Events.On("deej:sessions", () => { void refreshSessions(); }), + Events.On("deej:discord", () => { + void refreshDiscordUsers(); + }), ]; SettingsService.GetStatus() @@ -61,6 +79,7 @@ export function init(): () => void { void refreshSettings(); void refreshSessions(); + void refreshDiscordUsers(); return () => offs.forEach((off) => off()); } diff --git a/frontend/src/lib/targets.ts b/frontend/src/lib/targets.ts index e1b6e8cc..b9ca7898 100644 --- a/frontend/src/lib/targets.ts +++ b/frontend/src/lib/targets.ts @@ -2,6 +2,9 @@ import { m } from "../paraglide/messages"; import { app } from "./state.svelte"; export const OBS_PREFIX = "deej.obs:"; +export const DISCORD_PREFIX = "deej.discord:"; +export const DISCORD_INPUT = "deej.discord.input"; +export const DISCORD_OUTPUT = "deej.discord.output"; // special target -> localized label/description message functions const specialTargets: Record string; desc: () => string }> = { @@ -11,6 +14,8 @@ const specialTargets: Record string; desc: () => string } "deej.current": { label: m.targetCurrent, desc: m.targetCurrentDesc }, "deej.current.fullscreen": { label: m.targetCurrentFullscreen, desc: m.targetCurrentFullscreenDesc }, "deej.unmapped": { label: m.targetUnmapped, desc: m.targetUnmappedDesc }, + [DISCORD_INPUT]: { label: m.targetDiscordInput, desc: m.targetDiscordInputDesc }, + [DISCORD_OUTPUT]: { label: m.targetDiscordOutput, desc: m.targetDiscordOutputDesc }, }; export function specialTargetLabel(target: string): string | null { @@ -37,6 +42,10 @@ export function targetLabel(target: string): string { if (lower.startsWith(OBS_PREFIX)) { return `${target.slice(OBS_PREFIX.length)} (OBS)`; } + if (lower.startsWith(DISCORD_PREFIX)) { + const id = target.slice(DISCORD_PREFIX.length); + return `${app.discordUserNames[id] ?? id} (Discord)`; + } const session = app.sessions.find((s) => s.key === lower); if (session) { diff --git a/go.mod b/go.mod index 63d4c82a..02bc51f5 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.0 require ( git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 + github.com/Microsoft/go-winio v0.6.2 github.com/andreykaipov/goobs v1.7.1 github.com/fsnotify/fsnotify v1.9.0 github.com/go-ole/go-ole v1.3.0 diff --git a/go.sum b/go.sum index 142712c3..f8a9bbf4 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= github.com/andreykaipov/goobs v1.7.1 h1:1Wh7batGb/eRs6ywVwqD7JljxdNxQpz1kpJK2j4dhAI= diff --git a/pkg/deej/config_model.go b/pkg/deej/config_model.go index ec0e8ce5..bd32b37e 100644 --- a/pkg/deej/config_model.go +++ b/pkg/deej/config_model.go @@ -17,13 +17,14 @@ import ( // in defaultSettings and, if needed, a rule in normalize) to reach the file, // the runtime snapshot and the settings GUI type Settings struct { - ActiveProfile string `yaml:"active_profile" json:"activeProfile"` - Profiles []Profile `yaml:"profiles" json:"profiles"` - InvertSliders bool `yaml:"invert_sliders" json:"invertSliders"` - COM COMSettings `yaml:"com" json:"com"` - NoiseReduction string `yaml:"noise_reduction,omitempty" json:"noiseReduction"` - Language string `yaml:"language" json:"language"` - OBS OBSSettings `yaml:"obs" json:"obs"` + ActiveProfile string `yaml:"active_profile" json:"activeProfile"` + Profiles []Profile `yaml:"profiles" json:"profiles"` + InvertSliders bool `yaml:"invert_sliders" json:"invertSliders"` + COM COMSettings `yaml:"com" json:"com"` + NoiseReduction string `yaml:"noise_reduction,omitempty" json:"noiseReduction"` + Language string `yaml:"language" json:"language"` + OBS OBSSettings `yaml:"obs" json:"obs"` + Discord DiscordSettings `yaml:"discord" json:"discord"` } // defaultProfileName is the profile a config without any gets, and the one a @@ -147,6 +148,17 @@ type OBSSettings struct { Password string `yaml:"password" json:"password"` } +// DiscordSettings describes the Discord RPC integration. deej can't ship +// credentials of its own: Discord gates the rpc.voice.* scopes to an +// application's owner, so the user creates their own application and pastes +// its ID and secret here. The OAuth token that comes out of linking is not +// kept here - it lives in its own file next to the config (see discord.go) +type DiscordSettings struct { + Enabled bool `yaml:"enabled" json:"enabled"` + ClientID string `yaml:"client_id" json:"clientId"` + ClientSecret string `yaml:"client_secret" json:"clientSecret"` +} + func defaultSettings() Settings { return Settings{ // the active profile is left to normalize, which resolves an empty @@ -230,11 +242,53 @@ func (s *Settings) normalize() []string { s.OBS.Host = defaults.OBS.Host } + problems = append(problems, s.normalizeDiscord()...) problems = append(problems, s.normalizeProfiles()...) return problems } +// normalizeDiscord canonicalizes the Discord credentials. The integration +// can't do anything without both of them, so enabling it without them is +// reported rather than left as a connection that quietly never happens +func (s *Settings) normalizeDiscord() []string { + var problems []string + + s.Discord.ClientID = strings.TrimSpace(s.Discord.ClientID) + s.Discord.ClientSecret = strings.TrimSpace(s.Discord.ClientSecret) + + if !s.Discord.Enabled { + return nil + } + + switch { + case s.Discord.ClientID == "": + problems = append(problems, "discord client id must not be empty") + + // a client id is a snowflake; catching a pasted client *secret* here saves + // a confusing round trip through discord's OAuth error responses + case !isDigits(s.Discord.ClientID): + problems = append(problems, fmt.Sprintf("discord client id must be numeric, got %q", s.Discord.ClientID)) + s.Discord.ClientID = "" + } + + if s.Discord.ClientSecret == "" { + problems = append(problems, "discord client secret must not be empty") + } + + // leave the file self-consistent: enabled without usable credentials would + // otherwise sit there looking like it should be working + if len(problems) > 0 { + s.Discord.Enabled = false + } + + return problems +} + +func isDigits(value string) bool { + return value != "" && strings.IndexFunc(value, func(r rune) bool { return r < '0' || r > '9' }) < 0 +} + // normalizeProfiles canonicalizes the profile list and the active profile. // There is always at least one profile afterwards, and ActiveProfile always // names one of them diff --git a/pkg/deej/deej.go b/pkg/deej/deej.go index a1866b25..9ca0ff54 100644 --- a/pkg/deej/deej.go +++ b/pkg/deej/deej.go @@ -32,6 +32,7 @@ type Deej struct { serial *SerialIO sessions *sessionMap obs *OBSClient + discord *DiscordClient bundle *i18n.Bundle localizer *i18n.Localizer tray trayState @@ -104,6 +105,7 @@ func NewDeej(logger *zap.SugaredLogger, verbose bool, configPath string) (*Deej, d.sessions = sessions d.obs = NewOBSClient(d, logger) + d.discord = NewDiscordClient(d, logger) logger.Debug("Created deej instance") @@ -210,6 +212,7 @@ func (d *Deej) run() { d.serial.Start() d.obs.Start() + d.discord.Start() // wait until stopped (gracefully) <-d.stopChannel @@ -234,6 +237,7 @@ func (d *Deej) stop() error { d.config.StopWatchingConfigFile() d.serial.Stop() d.obs.Stop() + d.discord.Stop() // release the session map if err := d.sessions.release(); err != nil { diff --git a/pkg/deej/discord.go b/pkg/deej/discord.go new file mode 100644 index 00000000..1d96d1e8 --- /dev/null +++ b/pkg/deej/discord.go @@ -0,0 +1,1271 @@ +package deej + +import ( + "cmp" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "maps" + "math" + "net/http" + "net/url" + "os" + "path/filepath" + "slices" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/google/uuid" + "go.uber.org/zap" + + "github.com/nik9play/deej/pkg/reconnect" +) + +const ( + discordRetryDelay = 5 * time.Second + + // the roster is kept current by VOICE_STATE_* events, so this is only the + // safety net that repairs it if one is ever missed or dropped + discordReconcileInterval = 30 * time.Second + + // how many roster events may queue before they're dropped. Dropping is + // safe: the reconcile pass above repairs whatever was missed, and never + // blocking here is what keeps the read loop from deadlocking against the + // event handler's own requests + discordEventBufferSize = 64 + + // minimum spacing between volume commands. A slider sweep produces tens of + // events a second, which would trip Discord's RPC rate limit if each one + // became a request + discordFlushInterval = 100 * time.Millisecond + + // how long to wait for the reply to a single RPC command + discordCommandTimeout = 10 * time.Second + + // authorizing means clicking a button in the Discord client, so this one + // waits on a human rather than on software + discordAuthorizeTimeout = 2 * time.Minute + + discordTokenURL = "https://discord.com/api/oauth2/token" + + // deej never receives this redirect - the authorization code comes back + // over the IPC connection - but the token exchange still has to echo a URI + // the application has registered + discordRedirectURI = "http://localhost" + + discordTokenFileName = "discord_token.json" +) + +// the scopes deej asks for: rpc to talk to the client at all, rpc.voice.read +// to see who's in the voice channel, rpc.voice.write to set volumes +var discordScopes = []string{"rpc", "rpc.voice.read", "rpc.voice.write"} + +// IPC frame opcodes +const ( + discordOpHandshake uint32 = 0 + discordOpFrame uint32 = 1 + discordOpClose uint32 = 2 + discordOpPing uint32 = 3 + discordOpPong uint32 = 4 +) + +// pending volume keys, also the throttle's map keys +const ( + discordKeyInput = "input" + discordKeyOutput = "output" + discordKeyUser = "user:" +) + +var discordHTTPClient = &http.Client{Timeout: 15 * time.Second} + +// DiscordClient controls volumes inside the Discord client over its local RPC +// connection: the volume of individual people in a voice channel, and the +// user's own input and output levels. Mapping the discord.exe process covers +// neither of those, which is the whole reason this exists +type DiscordClient struct { + deej *Deej + logger *zap.SugaredLogger + + reconnector *reconnect.Reconnector[*discordConn] + + token atomic.Pointer[discordToken] + roster atomic.Pointer[discordRoster] + + // volumeLock guards the throttle's two maps: pending is what the slider + // handler wrote, lastSent is what actually reached Discord + volumeLock sync.Mutex + pending map[string]float32 + lastSent map[string]float32 + + // every roster write happens on the event loop, so the roster pointer + // above needs no lock of its own + events chan discordEvent + reconcile chan struct{} + + rosterChangeChannel chan struct{} + + stopChannel chan struct{} +} + +// discordEvent is one RPC event, moved off the read loop so the handler can +// issue its own requests without deadlocking against it +type discordEvent struct { + name string + data json.RawMessage +} + +func NewDiscordClient(deej *Deej, logger *zap.SugaredLogger) *DiscordClient { + logger = logger.Named("discord") + + d := &DiscordClient{ + deej: deej, + logger: logger, + pending: map[string]float32{}, + lastSent: map[string]float32{}, + events: make(chan discordEvent, discordEventBufferSize), + reconcile: make(chan struct{}, 1), + rosterChangeChannel: make(chan struct{}, 1), + } + + d.reconnector = reconnect.New(reconnect.Options[*discordConn]{ + Logger: logger, + + // without a stored token the only way forward is the interactive + // authorization, which must not happen on a retry loop + Enabled: func() bool { + cfg := d.deej.config.Values().Discord + return cfg.Enabled && cfg.ClientID != "" && d.token.Load() != nil + }, + + Dial: d.dial, + Watch: d.watch, + Close: d.close, + OnUp: d.onUp, + OnDown: func(err error) { + d.logger.Warnw("Discord connection error, reconnecting...", "error", err) + d.roster.Store(nil) + d.notifyRosterChange() + }, + Backoff: func(int) time.Duration { return discordRetryDelay }, + }) + + logger.Debug("Created Discord client instance") + + return d +} + +func (d *DiscordClient) Start() { + d.logger.Info("Discord client starting") + + // NewDiscordClient is constructed before the initial config load. + d.loadToken() + d.setupOnConfigReload() + + d.stopChannel = make(chan struct{}) + d.reconnector.Start() + + // the event loop issues blocking requests of its own, so it doesn't share + // a goroutine with the volume flush + go discordTickLoop(d.stopChannel, discordFlushInterval, d.flush) + go d.eventLoop(d.stopChannel) +} + +// SubscribeToRosterChange returns a channel that signals whenever the voice +// channel roster changes. Signals are coalesced - consumers should re-read +// VoiceUsers rather than count them +func (d *DiscordClient) SubscribeToRosterChange() <-chan struct{} { + return d.rosterChangeChannel +} + +func (d *DiscordClient) notifyRosterChange() { + select { + case d.rosterChangeChannel <- struct{}{}: + default: + // a signal is already pending, which says everything it needs to + } +} + +func (d *DiscordClient) Stop() { + if d.stopChannel != nil { + close(d.stopChannel) + d.stopChannel = nil + } + + d.reconnector.Stop() + d.logger.Info("Discord client stopped") +} + +func (d *DiscordClient) IsConnected() bool { + return d.reconnector.Connected() +} + +func discordTickLoop(stopChannel <-chan struct{}, interval time.Duration, job func()) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-stopChannel: + return + case <-ticker.C: + job() + } + } +} + +// Status reports what the settings window shows: whether an account has been +// linked, whether the connection is up, and the voice channel deej can see +func (d *DiscordClient) Status() (linked bool, connected bool, channel string) { + linked = d.token.Load() != nil + connected = d.reconnector.Connected() + + if roster := d.roster.Load(); roster != nil { + channel = roster.channel + } + + return linked, connected, channel +} + +// VoiceUsers returns the members of the voice channel the user is currently +// in, for the settings window's target picker +func (d *DiscordClient) VoiceUsers() []DiscordUserDTO { + roster := d.roster.Load() + if roster == nil { + return nil + } + + return slices.Clone(roster.users) +} + +// SetInputVolume sets the user's own microphone level inside Discord +func (d *DiscordClient) SetInputVolume(percent float32) { + d.setVolume(discordKeyInput, percent) +} + +// SetOutputVolume sets Discord's own output level +func (d *DiscordClient) SetOutputVolume(percent float32) { + d.setVolume(discordKeyOutput, percent) +} + +// SetUserVolume sets the volume of one person in the current voice channel. +// The name is matched case-insensitively against the roster; an all-digit +// name is taken as a user ID +func (d *DiscordClient) SetUserVolume(name string, percent float32) { + d.setVolume(discordKeyUser+strings.ToLower(strings.TrimSpace(name)), percent) +} + +func discordTargetKey(target string) (string, bool) { + lowerTarget := strings.ToLower(target) + + switch { + case lowerTarget == discordInputTarget: + return discordKeyInput, true + case lowerTarget == discordOutputTarget: + return discordKeyOutput, true + case strings.HasPrefix(lowerTarget, discordUserTargetPrefix): + return discordKeyUser + strings.ToLower(strings.TrimSpace(target[len(discordUserTargetPrefix):])), true + default: + return "", false + } +} + +// replayMappedSliderValues restores Discord's per-connection voice overrides +// from the physical sliders after the roster is current. +func (d *DiscordClient) replayMappedSliderValues() { + if d.deej == nil || d.deej.config == nil || d.deej.serial == nil { + return + } + + values := d.deej.serial.CurrentSliderPercentValues() + mapping := d.deej.config.Values().ActiveMapping() + + d.volumeLock.Lock() + defer d.volumeLock.Unlock() + + for _, entry := range mapping { + if entry.Slider < 0 || entry.Slider >= len(values) { + continue + } + + for _, target := range entry.Targets { + if key, ok := discordTargetKey(target); ok { + d.pending[key] = values[entry.Slider] + } + } + } +} + +// setVolume records the latest value for a target, which the flush loop picks +// up. Values arriving while disconnected are dropped rather than queued: a +// slider position from ten minutes ago shouldn't be applied on reconnect +func (d *DiscordClient) setVolume(key string, percent float32) { + if !d.IsConnected() { + return + } + + d.volumeLock.Lock() + defer d.volumeLock.Unlock() + + d.pending[key] = percent +} + +// flush sends the values collected since the last tick, one request per +// target, skipping the ones Discord already has +func (d *DiscordClient) flush() { + conn, ok := d.reconnector.Current() + if !ok { + return + } + + // the requests below block, so they happen outside the lock + sent := map[string]float32{} + for key, percent := range d.takePending() { + if err := d.send(conn, key, percent); err != nil { + d.logger.Debugw("Failed to set Discord volume", "target", key, "error", err) + continue + } + + sent[key] = percent + } + + d.markSent(sent) +} + +// takePending swaps out everything collected since the last flush, dropping +// the targets Discord already has at that value +func (d *DiscordClient) takePending() map[string]float32 { + d.volumeLock.Lock() + defer d.volumeLock.Unlock() + + todo := make(map[string]float32, len(d.pending)) + for key, percent := range d.pending { + if last, sent := d.lastSent[key]; !sent || last != percent { + todo[key] = percent + } + } + + clear(d.pending) + + return todo +} + +func (d *DiscordClient) markSent(sent map[string]float32) { + if len(sent) == 0 { + return + } + + d.volumeLock.Lock() + defer d.volumeLock.Unlock() + + maps.Copy(d.lastSent, sent) +} + +func (d *DiscordClient) send(conn *discordConn, key string, percent float32) error { + switch key { + case discordKeyInput: + _, err := conn.request("SET_VOICE_SETTINGS", map[string]any{"input": map[string]any{"volume": discordVolume(percent)}}) + return err + + case discordKeyOutput: + _, err := conn.request("SET_VOICE_SETTINGS", map[string]any{"output": map[string]any{"volume": discordVolume(percent)}}) + return err + } + + name := strings.TrimPrefix(key, discordKeyUser) + + userID, ok := d.resolveUser(name) + if !ok { + return fmt.Errorf("no user %q in the current voice channel", name) + } + + _, err := conn.request("SET_USER_VOICE_SETTINGS", map[string]any{"user_id": userID, "volume": discordUserVolume(percent)}) + + return err +} + +// discordVolume converts a 0.0-1.0 slider position to Discord's linear input +// and output voice-settings scale. +func discordVolume(percent float32) float64 { + return math.Round(min(max(float64(percent), 0), 1) * 100) +} + +// discordUserVolume converts a 0.0-1.0 slider position to the amplitude +// percentage expected for another user's volume. Discord's current client uses +// a cube-root display curve, so the RPC value must use its inverse cubic curve. +func discordUserVolume(percent float32) float64 { + position := min(max(float64(percent), 0), 1) + return position * position * position * 100 +} + +// resolveUser maps a mapping target to a Discord user ID. An all-digit target +// is taken as an ID directly, so someone who renames (or whose display name +// deej can't see) can still be pinned in the config +func (d *DiscordClient) resolveUser(name string) (string, bool) { + if isDigits(name) { + return name, true + } + + roster := d.roster.Load() + if roster == nil { + return "", false + } + + for _, user := range roster.users { + if strings.EqualFold(user.Name, name) { + return user.ID, true + } + } + + return "", false +} + +// discordRoster is a snapshot of the current voice channel +type discordRoster struct { + id string // channel id, the scope the voice state subscriptions use + channel string // channel name, for display + users []DiscordUserDTO +} + +// discordVoiceState is one member's entry, both as VOICE_STATE_* events carry +// it and as GET_SELECTED_VOICE_CHANNEL nests it under voice_states +type discordVoiceState struct { + Nick string `json:"nick"` + User struct { + ID string `json:"id"` + Username string `json:"username"` + GlobalName string `json:"global_name"` + } `json:"user"` +} + +func (s discordVoiceState) toUser() DiscordUserDTO { + // the per-server nickname is what the user sees in the client, so it's + // what they'd reach for when writing a mapping + return DiscordUserDTO{ + ID: s.User.ID, + Name: cmp.Or(s.Nick, s.User.GlobalName, s.User.Username), + } +} + +// queueEvent hands an RPC event to the event loop. It is called from a +// connection's read loop, so it drops rather than blocks when the buffer is +// full - the periodic reconcile repairs whatever gets dropped +func (d *DiscordClient) queueEvent(evt string, data json.RawMessage) { + select { + case d.events <- discordEvent{name: evt, data: data}: + default: + d.logger.Debugw("Discord event buffer full, dropping event", "event", evt) + } +} + +// eventLoop owns the roster. Every write to it happens here - from RPC events, +// from the periodic reconcile, and from a connection coming up - so the roster +// needs no lock beyond the atomic pointer readers use +func (d *DiscordClient) eventLoop(stopChannel <-chan struct{}) { + ticker := time.NewTicker(discordReconcileInterval) + defer ticker.Stop() + + // the connection the current subscriptions belong to, and the channel + // those subscriptions are scoped to. A new connection carries neither, + // which is why the connection's identity is what's tracked here + var subscribedConn *discordConn + var subscribedChannel string + + for { + select { + case <-stopChannel: + return + + case <-d.reconcile: + d.reconcileRoster(&subscribedConn, &subscribedChannel) + + case <-ticker.C: + d.reconcileRoster(&subscribedConn, &subscribedChannel) + + case event := <-d.events: + // changing voice channel moves the subscriptions with it, which + // the reconcile pass handles along with reading the new roster + if event.name == "VOICE_CHANNEL_SELECT" { + d.reconcileRoster(&subscribedConn, &subscribedChannel) + continue + } + + d.applyVoiceStateEvent(event) + } + } +} + +// applyVoiceStateEvent folds one VOICE_STATE_* event into the roster +func (d *DiscordClient) applyVoiceStateEvent(event discordEvent) { + var state discordVoiceState + if err := json.Unmarshal(event.data, &state); err != nil || state.User.ID == "" { + return + } + + roster := d.roster.Load() + if roster == nil { + return + } + + user := state.toUser() + + users := slices.Clone(roster.users) + index := slices.IndexFunc(users, func(existing DiscordUserDTO) bool { return existing.ID == user.ID }) + joined := index < 0 && event.name != "VOICE_STATE_DELETE" + + switch event.name { + case "VOICE_STATE_DELETE": + if index < 0 { + return + } + users = slices.Delete(users, index, index+1) + + default: // CREATE and UPDATE both mean "this is the member's current state" + if index >= 0 { + if users[index] == user { + return + } + users[index] = user + } else { + users = append(users, user) + } + } + + sortDiscordUsers(users) + + d.roster.Store(&discordRoster{id: roster.id, channel: roster.channel, users: users}) + d.notifyRosterChange() + if joined { + // Discord may have discarded a user's override while they were away. + d.volumeLock.Lock() + clear(d.lastSent) + d.volumeLock.Unlock() + d.replayMappedSliderValues() + } +} + +// reconcileRoster re-reads the voice channel from scratch and re-points the +// subscriptions at it. It runs on connect, whenever the user changes voice +// channel, and periodically to repair anything a dropped event missed +func (d *DiscordClient) reconcileRoster(subscribedConn **discordConn, subscribedChannel *string) { + conn, ok := d.reconnector.Current() + if !ok { + *subscribedConn = nil + *subscribedChannel = "" + + if d.roster.Swap(nil) != nil { + d.notifyRosterChange() + } + + return + } + + // a new connection carries none of the old one's subscriptions + if conn != *subscribedConn { + *subscribedConn = conn + *subscribedChannel = "" + + if _, err := conn.requestEvent("SUBSCRIBE", "VOICE_CHANNEL_SELECT", nil); err != nil { + d.logger.Debugw("Failed to subscribe to Discord voice channel changes", "error", err) + } + } + + channel, err := d.readVoiceChannel(conn) + if err != nil { + d.logger.Debugw("Failed to read Discord voice channel", "error", err) + return + } + + if channel.id != *subscribedChannel { + if d.resubscribeVoiceStates(conn, *subscribedChannel, channel.id) { + *subscribedChannel = channel.id + } + } + + previous := d.roster.Load() + d.roster.Store(channel) + d.replayMappedSliderValues() + + if previous == nil || previous.channel != channel.channel || !slices.Equal(previous.users, channel.users) { + d.notifyRosterChange() + } +} + +// readVoiceChannel fetches the full current voice channel state +func (d *DiscordClient) readVoiceChannel(conn *discordConn) (*discordRoster, error) { + data, err := conn.request("GET_SELECTED_VOICE_CHANNEL", nil) + if err != nil { + return nil, err + } + + // null when the user isn't in a voice channel + var channel *struct { + ID string `json:"id"` + Name string `json:"name"` + VoiceStates []discordVoiceState `json:"voice_states"` + } + + if err := json.Unmarshal(data, &channel); err != nil { + return nil, fmt.Errorf("parse voice channel: %w", err) + } + + if channel == nil { + return &discordRoster{}, nil + } + + roster := &discordRoster{id: channel.ID, channel: channel.Name} + for _, state := range channel.VoiceStates { + roster.users = append(roster.users, state.toUser()) + } + + sortDiscordUsers(roster.users) + + return roster, nil +} + +// resubscribeVoiceStates moves the per-channel event subscriptions from one +// voice channel to another; either id may be empty, meaning "not in a channel" +func (d *DiscordClient) resubscribeVoiceStates(conn *discordConn, from string, to string) bool { + events := []string{"VOICE_STATE_CREATE", "VOICE_STATE_UPDATE", "VOICE_STATE_DELETE"} + subscribed := true + + for _, name := range events { + if from != "" { + if _, err := conn.requestEvent("UNSUBSCRIBE", name, map[string]any{"channel_id": from}); err != nil { + d.logger.Debugw("Failed to unsubscribe from Discord voice states", "event", name, "error", err) + } + } + + if to != "" { + if _, err := conn.requestEvent("SUBSCRIBE", name, map[string]any{"channel_id": to}); err != nil { + d.logger.Debugw("Failed to subscribe to Discord voice states", "event", name, "error", err) + subscribed = false + } + } + } + + return subscribed +} + +func sortDiscordUsers(users []DiscordUserDTO) { + slices.SortFunc(users, func(a, b DiscordUserDTO) int { + // case-insensitively, or a plain byte compare would file every + // capitalised display name ahead of every lowercase one + if diff := cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)); diff != 0 { + return diff + } + + // discord display names are not unique, so keep the order stable + return cmp.Compare(a.ID, b.ID) + }) +} + +// Link runs the one interactive step of the integration: Discord shows the +// user a consent dialog, and approving it hands back an authorization code +// that deej exchanges for a token it can reuse from now on. This is deliberately +// not part of dial - a retry every few seconds would mean a popup every few +// seconds - so it lives behind the settings window's button +func (d *DiscordClient) Link() error { + cfg := d.deej.config.Values().Discord + + if cfg.ClientID == "" || cfg.ClientSecret == "" { + return errors.New("save a Discord client ID and secret first") + } + + conn, err := d.open(cfg) + if err != nil { + return err + } + defer func() { _ = conn.Close() }() + + data, err := conn.requestWithTimeout("AUTHORIZE", map[string]any{ + "client_id": cfg.ClientID, + "scopes": discordScopes, + }, discordAuthorizeTimeout) + if err != nil { + return fmt.Errorf("authorize: %w", err) + } + + var authorized struct { + Code string `json:"code"` + } + if err := json.Unmarshal(data, &authorized); err != nil || authorized.Code == "" { + return errors.New("discord returned no authorization code") + } + + token, err := exchangeToken(cfg, url.Values{ + "grant_type": {"authorization_code"}, + "code": {authorized.Code}, + "redirect_uri": {discordRedirectURI}, + }) + if err != nil { + return err + } + + if err := d.storeToken(token); err != nil { + return err + } + + d.logger.Info("Linked Discord account") + d.reconnector.Reconnect(errors.New("account linked")) + + return nil +} + +// Unlink forgets the stored token, which also drops the connection +func (d *DiscordClient) Unlink() error { + d.token.Store(nil) + + if err := os.Remove(d.tokenPath()); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("remove Discord token: %w", err) + } + + d.logger.Info("Unlinked Discord account") + d.reconnector.Reconnect(errors.New("account unlinked")) + + return nil +} + +// dial establishes an authenticated connection using the stored token, +// without touching any client state - the reconnector decides whether the +// result is adopted +func (d *DiscordClient) dial() (*discordConn, error) { + cfg := d.deej.config.Values().Discord + + token := d.token.Load() + if token == nil { + return nil, errors.New("no linked Discord account") + } + + // refresh before opening anything, so an expired token doesn't cost a + // connection that's about to be rejected + token, err := d.ensureFreshToken(cfg, token) + if err != nil { + return nil, err + } + + conn, err := d.open(cfg) + if err != nil { + return nil, err + } + + if _, err := conn.request("AUTHENTICATE", map[string]any{"access_token": token.AccessToken}); err != nil { + _ = conn.Close() + return nil, fmt.Errorf("authenticate: %w", err) + } + + return conn, nil +} + +// open connects to the Discord client and completes the handshake, leaving a +// connection that can carry commands but hasn't authenticated yet +func (d *DiscordClient) open(cfg DiscordSettings) (*discordConn, error) { + rw, err := dialDiscordIPC() + if err != nil { + return nil, err + } + + conn := newDiscordConn(rw, cfg, d.queueEvent) + + handshake, err := json.Marshal(map[string]any{"v": 1, "client_id": cfg.ClientID}) + if err != nil { + _ = conn.Close() + return nil, err + } + + if err := conn.write(discordOpHandshake, handshake); err != nil { + _ = conn.Close() + return nil, fmt.Errorf("handshake: %w", err) + } + + select { + case <-conn.ready: + return conn, nil + + // the connection failed before it was ever adopted, so taking its error + // here doesn't strand a Watch + case <-conn.done: + _ = conn.Close() + return nil, fmt.Errorf("handshake: %w", conn.doneErr) + + case <-time.After(discordCommandTimeout): + _ = conn.Close() + return nil, errors.New("timed out waiting for the Discord handshake") + } +} + +// watch waits for this connection's read loop to finish, which happens either +// on a connection error or because Close was called +func (d *DiscordClient) watch(conn *discordConn, errChannel chan<- error) { + <-conn.done + + select { + case errChannel <- conn.doneErr: + default: + } +} + +func (d *DiscordClient) close(conn *discordConn) { + _ = conn.Close() + d.logger.Info("Disconnected from Discord") +} + +func (d *DiscordClient) onUp(conn *discordConn) { + // re-check the snapshot now that the connection is adopted + if d.deej.config.Values().Discord != conn.cfg { + d.logger.Debug("Discord config changed while connecting, triggering reconnect") + d.reconnector.Reconnect(errors.New("config changed during dial")) + return + } + + // a fresh connection knows nothing about what deej last sent + d.volumeLock.Lock() + clear(d.lastSent) + d.volumeLock.Unlock() + + d.logger.Info("Connected to Discord") + + // the event loop subscribes and reads the initial roster; it must not run + // here, since this is the reconnector's goroutine + d.requestReconcile() +} + +func (d *DiscordClient) requestReconcile() { + select { + case d.reconcile <- struct{}{}: + default: + // one is already queued, which does the same work + } +} + +// setupOnConfigReload triggers a reconnect when the Discord credentials change +func (d *DiscordClient) setupOnConfigReload() { + configReloadedChannel := d.deej.config.SubscribeToChanges() + previous := d.deej.config.Values().Discord + + go func() { + for { + <-configReloadedChannel + current := d.deej.config.Values().Discord + + credentialsChanged := discordCredentialsChanged(previous, current) + if credentialsChanged { + d.token.Store(nil) + if err := os.Remove(d.tokenPath()); err != nil && !errors.Is(err, os.ErrNotExist) { + d.logger.Warnw("Failed to remove Discord token after credentials changed", "error", err) + } + } + previous = current + + conn, ok := d.reconnector.Current() + if !ok { + if credentialsChanged { + d.reconnector.Reconnect(errors.New("Discord credentials changed")) + } + continue + } + + if current != conn.cfg { + d.logger.Debug("Discord config changed, triggering reconnect") + d.reconnector.Reconnect(errors.New("config changed")) + } + } + }() +} + +func discordCredentialsChanged(before DiscordSettings, after DiscordSettings) bool { + return before.ClientID != after.ClientID || before.ClientSecret != after.ClientSecret +} + +// discordToken is the OAuth token deej persists between runs. It's kept beside +// the config file rather than inside it: a token written by the backend would +// otherwise race the settings window's whole-document saves, and every write +// would churn the config watcher +type discordToken struct { + ClientID string `json:"client_id"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresAt time.Time `json:"expires_at"` +} + +// expired reports whether the token needs refreshing, a minute early so one +// doesn't lapse midway through a request +func (t *discordToken) expired() bool { + return time.Now().After(t.ExpiresAt.Add(-time.Minute)) +} + +func (d *DiscordClient) tokenPath() string { + return filepath.Join(filepath.Dir(d.deej.config.configPath), discordTokenFileName) +} + +func (d *DiscordClient) loadToken() { + data, err := os.ReadFile(d.tokenPath()) + if err != nil { + return + } + + var token discordToken + if err := json.Unmarshal(data, &token); err != nil { + d.logger.Warnw("Ignoring malformed Discord token file", "path", d.tokenPath(), "error", err) + return + } + + if token.AccessToken == "" || token.ClientID != d.deej.config.Values().Discord.ClientID { + return + } + + d.token.Store(&token) + d.logger.Debug("Loaded stored Discord token") +} + +// storeToken persists the token next to the config file. writeFileAtomic +// creates at 0600 and the rename preserves it, which is exactly what a +// credential file needs +func (d *DiscordClient) storeToken(token *discordToken) error { + data, err := json.Marshal(token) + if err != nil { + return fmt.Errorf("marshal Discord token: %w", err) + } + + if err := writeFileAtomic(d.tokenPath(), data); err != nil { + return fmt.Errorf("write Discord token: %w", err) + } + + d.token.Store(token) + + return nil +} + +func (d *DiscordClient) ensureFreshToken(cfg DiscordSettings, token *discordToken) (*discordToken, error) { + if !token.expired() { + return token, nil + } + + if token.RefreshToken == "" { + return nil, errors.New("the stored Discord token expired and can't be refreshed, link again") + } + + d.logger.Debug("Discord token expired, refreshing") + + refreshed, err := exchangeToken(cfg, url.Values{ + "grant_type": {"refresh_token"}, + "refresh_token": {token.RefreshToken}, + }) + if err != nil { + return nil, fmt.Errorf("refresh Discord token: %w", err) + } + + if err := d.storeToken(refreshed); err != nil { + return nil, err + } + + return refreshed, nil +} + +// exchangeToken posts to Discord's OAuth token endpoint, for both the initial +// authorization code and later refreshes +func exchangeToken(cfg DiscordSettings, form url.Values) (*discordToken, error) { + form.Set("client_id", cfg.ClientID) + form.Set("client_secret", cfg.ClientSecret) + + resp, err := discordHTTPClient.PostForm(discordTokenURL, form) + if err != nil { + return nil, fmt.Errorf("token request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<16)) + if err != nil { + return nil, fmt.Errorf("read token response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + // the body carries Discord's own error description (and echoes nothing + // secret), which is far more useful in the settings window than a bare + // status code + return nil, fmt.Errorf("token request failed (%s): %s", resp.Status, strings.TrimSpace(string(body))) + } + + var parsed struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + } + if err := json.Unmarshal(body, &parsed); err != nil { + return nil, fmt.Errorf("parse token response: %w", err) + } + + if parsed.AccessToken == "" { + return nil, errors.New("the token response had no access token") + } + + return &discordToken{ + ClientID: cfg.ClientID, + AccessToken: parsed.AccessToken, + RefreshToken: parsed.RefreshToken, + ExpiresAt: time.Now().Add(time.Duration(parsed.ExpiresIn) * time.Second), + }, nil +} + +// discordConn is one Discord IPC connection generation, carrying the config +// snapshot it was opened with so config reloads can detect credential changes +type discordConn struct { + rw io.ReadWriteCloser + cfg DiscordSettings + + writeLock sync.Mutex + + // pending correlates replies to the callers waiting on them, by nonce + pendingLock sync.Mutex + pending map[string]chan discordFrame + + // onEvent receives subscribed RPC events. It is called from the read loop, + // so it must never block + onEvent func(evt string, data json.RawMessage) + + ready chan struct{} + readyOnce sync.Once + + // done is closed when the read loop exits, broadcasting the stored error + // to every caller waiting on this connection + done chan struct{} + doneOnce sync.Once + doneErr error +} + +func newDiscordConn(rw io.ReadWriteCloser, cfg DiscordSettings, onEvent func(evt string, data json.RawMessage)) *discordConn { + conn := &discordConn{ + rw: rw, + cfg: cfg, + onEvent: onEvent, + pending: map[string]chan discordFrame{}, + ready: make(chan struct{}), + done: make(chan struct{}), + } + + go conn.readLoop() + + return conn +} + +// Close releases the transport, which unblocks the read loop and in turn any +// Watch waiting on it +func (c *discordConn) Close() error { + return c.rw.Close() +} + +// discordFrame is the envelope every RPC command, reply and event shares +type discordFrame struct { + Cmd string `json:"cmd"` + Nonce string `json:"nonce,omitempty"` + Evt string `json:"evt,omitempty"` + Args any `json:"args,omitempty"` + Data json.RawMessage `json:"data,omitempty"` +} + +func (c *discordConn) request(cmd string, args any) (json.RawMessage, error) { + return c.roundTrip(discordFrame{Cmd: cmd, Args: args}, discordCommandTimeout) +} + +// requestEvent issues SUBSCRIBE and UNSUBSCRIBE, the two commands that name +// their event in evt rather than in args +func (c *discordConn) requestEvent(cmd string, evt string, args any) (json.RawMessage, error) { + return c.roundTrip(discordFrame{Cmd: cmd, Evt: evt, Args: args}, discordCommandTimeout) +} + +func (c *discordConn) requestWithTimeout(cmd string, args any, timeout time.Duration) (json.RawMessage, error) { + return c.roundTrip(discordFrame{Cmd: cmd, Args: args}, timeout) +} + +func (c *discordConn) roundTrip(frame discordFrame, timeout time.Duration) (json.RawMessage, error) { + cmd := frame.Cmd + nonce := uuid.NewString() + frame.Nonce = nonce + + body, err := json.Marshal(frame) + if err != nil { + return nil, fmt.Errorf("marshal %s: %w", cmd, err) + } + + // buffered, so the read loop never blocks handing over a reply nobody is + // waiting for anymore + replyChannel := make(chan discordFrame, 1) + + c.pendingLock.Lock() + c.pending[nonce] = replyChannel + c.pendingLock.Unlock() + + defer func() { + c.pendingLock.Lock() + delete(c.pending, nonce) + c.pendingLock.Unlock() + }() + + if err := c.write(discordOpFrame, body); err != nil { + return nil, err + } + + select { + case reply := <-replyChannel: + return discordReplyData(reply) + + case <-c.done: + return nil, c.doneErr + + case <-time.After(timeout): + return nil, fmt.Errorf("timed out waiting for a reply to %s", cmd) + } +} + +// discordReplyData unwraps a reply, turning an ERROR frame into a Go error +func discordReplyData(reply discordFrame) (json.RawMessage, error) { + if reply.Evt != "ERROR" { + return reply.Data, nil + } + + var rpcErr struct { + Code int `json:"code"` + Message string `json:"message"` + } + if err := json.Unmarshal(reply.Data, &rpcErr); err == nil && rpcErr.Message != "" { + return nil, fmt.Errorf("discord rpc error %d: %s", rpcErr.Code, rpcErr.Message) + } + + return nil, errors.New("discord rpc error") +} + +func (c *discordConn) write(opcode uint32, body []byte) error { + c.writeLock.Lock() + defer c.writeLock.Unlock() + + return writeDiscordFrame(c.rw, opcode, body) +} + +func (c *discordConn) fail(err error) { + c.doneOnce.Do(func() { + c.doneErr = err + close(c.done) + }) +} + +// readLoop is the connection's only reader: it hands replies to the callers +// waiting on their nonce and answers pings, until the transport fails or is +// closed +func (c *discordConn) readLoop() { + for { + opcode, body, err := readDiscordFrame(c.rw) + if err != nil { + c.fail(fmt.Errorf("read frame: %w", err)) + return + } + + switch opcode { + case discordOpPing: + // the client wants its own payload echoed back + if err := c.write(discordOpPong, body); err != nil { + c.fail(err) + return + } + continue + + case discordOpClose: + c.fail(fmt.Errorf("discord closed the connection: %s", strings.TrimSpace(string(body)))) + return + + case discordOpFrame: + + default: + continue + } + + var frame discordFrame + if err := json.Unmarshal(body, &frame); err != nil { + continue + } + + // the handshake is acknowledged with an event rather than a reply, so + // it can't be correlated by nonce like everything else + if frame.Cmd == "DISPATCH" && frame.Evt == "READY" { + c.readyOnce.Do(func() { close(c.ready) }) + continue + } + + if frame.Nonce == "" { + // a subscribed event. onEvent must not block: it's handing work to + // a goroutine that issues requests this very loop has to answer + if frame.Cmd == "DISPATCH" && frame.Evt != "" && c.onEvent != nil { + c.onEvent(frame.Evt, frame.Data) + } + + continue + } + + c.pendingLock.Lock() + replyChannel, waiting := c.pending[frame.Nonce] + delete(c.pending, frame.Nonce) + c.pendingLock.Unlock() + + if waiting { + replyChannel <- frame + } + } +} + +// discordMaxFrameSize caps what a single frame may claim, so a desynced stream +// can't make deej allocate wildly +const discordMaxFrameSize = 1 << 20 + +// writeDiscordFrame writes one IPC frame: a little-endian opcode and length, +// then the JSON body +func writeDiscordFrame(w io.Writer, opcode uint32, body []byte) error { + frame := make([]byte, 8, 8+len(body)) + binary.LittleEndian.PutUint32(frame[0:4], opcode) + binary.LittleEndian.PutUint32(frame[4:8], uint32(len(body))) + + if _, err := w.Write(append(frame, body...)); err != nil { + return fmt.Errorf("write frame: %w", err) + } + + return nil +} + +func readDiscordFrame(r io.Reader) (uint32, []byte, error) { + header := make([]byte, 8) + if _, err := io.ReadFull(r, header); err != nil { + return 0, nil, err + } + + opcode := binary.LittleEndian.Uint32(header[0:4]) + length := binary.LittleEndian.Uint32(header[4:8]) + + if length > discordMaxFrameSize { + return 0, nil, fmt.Errorf("frame too large: %d bytes", length) + } + + body := make([]byte, length) + if _, err := io.ReadFull(r, body); err != nil { + return 0, nil, err + } + + return opcode, body, nil +} + +// dialDiscordIPC opens the Discord client's local RPC transport. Discord takes +// the first free slot of ten, so all of them get probed +func dialDiscordIPC() (io.ReadWriteCloser, error) { + for _, base := range discordIPCPaths() { + for i := range 10 { + rw, err := openDiscordIPC(fmt.Sprintf("%s%d", base, i)) + if err != nil { + continue + } + + return rw, nil + } + } + + return nil, errors.New("no Discord IPC socket found (is Discord running?)") +} diff --git a/pkg/deej/discord_ipc_other.go b/pkg/deej/discord_ipc_other.go new file mode 100644 index 00000000..c70ca134 --- /dev/null +++ b/pkg/deej/discord_ipc_other.go @@ -0,0 +1,31 @@ +//go:build !windows + +package deej + +import ( + "cmp" + "io" + "net" + "os" + "path/filepath" +) + +func discordIPCPaths() []string { + root := cmp.Or(os.Getenv("XDG_RUNTIME_DIR"), os.Getenv("TMPDIR"), "/tmp") + + // flatpak and snap installs put their socket in a sandbox subdirectory + dirs := []string{"", "app/com.discordapp.Discord", "app/com.discordapp.DiscordCanary", "snap.discord"} + + paths := make([]string, 0, len(dirs)) + for _, dir := range dirs { + paths = append(paths, filepath.Join(root, dir, "discord-ipc-")) + } + + return paths +} + +// openDiscordIPC connects to one candidate socket. A unix socket supports +// concurrent reads and writes, which is what the read loop relies on +func openDiscordIPC(path string) (io.ReadWriteCloser, error) { + return net.Dial("unix", path) +} diff --git a/pkg/deej/discord_ipc_windows.go b/pkg/deej/discord_ipc_windows.go new file mode 100644 index 00000000..27f3dbb2 --- /dev/null +++ b/pkg/deej/discord_ipc_windows.go @@ -0,0 +1,29 @@ +package deej + +import ( + "io" + "time" + + "github.com/Microsoft/go-winio" +) + +// how long to wait for the named pipe to accept a connection +const discordPipeDialTimeout = 2 * time.Second + +func discordIPCPaths() []string { + return []string{`\\.\pipe\discord-ipc-`} +} + +// openDiscordIPC connects to one candidate pipe. +// +// This deliberately does not use os.OpenFile: that opens the pipe as a +// synchronous handle, and Windows serializes I/O on one of those - a blocking +// ReadFile from the read loop makes a concurrent WriteFile on the same handle +// wait behind it, which deadlocks the moment deej sends a command while +// waiting for a reply. winio opens the pipe overlapped, which is what makes +// concurrent reads and writes safe +func openDiscordIPC(path string) (io.ReadWriteCloser, error) { + timeout := discordPipeDialTimeout + + return winio.DialPipe(path, &timeout) +} diff --git a/pkg/deej/discord_test.go b/pkg/deej/discord_test.go new file mode 100644 index 00000000..de9243a3 --- /dev/null +++ b/pkg/deej/discord_test.go @@ -0,0 +1,502 @@ +package deej + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "maps" + "math" + "path/filepath" + "slices" + "testing" + "time" + + "go.uber.org/zap" +) + +func newTestDiscordClient() *DiscordClient { + return &DiscordClient{ + pending: map[string]float32{}, + lastSent: map[string]float32{}, + } +} + +func TestNewDiscordClientBeforeConfigLoad(t *testing.T) { + config := &CanonicalConfig{configPath: filepath.Join(t.TempDir(), "config.yaml")} + deej := &Deej{config: config} + + client := NewDiscordClient(deej, zap.NewNop().Sugar()) + if client == nil { + t.Fatal("expected Discord client") + } +} + +func TestDiscordFrameRoundTrip(t *testing.T) { + var buf bytes.Buffer + + body := []byte(`{"cmd":"AUTHENTICATE","nonce":"abc"}`) + if err := writeDiscordFrame(&buf, discordOpFrame, body); err != nil { + t.Fatalf("write frame: %v", err) + } + + opcode, got, err := readDiscordFrame(&buf) + if err != nil { + t.Fatalf("read frame: %v", err) + } + + if opcode != discordOpFrame { + t.Errorf("opcode = %d, want %d", opcode, discordOpFrame) + } + if !bytes.Equal(got, body) { + t.Errorf("body = %q, want %q", got, body) + } +} + +// the transport is a stream, so a frame can arrive in pieces +func TestDiscordFrameReadsAcrossChunks(t *testing.T) { + var buf bytes.Buffer + + body := bytes.Repeat([]byte("x"), 5000) + if err := writeDiscordFrame(&buf, discordOpFrame, body); err != nil { + t.Fatalf("write frame: %v", err) + } + + // hand the reader one byte at a time + opcode, got, err := readDiscordFrame(iotest(buf.Bytes())) + if err != nil { + t.Fatalf("read frame: %v", err) + } + + if opcode != discordOpFrame { + t.Errorf("opcode = %d, want %d", opcode, discordOpFrame) + } + if !bytes.Equal(got, body) { + t.Errorf("body length = %d, want %d", len(got), len(body)) + } +} + +// oneByteReader hands out a single byte per Read, the worst case io.ReadFull +// has to cope with +type oneByteReader struct { + data []byte +} + +func iotest(data []byte) io.Reader { + return &oneByteReader{data: data} +} + +func (r *oneByteReader) Read(p []byte) (int, error) { + if len(r.data) == 0 { + return 0, io.EOF + } + if len(p) == 0 { + return 0, nil + } + + p[0] = r.data[0] + r.data = r.data[1:] + + return 1, nil +} + +func TestDiscordFrameRejectsOversizedLength(t *testing.T) { + var buf bytes.Buffer + + // a header claiming more than the cap, with no body behind it + if err := writeDiscordFrame(&buf, discordOpFrame, nil); err != nil { + t.Fatalf("write frame: %v", err) + } + header := buf.Bytes() + header[4], header[5], header[6], header[7] = 0xFF, 0xFF, 0xFF, 0xFF + + if _, _, err := readDiscordFrame(bytes.NewReader(header)); err == nil { + t.Fatal("expected an error for an oversized frame length") + } +} + +func TestDiscordVolume(t *testing.T) { + cases := []struct { + percent float32 + want float64 + }{ + {0, 0}, + {0.5, 50}, + {1, 100}, + {0.335, 34}, // rounded, not truncated + {-1, 0}, // clamped + {2, 100}, + } + + for _, testCase := range cases { + if got := discordVolume(testCase.percent); got != testCase.want { + t.Errorf("discordVolume(%v) = %v, want %v", testCase.percent, got, testCase.want) + } + } +} + +func TestDiscordUserVolume(t *testing.T) { + cases := []struct { + percent float32 + want float64 + }{ + {0, 0}, + {0.01, 0.0001}, + {0.02, 0.0008}, + {0.5, 12.5}, + {1, 100}, + {-1, 0}, // clamped + {2, 100}, + } + + for _, testCase := range cases { + if got := discordUserVolume(testCase.percent); math.Abs(got-testCase.want) > 1e-9 { + t.Errorf("discordUserVolume(%v) = %v, want %v", testCase.percent, got, testCase.want) + } + } +} + +func TestDiscordReplyDataUnwrapsError(t *testing.T) { + data, err := discordReplyData(discordFrame{Data: json.RawMessage(`{"ok":true}`)}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(data) != `{"ok":true}` { + t.Errorf("data = %s", data) + } + + _, err = discordReplyData(discordFrame{ + Evt: "ERROR", + Data: json.RawMessage(`{"code":4006,"message":"Not authenticated"}`), + }) + if err == nil { + t.Fatal("expected an error for an ERROR frame") + } + if got := err.Error(); got != "discord rpc error 4006: Not authenticated" { + t.Errorf("error = %q", got) + } +} + +func TestDiscordConnectionFailureWakesEveryRequest(t *testing.T) { + conn := &discordConn{ + rw: &testDiscordTransport{}, + pending: map[string]chan discordFrame{}, + done: make(chan struct{}), + } + + results := make(chan error, 2) + for range 2 { + go func() { + _, err := conn.requestWithTimeout("TEST", nil, 5*time.Second) + results <- err + }() + } + + deadline := time.Now().Add(time.Second) + for { + conn.pendingLock.Lock() + pending := len(conn.pending) + conn.pendingLock.Unlock() + if pending == 2 { + break + } + if time.Now().After(deadline) { + t.Fatal("requests did not become pending") + } + time.Sleep(time.Millisecond) + } + + want := errors.New("connection lost") + conn.fail(want) + for range 2 { + select { + case err := <-results: + if !errors.Is(err, want) { + t.Errorf("request error = %v, want %v", err, want) + } + case <-time.After(time.Second): + t.Fatal("request remained blocked after connection failure") + } + } +} + +func TestDiscordVoiceStateSubscriptionFailureIsReported(t *testing.T) { + done := make(chan struct{}) + close(done) + + conn := &discordConn{ + rw: &testDiscordTransport{}, + pending: map[string]chan discordFrame{}, + done: done, + doneErr: errors.New("connection lost"), + } + client := newTestDiscordClient() + client.logger = zap.NewNop().Sugar() + + if client.resubscribeVoiceStates(conn, "", "channel") { + t.Fatal("failed subscriptions must not be reported as successful") + } +} + +type testDiscordTransport struct { + bytes.Buffer +} + +func (*testDiscordTransport) Close() error { return nil } + +// a slider sweep must collapse to one send per target, and the final resting +// position must be the value that survives +func TestDiscordThrottleCollapsesBurst(t *testing.T) { + client := newTestDiscordClient() + + for _, percent := range []float32{0.1, 0.2, 0.3, 0.4} { + client.pending[discordKeyInput] = percent + } + + todo := client.takePending() + if len(todo) != 1 { + t.Fatalf("takePending returned %d entries, want 1", len(todo)) + } + if todo[discordKeyInput] != 0.4 { + t.Errorf("value = %v, want the last one (0.4)", todo[discordKeyInput]) + } + + if len(client.pending) != 0 { + t.Errorf("pending should be drained, got %d entries", len(client.pending)) + } +} + +func TestDiscordThrottleSkipsUnchangedValues(t *testing.T) { + client := newTestDiscordClient() + + client.pending[discordKeyOutput] = 0.5 + client.markSent(client.takePending()) + + // the same value again is a no-op... + client.pending[discordKeyOutput] = 0.5 + if todo := client.takePending(); len(todo) != 0 { + t.Errorf("unchanged value should not be resent, got %v", todo) + } + + // ...but a different one still goes out + client.pending[discordKeyOutput] = 0.6 + if todo := client.takePending(); todo[discordKeyOutput] != 0.6 { + t.Errorf("changed value should be sent, got %v", todo) + } +} + +// the roster is maintained incrementally from VOICE_STATE_* events, so the +// folding has to add, update, remove and keep the list sorted +func TestDiscordApplyVoiceStateEvent(t *testing.T) { + client := newTestDiscordClient() + client.roster.Store(&discordRoster{id: "c1", channel: "General"}) + + names := func() []string { + roster := client.roster.Load() + out := make([]string, 0, len(roster.users)) + for _, user := range roster.users { + out = append(out, user.Name+"="+user.ID) + } + return out + } + + apply := func(name string, payload string) { + client.applyVoiceStateEvent(discordEvent{name: name, data: json.RawMessage(payload)}) + } + + apply("VOICE_STATE_CREATE", `{"user":{"id":"2","username":"zoe"}}`) + apply("VOICE_STATE_CREATE", `{"user":{"id":"1","username":"adam"}}`) + + if got := names(); !slices.Equal(got, []string{"adam=1", "zoe=2"}) { + t.Fatalf("after two joins: %v, want them sorted by name", got) + } + + // a nickname wins over the global name, and an update replaces in place + apply("VOICE_STATE_UPDATE", `{"nick":"Bob","user":{"id":"2","username":"zoe","global_name":"Zoe"}}`) + + if got := names(); !slices.Equal(got, []string{"adam=1", "Bob=2"}) { + t.Fatalf("after a rename: %v", got) + } + + apply("VOICE_STATE_DELETE", `{"user":{"id":"1","username":"adam"}}`) + + if got := names(); !slices.Equal(got, []string{"Bob=2"}) { + t.Fatalf("after a leave: %v", got) + } + + // the channel identity survives the folding + if roster := client.roster.Load(); roster.id != "c1" || roster.channel != "General" { + t.Errorf("channel lost: %+v", roster) + } + + // events for someone already gone, and malformed ones, change nothing + apply("VOICE_STATE_DELETE", `{"user":{"id":"404","username":"ghost"}}`) + apply("VOICE_STATE_CREATE", `not json`) + apply("VOICE_STATE_CREATE", `{"user":{}}`) + + if got := names(); !slices.Equal(got, []string{"Bob=2"}) { + t.Errorf("roster should be unchanged: %v", got) + } +} + +// an event arriving before the first reconcile has no roster to fold into, +// and must not invent one out of a single member +func TestDiscordVoiceStateEventWithoutRoster(t *testing.T) { + client := newTestDiscordClient() + + client.applyVoiceStateEvent(discordEvent{ + name: "VOICE_STATE_CREATE", + data: json.RawMessage(`{"user":{"id":"1","username":"adam"}}`), + }) + + if roster := client.roster.Load(); roster != nil { + t.Errorf("expected no roster, got %+v", roster) + } +} + +func TestDiscordResolveUser(t *testing.T) { + client := newTestDiscordClient() + client.roster.Store(&discordRoster{ + channel: "General", + users: []DiscordUserDTO{{ID: "123", Name: "Nikita"}}, + }) + + // case-insensitive display name match + if id, ok := client.resolveUser("nikita"); !ok || id != "123" { + t.Errorf("resolveUser(nikita) = %q, %v; want 123, true", id, ok) + } + + // an all-digit target is a user id, and works with nobody in the roster + if id, ok := client.resolveUser("987654321"); !ok || id != "987654321" { + t.Errorf("resolveUser(987654321) = %q, %v; want it passed through", id, ok) + } + + if _, ok := client.resolveUser("someone else"); ok { + t.Error("expected a miss for a user who isn't in the channel") + } +} + +// the slider handler has to route discord targets away from the audio session +// path, and leave everything else alone +func TestDiscordTargetRouting(t *testing.T) { + sessions := &sessionMap{deej: &Deej{}} + + handled := []string{ + "deej.discord.input", + "deej.discord.output", + "deej.discord:Nikita", + "DEEJ.DISCORD:Nikita", // targets are case-insensitive + } + + for _, target := range handled { + if !sessions.applySpecialTargetAction(target, 0.5) { + t.Errorf("%q should be handled as a discord target", target) + } + } + + notHandled := []string{"discord.exe", "master", "deej.current", "deej.discordant"} + + for _, target := range notHandled { + if sessions.applySpecialTargetAction(target, 0.5) { + t.Errorf("%q should fall through to the audio session path", target) + } + } +} + +func TestDiscordReplayMappedSliderValues(t *testing.T) { + settings := defaultSettings() + settings.Profiles[0].SliderMapping = SliderMappings{ + {Slider: 0, Targets: []string{"master", "DEEJ.DISCORD.INPUT", "deej.discord:Nikita"}}, + {Slider: 1, Targets: []string{"deej.discord.output"}}, + {Slider: 99, Targets: []string{"deej.discord:missing-slider"}}, + } + + config := &CanonicalConfig{} + config.current.Store(&settings) + deej := &Deej{config: config} + deej.serial = &SerialIO{deej: deej, currentSliderValues: []int{256, 768}} + client := newTestDiscordClient() + client.deej = deej + deej.discord = client + + client.replayMappedSliderValues() + + values := deej.serial.CurrentSliderPercentValues() + want := map[string]float32{ + discordKeyInput: values[0], + discordKeyUser + "nikita": values[0], + discordKeyOutput: values[1], + } + if !maps.Equal(client.pending, want) { + t.Errorf("pending = %v, want %v", client.pending, want) + } + + client.markSent(client.takePending()) + client.roster.Store(&discordRoster{id: "channel"}) + client.applyVoiceStateEvent(discordEvent{ + name: "VOICE_STATE_CREATE", + data: json.RawMessage(`{"user":{"id":"123","username":"Nikita"}}`), + }) + + if len(client.lastSent) != 0 { + t.Errorf("join should invalidate sent values, got %v", client.lastSent) + } + if !maps.Equal(client.pending, want) { + t.Errorf("pending after join = %v, want replay %v", client.pending, want) + } +} + +func TestNormalizeDiscordRequiresCredentials(t *testing.T) { + settings := defaultSettings() + settings.Discord = DiscordSettings{Enabled: true} + + problems := settings.normalize() + if len(problems) != 2 { + t.Fatalf("expected both credentials to be reported, got %v", problems) + } + if settings.Discord.Enabled { + t.Error("enabling without credentials should leave the integration off") + } + + // a client secret pasted into the client id field is caught up front + settings = defaultSettings() + settings.Discord = DiscordSettings{Enabled: true, ClientID: "not-a-snowflake", ClientSecret: "secret"} + + if problems := settings.normalize(); len(problems) != 1 { + t.Fatalf("expected the non-numeric client id to be reported, got %v", problems) + } + + // the valid case is left alone, whitespace aside + settings = defaultSettings() + settings.Discord = DiscordSettings{Enabled: true, ClientID: " 1234567890 ", ClientSecret: " secret "} + + if problems := settings.normalize(); len(problems) != 0 { + t.Fatalf("expected no problems, got %v", problems) + } + if settings.Discord.ClientID != "1234567890" || settings.Discord.ClientSecret != "secret" { + t.Errorf("credentials should be trimmed, got %+v", settings.Discord) + } + if !settings.Discord.Enabled { + t.Error("valid credentials should stay enabled") + } +} + +func TestDiscordCredentialsChanged(t *testing.T) { + base := DiscordSettings{Enabled: true, ClientID: "123", ClientSecret: "secret"} + + if discordCredentialsChanged(base, base) { + t.Fatal("unchanged credentials should keep the linked account") + } + + changedID := base + changedID.ClientID = "456" + if !discordCredentialsChanged(base, changedID) { + t.Fatal("changing the client ID should invalidate the linked account") + } + + changedSecret := base + changedSecret.ClientSecret = "new-secret" + if !discordCredentialsChanged(base, changedSecret) { + t.Fatal("changing the client secret should invalidate the linked account") + } +} diff --git a/pkg/deej/session_map.go b/pkg/deej/session_map.go index 0c2f52ba..c2833c4a 100644 --- a/pkg/deej/session_map.go +++ b/pkg/deej/session_map.go @@ -38,6 +38,13 @@ const ( // obs targets are handled directly via OBS WebSocket API obsTargetPrefix = "deej.obs:" + // discord targets are handled directly via Discord's local RPC connection. + // mapping the discord.exe process covers none of these: they reach inside + // Discord rather than at its audio session + discordUserTargetPrefix = "deej.discord:" // one person in the current voice channel + discordInputTarget = "deej.discord.input" // own microphone level + discordOutputTarget = "deej.discord.output" + // targets the currently active window (Windows-only, experimental) specialTargetCurrentWindow = "current" @@ -265,14 +272,18 @@ func (m *sessionMap) sessionMapped(session Session) bool { // look through the actual mappings for _, entry := range m.deej.config.Values().ActiveMapping() { for _, target := range entry.Targets { + target = strings.ToLower(target) // ignore special transforms if m.targetHasSpecialTransform(target) { continue } - // safe to assume this has a single element because we made sure there's no special transform - target = m.resolveTarget(target)[0] + resolvedTargets := m.resolveTarget(target) + if len(resolvedTargets) == 0 { + continue + } + target = resolvedTargets[0] if target == session.Key() { return true @@ -337,14 +348,23 @@ func (m *sessionMap) setSessionVolumes(target string, volume float32) { } // applySpecialTargetAction handles targets that control external systems rather than audio sessions -// (e.g. OBS, and potentially Discord or others in the future). +// (e.g. OBS and Discord). // Returns true if the target was handled, false if it should be treated as a normal audio target. func (m *sessionMap) applySpecialTargetAction(target string, volume float32) bool { + lowerTarget := strings.ToLower(target) + discordKey, isDiscordTarget := discordTargetKey(target) + switch { - case strings.HasPrefix(strings.ToLower(target), obsTargetPrefix): + case strings.HasPrefix(lowerTarget, obsTargetPrefix): inputName := target[len(obsTargetPrefix):] m.handleOBSTarget(inputName, volume) return true + + case isDiscordTarget: + if m.deej.discord != nil { + m.deej.discord.setVolume(discordKey, volume) + } + return true } return false diff --git a/pkg/deej/session_map_test.go b/pkg/deej/session_map_test.go new file mode 100644 index 00000000..571b8f1d --- /dev/null +++ b/pkg/deej/session_map_test.go @@ -0,0 +1,23 @@ +package deej + +import "testing" + +type testSession struct{ baseSession } + +func (*testSession) GetVolume() float32 { return 0 } +func (*testSession) SetVolume(float32) error { return nil } +func (*testSession) Release() {} + +func TestSessionMappedIgnoresMixedCaseSpecialTarget(t *testing.T) { + settings := defaultSettings() + settings.Profiles[0].SliderMapping = SliderMappings{ + {Slider: 0, Targets: []string{"DEEJ.DISCORD:Nikita"}}, + } + config := &CanonicalConfig{} + config.current.Store(&settings) + m := &sessionMap{deej: &Deej{config: config}} + + if m.sessionMapped(&testSession{baseSession: baseSession{name: "other.exe"}}) { + t.Fatal("Discord target should not map an audio session") + } +} diff --git a/pkg/deej/settings_service.go b/pkg/deej/settings_service.go index d8cd9513..4dfa957a 100644 --- a/pkg/deej/settings_service.go +++ b/pkg/deej/settings_service.go @@ -250,6 +250,61 @@ func (s *SettingsService) GetOBSInputs() ([]string, error) { return s.d.obs.ListInputs() } +// DiscordUserDTO describes one member of the current voice channel +type DiscordUserDTO struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// DiscordStatusDTO describes the Discord integration's state for the settings GUI +type DiscordStatusDTO struct { + Linked bool `json:"linked"` // an account has been authorized + Connected bool `json:"connected"` // the RPC connection is up + Channel string `json:"channel"` // the voice channel the user is in, if any +} + +// GetDiscordStatus returns the Discord integration's live state +func (s *SettingsService) GetDiscordStatus() DiscordStatusDTO { + if s.d.discord == nil { + return DiscordStatusDTO{} + } + + linked, connected, channel := s.d.discord.Status() + + return DiscordStatusDTO{Linked: linked, Connected: connected, Channel: channel} +} + +// GetDiscordUsers returns the members of the voice channel the user is +// currently in, for slider mapping suggestions. The roster is kept current by +// RPC events, so this serves it as-is; the GUI is told to re-read it by the +// deej:discord event rather than by polling +func (s *SettingsService) GetDiscordUsers() ([]DiscordUserDTO, error) { + if s.d.discord == nil { + return nil, errors.New("discord integration is not initialized") + } + + return s.d.discord.VoiceUsers(), nil +} + +// LinkDiscord runs the interactive authorization. Discord shows the user a +// consent dialog, so this blocks until they answer it (or time out) +func (s *SettingsService) LinkDiscord() error { + if s.d.discord == nil { + return errors.New("discord integration is not initialized") + } + + return s.d.discord.Link() +} + +// UnlinkDiscord forgets the stored authorization +func (s *SettingsService) UnlinkDiscord() error { + if s.d.discord == nil { + return errors.New("discord integration is not initialized") + } + + return s.d.discord.Unlink() +} + // ListSerialPorts enumerates the serial ports available on this machine func (s *SettingsService) ListSerialPorts() ([]SerialPortDTO, error) { ports, err := enumerator.GetDetailedPortsList() diff --git a/pkg/deej/tray.go b/pkg/deej/tray.go index 0c3e1329..69a685c2 100644 --- a/pkg/deej/tray.go +++ b/pkg/deej/tray.go @@ -37,6 +37,7 @@ const ( eventState = "deej:state" // {connected bool, comPort string} eventConfig = "deej:config" // no payload; config was (re)applied eventSessions = "deej:sessions" // no payload; audio sessions changed + eventDiscord = "deej:discord" // no payload; the discord voice roster changed ) func getConfigItemText(d *Deej) (string, string) { @@ -411,6 +412,7 @@ func (d *Deej) initializeTray(onDone func()) { stateChangeChannel := d.serial.SubscribeToStateChangeEvent() sessionCountChangeChannel := d.sessions.SubscribeToSessionCountChange() configReloadedChannel := d.config.SubscribeToChanges() + discordRosterChannel := d.discord.SubscribeToRosterChange() emitState := func() { app.Event.Emit(eventState, map[string]any{ @@ -439,6 +441,10 @@ func (d *Deej) initializeTray(onDone func()) { emitState() app.Event.Emit(eventSliders, d.serial.CurrentSliderPercentValues()) + // someone joined or left the discord voice channel + case <-discordRosterChannel: + app.Event.Emit(eventDiscord) + // session count changed case <-sessionCountChangeChannel: application.InvokeAsync(func() { From 8b0e0b0f9892ada049ea4cc2698ba36c9d93b487 Mon Sep 17 00:00:00 2001 From: Nikita Date: Sun, 9 Aug 2026 19:29:09 +0300 Subject: [PATCH 31/47] feat: add configurable volume conversion --- CHANGELOG.md | 4 +++ CLAUDE.md | 7 ++-- config_examples/config.example.ru.yaml | 7 ++++ config_examples/config.example.yaml | 7 ++++ .../nik9play/deej/pkg/deej/models.ts | 8 +++++ frontend/messages/en.json | 4 +++ frontend/messages/ru.json | 4 +++ frontend/src/components/DiscordSection.svelte | 11 +++++- frontend/src/components/ObsSection.svelte | 9 +++++ pkg/deej/config_model.go | 25 +++++++++----- pkg/deej/discord.go | 20 +++++++---- pkg/deej/discord_test.go | 23 ++++++++----- pkg/deej/obs.go | 16 ++++++--- pkg/deej/obs_test.go | 34 +++++++++++++++++++ 14 files changed, 147 insertions(+), 32 deletions(-) create mode 100644 pkg/deej/obs_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f687157..e27b0c22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,9 @@ host: localhost port: 4455 password: "" # set if you use a password in OBS + volume_conversion: true ``` + Keep `volume_conversion` enabled (the default) so the physical slider stays aligned with the fader shown in OBS and offers finer control at low volumes. Set it to `false` if you prefer the volume to rise faster as you move the slider. - **Discord integration** — Control the volume of individual people in a voice call, plus your own microphone and output levels inside Discord. Mapping the `discord.exe` process only ever moved all of Discord at once; this reaches inside it: ```yaml slider_mapping: @@ -42,7 +44,9 @@ enabled: true client_id: "" client_secret: "" + volume_conversion: true ``` + For individual participant targets, keep `volume_conversion` enabled (the default) so the physical slider stays aligned with Discord's on-screen volume control and offers finer control at low volumes. Set it to `false` if you prefer the volume to rise faster. This setting does not affect `deej.discord.input` or `deej.discord.output`. Discord only allows this for an application you own, so deej can't ship its own credentials. Create one at [discord.com/developers](https://discord.com/developers/applications), add `http://localhost` as a redirect URI under _OAuth2_, paste its client ID and secret into the settings window's _Discord_ tab, and click _Link Discord account_ — Discord asks you to approve it once, and deej reconnects on its own from then on. The target picker lists whoever is in your voice channel, and a name that isn't there yet can be typed in by hand; it starts working once they join. - **Named audio device support on Linux** — Sliders can now be mapped to specific audio devices by name on Linux, same as Windows. Input devices are suffixed with `(input)` (e.g. `hyperx cloud (input)`), so a card's input and output can be controlled separately even when they share the same device name. - **Event-driven session tracking** — deej now listens for system audio events instead of polling, so sliders respond correctly as soon as an app opens or closes. diff --git a/CLAUDE.md b/CLAUDE.md index f37788d8..256e651c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,7 +108,7 @@ Only `slider_mapping` and `hotkey` are per-profile; `com`, `obs`, `discord`, `la Special targets: `master`, `system`, `mic`, `deej.current`, `deej.current.fullscreen`, `deej.unmapped`, `deej.obs:` (OBS input volume), `deej.discord:` (one person's volume in the current voice channel, matched case-insensitively against their display name, or a raw user ID), `deej.discord.input` and `deej.discord.output` (the user's own levels inside Discord). -Serial connection settings live in the `com:` section (OBS settings in an analogous `obs:` section): +Serial connection settings live in the `com:` section (OBS settings use an analogous `obs:` section; its `volume_conversion` defaults to true and selects cubic `position³` versus linear `position` input-volume multipliers): ```yaml com: port: auto # COM port name, or "auto" to detect by USB VID/PID @@ -119,14 +119,17 @@ com: The legacy flat keys (`com_port`, `baud_rate`, `com_vid`, `com_pid`) are still read for backwards compatibility, but new configs and GUI saves use the `com:` section only. -Discord settings live in a `discord:` section holding only `enabled`, `client_id` and `client_secret` — the OAuth token that linking produces is stored separately in `discord_token.json`, so backend writes never race the GUI's whole-document saves: +Discord settings live in a `discord:` section holding `enabled`, `client_id`, `client_secret` and `volume_conversion` — the OAuth token that linking produces is stored separately in `discord_token.json`, so backend writes never race the GUI's whole-document saves: ```yaml discord: enabled: false client_id: "" # from an application you create at discord.com/developers client_secret: "" + volume_conversion: true ``` +Discord `volume_conversion` applies only to participant targets (`deej.discord:`): true sends `position³ × 100` to match Discord's displayed curve, while false sends `position × 100`. The input and output targets always use the linear 0–100 scale. + The config can be edited by hand (hot-reloaded) or through the settings GUI, which writes the same file. ### Data Flow diff --git a/config_examples/config.example.ru.yaml b/config_examples/config.example.ru.yaml index b68231b7..221fc13e 100644 --- a/config_examples/config.example.ru.yaml +++ b/config_examples/config.example.ru.yaml @@ -61,6 +61,9 @@ obs: host: localhost port: 4455 password: "" + # рекомендуется: положение физического ползунка совпадает с фейдером в OBS, + # а на низкой громкости регулировка точнее; false ускоряет рост громкости + volume_conversion: true # Интеграция с Discord (опционально) # Управление громкостью отдельных собеседников через 'deej.discord:<имя>' в slider_mapping, @@ -73,3 +76,7 @@ discord: enabled: false client_id: "" client_secret: "" + # рекомендуется для отдельных собеседников: положение физического ползунка + # совпадает с регулятором Discord, а на низкой громкости регулировка точнее; + # false ускоряет рост громкости. На цели input/output настройка не влияет + volume_conversion: true diff --git a/config_examples/config.example.yaml b/config_examples/config.example.yaml index ef06a0b3..42cb8b89 100644 --- a/config_examples/config.example.yaml +++ b/config_examples/config.example.yaml @@ -62,6 +62,9 @@ obs: host: localhost port: 4455 password: "" + # recommended: keeps the physical slider aligned with the fader shown in OBS + # and gives finer control at low volumes; false makes volume rise faster + volume_conversion: true # Discord integration (optional) # control the volume of individual people in a voice call with 'deej.discord:', @@ -74,3 +77,7 @@ discord: enabled: false client_id: "" client_secret: "" + # recommended for individual participants: keeps the physical slider aligned + # with Discord's on-screen control and improves low-volume precision; + # false makes volume rise faster. input/output targets are unaffected + volume_conversion: true diff --git a/frontend/bindings/github.com/nik9play/deej/pkg/deej/models.ts b/frontend/bindings/github.com/nik9play/deej/pkg/deej/models.ts index e095f22e..7ab60b62 100644 --- a/frontend/bindings/github.com/nik9play/deej/pkg/deej/models.ts +++ b/frontend/bindings/github.com/nik9play/deej/pkg/deej/models.ts @@ -96,6 +96,7 @@ export class DiscordSettings { "enabled": boolean; "clientId": string; "clientSecret": string; + "volumeConversion": boolean; /** Creates a new DiscordSettings instance. */ constructor($$source: Partial = {}) { @@ -108,6 +109,9 @@ export class DiscordSettings { if (!("clientSecret" in $$source)) { this["clientSecret"] = ""; } + if (!("volumeConversion" in $$source)) { + this["volumeConversion"] = false; + } Object.assign(this, $$source); } @@ -207,6 +211,7 @@ export class OBSSettings { "host": string; "port": number; "password": string; + "volumeConversion": boolean; /** Creates a new OBSSettings instance. */ constructor($$source: Partial = {}) { @@ -222,6 +227,9 @@ export class OBSSettings { if (!("password" in $$source)) { this["password"] = ""; } + if (!("volumeConversion" in $$source)) { + this["volumeConversion"] = false; + } Object.assign(this, $$source); } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 020d7825..45d51dd2 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -41,6 +41,8 @@ "obs": "OBS integration", "obsEnabled": "Enable OBS Studio integration via WebSocket", "obsHint": "Control audio sources in OBS Studio.", + "obsVolumeConversion": "Match OBS's volume curve", + "obsVolumeConversionHint": "Recommended. Keeps the physical slider aligned with the fader shown in OBS and gives finer control at low volumes. Turn it off if you prefer volume to rise faster.", "obsWebsocketHint": "Enable the WebSocket server in OBS first: Tools → WebSocket Server Settings.", "obsHost": "Address", "obsPort": "Port", @@ -48,6 +50,8 @@ "discord": "Discord integration", "discordEnabled": "Enable Discord integration", "discordHint": "Control the volume of individual people in a voice call, and your own mic and output levels inside Discord.", + "discordVolumeConversion": "Match Discord's volume curve", + "discordVolumeConversionHint": "Recommended. Keeps the physical slider aligned with Discord's on-screen volume control and gives finer control at low volumes. Turn it off if you prefer volume to rise faster.", "discordSetupHint": "deej don't ship with application token. Create one in the {#developerPortal}Discord Developer Portal{/developerPortal}. Add {#localhost}http://localhost{/localhost} as a redirect URI under OAuth2, then paste its client ID and secret below.", "copyLocalhost": "Copy http://localhost", "discordClientId": "Client ID", diff --git a/frontend/messages/ru.json b/frontend/messages/ru.json index 3a76ad7d..cc8471bb 100644 --- a/frontend/messages/ru.json +++ b/frontend/messages/ru.json @@ -41,6 +41,8 @@ "obs": "Интеграция с OBS", "obsEnabled": "Включить интеграцию с OBS Studio через WebSocket", "obsHint": "Управляйте источниками звука в OBS Studio.", + "obsVolumeConversion": "Учитывать кривую громкости OBS", + "obsVolumeConversionHint": "Рекомендуется. Положение физического ползунка совпадает с фейдером в OBS, а на низкой громкости регулировка становится точнее. Отключите, если громкость должна расти быстрее.", "obsWebsocketHint": "Сначала включите сервер WebSocket в OBS: Сервис → Настройки сервера WebSocket.", "obsHost": "Адрес", "obsPort": "Порт", @@ -48,6 +50,8 @@ "discord": "Интеграция с Discord", "discordEnabled": "Включить интеграцию с Discord", "discordHint": "Управление громкостью отдельных собеседников в голосовом канале, а также громкостью микрофона и динамика внутри Discord.", + "discordVolumeConversion": "Учитывать кривую громкости Discord", + "discordVolumeConversionHint": "Рекомендуется. Положение физического ползунка совпадает с регулятором в Discord, а на низкой громкости регулировка становится точнее. Отключите, если громкость должна расти быстрее.", "discordSetupHint": "deej не поставляется со своим токеном приложения. Создайте приложение в {#developerPortal}Discord Developer Portal{/developerPortal}. Добавьте {#localhost}http://localhost{/localhost} в Redirect URI в разделе OAuth2, затем вставьте сюда его Client ID и секрет.", "copyLocalhost": "Скопировать http://localhost", "discordClientId": "Client ID", diff --git a/frontend/src/components/DiscordSection.svelte b/frontend/src/components/DiscordSection.svelte index 4dbfc124..b3ebbb9a 100644 --- a/frontend/src/components/DiscordSection.svelte +++ b/frontend/src/components/DiscordSection.svelte @@ -112,6 +112,15 @@
{m.discordHint()}
{#if draft.enabled} +
+ +
{m.discordVolumeConversionHint()}
+
+
{#snippet developerPortal({ children })} @@ -126,7 +135,7 @@ {#snippet localhost({ children })}
{#if draft.enabled} +
+ +
{m.obsVolumeConversionHint()}
+
+
{m.obsWebsocketHint()}
diff --git a/pkg/deej/config_model.go b/pkg/deej/config_model.go index bd32b37e..57df9cdb 100644 --- a/pkg/deej/config_model.go +++ b/pkg/deej/config_model.go @@ -142,10 +142,11 @@ func isYAMLTypeError(err error) bool { // OBSSettings describes the OBS websocket connection parameters type OBSSettings struct { - Enabled bool `yaml:"enabled" json:"enabled"` - Host string `yaml:"host" json:"host"` - Port int `yaml:"port" json:"port"` - Password string `yaml:"password" json:"password"` + Enabled bool `yaml:"enabled" json:"enabled"` + Host string `yaml:"host" json:"host"` + Port int `yaml:"port" json:"port"` + Password string `yaml:"password" json:"password"` + VolumeConversion bool `yaml:"volume_conversion" json:"volumeConversion"` } // DiscordSettings describes the Discord RPC integration. deej can't ship @@ -154,9 +155,10 @@ type OBSSettings struct { // its ID and secret here. The OAuth token that comes out of linking is not // kept here - it lives in its own file next to the config (see discord.go) type DiscordSettings struct { - Enabled bool `yaml:"enabled" json:"enabled"` - ClientID string `yaml:"client_id" json:"clientId"` - ClientSecret string `yaml:"client_secret" json:"clientSecret"` + Enabled bool `yaml:"enabled" json:"enabled"` + ClientID string `yaml:"client_id" json:"clientId"` + ClientSecret string `yaml:"client_secret" json:"clientSecret"` + VolumeConversion bool `yaml:"volume_conversion" json:"volumeConversion"` } func defaultSettings() Settings { @@ -177,8 +179,13 @@ func defaultSettings() Settings { }, OBS: OBSSettings{ - Host: "localhost", - Port: 4455, + Host: "localhost", + Port: 4455, + VolumeConversion: true, + }, + Discord: DiscordSettings{ + // Preserve Discord's displayed volume curve unless explicitly disabled. + VolumeConversion: true, }, } } diff --git a/pkg/deej/discord.go b/pkg/deej/discord.go index 1d96d1e8..c1aa1038 100644 --- a/pkg/deej/discord.go +++ b/pkg/deej/discord.go @@ -382,7 +382,11 @@ func (d *DiscordClient) send(conn *discordConn, key string, percent float32) err return fmt.Errorf("no user %q in the current voice channel", name) } - _, err := conn.request("SET_USER_VOICE_SETTINGS", map[string]any{"user_id": userID, "volume": discordUserVolume(percent)}) + cfg := d.deej.config.Values().Discord + _, err := conn.request("SET_USER_VOICE_SETTINGS", map[string]any{ + "user_id": userID, + "volume": discordUserVolume(percent, cfg.VolumeConversion), + }) return err } @@ -393,12 +397,16 @@ func discordVolume(percent float32) float64 { return math.Round(min(max(float64(percent), 0), 1) * 100) } -// discordUserVolume converts a 0.0-1.0 slider position to the amplitude -// percentage expected for another user's volume. Discord's current client uses -// a cube-root display curve, so the RPC value must use its inverse cubic curve. -func discordUserVolume(percent float32) float64 { +// discordUserVolume maps a 0.0-1.0 slider position onto Discord's participant +// 0-100 participant-volume range. When conversion is enabled, the RPC value +// uses the inverse of Discord's cube-root display curve. +func discordUserVolume(percent float32, convert bool) float64 { position := min(max(float64(percent), 0), 1) - return position * position * position * 100 + if convert { + position = position * position * position + } + + return position * 100 } // resolveUser maps a mapping target to a Discord user ID. An all-digit target diff --git a/pkg/deej/discord_test.go b/pkg/deej/discord_test.go index de9243a3..3d67c797 100644 --- a/pkg/deej/discord_test.go +++ b/pkg/deej/discord_test.go @@ -138,20 +138,22 @@ func TestDiscordVolume(t *testing.T) { func TestDiscordUserVolume(t *testing.T) { cases := []struct { percent float32 + convert bool want float64 }{ - {0, 0}, - {0.01, 0.0001}, - {0.02, 0.0008}, - {0.5, 12.5}, - {1, 100}, - {-1, 0}, // clamped - {2, 100}, + {0, true, 0}, + {0.01, true, 0.0001}, + {0.02, true, 0.0008}, + {0.5, true, 12.5}, + {1, true, 100}, + {0.5, false, 50}, + {-1, false, 0}, // clamped + {2, false, 100}, } for _, testCase := range cases { - if got := discordUserVolume(testCase.percent); math.Abs(got-testCase.want) > 1e-9 { - t.Errorf("discordUserVolume(%v) = %v, want %v", testCase.percent, got, testCase.want) + if got := discordUserVolume(testCase.percent, testCase.convert); math.Abs(got-testCase.want) > 1e-9 { + t.Errorf("discordUserVolume(%v, %v) = %v, want %v", testCase.percent, testCase.convert, got, testCase.want) } } } @@ -448,6 +450,9 @@ func TestDiscordReplayMappedSliderValues(t *testing.T) { func TestNormalizeDiscordRequiresCredentials(t *testing.T) { settings := defaultSettings() + if !settings.Discord.VolumeConversion { + t.Fatal("volume conversion should default on for configs without the new key") + } settings.Discord = DiscordSettings{Enabled: true} problems := settings.normalize() diff --git a/pkg/deej/obs.go b/pkg/deej/obs.go index 0ffae61b..deac7ce7 100644 --- a/pkg/deej/obs.go +++ b/pkg/deej/obs.go @@ -72,10 +72,15 @@ func (o *OBSClient) IsConnected() bool { } // obsVolumeMulFromPercent converts a 0.0-1.0 slider position to an OBS volume -// multiplier using the cubic curve OBS's own mixer faders use -func obsVolumeMulFromPercent(percent float32) float64 { - p := float64(percent) - return p * p * p +// multiplier. Conversion uses the cubic curve OBS's own mixer faders use; +// when disabled, the physical slider maps linearly to the multiplier. +func obsVolumeMulFromPercent(percent float32, convert bool) float64 { + position := min(max(float64(percent), 0), 1) + if convert { + return position * position * position + } + + return position } func (o *OBSClient) SetInputVolume(inputName string, percent float32) error { @@ -84,7 +89,8 @@ func (o *OBSClient) SetInputVolume(inputName string, percent float32) error { return fmt.Errorf("not connected to OBS") } - vol := obsVolumeMulFromPercent(percent) + convert := o.deej.config.Values().OBS.VolumeConversion + vol := obsVolumeMulFromPercent(percent, convert) _, err := conn.client.Inputs.SetInputVolume(&inputs.SetInputVolumeParams{ InputName: &inputName, InputVolumeMul: &vol, diff --git a/pkg/deej/obs_test.go b/pkg/deej/obs_test.go new file mode 100644 index 00000000..60f9ad43 --- /dev/null +++ b/pkg/deej/obs_test.go @@ -0,0 +1,34 @@ +package deej + +import ( + "math" + "testing" +) + +func TestOBSVolumeMulFromPercent(t *testing.T) { + cases := []struct { + percent float32 + convert bool + want float64 + }{ + {0, true, 0}, + {0.5, true, 0.125}, + {1, true, 1}, + {0.5, false, 0.5}, + {-1, false, 0}, + {2, false, 1}, + } + + for _, testCase := range cases { + got := obsVolumeMulFromPercent(testCase.percent, testCase.convert) + if math.Abs(got-testCase.want) > 1e-9 { + t.Errorf("obsVolumeMulFromPercent(%v, %v) = %v, want %v", testCase.percent, testCase.convert, got, testCase.want) + } + } +} + +func TestOBSVolumeConversionDefaultsOn(t *testing.T) { + if !defaultSettings().OBS.VolumeConversion { + t.Fatal("OBS volume conversion should default on for configs without the key") + } +} From 236aa2b931bfc0b4ab0b27ded9f1b6a69cd41df5 Mon Sep 17 00:00:00 2001 From: Nikita Date: Tue, 11 Aug 2026 18:10:10 +0300 Subject: [PATCH 32/47] fix(ui): handle platform fonts and long labels --- .../bindings/github.com/nik9play/deej/pkg/deej/models.ts | 8 ++++++-- frontend/src/app.css | 7 ++++++- frontend/src/components/SliderColumn.svelte | 2 +- frontend/src/components/TargetDialog.svelte | 4 ++-- frontend/src/main.ts | 1 + pkg/deej/settings_service.go | 2 ++ 6 files changed, 18 insertions(+), 6 deletions(-) diff --git a/frontend/bindings/github.com/nik9play/deej/pkg/deej/models.ts b/frontend/bindings/github.com/nik9play/deej/pkg/deej/models.ts index 7ab60b62..2c0b4fa7 100644 --- a/frontend/bindings/github.com/nik9play/deej/pkg/deej/models.ts +++ b/frontend/bindings/github.com/nik9play/deej/pkg/deej/models.ts @@ -12,6 +12,7 @@ export class AppInfoDTO { "version": string; "configPath": string; "resolvedLanguage": string; + "platform": string; "specialTargets": string[]; "autostartAvailable": boolean; @@ -26,6 +27,9 @@ export class AppInfoDTO { if (!("resolvedLanguage" in $$source)) { this["resolvedLanguage"] = ""; } + if (!("platform" in $$source)) { + this["platform"] = ""; + } if (!("specialTargets" in $$source)) { this["specialTargets"] = []; } @@ -40,10 +44,10 @@ export class AppInfoDTO { * Creates a new AppInfoDTO instance from a string or object. */ static createFrom($$source: any = {}): AppInfoDTO { - const $$createField3_0 = $$createType0; + const $$createField4_0 = $$createType0; let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; if ("specialTargets" in $$parsedSource) { - $$parsedSource["specialTargets"] = $$createField3_0($$parsedSource["specialTargets"]); + $$parsedSource["specialTargets"] = $$createField4_0($$parsedSource["specialTargets"]); } return new AppInfoDTO($$parsedSource as Partial); } diff --git a/frontend/src/app.css b/frontend/src/app.css index c4b2cd0e..044e6ab4 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -15,10 +15,15 @@ --ui-field: #ffffff; --ui-chip: #f0f0f0; --ui-track: #e4e4e4; + --app-font-sans: sans-serif; color-scheme: light dark; } +:root[data-platform="windows"] { + --app-font-sans: "Segoe UI Variable Text", sans-serif; +} + @media (prefers-color-scheme: dark) { :root { --ui-surface: #0a0a0a; @@ -50,7 +55,7 @@ --color-chip: var(--ui-chip); --color-track: var(--ui-track); - --font-sans: "Segoe UI", system-ui, -apple-system, sans-serif; + --font-sans: var(--app-font-sans); } @layer base { diff --git a/frontend/src/components/SliderColumn.svelte b/frontend/src/components/SliderColumn.svelte index 8fc87ace..c600f85c 100644 --- a/frontend/src/components/SliderColumn.svelte +++ b/frontend/src/components/SliderColumn.svelte @@ -64,7 +64,7 @@ {#if targets.length === 0} {m.unmapped()} {:else} - {targetLabel(targets[0])} + {targetLabel(targets[0])} {#if targets.length > 1} +{targets.length - 1} {/if} diff --git a/frontend/src/components/TargetDialog.svelte b/frontend/src/components/TargetDialog.svelte index babd64de..58647151 100644 --- a/frontend/src/components/TargetDialog.svelte +++ b/frontend/src/components/TargetDialog.svelte @@ -354,10 +354,10 @@
{#each targets as target (target)} - {targetLabel(target)} + {targetLabel(target)}