diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 81603c14..83d01890 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,20 @@ 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 + + # 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 @@ -70,9 +91,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,34 +114,78 @@ 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) + # 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 - env: - GOARCH: arm64 - - name: Rename binaries (arm64) + - name: Rename binaries + run: | + 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-${{ 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: | - mv build/deej-release build/deej-linux-arm64 - mv build/deej-dev build/deej-linux-arm64-dev + 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 + 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 @@ -137,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/.gitignore b/.gitignore index eba73a37..85bd1d2a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,9 +8,15 @@ venv/ *.exe deej-dev deej-release -build/ +build/* +!build/config.yml releases/ *.bin logs/ preferences.yaml -config.yaml \ No newline at end of file +config.yaml +frontend/node_modules/ +frontend/dist/* +!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/Taskfile.linux.yml b/Taskfile.linux.yml new file mode 100644 index 00000000..d345c58f --- /dev/null +++ b/Taskfile.linux.yml @@ -0,0 +1,59 @@ +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 -tags production -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: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: + 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 diff --git a/Taskfile.windows.yml b/Taskfile.windows.yml new file mode 100644 index 00000000..4a92b8ab --- /dev/null +++ b/Taskfile.windows.yml @@ -0,0 +1,52 @@ +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 -tags production -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 + + 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] + 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..9504752d --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,84 @@ +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 -ts -silent ./pkg/... + + build: + desc: Build deej for the current OS (release) + cmds: + - task: "{{OS}}:build" + + test: + 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 diff --git a/config_examples/config.example.ru.yaml b/config_examples/config.example.ru.yaml index a8bbe4f0..b79f7999 100644 --- a/config_examples/config.example.ru.yaml +++ b/config_examples/config.example.ru.yaml @@ -4,7 +4,8 @@ # Впишите 'mic' для управления громкостью микрофона # Впишите 'deej.unmapped' для управления громкостью всех каналов, кроме используемых в ползунках # Только Windows - Впишите 'deej.current' для управления громкостью приложения, которое сейчас в фокусе -# Только Windows - Вы можете вписать полное имя аудиоустройства, чтобы управлять его громкостью +# Вы можете вписать полное имя аудиоустройства, чтобы управлять его громкостью +# На Linux устройства ввода получают суффикс ' (input)', например "HyperX Cloud (input)", так как вход и выход устройства могут иметь одинаковое имя # Только Windows - Вы можете вписать 'system' для управления громкостью звуков Windows, таких как уведомления # Вы можете вписать 'deej.obs:<имя источника>' для управления аудиоисточниками OBS (требуется obs.enabled: true) slider_mapping: @@ -17,14 +18,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..846b1c41 100644 --- a/config_examples/config.example.yaml +++ b/config_examples/config.example.yaml @@ -4,7 +4,8 @@ # you can use 'deej.unmapped' to control all apps that aren't bound to any slider (this ignores master, system, mic and device-targeting sessions) # windows only - you can use 'deej.current' to control the currently active app (whether full-screen or not) # windows only - you can use 'deej.current.fullscreen' to control the currently active full-screen app -# windows only - you can use a device's full name, i.e. "Speakers (Realtek High Definition Audio)", to bind it. this works for both output and input devices +# you can use a device's full name, i.e. "Speakers (Realtek High Definition Audio)", to bind it. this works for both output and input devices +# on linux, input devices get an ' (input)' suffix, i.e. "HyperX Cloud (input)", since a device's input and output can share the same name # windows only - you can use 'system' to control the "system sounds" volume # you can use 'deej.obs:' to control OBS audio sources (requires obs.enabled: true) # important: slider indexes start at 0, regardless of which analog pins you're using! @@ -19,12 +20,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/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/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..5dce056b --- /dev/null +++ b/frontend/bindings/github.com/nik9play/deej/pkg/deej/index.ts @@ -0,0 +1,23 @@ +// 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, + COMSettings, + OBSSettings, + SerialPortDTO, + SessionInfoDTO, + 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 new file mode 100644 index 00000000..800ffaa8 --- /dev/null +++ b/frontend/bindings/github.com/nik9play/deej/pkg/deej/models.ts @@ -0,0 +1,369 @@ +// 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[]; + "autostartAvailable": boolean; + + /** 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"] = []; + } + if (!("autostartAvailable" in $$source)) { + this["autostartAvailable"] = false; + } + + 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); + } +} + +/** + * 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 + */ +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); + } +} + +/** + * SessionInfoDTO describes a running audio session for target suggestions + */ +export class SessionInfoDTO { + "key": string; + + /** + * friendly name, may be empty + */ + "displayName": string; + + /** + * device master session, not a process + */ + "isDevice": boolean; + + /** + * capture-side session (microphone) + */ + "isInput": boolean; + + /** Creates a new SessionInfoDTO instance. */ + constructor($$source: Partial = {}) { + if (!("key" in $$source)) { + this["key"] = ""; + } + if (!("displayName" in $$source)) { + this["displayName"] = ""; + } + if (!("isDevice" in $$source)) { + this["isDevice"] = false; + } + if (!("isInput" in $$source)) { + this["isInput"] = false; + } + + 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); + } +} + +/** + * 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 Settings { + "sliderMapping": SliderMappings; + "invertSliders": boolean; + "com": COMSettings; + "noiseReduction": string; + "language": string; + "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 (!("obs" in $$source)) { + this["obs"] = (new OBSSettings()); + } + + Object.assign(this, $$source); + } + + /** + * Creates a new Settings instance from a string or object. + */ + static createFrom($$source: any = {}): Settings { + const $$createField0_0 = $$createType1; + const $$createField2_0 = $$createType4; + const $$createField5_0 = $$createType5; + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + if ("sliderMapping" in $$parsedSource) { + $$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 Settings($$parsedSource as Partial); + } +} + +/** + * SliderMappingEntry is one slider's targets, JSON-friendly for the GUI + */ +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); + } +} + +/** + * 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 + */ +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 = $$createType6; + 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); +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 new file mode 100644 index 00000000..51bb1fc7 --- /dev/null +++ b/frontend/bindings/github.com/nik9play/deej/pkg/deej/settingsservice.ts @@ -0,0 +1,127 @@ +// 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); + }); +} + +/** + * 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 + */ +export function GetOBSInputs(): $CancellablePromise { + return $Call.ByID(1620740735).then(($result: any) => { + return $$createType1($result); + }); +} + +/** + * 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 + */ +export function GetProcesses(): $CancellablePromise { + return $Call.ByID(3688174489).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 $$createType4($result); + }); +} + +/** + * GetSettings returns the current contents of the user config file + */ +export function GetSettings(): $CancellablePromise<$models.Settings> { + return $Call.ByID(1935164325).then(($result: any) => { + return $$createType5($result); + }); +} + +/** + * 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 $$createType6($result); + }); +} + +/** + * ListSerialPorts enumerates the serial ports available on this machine + */ +export function ListSerialPorts(): $CancellablePromise<$models.SerialPortDTO[]> { + return $Call.ByID(102515344).then(($result: any) => { + return $$createType8($result); + }); +} + +/** + * SaveSettings validates and writes the given settings to the user config + * file, applying them immediately + */ +export function SaveSettings(settings: $models.Settings): $CancellablePromise { + return $Call.ByID(523154580, settings); +} + +/** + * SetAutostart enables or disables running deej at system startup, applying + * the change immediately + */ +export function SetAutostart(state: boolean): $CancellablePromise { + return $Call.ByID(470775857, state); +} + +// Private type creation functions +const $$createType0 = $models.AppInfoDTO.createFrom; +const $$createType1 = $Create.Array($Create.Any); +const $$createType2 = $Create.Map($Create.Any, $Create.Any); +const $$createType3 = $models.SessionInfoDTO.createFrom; +const $$createType4 = $Create.Array($$createType3); +const $$createType5 = $models.Settings.createFrom; +const $$createType6 = $models.StatusDTO.createFrom; +const $$createType7 = $models.SerialPortDTO.createFrom; +const $$createType8 = $Create.Array($$createType7); 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/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/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/messages/en.json b/frontend/messages/en.json new file mode 100644 index 00000000..ab759d79 --- /dev/null +++ b/frontend/messages/en.json @@ -0,0 +1,81 @@ +{ + "$schema": "https://inlang.com/schema/inlang-message-format", + "general": "General", + "autostart": "Run at startup", + "autostartHint": "deej will launch at startup", + "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": "System language", + "languageHint": "Applied after restarting deej.", + "obs": "OBS integration", + "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", + "slider": "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 {number}", + "addTarget": "Add target", + "tabApps": "Apps", + "tabDevices": "Devices", + "tabSpecial": "Special", + "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", + "otherProcesses": "Other processes", + "obsDisabled": "OBS integration is disabled.", + "openSettings": "Open settings", + "obsNotConnected": "Not connected to OBS. You can still add an input name manually.", + "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 volume", + "targetCurrent": "Current app", + "targetCurrentDesc": "Whichever app is currently in focus", + "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", + "ok": "OK", + "cancel": "Cancel", + "minimize": "Minimize", + "maximize": "Maximize", + "restore": "Restore", + "close": "Close", + "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/messages/ru.json b/frontend/messages/ru.json new file mode 100644 index 00000000..7f402e7f --- /dev/null +++ b/frontend/messages/ru.json @@ -0,0 +1,81 @@ +{ + "$schema": "https://inlang.com/schema/inlang-message-format", + "general": "Общие", + "autostart": "Запускать вместе с системой", + "autostartHint": "Запускать 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": "обычное (обычное железо)", + "noiseLow": "слабое (отличное железо)", + "noiseHigh": "сильное (шумное железо)", + "noiseNone": "нет (выполняется на устройстве)", + "language": "Язык", + "languageAuto": "Язык системы", + "languageHint": "Применяется после перезапуска deej.", + "obs": "Интеграция с OBS", + "obsEnabled": "Включить интеграцию с OBS Studio через WebSocket", + "obsHint": "Управляйте источниками звука в OBS Studio.", + "obsWebsocketHint": "Сначала включите сервер WebSocket в OBS: Сервис → Настройки сервера WebSocket.", + "obsHost": "Адрес", + "obsPort": "Порт", + "obsPassword": "Пароль", + "slider": "Слайдер", + "removeTarget": "Удалить цель", + "noTargets": "Нет целей - этот слайдер ничего не делает", + "settings": "Настройки", + "waitingForDevice": "Ожидание устройства...", + "connected": "Подключено", + "disconnected": "Нет подключения", + "unmapped": "не назначен", + "targetsFor": "Цели для слайдера {number}", + "addTarget": "Добавить цель", + "tabApps": "Приложения", + "tabDevices": "Устройства", + "tabSpecial": "Специальные", + "tabObs": "OBS", + "noDevices": "Аудиоустройства не найдены", + "searchApps": "Поиск приложений или имя процесса...", + "searchDevices": "Поиск устройств...", + "searchObsInputs": "Поиск источников OBS или имя источника...", + "noSessions": "Нет активных аудиосессий", + "otherProcesses": "Другие процессы", + "obsDisabled": "Интеграция с OBS отключена.", + "openSettings": "Открыть настройки", + "obsNotConnected": "Нет подключения к OBS. Имя источника можно добавить вручную.", + "noObsInputs": "В OBS не найдено источников", + "targetMaster": "Общая громкость", + "targetMasterDesc": "Громкость устройства вывода по умолчанию", + "targetSystem": "Системные звуки", + "targetSystemDesc": "Звуки системных уведомлений", + "targetMic": "Микрофон", + "targetMicDesc": "Громкость устройства ввода по умолчанию", + "targetCurrent": "Текущее приложение", + "targetCurrentDesc": "Приложение, которое сейчас в фокусе", + "targetCurrentFullscreen": "Текущее полноэкранное приложение", + "targetCurrentFullscreenDesc": "Приложение в фокусе, только в полноэкранном режиме", + "targetUnmapped": "Неназначенные приложения", + "targetUnmappedDesc": "Все приложения, не назначенные другим слайдерам", + "ok": "ОК", + "cancel": "Отмена", + "minimize": "Свернуть", + "maximize": "Развернуть", + "restore": "Восстановить", + "close": "Закрыть", + "save": "Сохранить", + "revert": "Отменить", + "saved": "Настройки сохранены", + "saveError": "Не удалось сохранить настройки", + "loadError": "Не удалось загрузить настройки", + "configPath": "Файл конфигурации", + "version": "Версия" +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 00000000..0303c4d1 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1979 @@ +{ + "name": "deej-frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "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" + }, + "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" + } + }, + "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/@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/@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", + "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==", + "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==", + "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==", + "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==", + "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==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@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", + "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", + "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/@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", + "integrity": "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==", + "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/@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", + "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==", + "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==", + "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==", + "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==", + "license": "Apache-2.0", + "engines": { + "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", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "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", + "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==", + "license": "MIT", + "engines": { + "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", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "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", + "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==", + "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==", + "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", + "integrity": "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA==", + "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/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/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", + "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==", + "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/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", + "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==", + "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==", + "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==", + "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/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", + "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/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", + "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==", + "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/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", + "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==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "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", + "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/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", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "license": "MIT" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 00000000..f8f0a8a0 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,30 @@ +{ + "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": { + "@inlang/paraglide-js": "^2.21.0", + "@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/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/App.svelte b/frontend/src/App.svelte new file mode 100644 index 00000000..a4b18a4c --- /dev/null +++ b/frontend/src/App.svelte @@ -0,0 +1,45 @@ + + + openSettings()} /> + +
+ +
+ + + { + targetDialogOpen = false; + openSettings("obs"); + }} +/> diff --git a/frontend/src/app.css b/frontend/src/app.css new file mode 100644 index 00000000..c4b2cd0e --- /dev/null +++ b/frontend/src/app.css @@ -0,0 +1,252 @@ +@import "tailwindcss"; + +/* theme tokens; monochrome black & white, light values with dark overrides via + prefers-color-scheme, exposed to tailwind through @theme inline */ +:root { + --ui-surface: #fafafa; + --ui-card: #ffffff; + --ui-body: #0a0a0a; + --ui-muted: #737373; + --ui-edge: #e4e4e4; + --ui-accent: #0a0a0a; + --ui-on-accent: #ffffff; + --ui-danger: #171717; + --ui-success: #171717; + --ui-field: #ffffff; + --ui-chip: #f0f0f0; + --ui-track: #e4e4e4; + + color-scheme: light dark; +} + +@media (prefers-color-scheme: dark) { + :root { + --ui-surface: #0a0a0a; + --ui-card: #161616; + --ui-body: #f2f2f2; + --ui-muted: #8a8a8a; + --ui-edge: #2a2a2a; + --ui-accent: #f2f2f2; + --ui-on-accent: #0a0a0a; + --ui-danger: #e5e5e5; + --ui-success: #e5e5e5; + --ui-field: #0f0f0f; + --ui-chip: #1e1e1e; + --ui-track: #262626; + } +} + +@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; + } + + /* never show the text/hand cursors; everything is a default arrow */ + button, + a, + [role="button"], + label, + summary { + cursor: default; + } + + ::-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/80; + } + + .label { + @apply text-[13px] font-semibold; + } + + .hint { + @apply text-xs text-muted; + } + + /* macOS-style secondary button: semitransparent white/grey gradient with a + bright top highlight. borderless; instant hover/press, no transition. */ + .btn { + @apply rounded-md bg-linear-to-b from-white/90 to-neutral-100/70 px-3.5 py-1.5 text-sm font-medium text-body + shadow-[inset_0_1px_0_rgb(255_255_255/0.9),0_1px_2px_rgb(0_0_0/0.12)] + enabled:hover:from-white enabled:hover:to-neutral-200/80 + enabled:active:from-neutral-100/90 enabled:active:to-neutral-200/90 + focus-visible:outline-2 focus-visible:outline-accent disabled:opacity-50 + dark:from-white/10 dark:to-white/5 + dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.2),0_1px_2px_rgb(0_0_0/0.45)] + dark:enabled:hover:from-white/20 dark:enabled:hover:to-white/10 + dark:enabled:active:from-white/5 dark:enabled:active:to-white/5; + } + + /* monochrome accent: near-black in light mode, near-white in dark mode */ + .btn-primary { + @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, + so the same classes drive both open and close. */ +@keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes fade-out { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +/* Only animate `transform` (scale + slide); Tailwind v4 centers the dialog with + the independent `translate` property, so the two compose without conflict. */ +@keyframes dialog-in { + from { + opacity: 0; + transform: translateY(8px) scale(0.96); + } + to { + opacity: 1; + transform: none; + } +} + +@keyframes dialog-out { + from { + opacity: 1; + transform: none; + } + to { + opacity: 0; + transform: translateY(8px) scale(0.96); + } +} + +@keyframes popover-in { + from { + opacity: 0; + transform: translateY(-4px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes popover-out { + from { + opacity: 1; + transform: translateY(0) scale(1); + } + to { + opacity: 0; + transform: translateY(-4px) scale(0.98); + } +} + +/* Fluent motion curve: decelerate on the way in, accelerate on the way out. */ +.anim-overlay[data-state="open"] { + animation: fade-in 160ms cubic-bezier(0.1, 0.9, 0.2, 1); +} +.anim-overlay[data-state="closed"] { + animation: fade-out 130ms cubic-bezier(0.4, 0, 1, 1); +} + +.anim-dialog[data-state="open"] { + animation: dialog-in 200ms cubic-bezier(0.1, 0.9, 0.2, 1); +} +.anim-dialog[data-state="closed"] { + animation: dialog-out 150ms cubic-bezier(0.4, 0, 1, 1); +} + +/* Popovers use "open"; tooltips use "delayed-open"/"instant-open". */ +.anim-popover[data-state="open"], +.anim-popover[data-state="delayed-open"], +.anim-popover[data-state="instant-open"] { + animation: popover-in 150ms cubic-bezier(0.1, 0.9, 0.2, 1); +} +.anim-popover[data-state="closed"] { + animation: popover-out 110ms cubic-bezier(0.4, 0, 1, 1); +} + +@media (prefers-reduced-motion: reduce) { + .anim-overlay[data-state], + .anim-dialog[data-state], + .anim-popover[data-state] { + animation-duration: 1ms; + } +} diff --git a/frontend/src/components/BehaviorSection.svelte b/frontend/src/components/BehaviorSection.svelte new file mode 100644 index 00000000..8e7bccab --- /dev/null +++ b/frontend/src/components/BehaviorSection.svelte @@ -0,0 +1,31 @@ + + +
+
+ +
+ +
+ + +
+
diff --git a/frontend/src/components/ConnectionSection.svelte b/frontend/src/components/ConnectionSection.svelte new file mode 100644 index 00000000..d7c30f16 --- /dev/null +++ b/frontend/src/components/ConnectionSection.svelte @@ -0,0 +1,74 @@ + + +
+
+ +
+
+ +
+ +
+
+ +
+ + String(settings.com.baudRate || ""), (v) => (settings.com.baudRate = parseInt(v, 10) || 0)} + /> +
+ + {#if settings.com.port === "auto"} +
+
+ + +
+
+ + +
+
+
{m.vidPidHint()}
+ {/if} +
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/Mixer.svelte b/frontend/src/components/Mixer.svelte new file mode 100644 index 00000000..8904963a --- /dev/null +++ b/frontend/src/components/Mixer.svelte @@ -0,0 +1,54 @@ + + +
+ {#if !app.connected} +
+ {m.waitingForDevice()}{app.comPort ? ` (${app.comPort})` : ""} +
+ {/if} + +
+ {#each columns as column (column.slider)} + onEditTargets(column.slider)} + /> + {/each} +
+
diff --git a/frontend/src/components/ObsSection.svelte b/frontend/src/components/ObsSection.svelte new file mode 100644 index 00000000..ab49b2c5 --- /dev/null +++ b/frontend/src/components/ObsSection.svelte @@ -0,0 +1,30 @@ + + +
+ +
{m.obsHint()}
+ + {#if settings.obs.enabled} +
{m.obsWebsocketHint()}
+
+
+ + +
+
+ + +
+
+ + +
+
+ {/if} +
diff --git a/frontend/src/components/SettingsDialog.svelte b/frontend/src/components/SettingsDialog.svelte new file mode 100644 index 00000000..55574016 --- /dev/null +++ b/frontend/src/components/SettingsDialog.svelte @@ -0,0 +1,136 @@ + + + + + + +
+ {m.settings()} + + + +
+ + {#if settings} + + + {#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)} + + + {tabItem.label} + + {/each} + + +
+ + + + +
+
+ +
+ {#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 new file mode 100644 index 00000000..8fc87ace --- /dev/null +++ b/frontend/src/components/SliderColumn.svelte @@ -0,0 +1,89 @@ + + +
+ {percent}% + +
+
+
+ +
+ +
+ +
+ +
+
+
+
+ + + + { + tooltipOpen = false; + onEdit(); + }} + > + {#if targets.length === 0} + {m.unmapped()} + {:else} + {targetLabel(targets[0])} + {#if targets.length > 1} + +{targets.length - 1} + {/if} + {/if} + + {#if targets.length > 0} + + +
+ {#each targets as target (target)} + {targetLabel(target)} + {/each} +
+
+
+ {/if} +
+
+
diff --git a/frontend/src/components/TargetDialog.svelte b/frontend/src/components/TargetDialog.svelte new file mode 100644 index 00000000..cc386239 --- /dev/null +++ b/frontend/src/components/TargetDialog.svelte @@ -0,0 +1,444 @@ + + +{#snippet obsSettingsLink()} + +{/snippet} + +{#snippet itemRow(target: string, label: string, hint: string, dimmed: boolean = false, icon: string | typeof Speaker | undefined = undefined)} + +{/snippet} + + + + + +
+ {m.targetsFor({ number: slider + 1 })} + + + +
+ +
+
+ {#each targets as target (target)} + + {targetLabel(target)} + + + {:else} + {m.noTargets()} + {/each} +
+ + + + {#each [{ value: "apps", label: m.tabApps(), Icon: AppWindow }, { value: "devices", label: m.tabDevices(), Icon: Speaker }, { value: "special", label: m.tabSpecial(), Icon: Sparkles }, { value: "obs", label: m.tabObs(), Icon: Video }] as tabItem (tabItem.value)} + + + {tabItem.label} + + {/each} + + + + { + if (e.key === "Enter") { + e.preventDefault(); + addFreeText(); + } + }} + /> +
+ {#if freeText !== ""} + + {/if} + {#each appItems as session (session.key)} + {@render itemRow( + session.key, + session.displayName || prettifyProcessName(session.key), + session.displayName ? session.key : "", + false, + icons[session.key] ?? AppWindow, + )} + {:else} + {#if freeText === "" && processItems.length === 0} +
{m.noSessions()}
+ {/if} + {/each} + {#if processItems.length > 0} +
+ {m.otherProcesses()} +
+ {#each processItems as name (name)} + {@render itemRow( + name, + prettifyProcessName(name), + name.toLowerCase().endsWith(".exe") ? name : "", + true, + icons[name] ?? AppWindow, + )} + {/each} + {/if} +
+
+ + + +
+ {#each deviceItems as session (session.key)} + {@render itemRow(session.key, session.displayName || session.key, "", false, session.isInput ? Mic : Speaker)} + {:else} +
{m.noDevices()}
+ {/each} +
+
+ + +
+ {#each specialTargets as target (target)} + {@render itemRow(target, specialTargetLabel(target) ?? target, specialTargetDescription(target) ?? "")} + {/each} +
+
+ + + {#if !app.settings?.obs.enabled} +
+ {m.obsDisabled()} + {@render obsSettingsLink()} +
+ {:else} + { + if (e.key === "Enter") { + e.preventDefault(); + addObsFreeText(); + } + }} + /> +
+ {#if obsFreeText !== ""} + + {/if} + {#if obsError} +
+ {m.obsNotConnected()} + {@render obsSettingsLink()} +
+ {:else} + {#each obsItems as inputName (inputName)} + {@render itemRow(OBS_PREFIX + inputName, inputName, "")} + {:else} + {#if obsFreeText === ""} +
{m.noObsInputs()}
+ {/if} + {/each} + {/if} +
+ {/if} +
+
+
+ +
+ {#if errorText} + {errorText} + {/if} + + +
+
+
+
diff --git a/frontend/src/components/Titlebar.svelte b/frontend/src/components/Titlebar.svelte new file mode 100644 index 00000000..3c92a6bf --- /dev/null +++ b/frontend/src/components/Titlebar.svelte @@ -0,0 +1,103 @@ + + + +
+ deej + + + + {#if app.connected} + {app.comPort || m.connected()} + {:else} + {m.disconnected()} + {/if} + + +
+ + + + +
+
diff --git a/frontend/src/components/ui/FieldCheckbox.svelte b/frontend/src/components/ui/FieldCheckbox.svelte new file mode 100644 index 00000000..c6fd0d7f --- /dev/null +++ b/frontend/src/components/ui/FieldCheckbox.svelte @@ -0,0 +1,32 @@ + + +
+ + {#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..a426e497 --- /dev/null +++ b/frontend/src/components/ui/FieldCombobox.svelte @@ -0,0 +1,96 @@ + + + { + value = selected; + search = ""; + }} + onOpenChange={(open) => { + if (open) search = ""; + }} +> +
+ { + value = e.currentTarget.value; + search = e.currentTarget.value; + }} + onclick={() => { + open = true; + }} + /> + + + +
+ + + {#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..023fcdb9 --- /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} + + + + diff --git a/frontend/src/lib/state.svelte.ts b/frontend/src/lib/state.svelte.ts new file mode 100644 index 00000000..f20b5368 --- /dev/null +++ b/frontend/src/lib/state.svelte.ts @@ -0,0 +1,66 @@ +import { Events } from "@wailsio/runtime"; +import { SessionInfoDTO, Settings, SettingsService } from "../../bindings/github.com/nik9play/deej/pkg/deej"; +import { tickOnChange } from "./tick"; + +// live application state, fed by wails events from the Go side +export const app = $state({ + connected: false, + comPort: "", + values: [] as number[], // 0..1 per slider, as sessions receive them + settings: null as Settings | null, + sessions: [] as SessionInfoDTO[], // running audio sessions with friendly names +}); + +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); + } +} + +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 { + const offs = [ + Events.On("deej:sliders", (ev) => { + const values = (ev.data as number[]) ?? []; + app.values = values; + tickOnChange(values); + }), + 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(); + }), + Events.On("deej:sessions", () => { + void refreshSessions(); + }), + ]; + + 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(); + 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..e1b6e8cc --- /dev/null +++ b/frontend/src/lib/targets.ts @@ -0,0 +1,52 @@ +import { m } from "../paraglide/messages"; +import { app } from "./state.svelte"; + +export const OBS_PREFIX = "deej.obs:"; + +// special target -> localized label/description message functions +const specialTargets: Record string; desc: () => string }> = { + master: { label: m.targetMaster, desc: m.targetMasterDesc }, + system: { label: m.targetSystem, desc: m.targetSystemDesc }, + mic: { label: m.targetMic, desc: m.targetMicDesc }, + "deej.current": { label: m.targetCurrent, desc: m.targetCurrentDesc }, + "deej.current.fullscreen": { label: m.targetCurrentFullscreen, desc: m.targetCurrentFullscreenDesc }, + "deej.unmapped": { label: m.targetUnmapped, desc: m.targetUnmappedDesc }, +}; + +export function specialTargetLabel(target: string): string | null { + const entry = specialTargets[target.toLowerCase()]; + return entry ? entry.label() : null; +} + +export function specialTargetDescription(target: string): string | null { + const entry = specialTargets[target.toLowerCase()]; + return entry ? entry.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 +// the target as written in the config 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) { + return session.displayName || prettifyProcessName(target); + } + + return 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/frontend/src/lib/tick.ts b/frontend/src/lib/tick.ts new file mode 100644 index 00000000..fd3fffbf --- /dev/null +++ b/frontend/src/lib/tick.ts @@ -0,0 +1,62 @@ +// synthesized ui tick for hardware slider movement — no audio assets needed + +// minimum travel (0..1) before a slider produces another tick, so raw analog +// jitter stays silent +const STEP = 0.015; +// global cooldown so several sliders moving at once don't stack into a buzz +const COOLDOWN_MS = 30; + +const TICK_FREQ_HZ = 500; +const TICK_GAIN = 0.02; +const TICK_LENGTH_S = 0.03; + +let ctx: AudioContext | null = null; +let lastValues: number[] | null = null; +let lastTickAt = 0; + +// feed every incoming slider snapshot here; ticks when any slider crosses a +// step boundary. the first snapshot only sets the baseline, so opening the +// window doesn't click +export function tickOnChange(values: number[]): void { + if (lastValues === null || lastValues.length !== values.length) { + lastValues = [...values]; + return; + } + + let moved = false; + for (let i = 0; i < values.length; i++) { + if (Math.abs(values[i] - lastValues[i]) >= STEP) { + lastValues[i] = values[i]; + moved = true; + } + } + + const now = performance.now(); + if (!moved || now - lastTickAt < COOLDOWN_MS) return; + if (document.visibilityState !== "visible") return; + lastTickAt = now; + play(); +} + +function play(): void { + try { + ctx ??= new AudioContext(); + if (ctx.state !== "running") { + // autoplay policy keeps the context suspended until the user interacts + // with the window; retry on the next tick instead of queueing sounds + void ctx.resume(); + return; + } + + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.frequency.value = TICK_FREQ_HZ; + gain.gain.setValueAtTime(TICK_GAIN, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + TICK_LENGTH_S); + osc.connect(gain).connect(ctx.destination); + osc.start(); + osc.stop(ctx.currentTime + TICK_LENGTH_S); + } catch (err) { + console.error("tick playback failed", err); + } +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 00000000..3a84676b --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,23 @@ +import { mount } from "svelte"; +import App from "./App.svelte"; +import { setLocale } from "./paraglide/runtime"; +import { AppInfoDTO, SettingsService } from "../bindings/github.com/nik9play/deej/pkg/deej"; +import "./app.css"; + +let appInfo: AppInfoDTO | null = null; +try { + appInfo = await SettingsService.GetAppInfo(); + // The Go host resolves the language; mirror it into paraglide's in-memory + // locale before mount. reload: false — there's no server or in-app reload. + const locale = appInfo.resolvedLanguage.toLowerCase().startsWith("ru") ? "ru" : "en"; + setLocale(locale, { reload: false }); +} 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/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..3d6b4ddb --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "@tsconfig/svelte/tsconfig.json", + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "module": "ESNext", + "resolveJsonModule": true, + "allowJs": true, + "checkJs": true, + "isolatedModules": true, + "noEmit": true + }, + "include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte", "bindings"], + "exclude": ["src/paraglide"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 00000000..5c974aa8 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,41 @@ +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 { paraglideVitePlugin } from "@inlang/paraglide-js"; +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: { + host: "127.0.0.1", + port: Number(process.env.WAILS_VITE_PORT) || 9245, + strictPort: true, + }, + plugins: [ + tailwindcss(), + svelte(), + paraglideVitePlugin({ + project: "./project.inlang", + outdir: "./src/paraglide", + // The Go host sets the locale once at startup from appInfo.resolvedLanguage + // (see main.ts); there's no SSR or in-app reload, so an in-memory locale is + // all we need. baseLocale (en) is the fallback before setLocale runs. + strategy: ["globalVariable", "baseLocale"], + }), + wails("./bindings"), + keepGitkeep(), + ], +}); diff --git a/go.mod b/go.mod index f4bde9bd..63d4c82a 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 @@ -16,29 +15,28 @@ 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 - golang.org/x/sys v0.41.0 - golang.org/x/text v0.34.0 + go.yaml.in/yaml/v3 v3.0.4 + 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/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 - go.yaml.in/yaml/v3 v3.0.4 // indirect gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect ) diff --git a/go.sum b/go.sum index 68b5f91f..142712c3 100644 --- a/go.sum +++ b/go.sum @@ -1,37 +1,39 @@ -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= +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= +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= -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.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 +42,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= @@ -54,28 +62,15 @@ 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= go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -87,14 +82,15 @@ 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= -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 d628461c..d1d0ca5a 100644 --- a/pkg/deej/config.go +++ b/pkg/deej/config.go @@ -1,58 +1,34 @@ package deej import ( + "crypto/sha256" + "errors" "fmt" "os" "path/filepath" "strings" + "sync" "sync/atomic" "time" "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, @@ -60,15 +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 + + stopWatcher chan struct{} + consumersLock sync.Mutex reloadConsumers []chan bool - userConfig *viper.Viper - internalConfig *viper.Viper + // serializes the read-modify-write cycles of Load and SaveUserSettings + lock sync.Mutex + + // hash of the last config content written by the GUI, so the file + // watcher can tell our own writes apart from hand edits + lastSelfWrite atomic.Value // string configPath string } @@ -80,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") @@ -135,51 +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.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 @@ -206,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", @@ -228,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 @@ -281,128 +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 - } - - // 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 new file mode 100644 index 00000000..cad5b3c6 --- /dev/null +++ b/pkg/deej/config_settings.go @@ -0,0 +1,487 @@ +package deej + +import ( + "bytes" + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "sort" + "strconv" + "strings" + + "github.com/nicksnyder/go-i18n/v2/i18n" + "go.uber.org/zap" + "go.yaml.in/yaml/v3" +) + +// Settings is the single source of truth for deej's user-facing +// configuration. The yaml tags define the config file keys, the json tags +// the GUI wire format: a new setting only needs a field here (plus a default +// in defaultSettings and, if needed, a rule in Validate/sanitize) to reach +// the file, the runtime snapshot and the settings GUI +type Settings struct { + Mapping SliderMappings `yaml:"slider_mapping" json:"sliderMapping"` + InvertSliders bool `yaml:"invert_sliders" json:"invertSliders"` + COM COMSettings `yaml:"com" json:"com"` + NoiseReduction string `yaml:"noise_reduction,omitempty" json:"noiseReduction"` + Language string `yaml:"language" json:"language"` + OBS OBSSettings `yaml:"obs" json:"obs"` +} + +// COMSettings describes the Arduino serial connection parameters +type COMSettings struct { + Port string `yaml:"port" json:"port"` + BaudRate int `yaml:"baud_rate" json:"baudRate"` + VID HexWord `yaml:"vid" json:"vid"` + PID HexWord `yaml:"pid" json:"pid"` +} + +// UnmarshalYAML reads the com section, plus the legacy flat keys (com_port, +// baud_rate, com_vid, com_pid) that older config files use. Keys in the com +// section win over their legacy counterparts; saves only ever write the +// section +func (s *Settings) UnmarshalYAML(node *yaml.Node) error { + var legacy struct { + Port *string `yaml:"com_port"` + BaudRate *int `yaml:"baud_rate"` + VID *HexWord `yaml:"com_vid"` + PID *HexWord `yaml:"com_pid"` + } + legacyErr := node.Decode(&legacy) + if legacyErr != nil && !isYAMLTypeError(legacyErr) { + return legacyErr + } + + if legacy.Port != nil { + s.COM.Port = *legacy.Port + } + if legacy.BaudRate != nil { + s.COM.BaudRate = *legacy.BaudRate + } + if legacy.VID != nil { + s.COM.VID = *legacy.VID + } + if legacy.PID != nil { + s.COM.PID = *legacy.PID + } + + // the alias type drops Settings' methods, so this can't recurse; keys the + // document does specify overwrite the legacy values applied above + type settingsAlias Settings + aliasErr := node.Decode((*settingsAlias)(s)) + if aliasErr != nil && !isYAMLTypeError(aliasErr) { + return aliasErr + } + + // surviving errors are type errors only; the caller treats those as + // non-fatal (the affected fields keep their previous values) + return errors.Join(legacyErr, aliasErr) +} + +func isYAMLTypeError(err error) bool { + var typeErr *yaml.TypeError + return errors.As(err, &typeErr) +} + +// OBSSettings describes the OBS websocket connection parameters +type OBSSettings struct { + Enabled bool `yaml:"enabled" json:"enabled"` + Host string `yaml:"host" json:"host"` + Port int `yaml:"port" json:"port"` + Password string `yaml:"password" json:"password"` +} + +func defaultSettings() Settings { + return Settings{ + Mapping: SliderMappings{}, + Language: "auto", + + COM: COMSettings{ + Port: "COM4", + BaudRate: 9600, + + // ch340 chip + VID: "1A86", + PID: "7523", + }, + + OBS: OBSSettings{ + Host: "localhost", + Port: 4455, + }, + } +} + +var validNoiseReductionLevels = []string{"", "low", "default", "high", "none"} +var validLanguages = []string{"auto", "en", "ru"} + +// Validate checks the settings for values that would produce a broken +// config file; used for GUI saves, which must be rejected rather than fixed +func (s *Settings) Validate() error { + if s.COM.Port == "" { + return fmt.Errorf("com port must not be empty") + } + + if s.COM.BaudRate <= 0 { + return fmt.Errorf("baud rate must be a positive number") + } + + // an empty VID/PID means "use the built-in default", so only validate + // non-empty values + if _, err := s.COM.VID.parse(); s.COM.VID != "" && err != nil { + return fmt.Errorf("com vid: %w", err) + } + + if _, err := s.COM.PID.parse(); s.COM.PID != "" && err != nil { + return fmt.Errorf("com pid: %w", err) + } + + if !slices.Contains(validNoiseReductionLevels, s.NoiseReduction) { + return fmt.Errorf("invalid noise reduction level: %s", s.NoiseReduction) + } + + if !slices.Contains(validLanguages, s.Language) { + return fmt.Errorf("invalid language: %s", s.Language) + } + + if s.OBS.Port < 1 || s.OBS.Port > 65535 { + return fmt.Errorf("obs port must be between 1 and 65535") + } + + seenSliders := map[int]bool{} + for _, entry := range s.Mapping { + if entry.Slider < 0 { + return fmt.Errorf("slider index must not be negative: %d", entry.Slider) + } + if seenSliders[entry.Slider] { + return fmt.Errorf("duplicate slider index: %d", entry.Slider) + } + seenSliders[entry.Slider] = true + } + + return nil +} + +// sanitize brings settings to a canonical, valid state, falling back to the +// default (with a warning) for any value a hand-edited file got wrong. GUI +// saves pass through it too, after Validate, for canonicalization only +func (s *Settings) sanitize(logger *zap.SugaredLogger) { + defaults := defaultSettings() + + if s.COM.Port == "" { + logger.Warnw("Empty com port, using default value", "defaultValue", defaults.COM.Port) + s.COM.Port = defaults.COM.Port + } + + if s.COM.BaudRate <= 0 { + logger.Warnw("Invalid baud rate specified, using default value", + "invalidValue", s.COM.BaudRate, + "defaultValue", defaults.COM.BaudRate) + s.COM.BaudRate = defaults.COM.BaudRate + } + + s.COM.VID = s.COM.VID.canonicalOrDefault(logger, "com.vid", defaults.COM.VID) + s.COM.PID = s.COM.PID.canonicalOrDefault(logger, "com.pid", defaults.COM.PID) + + if !slices.Contains(validNoiseReductionLevels, s.NoiseReduction) { + logger.Warnw("Invalid noise reduction level, using default", "invalidValue", s.NoiseReduction) + s.NoiseReduction = defaults.NoiseReduction + } + + if !slices.Contains(validLanguages, s.Language) { + logger.Warnw("Invalid language, using default", "invalidValue", s.Language, "defaultValue", defaults.Language) + s.Language = defaults.Language + } + + if s.OBS.Port < 1 || s.OBS.Port > 65535 { + logger.Warnw("Invalid obs port, using default value", + "invalidValue", s.OBS.Port, + "defaultValue", defaults.OBS.Port) + s.OBS.Port = defaults.OBS.Port + } + + if s.OBS.Host == "" { + s.OBS.Host = defaults.OBS.Host + } + + // canonicalize the mapping: drop negative or duplicate sliders and empty + // targets, and keep it sorted by slider index + mapping := SliderMappings{} + seenSliders := map[int]bool{} + for _, entry := range s.Mapping { + if entry.Slider < 0 || seenSliders[entry.Slider] { + logger.Warnw("Ignoring invalid slider mapping entry", "slider", entry.Slider) + continue + } + seenSliders[entry.Slider] = true + + targets := slices.DeleteFunc(slices.Clone(entry.Targets), func(t string) bool { return t == "" }) + mapping = append(mapping, SliderMappingEntry{Slider: entry.Slider, Targets: targets}) + } + sort.Slice(mapping, func(i, j int) bool { return mapping[i].Slider < mapping[j].Slider }) + s.Mapping = mapping +} + +// HexWord is a 16-bit value carried as a hex string (e.g. "1A86"), the form +// both the config file and the GUI use. An empty value means "use the +// built-in default" +type HexWord string + +// Value returns the numeric form; ok is false for empty or malformed values +func (h HexWord) Value() (uint16, bool) { + parsed, err := h.parse() + if err != nil { + return 0, false + } + + return parsed, true +} + +func (h HexWord) parse() (uint16, error) { + trimmed := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(string(h))), "0x") + parsed, err := strconv.ParseUint(trimmed, 16, 16) + if err != nil { + return 0, fmt.Errorf("must be a 16-bit hex value: %s", string(h)) + } + + return uint16(parsed), nil +} + +func (h HexWord) canonicalOrDefault(logger *zap.SugaredLogger, key string, def HexWord) HexWord { + if strings.TrimSpace(string(h)) == "" { + return def + } + + parsed, err := h.parse() + if err != nil { + logger.Warnw("Invalid hex value, using default", "key", key, "invalidValue", string(h), "defaultValue", string(def)) + return def + } + + return HexWord(fmt.Sprintf("%04X", parsed)) +} + +// UnmarshalYAML accepts both YAML integers (0x1A86, 6790) and hex strings +// ("1A86", "0x1A86") +func (h *HexWord) UnmarshalYAML(node *yaml.Node) error { + var number uint64 + if err := node.Decode(&number); err == nil { + if number > 0xFFFF { + return fmt.Errorf("must be a 16-bit value: %s", node.Value) + } + + *h = HexWord(fmt.Sprintf("%04X", number)) + return nil + } + + var str string + if err := node.Decode(&str); err != nil { + return err + } + + *h = HexWord(str) + return nil +} + +// MarshalYAML writes the value as a 0x-prefixed YAML integer +func (h HexWord) MarshalYAML() (any, error) { + value, ok := h.Value() + if !ok { + // sanitized settings never hit this; keep whatever the value is + return string(h), nil + } + + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!int", Value: fmt.Sprintf("0x%04X", value)}, nil +} + +// SliderMappingEntry is one slider's targets, JSON-friendly for the GUI +type SliderMappingEntry struct { + Slider int `json:"slider"` + Targets []string `json:"targets"` +} + +// SliderMappings is the slider_mapping config key. In the file it's a YAML +// mapping of slider index to a single target or a list of targets +type SliderMappings []SliderMappingEntry + +// UnmarshalYAML is deliberately lenient: hand-edited entries it can't make +// sense of are skipped rather than failing the whole config load. Strictness +// for GUI saves comes from Settings.Validate instead +func (sm *SliderMappings) UnmarshalYAML(node *yaml.Node) error { + entries := SliderMappings{} + + if node.Kind == yaml.MappingNode { + for i := 0; i+1 < len(node.Content); i += 2 { + keyNode, valueNode := node.Content[i], node.Content[i+1] + + slider, err := strconv.Atoi(keyNode.Value) + if err != nil { + continue + } + + targets := []string{} + switch valueNode.Kind { + case yaml.ScalarNode: + if valueNode.Tag != "!!null" && valueNode.Value != "" { + targets = append(targets, valueNode.Value) + } + case yaml.SequenceNode: + for _, item := range valueNode.Content { + if item.Kind == yaml.ScalarNode && item.Tag != "!!null" { + targets = append(targets, item.Value) + } + } + } + + entries = append(entries, SliderMappingEntry{Slider: slider, Targets: targets}) + } + } + + *sm = entries + return nil +} + +// MarshalYAML builds the slider_mapping value: single targets are written +// as plain scalars, multiple targets as sequences, matching the style of +// the example config +func (sm SliderMappings) MarshalYAML() (any, error) { + sorted := sm.clone() + sort.Slice(sorted, func(i, j int) bool { return sorted[i].Slider < sorted[j].Slider }) + + mapping := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} + + for _, entry := range sorted { + targets := slices.DeleteFunc(entry.Targets, func(t string) bool { return t == "" }) + if len(targets) == 0 { + continue + } + + var value *yaml.Node + if len(targets) == 1 { + value = yamlStringNode(targets[0]) + } else { + value = &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"} + for _, target := range targets { + value.Content = append(value.Content, yamlStringNode(target)) + } + } + + mapping.Content = append(mapping.Content, yamlIntNode(entry.Slider), value) + } + + return mapping, nil +} + +func (sm SliderMappings) clone() SliderMappings { + out := make(SliderMappings, len(sm)) + for i, entry := range sm { + out[i] = SliderMappingEntry{Slider: entry.Slider, Targets: slices.Clone(entry.Targets)} + } + + return out +} + +// UserSettings returns the current contents of the user config file +func (cc *CanonicalConfig) UserSettings() Settings { + settings := cc.Values().Settings + settings.Mapping = settings.Mapping.clone() + + return settings +} + +// SaveUserSettings validates the settings, rewrites the user config file on +// disk and applies the new config immediately. The file is fully regenerated: +// comments, key order and unknown keys are not preserved +func (cc *CanonicalConfig) SaveUserSettings(settings Settings, localizer *i18n.Localizer) error { + if err := settings.Validate(); err != nil { + return err + } + + // canonicalize (blank VID/PID -> defaults, mapping sorted and filtered) + settings.sanitize(cc.logger) + + if err := cc.saveAndReload(settings, localizer); err != nil { + return err + } + + cc.onConfigReloaded() + + return nil +} + +func (cc *CanonicalConfig) saveAndReload(settings Settings, localizer *i18n.Localizer) error { + cc.lock.Lock() + defer cc.lock.Unlock() + + var buf bytes.Buffer + encoder := yaml.NewEncoder(&buf) + encoder.SetIndent(2) + if err := encoder.Encode(&settings); err != nil { + return fmt.Errorf("marshal config for save: %w", err) + } + if err := encoder.Close(); err != nil { + return fmt.Errorf("marshal config for save: %w", err) + } + out := buf.Bytes() + + // remember the exact content we wrote, so the file watcher can tell this + // write apart from a hand edit and skip the redundant reload + cc.lastSelfWrite.Store(contentHash(out)) + + if err := writeFileAtomic(cc.configPath, out); err != nil { + cc.logger.Warnw("Failed to write config file", "error", err) + return fmt.Errorf("write config for save: %w", err) + } + + cc.logger.Infow("Saved user settings to config file", "path", cc.configPath) + + // apply immediately instead of relying on the watcher's debounce timing + if err := cc.loadLocked(localizer); err != nil { + return fmt.Errorf("load config after save: %w", err) + } + + return nil +} + +// writeFileAtomic writes data to a temp file in the target's directory and +// renames it over the target, so a crash mid-write can't corrupt the config +func writeFileAtomic(path string, data []byte) error { + tmp, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".tmp-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + _ = os.Remove(tmpPath) + return err + } + + if err := tmp.Close(); err != nil { + _ = os.Remove(tmpPath) + return err + } + + if err := os.Chmod(tmpPath, 0o644); err != nil { + _ = os.Remove(tmpPath) + return err + } + + if err := os.Rename(tmpPath, path); err != nil { + _ = os.Remove(tmpPath) + return err + } + + return nil +} + +func yamlStringNode(value string) *yaml.Node { + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value} +} + +func yamlIntNode(value int) *yaml.Node { + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!int", Value: strconv.Itoa(value)} +} diff --git a/pkg/deej/config_settings_test.go b/pkg/deej/config_settings_test.go new file mode 100644 index 00000000..14d34b82 --- /dev/null +++ b/pkg/deej/config_settings_test.go @@ -0,0 +1,456 @@ +package deej + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "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 = `slider_mapping: + 0: master + 1: [chrome.exe, firefox.exe] + +invert_sliders: false + +com: + port: COM4 + baud_rate: 9600 + +obs: + enabled: false + host: localhost + port: 4455 + password: "" +` + +func TestSaveUserSettingsWritesAndApplies(t *testing.T) { + cc := newTestConfig(t, testConfigContents) + + 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(settings, 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{ + "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.COM.Port != "COM7" { + t.Errorf("com port not applied, got %q", values.COM.Port) + } + if !values.InvertSliders { + t.Error("invert_sliders not applied") + } + if !values.OBS.Enabled { + t.Error("obs.enabled not applied") + } + targets, ok := values.SliderMapping.get(2) + if !ok || len(targets) != 2 { + t.Errorf("slider mapping not applied, got %v", targets) + } +} + +func TestSaveUserSettingsRoundTrip(t *testing.T) { + cc := newTestConfig(t, testConfigContents) + + settings := cc.UserSettings() + settings.COM.BaudRate = 115200 + settings.COM.VID = "2341" + settings.COM.PID = "0043" + settings.Language = "ru" + settings.OBS.Host = "192.168.1.5" + settings.OBS.Port = 4456 + settings.OBS.Password = "secret" + + if err := cc.SaveUserSettings(settings, newTestLocalizer()); err != nil { + t.Fatalf("save settings: %v", err) + } + + got := cc.UserSettings() + if got.COM.BaudRate != 115200 || got.COM.VID != "2341" || got.COM.PID != "0043" || + got.Language != "ru" || got.OBS.Host != "192.168.1.5" || + got.OBS.Port != 4456 || got.OBS.Password != "secret" { + t.Errorf("round trip mismatch: %+v", got) + } + + if len(got.Mapping) != 2 { + t.Fatalf("expected 2 mapping entries, got %v", got.Mapping) + } + if got.Mapping[1].Slider != 1 || len(got.Mapping[1].Targets) != 2 { + t.Errorf("multi-target mapping mismatch: %+v", got.Mapping[1]) + } +} + +func TestSaveUserSettingsNotifiesConsumers(t *testing.T) { + cc := newTestConfig(t, testConfigContents) + + reloaded := cc.SubscribeToChanges() + done := make(chan bool) + go func() { + done <- <-reloaded + }() + + settings := cc.UserSettings() + if err := cc.SaveUserSettings(settings, newTestLocalizer()); err != nil { + t.Fatalf("save settings: %v", err) + } + + if !<-done { + t.Error("expected reload notification") + } +} + +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", + OBS: OBSSettings{Host: "localhost", Port: 4455}, + Mapping: SliderMappings{ + {Slider: 0, Targets: []string{"master"}}, + }, + } + + if err := valid.Validate(); err != nil { + t.Errorf("valid settings rejected: %v", err) + } + + // empty VID/PID mean "use the built-in default" and must be accepted + emptyVIDPID := valid + 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(*Settings) + }{ + {"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(s *Settings) { + s.Mapping = SliderMappings{ + {Slider: 1, Targets: []string{"a"}}, + {Slider: 1, Targets: []string{"b"}}, + } + }}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + settings := valid + tc.mutate(&settings) + if err := settings.Validate(); err == nil { + t.Error("expected validation error") + } + }) + } +} + +func TestSaveUserSettingsRejectsInvalid(t *testing.T) { + cc := newTestConfig(t, testConfigContents) + + settings := cc.UserSettings() + settings.COM.BaudRate = -1 + + if err := cc.SaveUserSettings(settings, 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") + } +} + +// 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/deej.go b/pkg/deej/deej.go index 599d6999..a1866b25 100644 --- a/pkg/deej/deej.go +++ b/pkg/deej/deej.go @@ -34,6 +34,10 @@ type Deej struct { obs *OBSClient bundle *i18n.Bundle localizer *i18n.Localizer + tray trayState + + // the language the localizer ended up using, after resolving "auto" + resolvedLanguage string stopChannel chan bool version string @@ -171,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/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/obs.go b/pkg/deej/obs.go index a148ae82..0ffae61b 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" @@ -18,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 { @@ -38,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, @@ -98,9 +99,32 @@ 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 { + 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 @@ -112,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) @@ -162,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 0871109f..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 { @@ -256,13 +253,39 @@ func (sio *SerialIO) CurrentSliderValues() []int { return slices.Clone(sio.currentSliderValues) } +// CurrentSliderPercentValues returns the current slider values as the 0..1 +// scalars the sessions receive (normalized and inversion-applied, mirroring +// handleLine) +func (sio *SerialIO) CurrentSliderPercentValues() []float32 { + config := sio.deej.config.Values() + + sio.stateLock.Lock() + defer sio.stateLock.Unlock() + + values := make([]float32, len(sio.currentSliderValues)) + for i, raw := range sio.currentSliderValues { + if raw < 0 { + // sentinel value before the first full line is received + continue + } + + value := util.NormalizeScalar(float32(raw) / 1023.0) + if config.InvertSliders { + value = 1 - value + } + values[i] = value + } + + return values +} + // Start attempts to connect to our arduino chip 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() @@ -308,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")) } @@ -409,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.go b/pkg/deej/session.go index 2ce15cf2..1cd47518 100644 --- a/pkg/deej/session.go +++ b/pkg/deej/session.go @@ -17,11 +17,19 @@ 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) IsDevice() bool + // IsInput returns true for capture-side sessions (microphones and other + // input devices) + IsInput() bool + Release() } @@ -40,18 +48,30 @@ type baseSession struct { system bool master bool device bool + input bool // used by Key(), needs to be set by child name string // 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 { 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_linux.go b/pkg/deej/session_finder_linux.go index c756fd18..6401afe1 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() @@ -569,6 +575,10 @@ func (sf *paSessionFinder) addSourceFromInfo(info *proto.GetSourceInfoReply) { } } + // a card's source usually shares its description with the sink, so suffix + // the direction to keep input and output separately addressable + description += " (input)" + sf.mu.Lock() if _, exists := sf.namedSources[info.SourceIndex]; exists { sf.mu.Unlock() 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 07a6f0ac..6237dea4 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()) @@ -91,8 +93,11 @@ 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 + s.displayName = name s.logger.Debugw(sessionCreationLogMessage, "session", s) diff --git a/pkg/deej/session_map.go b/pkg/deej/session_map.go index ef87b976..031785af 100644 --- a/pkg/deej/session_map.go +++ b/pkg/deej/session_map.go @@ -2,11 +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" ) @@ -230,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 } @@ -388,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: @@ -447,6 +464,34 @@ func (m *sessionMap) getSessionCount() int { return count } +// 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() + + infos := make([]SessionInfoDTO, 0, len(m.m)) + for key, sessions := range m.m { + info := SessionInfoDTO{Key: key} + for _, session := range sessions { + if session.IsDevice() { + info.IsDevice = true + } + if session.IsInput() { + info.IsInput = true + } + if info.DisplayName == "" { + info.DisplayName = session.DisplayName() + } + } + infos = append(infos, info) + } + + sort.Slice(infos, func(i, j int) bool { return infos[i].Key < infos[j].Key }) + + return infos +} + func (m *sessionMap) String() string { return fmt.Sprintf("<%d audio sessions>", m.getSessionCount()) } diff --git a/pkg/deej/session_windows.go b/pkg/deej/session_windows.go index 1821cffc..35688cea 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 @@ -90,6 +97,7 @@ func newMasterSession( eventCtx *ole.GUID, key string, loggerKey string, + isOutput bool, ) (*masterSession, error) { s := &masterSession{ @@ -99,8 +107,11 @@ 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 + s.displayName = key s.logger.Debugw(sessionCreationLogMessage, "session", s) diff --git a/pkg/deej/settings_service.go b/pkg/deej/settings_service.go new file mode 100644 index 00000000..cb4adca0 --- /dev/null +++ b/pkg/deej/settings_service.go @@ -0,0 +1,268 @@ +package deej + +import ( + "encoding/base64" + "errors" + "maps" + "runtime" + "sort" + "strings" + "sync" + + ps "github.com/mitchellh/go-ps" + "go.bug.st/serial/enumerator" + + "github.com/nik9play/deej/pkg/deej/util" +) + +// 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, iconCache: make(map[string]string)} +} + +// 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"` + AutostartAvailable bool `json:"autostartAvailable"` +} + +// 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 + 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 +type StatusDTO struct { + Connected bool `json:"connected"` + ComPort string `json:"comPort"` + SliderValues []float32 `json:"sliderValues"` // 0..1, as sessions receive them +} + +// GetSettings returns the current contents of the user config file +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(settings Settings) error { + return s.d.config.SaveUserSettings(settings, 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, + }, + AutostartAvailable: runtime.GOOS == "windows", + } +} + +// GetAutostart reports whether deej is set to run at system startup +func (s *SettingsService) GetAutostart() bool { + return util.GetAutostartState() +} + +// SetAutostart enables or disables running deej at system startup, applying +// the change immediately +func (s *SettingsService) SetAutostart(state bool) error { + return util.SetAutostartState(state) +} + +// GetStatus returns the current serial connection state and slider values, +// so the settings window doesn't have to wait for the first live event +func (s *SettingsService) GetStatus() StatusDTO { + return StatusDTO{ + Connected: s.d.serial.GetState(), + ComPort: s.d.serial.CurrentComPort(), + SliderValues: s.d.serial.CurrentSliderPercentValues(), + } +} + +// GetSessions returns the current audio sessions with friendly display +// names, for slider mapping suggestions +func (s *SettingsService) GetSessions() []SessionInfoDTO { + return s.d.sessions.sessionInfos() +} + +// 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 + } + + pidsByName := make(map[string][]int, len(processes)) + for _, process := range processes { + name := strings.ToLower(process.Executable()) + if name == "" || strings.HasPrefix(name, "[") { + continue + } + + 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) + } + + sort.Strings(names) + + 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) { + 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 +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/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 a94ade8d..da3559a4 100644 --- a/pkg/deej/tray.go +++ b/pkg/deej/tray.go @@ -1,16 +1,43 @@ +//go:build !headless + package deej import ( + "io/fs" + "log/slog" "strconv" "strings" + "sync" + "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{} + + // guards against concurrent settings window creation + settingsLock sync.Mutex +} + +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 + eventSessions = "deej:sessions" // no payload; audio sessions changed +) + func getConfigItemText(d *Deej) (string, string) { configTitle := d.localizer.MustLocalize(&i18n.LocalizeConfig{ DefaultMessage: &i18n.Message{ @@ -45,6 +72,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{ @@ -131,135 +175,213 @@ 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{}) - systray.SetTemplateIcon(icon.TrayDeejLogo, icon.TrayDeejLogo) + dist, err := fs.Sub(frontend.Dist, "dist") + if err != nil { + logger.Errorw("Failed to open frontend assets", "error", err) + } + + 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)}, + Services: []application.Service{ + application.NewService(newSettingsService(d)), + }, + 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()) + openSettingsTitle, _ := getOpenSettingsItemText(d) + settings.Add(openSettingsTitle).OnClick(func(*application.Context) { + logger.Info("Open settings menu item clicked, opening settings window") - if util.Linux() { - autostart.Hide() + d.openSettingsWindow() + }) + + configTitle, _ := getConfigItemText(d) + settings.Add(configTitle).OnClick(func(*application.Context) { + 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) } + }) - 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) + + tray.OnDoubleClick(func() { + d.openSettingsWindow() + }) + + 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() + configReloadedChannel := d.config.SubscribeToChanges() - // wait on things to happen + emitState := func() { + app.Event.Emit(eventState, map[string]any{ + "connected": d.serial.GetState(), + "comPort": d.serial.CurrentComPort(), + }) + } + + // 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) + app.Event.Emit(eventSliders, d.serial.CurrentSliderPercentValues()) // connection state changed case <-stateChangeChannel: setTooltip() - setValuesInfo() - statusInfo.SetTitle(getStatusItemTitle(d)) + application.InvokeAsync(func() { + setValuesInfo() + statusInfo.SetLabel(getStatusItemTitle(d)) + }) + emitState() + app.Event.Emit(eventSliders, d.serial.CurrentSliderPercentValues()) // 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)) + }) + app.Event.Emit(eventSessions) + + // config applied (GUI save or manual edit) + case <-configReloadedChannel: + app.Event.Emit(eventConfig) + emitState() + app.Event.Emit(eventSliders, d.serial.CurrentSliderPercentValues()) } } }() // actually start the main runtime go onDone() + }) + + // start the tray icon + logger.Debug("Running in tray") + if err := app.Run(); err != nil { + logger.Errorw("Wails application exited with error", "error", err) } +} + +// 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() - onExit := func() { - logger.Debug("Tray exited") + if win, ok := d.tray.app.Window.GetByName(settingsWindowName); ok { + win.Restore() + win.Focus() + return } - // start the tray icon - logger.Debug("Running in tray") - systray.Run(onReady, onExit) + settingsTitle, _ := getSettingsItemText(d) + + d.tray.app.Window.NewWithOptions(application.WebviewWindowOptions{ + Name: settingsWindowName, + Title: "deej - " + settingsTitle, + Width: 880, + Height: 620, + MinWidth: 800, + MinHeight: 600, + Frameless: true, + URL: "/", + }) } 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") + } } 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/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 new file mode 100644 index 00000000..61734366 --- /dev/null +++ b/pkg/deej/util/procinfo_windows.go @@ -0,0 +1,92 @@ +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) { + 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) + } + 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 windows.UTF16ToString(buf[:size]), nil +} + +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") +} diff --git a/pkg/icon/assets/tray-icon.png b/pkg/icon/assets/tray-icon.png new file mode 100644 index 00000000..35bf7abd Binary files /dev/null and b/pkg/icon/assets/tray-icon.png differ diff --git a/pkg/icon/icon_windows.go b/pkg/icon/icon_windows.go index 48e526ef..db1b9427 100644 --- a/pkg/icon/icon_windows.go +++ b/pkg/icon/icon_windows.go @@ -2,6 +2,7 @@ package icon import _ "embed" -// TrayDeejLogo is a binary representation of the deej logo; used for notifications and tray icon -//go:embed assets/tray-icon.ico +// TrayDeejLogo is a binary representation of the deej logo; used for notifications and tray icon. +// PNG, because wails' systray icon loader accepts PNG data but not full .ico containers +//go:embed assets/tray-icon.png var TrayDeejLogo []byte diff --git a/pkg/notify/notify.go b/pkg/notify/notify.go index 8679bd62..05e53ccd 100644 --- a/pkg/notify/notify.go +++ b/pkg/notify/notify.go @@ -36,10 +36,8 @@ func (tn *ToastNotifier) Notify(title string, message string) { } func (tn *ToastNotifier) createIconFile() (appIconPath string) { - fileName := "deej.ico" - if util.Linux() { - fileName = "deej.png" - } + // TrayDeejLogo is PNG data on all platforms + fileName := "deej.png" appIconPath = filepath.Join(os.TempDir(), fileName) 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 +} 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..aa260940 100755 --- a/scripts/linux/build.sh +++ b/scripts/linux/build.sh @@ -1,53 +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 - -# 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" +case "$1" in + dev) TASK_TARGET=linux:build:dev ;; + release) TASK_TARGET=linux:build ;; + *) echo "Usage: $0 [dev|release]"; exit 1 ;; +esac -# 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 451adeb7..3de60dc2 100644 --- a/scripts/windows/build.bat +++ b/scripts/windows/build.bat @@ -1,56 +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 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/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; 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%