From e2b89433d794263ddd4c3c47f0e762da983afcea Mon Sep 17 00:00:00 2001 From: Nikita Date: Sun, 12 Jul 2026 22:03:10 +0300 Subject: [PATCH 01/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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 +}