diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 81603c14..ef3b126f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -14,6 +14,11 @@ jobs: strategy: matrix: os: [windows-latest, ubuntu-latest] + include: + - os: ubuntu-latest + go-flags: -tags=purego + env: + CGO_ENABLED: "0" steps: - name: Checkout uses: actions/checkout@v4 @@ -21,10 +26,17 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: "1.25.7" + go-version-file: go.mod + + # Purego loads these libraries at runtime; compilation needs no headers. + - name: Install GTK runtime libraries + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libgtk-4-1 libwebkitgtk-6.0-4 - name: Run tests - run: go test ./... + run: go test ${{ matrix.go-flags }} ./... build-windows: name: Build (windows) @@ -38,41 +50,62 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: "1.25.7" + go-version-file: go.mod + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install Task + uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Install Wails CLI + shell: pwsh + run: | + $wailsVersion = go list -m -f '{{.Version}}' github.com/wailsapp/wails/v3 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + go install "github.com/wailsapp/wails/v3/cmd/wails3@$wailsVersion" - name: Build deej (amd64) - run: scripts/windows/build-all.bat - shell: cmd + run: task windows:build:all - name: Rename binaries (amd64) run: | - Rename-Item build/deej-release.exe deej-windows-amd64.exe - Rename-Item build/deej-dev.exe deej-windows-amd64-dev.exe + Rename-Item bin/deej-release.exe deej-windows-amd64.exe + Rename-Item bin/deej-dev.exe deej-windows-amd64-dev.exe - name: Build deej (arm64) - run: scripts/windows/build-all.bat - shell: cmd + run: task windows:build:all env: GOARCH: arm64 - name: Rename binaries (arm64) run: | - Rename-Item build/deej-release.exe deej-windows-arm64.exe - Rename-Item build/deej-dev.exe deej-windows-arm64-dev.exe + Rename-Item bin/deej-release.exe deej-windows-arm64.exe + Rename-Item bin/deej-dev.exe deej-windows-arm64-dev.exe # bundles both binaries into a single universal installer - name: Build installer - run: ISCC /Obuild "/DAppVersion=$(git describe --tags --always)" /DAmd64Exe=../../build/deej-windows-amd64.exe /DArm64Exe=../../build/deej-windows-arm64.exe scripts/windows/installer.iss + run: task windows:package:universal - name: Upload artifacts uses: actions/upload-artifact@v4 with: name: deej-windows - path: build/ + path: bin/ + # The purego GUI backend lets one runner cross-compile both architectures. build-linux: name: Build (linux) runs-on: ubuntu-latest + env: + CGO_ENABLED: "0" steps: - name: Checkout uses: actions/checkout@v4 @@ -82,36 +115,86 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: "1.25.7" + go-version-file: go.mod + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install Task + uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} - name: Build deej (amd64) - run: scripts/linux/build-all.sh + run: task linux:build:all + env: + GOARCH: amd64 - name: Rename binaries (amd64) run: | - mv build/deej-release build/deej-linux-amd64 - mv build/deej-dev build/deej-linux-amd64-dev + mv bin/deej-release bin/deej-linux-amd64 + mv bin/deej-dev bin/deej-linux-amd64-dev - name: Build deej (arm64) - run: scripts/linux/build-all.sh + run: task linux:build:all env: GOARCH: arm64 - name: Rename binaries (arm64) run: | - mv build/deej-release build/deej-linux-arm64 - mv build/deej-dev build/deej-linux-arm64-dev + mv bin/deej-release bin/deej-linux-arm64 + mv bin/deej-dev bin/deej-linux-arm64-dev - name: Upload artifacts uses: actions/upload-artifact@v4 with: name: deej-linux - path: build/ + path: bin/deej-linux-* + + # 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-file: go.mod + + - name: Install Task + uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Build deej (headless, amd64 + arm64) + run: | + GOARCH=amd64 task linux:build:headless + mv bin/deej-headless bin/deej-linux-amd64-headless + GOARCH=arm64 task linux:build:headless + mv bin/deej-headless bin/deej-linux-arm64-headless + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: deej-linux-headless + path: bin/ 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 +220,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..2ba46318 100644 --- a/.gitignore +++ b/.gitignore @@ -6,11 +6,25 @@ venv/ .vscode *.lnk *.exe +*.syso deej-dev deej-release -build/ +build/* +!build/config.yml +!build/tasks/ +!build/tasks/*.yml +!build/windows/ +build/windows/* +!build/windows/wails.exe.manifest +!build/windows/info.json +bin/ 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/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..7581e991 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,154 @@ +# AGENTS.md + +This file provides repository instructions for coding agents working in this project. + +## Project Overview + +deej is a hardware volume mixer client written in Go that pairs with an Arduino to provide physical volume control for Windows and Linux. It communicates with Arduino hardware via serial port and controls per-application audio volumes through OS-specific audio APIs. + +This is a fork of the original deej project (github.com/omriharel/deej) with automatic COM port reconnection, autorun support, updated dependencies, OBS websocket and Discord RPC integrations, i18n support, and a Wails-based settings GUI with profiles and global hotkeys. + +## Build Commands + +Builds are orchestrated by [Task](https://taskfile.dev) (`Taskfile.yml` plus `build/tasks/{windows,linux}.yml`) — the same build system that underpins `wails3 build`. The GUI `build`, `build:dev` and `build:all` tasks compile the embedded Svelte frontend before the Go binary, so Node.js and npm are required. They run `npm ci` only when `frontend/node_modules` is absent, then `npm run build`; after dependency or lockfile changes, run `npm ci` in `frontend/` explicitly. The Linux headless build needs neither Node.js nor frontend assets. The `*:build:dev:live` tasks skip the frontend build because Wails dev mode serves it from Vite. Run tasks with `wails3 task ` — the wails3 CLI embeds the Task runner and is what's installed here (the standalone `task` binary also works if present, but don't assume it). Binaries and installers go to `bin/`; frontend assets go to `frontend/dist/`. + +```bash +wails3 task dev # Go rebuild-on-change + Vite HMR +wails3 task frontend:build # build only the embedded frontend assets +wails3 task windows:build # release exe (optimized, -H=windowsgui, no console) +wails3 task windows:build:dev # dev exe (debug symbols, console window) +wails3 task windows:build:all # release + dev exe +wails3 task windows:package # release exe + Inno Setup installer (needs ISCC) +wails3 task windows:package:universal # installer from prebuilt amd64 + arm64 exe files +wails3 task linux:build # release GUI binary (CGO-free: purego + GTK4/WebKitGTK) +wails3 task linux:build:dev # dev GUI binary +wails3 task linux:build:all # release + dev GUI binaries +wails3 task linux:build:headless # GUI-less daemon, CGO_ENABLED=0, cross-compilable +wails3 task generate:bindings # regenerate Wails Go->TS bindings +wails3 task generate:resources # generate Windows icon/manifest/version resources for GOARCH +``` + +Every Windows build task runs `wails3 generate syso` for the target returned by `go env GOARCH`, writing `cmd/deej/wails_windows_.syso`. These generated files are git-ignored and regenerated on every build so icon, manifest and version-info changes take effect automatically. The manifest and version-info sources live in `build/windows/`; they were generated once with `wails3 generate build-assets` and are committed alongside the existing icon in `internal/icon/assets/logo.ico`. Build tasks run from the repository root; `build/` contains configuration only. Use the Wails CLI version required by `go.mod`; Windows CI installs that exact version. + +`wails3 task dev` runs `wails3 dev` with `build/config.yml`. It starts Vite, builds the current platform's dev binary through `*:build:dev:live`, and runs it with `FRONTEND_DEVSERVER_URL` and `WAILS_VITE_PORT` set. Go changes trigger a rebuild; frontend changes use Vite HMR. The committed `frontend/dist/.gitkeep` lets Go embed the assets directory before the first frontend build. + +Windows dev builds apply `-N -l` only to `github.com/nik9play/deej/...`; keep the standard library optimized to avoid the Windows arm64 `syscall.Syscall15` nosplit stack limit. + +CI invokes the same Task targets directly; there is no separate shell or batch +build implementation to keep in sync. + +### Cross-compilation and cgo + +The **Windows** target and all **Linux** builds support cross-compilation with `CGO_ENABLED=0`. Select the architecture with `GOARCH`; all Linux compile tasks set `GOOS=linux` themselves, while the Windows tasks require `GOOS=windows` when invoked on another OS. CI builds Linux amd64 and arm64 GUI binaries on one runner. The **Linux GUI** build (`linux:build`) uses Wails' experimental `purego` backend (GTK4/WebKitGTK 6.0 by default), so compilation needs no C compiler, development headers or Docker. Target machines still need runtime libraries (`libgtk-4-1` / `libwebkitgtk-6.0-4` on Ubuntu). Use `EXTRA_TAGS=gtk3` with a Linux task to select GTK3/WebKit2GTK 4.1 instead. + +deej also has a runtime headless switch (`DEEJ_NO_TRAY_ICON`) that skips the tray at startup, but that does **not** remove Wails or its GTK/WebKit runtime requirements — the `headless` build tag (see `cmd/deej/bootstrap_gui.go` / `cmd/deej/bootstrap_headless.go`) is what compiles Wails out entirely. + +## Tests and Frontend Checks + +Run from the repository root: + +```bash +wails3 task test # CGO-free tests; adds -tags purego on Linux +npm --prefix frontend run check # Svelte and TypeScript checks +``` + +For direct Linux tests use `CGO_ENABLED=0 go test -tags purego ./...`; CI installs GTK4/WebKitGTK runtime libraries, with no development headers. Before the first frontend check, install dependencies with `npm --prefix frontend ci` and run `wails3 task frontend:build` to generate the git-ignored Paraglide modules. After changing the Go service API or config model, regenerate bindings with `wails3 task generate:bindings`. + +## Linting + +Only use this command to lint the project: + +```bash +golangci-lint run +``` + +Enabled linters: errcheck, govet, revive, staticcheck, unused + +## Architecture + +### Packages and dependency boundaries + +- **cmd/deej/** owns CLI parsing, version linker variables, generated Windows `.syso` resources, and build-tagged GUI/headless bootstrap. Windows resource sources live in `build/windows/`. Only the GUI bootstrap imports `internal/desktop`. +- **internal/app/** composes core services, localization, logging, crash reporting and lifecycle. It does not import Wails, desktop or frontend assets. Load initial settings and resolve language before creating consumers; register mixer and independent GUI subscriptions before starting serial/OBS/Discord. +- **internal/config/** owns the single `Settings` model (`model.go`, `defaults.go`, `normalize.go`, `mapping.go`, `legacy.go`) and store/watcher (`store.go`, `watch.go`). `Values()` publishes immutable snapshots atomically. Read fresh settings per operation; never retain a constructor snapshot. `SliderMappings.Get` returns copied targets. Store outcomes are localized by app callbacks. +- **internal/audio/** owns `Session`, `SessionFinder`, events and Windows Core Audio / Linux PulseAudio implementations. Session removal handlers run synchronously before resources are released. Master/system/mic identifiers belong here. +- **internal/serial/** separates connections/subscriptions (`client.go`), CRLF parsing/normalization (`protocol.go`), filtering (`noise.go`) and port enumeration (`ports.go`). It receives settings, logging, notification callbacks and verbose flag. Reload resets tracking so the next valid line re-emits every value without physical movement. +- **internal/mixer/** routes sliders to targets (`mixer.go`, `targets.go`) through injected interfaces. It reads `Values().ActiveMapping()` per event and refreshes the unmapped-session cache on reload. It owns `SessionInfo`; desktop converts it to a DTO. +- **internal/integrations/obs/** owns websocket connections and cubic/linear input-volume conversion. +- **internal/integrations/discord/** separates client lifecycle, RPC, auth/token persistence, roster, volume throttling and platform IPC. Its public volume API takes Discord-local targets; mixer parses config strings. App provides the token path and current mapped slider volumes for restoration. +- **internal/desktop/** owns Wails, the single settings window, tray, profile hotkeys, service, DTOs and GUI events. Preserve `deej:sliders`, `deej:state`, `deej:config`, `deej:sessions`, `deej:discord` payloads. Re-register shortcuts only when accelerators change. `tray.SetMenu` dispatches to the main thread internally: call it off the main thread when updating a running tray, then reapply the changed menu tree. +- **internal/platform/** owns foreground/fullscreen windows, process information/icons, autorun, signals and opening files. Low-level Windows APIs live in `internal/platform/win`. +- **internal/i18n/** owns detection/fallback and embedded `lang/active.{en,ru}.toml` catalogs. +- **internal/reconnect/** provides generation-scoped recovery for serial/OBS/Discord/PulseAudio. Start/Stop are serialized; Close must unblock Watch. Preserve dial parameters per generation and re-check config on adoption; stale errors must never end newer connections. +- **internal/atomicfile/** shares config/token writes: temporary file beside destination, mode 0600, rename and failure cleanup. +- **internal/notify/** and **internal/icon/** own notifications and embedded assets. + +Configuration normalization stays private. GUI saves reject invalid values; file loads correct and log them. Preserve legacy keys, trailing-edge debounce, coalescing, serialized read-modify-write, unchanged-reload suppression and whole-file YAML regeneration. + +Discord Windows IPC must use `winio.DialPipe` (overlapped); synchronous handles deadlock concurrent reads/writes. Authorize only through explicit `Link()`, never reconnect retries. Credentials are user-supplied; `discord_token.json` stays beside config, outside YAML. One event loop owns roster writes, with channel-scoped subscriptions and 30-second reconciliation. Read-loop event handoff is buffered/nonblocking because handlers issue RPC requests. Volumes dedupe and flush every 100 ms. Preserve replay/invalidation after connection, join/rejoin, channel changes and reconciliation even without slider movement. Participant conversion is cubic or linear 0-100; own input/output always use linear 0-100. + +Each mixer/desktop consumer has a separate channel; sharing one distributes instead of broadcasting. Serial sends synchronously through unbuffered channels. Stop producers while consumers still receive the final serial state, then stop consumers and release audio. Repeated quit requests must not run duplicate Stop operations or close channels twice. The `headless` tag excludes Wails/desktop/frontend; `DEEJ_NO_TRAY_ICON` retains the GUI binary's runtime dependencies. + +### Settings GUI (frontend/) + +Svelte 5 + Vite + TypeScript, Tailwind CSS v4, bits-ui and `@lucide/svelte`. Mixer views/helpers live in `src/features/mixer/`; settings dialog and sections in `src/features/settings/`. Shared UI stays in `src/components/ui/`, Titlebar in `src/components/`. `src/lib/api.ts` is the single generated bindings/types access point; shared state, profiles and tick also live in lib. Window controls use Wails runtime directly. + +Frontend output is embedded by `frontend/embed.go`. Keep `frontend/dist/.gitkeep` and Vite's recreation of it. Bindings live in `frontend/bindings/`. After public Go model/service package changes, run `wails3 task generate:bindings` scanning `cmd` and `internal` together, rebuild frontend and exercise real GetAppInfo/GetSettings/temporary-config SaveSettings calls. Fully qualified Go names determine `Call.ByID`: stale bindings can compile but fail at runtime. + +Paraglide catalogs are `frontend/messages/{en,ru}.json`; configuration is `frontend/project.inlang/settings.json`. Vite generates ignored `src/paraglide/` modules; components call `m.()`. Initialize app info, platform and locale before mount; `main.ts` mirrors Go language with `setLocale(..., { reload: false })`. Preserve `$state.snapshot`, subscribe-before-fetch, window cleanup, settings drafts across tabs, patch merging, saves against current config and pending-change flush on close. + +### Config Format + +`slider_mapping` maps slider indices (0-based) to targets inside each entry of the top-level `profiles` list. A profile has a `name`, an optional `hotkey` and its `slider_mapping`. Shared settings live at the top level, with connection settings grouped into nested `com:`, `obs:` and `discord:` sections: + +```yaml +active_profile: default +profiles: + - name: default + slider_mapping: + 0: master # Master volume + 1: discord.exe # Process name (case-insensitive) + 2: deej.current # Currently active window + 3: [spotify.exe, chrome.exe] # Multiple targets + - name: gaming + hotkey: Ctrl+Alt+2 # global shortcut, switches to this profile from anywhere + slider_mapping: + 0: master +invert_sliders: false +language: auto # auto | en | ru +``` + +Only `slider_mapping` and `hotkey` are per-profile; `com`, `obs`, `discord`, `language`, `invert_sliders` and `noise_reduction` are shared. `normalize` guarantees at least one profile exists and that `active_profile` names one of them. A hotkey must have at least one modifier (a bare key would be grabbed system-wide). A config with a top-level `slider_mapping` and no `profiles:` still loads, as a single `default` profile, and is migrated on the next save. + +Special targets: `master`, `system`, `mic`, `deej.current`, `deej.current.fullscreen`, `deej.unmapped`, `deej.obs:` (OBS input volume), `deej.discord:` (one person's volume in the current voice channel, matched case-insensitively against their display name, or a raw user ID), `deej.discord.input` and `deej.discord.output` (the user's own levels inside Discord). + +Serial connection settings live in the `com:` section (OBS settings use an analogous `obs:` section; its `volume_conversion` defaults to true and selects cubic `position³` versus linear `position` input-volume multipliers): +```yaml +com: + port: auto # COM port name, or "auto" to detect by USB VID/PID + baud_rate: 9600 + vid: 0x1A86 # USB IDs for auto-detection (default: CH340 chip) + pid: 0x7523 +``` + +The legacy flat keys (`com_port`, `baud_rate`, `com_vid`, `com_pid`) are still read for backwards compatibility, but new configs and GUI saves use the `com:` section only. + +Discord settings live in a `discord:` section holding `enabled`, `client_id`, `client_secret` and `volume_conversion` — the OAuth token that linking produces is stored separately in `discord_token.json`, so backend writes never race the GUI's whole-document saves: +```yaml +discord: + enabled: false + client_id: "" # from an application you create at discord.com/developers + client_secret: "" + volume_conversion: true +``` + +Discord `volume_conversion` applies only to participant targets (`deej.discord:`): true sends `position³ × 100` to match Discord's displayed curve, while false sends `position × 100`. The input and output targets always use the linear 0–100 scale. + +The config can be edited by hand (hot-reloaded) or through the settings GUI, which writes the same file. + +### Data Flow + +1. Arduino sends analog readings over serial as pipe-delimited values +2. `serial.Client.watch` reads serial lines; `serial.Client.handleLine` validates them against `^\d{1,4}(\|\d{1,4})*\r\n$`, normalizes values, applies inversion and filters noise +3. Slider move events are emitted to `mixer.Mixer` (and to the tray/GUI as live events) +4. `mixer.Mixer.handleSliderMoveEvent` resolves targets and adjusts volumes diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..e27b0c22 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,116 @@ +# Changelog + +## v0.3.1 → current + +### New Features + +- **Profiles** — Keep several slider mappings and switch between them instantly. Each profile has its own `slider_mapping`; everything else (`com`, `obs`, `language`, ...) stays shared. Switch from the settings window's _Profiles_ tab, the profile dropdown in the title bar, the tray _Profiles_ menu, or a per-profile global hotkey that works even when deej isn't focused: + ```yaml + active_profile: default + profiles: + - name: default + slider_mapping: + 0: master + 1: discord.exe + - name: gaming + slider_mapping: + 0: master + 1: [game.exe, discord.exe] + hotkey: Ctrl+Alt+2 # activates this profile from anywhere + ``` + Existing configs with a top-level `slider_mapping` are read as before and migrated into a single `default` profile the next time settings are saved. +- **OBS integration** — Control OBS audio source volumes with your sliders. Enable WebSocket support in OBS via _Tools → WebSocket Server Settings_, then add to your config: + ```yaml + slider_mapping: + 0: deej.obs:Mic/Aux + 1: deej.obs:Desktop Audio + + obs: + enabled: true + host: localhost + port: 4455 + password: "" # set if you use a password in OBS + volume_conversion: true + ``` + Keep `volume_conversion` enabled (the default) so the physical slider stays aligned with the fader shown in OBS and offers finer control at low volumes. Set it to `false` if you prefer the volume to rise faster as you move the slider. +- **Discord integration** — Control the volume of individual people in a voice call, plus your own microphone and output levels inside Discord. Mapping the `discord.exe` process only ever moved all of Discord at once; this reaches inside it: + ```yaml + slider_mapping: + 0: deej.discord:Nikita # one person in your voice channel + 1: deej.discord.input # your own mic level in Discord + 2: deej.discord.output # Discord's own output level + + discord: + enabled: true + client_id: "" + client_secret: "" + volume_conversion: true + ``` + For individual participant targets, keep `volume_conversion` enabled (the default) so the physical slider stays aligned with Discord's on-screen volume control and offers finer control at low volumes. Set it to `false` if you prefer the volume to rise faster. This setting does not affect `deej.discord.input` or `deej.discord.output`. + Discord only allows this for an application you own, so deej can't ship its own credentials. Create one at [discord.com/developers](https://discord.com/developers/applications), add `http://localhost` as a redirect URI under _OAuth2_, paste its client ID and secret into the settings window's _Discord_ tab, and click _Link Discord account_ — Discord asks you to approve it once, and deej reconnects on its own from then on. The target picker lists whoever is in your voice channel, and a name that isn't there yet can be typed in by hand; it starts working once they join. +- **Named audio device support on Linux** — Sliders can now be mapped to specific audio devices by name on Linux, same as Windows. Input devices are suffixed with `(input)` (e.g. `hyperx cloud (input)`), so a card's input and output can be controlled separately even when they share the same device name. +- **Event-driven session tracking** — deej now listens for system audio events instead of polling, so sliders respond correctly as soon as an app opens or closes. + +### Improvements + +- Serial connection settings now live in a `com:` config section: + ```yaml + com: + port: auto + baud_rate: 9600 + vid: 0x1A86 + pid: 0x7523 + ``` + The old flat keys (`com_port`, `baud_rate`, `com_vid`, `com_pid`) are still read, but saving from the settings GUI writes the new format. +- Linux tray menu fixes. +- Updated Go version and dependencies. + +--- + +## v0.3.1 → текущая версия + +### Новые возможности + +- **Профили** — Храните несколько раскладок слайдеров и мгновенно переключайтесь между ними. У каждого профиля своя `slider_mapping`; всё остальное (`com`, `obs`, `language`, ...) общее. Переключайтесь на вкладке _Профили_ в окне настроек, через выпадающий список в заголовке окна, в меню трея _Профили_ или глобальной горячей клавишей профиля, которая работает, даже когда deej не в фокусе: + ```yaml + active_profile: default + profiles: + - name: default + slider_mapping: + 0: master + 1: discord.exe + - name: gaming + slider_mapping: + 0: master + 1: [game.exe, discord.exe] + hotkey: Ctrl+Alt+2 # активирует этот профиль откуда угодно + ``` + Существующие конфиги с `slider_mapping` на верхнем уровне читаются как раньше и переносятся в один профиль `default` при следующем сохранении настроек. +- **Интеграция с OBS** — Управляйте громкостью аудиоисточников OBS через слайдеры. Включите OBS WebSocket v5 в OBS через _Сервис → Настройки сервера WebSocket_, затем добавьте в конфиг: + ```yaml + slider_mapping: + 0: deej.obs:Mic/Aux + 1: deej.obs:Desktop Audio + + obs: + enabled: true + host: localhost + port: 4455 + password: "" # укажите, если в OBS задан пароль + ``` +- **Поддержка именованных устройств на Linux** — Слайдеры теперь можно привязать к конкретным аудиоустройствам по имени на Linux, как и на Windows. Устройства ввода получают суффикс `(input)` (например, `hyperx cloud (input)`), поэтому вход и выход одной карты управляются отдельно, даже если у них одинаковое имя. +- **Отслеживание сессий на основе событий** — deej теперь слушает системные аудиособытия вместо периодического опроса, поэтому слайдеры реагируют корректно сразу после открытия или закрытия приложения. + +### Улучшения + +- Настройки COM-порта теперь находятся в секции `com:` конфига: + ```yaml + com: + port: auto + baud_rate: 9600 + vid: 0x1A86 + pid: 0x7523 + ``` + Старые ключи (`com_port`, `baud_rate`, `com_vid`, `com_pid`) по-прежнему читаются, но сохранение из GUI настроек записывает новый формат. +- Исправления для меню в трее на Linux. +- Обновлена версия Go и зависимости. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..abedeeb0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,5 @@ +# CLAUDE.md + +Follow the repository instructions in [AGENTS.md](AGENTS.md). + +@AGENTS.md diff --git a/README.md b/README.md index b7b239b2..871409f9 100644 --- a/README.md +++ b/README.md @@ -9,3 +9,29 @@ This fork includes the following new features: - Option to enable autorun - Updated dependencies - Support for adding translations into different languages (currently, Russian translation is available) + + +## Development + +Use the Go version in `go.mod`, Node.js/npm for GUI builds, and the Wails v3 CLI. +Run from the repository root: + +```sh +npm --prefix frontend ci +wails3 task frontend:build +wails3 task dev +``` + +Build with `wails3 task windows:build:all`, `wails3 task linux:build:all`, or +`wails3 task linux:build:headless`. Binaries/installers go to `bin/`; +`build/config.yml` and `build/tasks/` contain build/development configuration; +`build/windows/` contains the Windows manifest and version-info sources. +Linux builds are CGO-free; GUI binaries need GTK/WebKit runtime libraries. +The headless target needs neither Node.js nor frontend assets. + +Run `wails3 task test`, `npm --prefix frontend run check`, and `golangci-lint run`. +Regenerate bindings with `wails3 task generate:bindings` after service/model changes. +The entry point is `cmd/deej`, core packages are in `internal/`, and Wails is +isolated in `internal/desktop`. Frontend features access generated bindings through +`frontend/src/lib/api.ts`. See [architecture](docs/architecture.md), +[build tasks](scripts/README.md), and [repository instructions](AGENTS.md). diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 00000000..ae7c6cfe --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,96 @@ +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), the cmd/deej entry point, and the custom +# version ldflags. Invoke with `task ` or `wails3 task `. + +includes: + windows: + taskfile: ./build/tasks/windows.yml + dir: "{{.ROOT_DIR}}" + vars: &platformvars + BIN_DIR: "{{.BIN_DIR}}" + LDFLAGS: "{{.LDFLAGS}}" + VERSION_TAG: "{{.VERSION_TAG}}" + linux: + taskfile: ./build/tasks/linux.yml + dir: "{{.ROOT_DIR}}" + vars: *platformvars + +vars: + APP_NAME: deej + BIN_DIR: bin + # 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 ./cmd/... ./internal/... + + generate:resources: + desc: Generate Windows resources for GOARCH (defaults to the host architecture) + cmds: + - task: windows:generate:syso + + build: + desc: Build deej for the current OS (release) + cmds: + - task: "{{OS}}:build" + + build:all: + desc: Build the release and development binaries for the current OS + cmds: + - task: "{{OS}}:build:all" + + test: + desc: Run the Go test suite + env: + CGO_ENABLED: "0" + cmds: + - go test {{if eq OS "linux"}}-tags purego{{end}} ./... + + 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..36c641d5 --- /dev/null +++ b/build/config.yml @@ -0,0 +1,47 @@ +# 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 + - bin + - 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/build/tasks/linux.yml b/build/tasks/linux.yml new file mode 100644 index 00000000..41d77adc --- /dev/null +++ b/build/tasks/linux.yml @@ -0,0 +1,83 @@ +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; uses Wails' experimental purego backend +# with CGO_ENABLED=0. Cross-compilable without a C compiler or +# GTK/WebKit headers; GTK/WebKit runtime libraries are needed +# on the target machine. EXTRA_TAGS=gtk3 selects legacy GTK3. +# 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). + +vars: + GUI_TAGS: 'purego{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}}' + +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-free, GTK4/WebKitGTK) -> deej-release + deps: [frontend] + cmds: + - task: compile:release + + build:dev: + desc: Build the full GUI app with debug symbols -> deej-dev + deps: [frontend] + cmds: + - task: compile:dev + + build:all: + desc: Build the release and development Linux GUI binaries + deps: [frontend] + cmds: + - task: compile:release + - task: compile:dev + + compile:release: + internal: true + env: + GOOS: linux + CGO_ENABLED: "0" + cmds: + - go build -tags "{{.GUI_TAGS}},production" -o {{.BIN_DIR}}/deej-release -ldflags "-s -w {{.LDFLAGS}} -X main.buildType=release" ./cmd/deej + + compile:dev: + internal: true + env: + GOOS: linux + CGO_ENABLED: "0" + cmds: + - go build -tags "{{.GUI_TAGS}}" -o {{.BIN_DIR}}/deej-dev -ldflags "{{.LDFLAGS}} -X main.buildType=dev" ./cmd/deej + + 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: + GOOS: linux + CGO_ENABLED: "0" + cmds: + - go build -tags "{{.GUI_TAGS}}" -o {{.BIN_DIR}}/deej-dev -ldflags "{{.LDFLAGS}} -X main.buildType=dev" ./cmd/deej + + 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" ./cmd/deej diff --git a/build/tasks/windows.yml b/build/tasks/windows.yml new file mode 100644 index 00000000..da9f9c44 --- /dev/null +++ b/build/tasks/windows.yml @@ -0,0 +1,83 @@ +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: + generate:syso: + desc: Generate Windows icon, manifest and version resources for GOARCH + vars: + RESOURCE_ARCH: + sh: go env GOARCH + cmds: + - wails3 generate syso -arch {{.RESOURCE_ARCH}} -icon internal/icon/assets/logo.ico -manifest build/windows/wails.exe.manifest -info build/windows/info.json -out cmd/deej/wails_windows_{{.RESOURCE_ARCH}}.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: [generate:syso, frontend] + cmds: + - task: compile:release + + build:dev: + desc: Build deej-dev.exe (debug symbols, console window) + deps: [generate:syso, frontend] + cmds: + - task: compile:dev + + build:all: + desc: Build the release and development Windows binaries + deps: [generate:syso, frontend] + cmds: + - task: compile:release + - task: compile:dev + + compile:release: + internal: true + cmds: + - go build -tags production -o {{.BIN_DIR}}/deej-release.exe -ldflags "-H=windowsgui -s -w {{.LDFLAGS}} -X main.buildType=release" ./cmd/deej + + compile:dev: + internal: true + # Keep the standard library optimized: -N -l on all packages exceeds the + # nosplit stack limit in syscall.Syscall15 on Windows arm64 with Go 1.26. + cmds: + - go build -o {{.BIN_DIR}}/deej-dev.exe -gcflags="github.com/nik9play/deej/...=-N -l" -ldflags "{{.LDFLAGS}} -X main.buildType=dev" ./cmd/deej + + 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: [generate:syso] + cmds: + - go build -o {{.BIN_DIR}}/deej-dev.exe -gcflags="github.com/nik9play/deej/...=-N -l" -ldflags "{{.LDFLAGS}} -X main.buildType=dev" ./cmd/deej + + 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 + + package:universal: + desc: Package prebuilt amd64 and arm64 binaries into one installer + vars: + AMD64_NAME: '{{default "deej-windows-amd64.exe" .AMD64_NAME}}' + ARM64_NAME: '{{default "deej-windows-arm64.exe" .ARM64_NAME}}' + preconditions: + - sh: test -f {{.BIN_DIR}}/{{.AMD64_NAME}} + msg: "Missing amd64 binary: {{.BIN_DIR}}/{{.AMD64_NAME}}" + - sh: test -f {{.BIN_DIR}}/{{.ARM64_NAME}} + msg: "Missing arm64 binary: {{.BIN_DIR}}/{{.ARM64_NAME}}" + cmds: + - ISCC /O{{.BIN_DIR}} "/DAppVersion={{.VERSION_TAG}}" /DAmd64Exe=../../{{.BIN_DIR}}/{{.AMD64_NAME}} /DArm64Exe=../../{{.BIN_DIR}}/{{.ARM64_NAME}} /Qp scripts/windows/installer.iss diff --git a/build/windows/info.json b/build/windows/info.json new file mode 100644 index 00000000..50d1815d --- /dev/null +++ b/build/windows/info.json @@ -0,0 +1,17 @@ +{ + "fixed": { + "file_version": "1.0.0.0", + "product_version": "1.0.0.0" + }, + "info": { + "0409": { + "FileVersion": "1.0.0.0", + "ProductVersion": "1.0.0.0", + "CompanyName": "das3d", + "FileDescription": "Hardware volume mixer", + "LegalCopyright": "", + "ProductName": "deej", + "Comments": "Hardware volume mixer" + } + } +} \ No newline at end of file diff --git a/build/windows/wails.exe.manifest b/build/windows/wails.exe.manifest new file mode 100644 index 00000000..6b43c664 --- /dev/null +++ b/build/windows/wails.exe.manifest @@ -0,0 +1,22 @@ + + + + + + + + + + + true/pm + permonitorv2,permonitor + + + + + + + + + + \ No newline at end of file diff --git a/cmd/deej/bootstrap_gui.go b/cmd/deej/bootstrap_gui.go new file mode 100644 index 00000000..19801739 --- /dev/null +++ b/cmd/deej/bootstrap_gui.go @@ -0,0 +1,20 @@ +//go:build !headless + +package main + +import ( + "github.com/nik9play/deej/internal/app" + "github.com/nik9play/deej/internal/desktop" + "go.uber.org/zap" + "os" +) + +func run(core *app.App, logger *zap.SugaredLogger, version string) error { + if _, disabled := os.LookupEnv("DEEJ_NO_TRAY_ICON"); disabled { + core.Start() + core.Wait() + return core.Stop() + } + shell := desktop.New(desktop.Options{Config: core.Config(), Serial: core.Serial(), Mixer: core.Mixer(), OBS: core.OBS(), Discord: core.Discord(), Logger: logger, Notifier: core.Notifier(), Localizer: core.Localizer(), Language: core.Language(), Version: version, Start: core.Start, Wait: core.Wait, Stop: core.Stop, RequestStop: core.RequestStop}) + return shell.Run() +} diff --git a/cmd/deej/bootstrap_headless.go b/cmd/deej/bootstrap_headless.go new file mode 100644 index 00000000..f591bd25 --- /dev/null +++ b/cmd/deej/bootstrap_headless.go @@ -0,0 +1,14 @@ +//go:build headless + +package main + +import ( + "github.com/nik9play/deej/internal/app" + "go.uber.org/zap" +) + +func run(core *app.App, _ *zap.SugaredLogger, _ string) error { + core.Start() + core.Wait() + return core.Stop() +} diff --git a/pkg/deej/cmd/main.go b/cmd/deej/main.go similarity index 60% rename from pkg/deej/cmd/main.go rename to cmd/deej/main.go index 762677f7..ea9400c1 100644 --- a/pkg/deej/cmd/main.go +++ b/cmd/deej/main.go @@ -1,15 +1,18 @@ -//go:generate go run github.com/tc-hib/go-winres@latest make --arch amd64,arm64 --out rsrc - package main import ( "flag" "fmt" - "github.com/nik9play/deej/pkg/deej" + "github.com/nik9play/deej/internal/app" + "go.uber.org/zap" + "os" + "os/signal" + "syscall" ) var ( + version string gitCommit string versionTag string buildType string @@ -29,7 +32,7 @@ func init() { func main() { // first we need a logger - logger, err := deej.NewLogger(buildType) + logger, err := app.NewLogger(buildType) if err != nil { panic(fmt.Sprintf("Failed to create logger: %v", err)) } @@ -48,7 +51,7 @@ func main() { } // create the deej instance - d, err := deej.NewDeej(logger, verbose, configPath) + d, err := app.New(logger, verbose, configPath) if err != nil { named.Fatalw("Failed to create deej object", "error", err) } @@ -61,11 +64,39 @@ func main() { } versionString := fmt.Sprintf("Version %s-%s", buildType, identifier) - d.SetVersion(versionString) + version = versionString } // onwards, to glory if err = d.Initialize(); err != nil { + _ = d.Stop() named.Fatalw("Failed to initialize deej", "error", err) } + signals := make(chan os.Signal, 1) + signal.Notify(signals, os.Interrupt, syscall.SIGTERM) + defer signal.Stop(signals) + signalDone := make(chan struct{}) + defer close(signalDone) + go func() { + select { + case <-signals: + d.RequestStop() + case <-signalDone: + } + }() + if err = runSafely(d, logger, version); err != nil { + named.Errorw("Application exited with error", "error", err) + os.Exit(1) + } +} + +func runSafely(core *app.App, logger *zap.SugaredLogger, version string) (err error) { + defer func() { + if recovered := recover(); recovered != nil { + core.ReportCrash(recovered) + _ = core.Stop() + err = fmt.Errorf("application panic: %v", recovered) + } + }() + return run(core, logger, version) } diff --git a/config_examples/config.example.ru.yaml b/config_examples/config.example.ru.yaml index b8b4aad3..221fc13e 100644 --- a/config_examples/config.example.ru.yaml +++ b/config_examples/config.example.ru.yaml @@ -8,6 +8,8 @@ # На Linux устройства ввода получают суффикс ' (input)', например "HyperX Cloud (input)", так как вход и выход устройства могут иметь одинаковое имя # Только Windows - Вы можете вписать 'system' для управления громкостью звуков Windows, таких как уведомления # Вы можете вписать 'deej.obs:<имя источника>' для управления аудиоисточниками OBS (требуется obs.enabled: true) +# Вы можете вписать 'deej.discord:<имя>' для управления громкостью собеседника в голосовом канале, а также +# 'deej.discord.input' / 'deej.discord.output' для своих уровней внутри Discord (требуется discord.enabled: true) slider_mapping: 0: master 1: deej.current @@ -15,17 +17,33 @@ slider_mapping: 3: discord.exe 4: chrome.exe +# Если нужно несколько раскладок, замените slider_mapping выше на список профилей: +# у каждого своя раскладка и необязательное сочетание клавиш, которое переключает +# на него из любого приложения. Остальные настройки файла общие для всех профилей +# active_profile: default +# profiles: +# - name: default +# slider_mapping: +# 0: master +# 1: discord.exe +# - name: gaming +# hotkey: Ctrl+Alt+2 +# slider_mapping: +# 0: master +# 1: [game.exe, discord.exe] + # Инвертирование значений ползунков микшера. (1023 - 0, 0 - 1023) invert_sliders: false -# Настройки 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 @@ -43,3 +61,22 @@ obs: host: localhost port: 4455 password: "" + # рекомендуется: положение физического ползунка совпадает с фейдером в OBS, + # а на низкой громкости регулировка точнее; false ускоряет рост громкости + volume_conversion: true + +# Интеграция с Discord (опционально) +# Управление громкостью отдельных собеседников через 'deej.discord:<имя>' в slider_mapping, +# а также своими уровнями внутри Discord через 'deej.discord.input' / 'deej.discord.output' +# Discord разрешает это только для вашего собственного приложения, поэтому создайте его на +# https://discord.com/developers/applications, добавьте http://localhost в Redirect URI +# в разделе OAuth2 и укажите client id и секрет здесь. Затем привяжите аккаунт в окне +# настроек deej - Discord один раз попросит подтвердить доступ +discord: + enabled: false + client_id: "" + client_secret: "" + # рекомендуется для отдельных собеседников: положение физического ползунка + # совпадает с регулятором Discord, а на низкой громкости регулировка точнее; + # false ускоряет рост громкости. На цели input/output настройка не влияет + volume_conversion: true diff --git a/config_examples/config.example.yaml b/config_examples/config.example.yaml index e3045849..42cb8b89 100644 --- a/config_examples/config.example.yaml +++ b/config_examples/config.example.yaml @@ -8,6 +8,8 @@ # on linux, input devices get an ' (input)' suffix, i.e. "HyperX Cloud (input)", since a device's input and output can share the same name # windows only - you can use 'system' to control the "system sounds" volume # you can use 'deej.obs:' to control OBS audio sources (requires obs.enabled: true) +# you can use 'deej.discord:' to control one person's volume in your voice channel, and +# 'deej.discord.input' / 'deej.discord.output' for your own levels inside discord (requires discord.enabled: true) # important: slider indexes start at 0, regardless of which analog pins you're using! slider_mapping: 0: firefox.exe @@ -16,16 +18,33 @@ slider_mapping: 3: master 4: mic +# for more than one layout, replace slider_mapping above with a list of profiles: +# each has its own mapping, and an optional hotkey that switches to it from any +# application. everything else in this file stays shared between them +# active_profile: default +# profiles: +# - name: default +# slider_mapping: +# 0: master +# 1: discord.exe +# - name: gaming +# hotkey: Ctrl+Alt+2 +# slider_mapping: +# 0: master +# 1: [game.exe, discord.exe] + # set this to true if you want the controls inverted (i.e. top is 0%, bottom is 100%) invert_sliders: false # 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) @@ -43,3 +62,22 @@ obs: host: localhost port: 4455 password: "" + # recommended: keeps the physical slider aligned with the fader shown in OBS + # and gives finer control at low volumes; false makes volume rise faster + volume_conversion: true + +# Discord integration (optional) +# control the volume of individual people in a voice call with 'deej.discord:', +# and your own levels inside discord with 'deej.discord.input' / 'deej.discord.output' +# discord only allows this for an application you own, so create one at +# https://discord.com/developers/applications, add http://localhost as a redirect URI +# under OAuth2, and put its client id and secret here. then link the account from +# deej's settings window - discord will ask you to approve it once +discord: + enabled: false + client_id: "" + client_secret: "" + # recommended for individual participants: keeps the physical slider aligned + # with Discord's on-screen control and improves low-volume precision; + # false makes volume rise faster. input/output targets are unaffected + volume_conversion: true diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 600218e0..750c0e8d 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -28,8 +28,12 @@ Despite the above, **deej is still a fully open-source project**. I don't want m ### Getting started with development -- Have a Go 1.14+ environment -- Use the build scripts under `pkg/deej/scripts` for your built binaries if you want them to have the notion of versioning +- Install the Go version declared in `go.mod` and the Wails v3 CLI. +- GUI builds require Node.js/npm. Run `npm --prefix frontend ci`, then `wails3 task frontend:build` before the first frontend check. +- Run `wails3 task dev` for Vite HMR and Go rebuilds. Taskfiles live in `build/tasks/`; outputs go to `bin/`. +- Run `wails3 task test`, `npm --prefix frontend run check`, and `golangci-lint run`. +- Regenerate bindings with `wails3 task generate:bindings` after API/model changes. +- Keep core packages independent of Wails. See [architecture](architecture.md), [AGENTS.md](../AGENTS.md), and [build tasks](../scripts/README.md). ## Issues diff --git a/docs/architecture-refactor-plan.md b/docs/architecture-refactor-plan.md new file mode 100644 index 00000000..51fbebfa --- /dev/null +++ b/docs/architecture-refactor-plan.md @@ -0,0 +1,339 @@ +# deej Architecture Refactoring Plan + +## Goal and scope + +Split the current `pkg/deej` into packages with explicit dependencies, move the +entry point to `cmd/deej`, group the frontend by feature, and separate build +configuration from build output. Keep a single Go module. + +Preserve user-facing behavior: configuration format and migrations, CLI, slider +targets, volume curves, GUI events, reconnection, profiles, hotkeys, notifications, +and both GUI and headless modes. Handle dependency upgrades and new features in +separate changes. + +Each stage must leave the project buildable. Until package extraction is complete, +`pkg/deej` remains the temporary composition point. Remove transitional adapters +when extracting `app`. + +## Target structure + +```text +cmd/deej/ entry point, GUI/headless bootstrap, winres and .syso +internal/ + app/ lifecycle, core composition, logging, crash reporting + config/ model, normalization, YAML, persistence, hot reload + audio/ audio session interfaces and Windows/Linux backends + serial/ serial transport, protocol and noise filtering + mixer/ slider -> targets -> volume routing + integrations/ + obs/ OBS client + discord/ RPC, auth, roster, volume, IPC transport + desktop/ Wails, window, tray, hotkeys, service, DTOs, GUI events + platform/ processes, windows, autorun, opening external files + win/ low-level Windows APIs + i18n/ Go localization and embedded TOML catalogs + reconnect/ shared connection recovery lifecycle + atomicfile/ shared atomic writes for config and Discord tokens + notify/ system notifications + icon/ embedded icons and their assets +frontend/src/ + features/mixer/ Mixer, SliderColumn, TargetDialog, targets.ts + features/settings/ SettingsDialog and sections/ + components/ Titlebar and shared UI components + lib/ api.ts, state.svelte.ts, profiles.ts, tick.ts +build/ + config.yml Wails development configuration + tasks/ windows.yml and linux.yml +bin/ binaries and installers +``` + +Keep tests beside their implementations. Keep `_windows.go` and `_linux.go` files +in the same package as their shared interfaces. + +## Dependency rules + +- `cmd/deej` connects the `app` core to `desktop`. Import `desktop` only from the + GUI bootstrap; the headless bootstrap must not import it. +- `app` manages the core and does not import Wails, `desktop`, or frontend assets. +- `config`, `audio`, `serial`, OBS, and Discord do not import `app`, `mixer`, or + `desktop`. Give `mixer` its dependencies through narrow interfaces. +- Pass dependencies to constructors: settings access and change subscriptions, + logging, notifications, event sources, or callbacks. Gradually remove `*Deej`; + do not replace it with another universal dependency container with the same + fields. +- `config.Settings` remains the single settings model for YAML, runtime, and GUI. + Do not introduce a separate GUI settings model. +- Participant types belong to Discord; audio session information belongs to + audio/mixer. The GUI service converts these types to DTOs while preserving + existing JSON fields. +- Define interfaces at their consumers when needed to separate dependencies. + A global event bus and a shared DTO package are unnecessary. +- Provide access to the current settings snapshot through a getter/provider, + rather than capturing one snapshot in a constructor. Each connection generation + retains its dial parameters; preserve checks for config changes during dial and + adoption. Serialize reconnect `Start/Stop`, ensure `Close` unblocks `Watch`, and + prevent failures from an old generation from terminating a newer connection. +- Adapt tests to new package boundaries in the same stage as production code. + Replace fixtures that access `CanonicalConfig.current`, `Deej`, or `baseSession` + directly with fake providers/sessions. Do not export private fields for tests. +- Move a GUI service or its public types as one coordinated change: generator + scope, bindings, TypeScript imports, and embedded frontend. Wails bindings use + numeric `Call.ByID` identifiers derived from the fully qualified Go method name; + successful compilation with stale bindings does not prove that the API works. + After each such move, exercise real `GetAppInfo` and `GetSettings` calls and + save a temporary test configuration through `SaveSettings`. + +## Stage 0. Establish the baseline + +1. Run the existing Go tests, frontend check, and lint. Record any pre-existing + failures separately from refactoring results. +2. Verify Windows GUI, Linux GUI, and Linux headless builds using current Task + targets. +3. Record manual verification scenarios: loading legacy config, saving from the + GUI, hand edits, profile switching, reconnection, and shutdown. + +Outcome: a known baseline and verification scenarios for subsequent stages. + +## Stage 1. Move the entry point and build files + +1. Move `pkg/deej/cmd` to `cmd/deej`, including `winres`, the `go:generate` + directive, and architecture-specific `.syso` files. +2. Update all Go build paths, resource existence checks, and winres icon paths. +3. Move platform Taskfiles to `build/tasks/`, preserving public task names and + execution from the repository root. +4. Use `bin/` for build output. Update Taskfiles, Inno Setup, CI rename/upload + steps, and `.gitignore` together. Keep build configuration in `build/`. + Account for the existing `build/*` ignore rule: unignore `build/tasks/` itself + before adding exceptions for its files. Check `git check-ignore`, ensure the + Taskfiles are tracked, and verify that tasks work in a clean checkout. +5. Preserve linker injection into `main.gitCommit`, `main.versionTag`, and + `main.buildType`, the `production` tag, and the release flag `-H=windowsgui`. + Apply Windows development flags `-N -l` only to + `github.com/nik9play/deej/...`; verify the arm64 development build and version + display in the GUI. +6. Keep `*:build:dev:live` free of frontend builds. Preserve + `frontend/dist/.gitkeep` and its recreation by the Vite plugin. Preserve the + Vite-first startup order, delay, and devserver environment variables in + `build/config.yml`. Add `bin` to the development watcher's ignored directories. + Verify a cold `wails3 dev` start, HMR, and Go rebuilds without rerunning npm build. + +Outcome: GUI and headless builds use `cmd/deej`; the installer and CI use the new +paths. Go package boundaries are unchanged at this stage. + +## Stage 2. Extract supporting packages + +1. Move `reconnect`, `notify`, `icon`, and `win` from `pkg` to their respective + locations under `internal`. Fix imports and installer/winres asset paths. +2. Extract `writeFileAtomic` from `config.go` into `internal/atomicfile` before + separating config and Discord: `DiscordClient.storeToken` also uses it. + Preserve temporary files in the destination directory, mode 0600, rename, and + cleanup on failure. Both consumers use the same implementation. +3. Break up `pkg/deej/util`: move process, window, autorun, and external file + opening functions to `platform`; move noise filtering alongside the serial + protocol when extracting it. +4. Replace simple filesystem and math wrappers with local standard-library calls + where a separate abstraction adds no value. +5. Extract Go localization into `internal/i18n`; move the TOML catalogs together + with the embedding code. Preserve language detection and fallback. + +Outcome: the extracted packages do not depend on `pkg/deej`; embedded assets work +on both operating systems. + +## Stage 3. Extract configuration + +1. Split `config_model.go` into `model.go`, `defaults.go`, `normalize.go`, + `mapping.go`, and `legacy.go` within `internal/config`. +2. Split `config.go` into `store.go` and `watch.go`. +3. Expose only the necessary operations, including safe slider target lookup. + Keep normalization, migration, and internal copying private to the package. +4. Report configuration loading outcomes through a small callback. Localize + notifications outside the store. +5. Before moving the first GUI-visible types, introduce `frontend/src/lib/api.ts` + as the bindings access point, including imports in `main.ts`, `App.svelte`, and + state. Then move Settings, immediately update the generator scope, regenerate + bindings, and rebuild the frontend. Do not defer this work to stage 8. +6. Rework serial/mixer/Discord fixtures that currently populate + `CanonicalConfig.current` directly. Keep normalization, YAML, and watcher tests + in config; also move the OBS default-value test from `obs_test.go` to config. + +Preserve immutable snapshots, atomic writes, serialized read-modify-write cycles, +trailing-edge debounce, coalescing, and skipping unchanged reloads. GUI saves +reject normalization errors; file loads apply corrections and log them. + +Outcome: config is independent of the GUI and orchestrator; existing model tests +are migrated and legacy YAML compatibility is verified. + +## Stage 4. Extract audio and serial + +1. Move `Session`, `SessionFinder`, events, and platform implementations into + `internal/audio`. Place master/system/mic identifiers at the audio level so + the package does not depend on the mixer. +2. Extract serial into `internal/serial`: `client.go` for connections and + subscriptions, `protocol.go` for parsing/normalization, and `noise.go` for + filtering. +3. Replace serial's `*Deej` dependency with settings access, logging, + notifications, and the verbose flag. Expose port enumeration through serial + for the GUI service as well. +4. Move tests and update calls from the temporary orchestrator. Fake audio + sessions implement the public `audio.Session` interface without embedding + private `baseSession`; serial tests receive a fake config provider. + +Preserve synchronous handling of session removal before resources are released, +handler registration before discovery, the CRLF protocol, inversion, and value +re-emission after reload even without physical slider movement. + +Outcome: audio and serial operate without knowledge of the orchestrator or GUI. + +## Stage 5. Extract OBS and Discord + +1. Move OBS into `internal/integrations/obs`; remove its `*Deej` dependency. +2. Split Discord into `client.go`, `rpc.go`, `auth.go`, `roster.go`, `volume.go`, + and platform-specific `ipc_*.go` files within one package. +3. Move participant/status types from the GUI service into Discord; keep GUI DTO + conversion in the service. +4. Give Discord the token-file path and a callback providing current Discord + targets and slider positions for volume restoration. Keep the adapter in + `pkg/deej` temporarily, then move it into `app`. +5. The public Discord volume API accepts targets within Discord. Parsing config + strings such as `deej.discord:*` belongs to the mixer. + +Preserve `winio.DialPipe`, a single owner for roster writes, nonblocking event +handoff from the read loop, reconciliation, deduplication, and the 100 ms flush. +Authorization runs only through an explicit Link action; the token remains beside +the config file, outside YAML. Preserve linear/cubic volume conversion for OBS +and Discord participants; Discord input/output always use the linear scale. + +Preserve replay of current values after connection, participant join/rejoin, +voice-channel changes, and reconciliation. Keep `lastSent` invalidation at the +existing points so deduplication cannot suppress volume restoration without +slider movement. Codec/roster/throttle tests belong to Discord; the routing test +in `discord_test.go` belongs to mixer, while assembling current mapped slider +values belongs to the app adapter. Until those packages are extracted, keep these +tests with the temporary orchestrator and update their dependencies. + +Outcome: both integrations are independent of mixer, serial, and GUI; current +volume restoration behavior is preserved. + +## Stage 6. Extract the mixer + +1. Move `session_map.go` into `internal/mixer`: `mixer.go` for event handling and + session storage, and `targets.go` for target resolution. +2. Inject slider event access, config, finder, OBS/Discord control, and foreground + window detection instead of `*Deej`. +3. Return session information using a type owned by the core, without depending + on the GUI service's `SessionInfoDTO`. +4. Preserve fresh `ActiveMapping()` lookup on every slider event and refresh the + unmapped-session cache after config changes. + +Outcome: the mixer can be tested with fake event sources and volume controllers. +Verify special target routing and profile switching without slider movement, +using the actual reload/reset/event sequence. + +## Stage 7. Separate app and desktop + +1. Move the `deej.go` core, logging, and crash reporting into `internal/app`. + Define explicit initialization, startup, and shutdown operations. +2. Split `tray.go` into `desktop.go`, `tray.go`, `hotkeys.go`, and `events.go`. + Move `settings_service.go` and extract `dto.go`. +3. Give desktop the required core services/operations; app must not import + desktop. Compose the GUI shell and headless startup in separate `cmd/deej` + bootstrap files selected by build tags. + Immediately after moving SettingsService, update the generator scope and API + facade, regenerate bindings, rebuild the embedded frontend, and exercise real + service calls. Preserving method names and JSON fields is insufficient for + `Call.ByID` compatibility. +4. Exclude GUI files from headless builds. Verify that the headless binary's + dependency graph contains neither Wails nor frontend assets. +5. Coordinate core and Wails shutdown: release audio sessions, stop reconnect and + watcher loops, and close the GUI before exiting the process. +6. Preserve the distinct runtime behavior of `DEEJ_NO_TRAY_ICON`. + +Make startup order explicit: load the initial config and resolve the language; +register mixer/finder handlers and separate subscriptions; ensure all consumers +are ready; only then start serial/OBS/Discord. In GUI mode, preserve subscriptions +inside ApplicationStarted before starting the runtime. Give each consumer its +own channel: sharing one channel between mixer and desktop would distribute +rather than broadcast events. Serial currently delivers events synchronously +through unbuffered channels. + +During shutdown, keep consumers receiving events until producers have stopped, +including the state event emitted when serial closes; then terminate consumer +goroutines. Define ownership of each subscription, cancellation, and channel +closure. Repeated exit requests must not run concurrent Stop operations or close +channels twice. Do not leave senders blocked when the GUI closes or startup fails. + +Preserve `deej:*` event names and payloads, a single settings window, and hotkey +updates only when the accelerator set changes. Preserve the execution context +required by each Wails API individually: `tray.SetMenu` switches to the main +thread itself. When updating a running GUI, call it off the main thread without +an `InvokeSync`/`InvokeAsync` wrapper that moves the call there. Reapply the menu +to the tray after changing its tree. Remove transitional adapters and the emptied +`pkg/deej` package. + +Outcome: app is independent of the GUI; headless builds require neither Node nor +a frontend build. + +## Stage 8. Regroup the frontend and verify bindings + +1. Move Mixer, SliderColumn, TargetDialog, and targets.ts into `features/mixer/`; + move SettingsDialog and its sections into `features/settings/`. +2. Keep shared elements in `components/ui` and Titlebar in `components`. +3. Use the `lib/api.ts` introduced in stage 3 for Go bindings and their types. + Update relative imports for components, UI, lib, and Paraglide after moving + files. Window control through the Wails runtime remains a GUI responsibility. +4. Verify the final `generate:bindings` setup: include the remaining `pkg` during + migration, then `cmd`/`internal` after completion. The generator cleans its + output by default; scan all required packages together so a later invocation + does not delete an earlier package's bindings. +5. Verify generated events, removal of stale generated modules at old package + paths, and the frontend build. +6. Preserve AppInfo/locale/platform initialization before mount; conversion of + generated models through `$state.snapshot`; subscribe-before-initial-fetch + ordering; and cleanup when closing the window. Preserve COM/OBS/Discord drafts + across tabs, patch merging, saving against current config, and flushing pending + changes on close. + +Outcome: components are grouped by feature, Settings remains generated from Go, +and frontend checks and the production build pass. + +## Verification and completion + +After each stage, run the tests and builds relevant to that change. Add tests for +changed boundaries: config migration/persistence, mapping changes, Discord volume +restoration, and lifecycle. Verify mechanical moves with existing tests and +compilation. + +Final verification: + +- Run `wails3 task test` on Windows and Linux; Linux uses `purego`. +- Run `golangci-lint run`, the project's only lint command. +- Run `wails3 task frontend:build` and `npm --prefix frontend run check`. + Install frontend dependencies with `npm --prefix frontend ci` before the first + check. +- Build Windows release/dev, Linux GUI release/dev, and Linux headless for + amd64/arm64 through the existing public Task targets. Also build Linux GUI with + `EXTRA_TAGS=gtk3`. +- Build the Windows installer when ISCC is available, including universal + packaging. +- Verify a clean headless build without Node or frontend assets; verify Wails dev + startup and HMR. +- Manually verify serial reconnection, profiles and hotkeys, config hot reload, + OBS/Discord, notifications, settings window open/close, and process shutdown. +- Verify Discord join/rejoin and voice-channel changes without slider movement: + current volume is restored and deduplication does not suppress it. +- Exercise GUI scenarios: draft -> another tab -> back; hand-edit config while + settings are open; rapid changes -> close; validation failure -> correction and + retry; reopen the window; start in en/ru with correct platform styling and no + duplicate event subscriptions. +- Verify startup/shutdown with active serial and an unavailable device: GUI Quit + and process signals must not leave blocked sends or duplicate Stop operations. +- Verify legacy config and the token file. Preserve user-data paths relative to + the executable or explicitly selected config, rather than the repository root. +- Search source, generated bindings, Taskfiles, installer, CI, and documentation + for stale paths. Verify embed paths and `.syso` generation from scratch. +- Update README, AGENTS.md, CLAUDE.md, docs/CONTRIBUTING.md, frontend/scripts + READMEs, and architecture documentation for the new packages and paths. + +Deliver stages as separate commits or small PRs. Within a large stage, split files +inside the current package first, then change dependencies and move the package. +This makes diffs easier to review and regressions easier to locate. diff --git a/docs/architecture-refactor-verification.md b/docs/architecture-refactor-verification.md new file mode 100644 index 00000000..28f09e8a --- /dev/null +++ b/docs/architecture-refactor-verification.md @@ -0,0 +1,125 @@ +# Architecture refactor verification + +No commits are created as part of this working-tree refactor. The input plan is +`architecture-refactor-plan.md`; this document records evidence separately. + +## Baseline before package moves + +- Windows Go tests: passed (`wails3 task test`). A first attempt overlapped npm ci + and encountered a transient removed node_modules path; rerun after install passed. +- Frontend production build and Svelte/TypeScript check: passed. +- Windows GUI release, Linux GUI release and Linux headless release builds: passed + through their existing Task targets. +- Lint: blocked before refactoring because installed golangci-lint was built with + Go 1.25 while `go.mod` targets Go 1.26.10. This is an existing tool mismatch. + +## Package checks + +- Configuration migration, normalization, YAML round-trip, watcher/reload and + settings validation tests moved with config and passed. +- Added checks for non-mutating settings saves, safe mapping lookup and idempotent + watcher stop; these passed. +- Atomic writes tested for destination replacement, owner permissions on Unix, + and temporary-file cleanup after failed rename; passed on Windows (Unix mode + assertion requires Unix execution). +- Shared reconnector tests and concurrent lifecycle/idempotency test passed. +- Supporting packages compile on Windows: platform, i18n, notify and icon. + +## Final automated verification + +- Windows `wails3 task test`: passed. +- Linux Ubuntu 24.04 / WSL `wails3 task test`: passed with Go 1.26.10 and Wails 3.1.0. +- Windows core/app/desktop/integration race checks and Linux + `CGO_ENABLED=1 go test -race -tags purego ./internal/...`: passed. +- `golangci-lint run`: 0 issues using a Go 1.26-compatible golangci-lint 2.15.0 + built under the temporary tools directory. The globally installed 2.5.0 remains + unchanged; application dependencies and lockfiles are unchanged. +- Bindings regenerated together from `./cmd/... ./internal/...`; obsolete + `pkg/deej` bindings removed. Frontend production build and Svelte/TypeScript + checks passed. Generated bindings are accessed through `lib/api.ts`. +- Windows-only dispatcher test parses every numeric ID from the generated + SettingsService module, resolves each against Wails' actual Bindings registry, + and calls GetAppInfo, GetSettings and SaveSettings through its JSON dispatcher. + Legacy YAML is loaded and saved to a temporary config; invalid save followed by + a corrected retry is covered. This test is Windows-only because constructing a + standalone Wails App before its GTK backend is initialized fails on Linux. +- Public Task builds: Windows release/dev; Linux GUI release/dev and headless; + amd64 and arm64; Linux GUI GTK3 release/dev on both architectures: passed. + Release metadata, production/windowsgui flags and package-scoped Windows dev + flags were inspected. Windows arm64 dev build passed. +- Windows resources regenerated from `cmd/deej/winres`; icon embeds and both + architecture resources resolve. Platform Taskfiles are not ignored; a temporary + Git index verified they are included with the source changes. No actual index + staging or commits were performed. +- Ordinary and universal Inno Setup targets passed. Installers were built but not + installed. Build output is under `bin/`, build configuration under `build/`. +- Linux headless Task build passed with `frontend/dist` temporarily absent and the + Node installation directory removed from PATH. Assets were restored afterwards. + Its dependency graph includes neither Wails, desktop nor frontend. +- `git diff --check` and stale-path searches passed (the supplied historical + refactoring plan intentionally still describes the old package paths). + +Final task/test/lint logs are in ignored `bin/verification/`. Preserved installers +are `bin/deej-setup-amd64.exe` and `bin/deej-setup-universal.exe`; portable Windows +outputs are `bin/deej-windows-{amd64,arm64}{,-dev}.exe`. + +## Runtime smoke verification + +All runtime tests used temporary config files with empty slider mappings and an +unavailable serial port; the user's installed deej instance/config was untouched. +The GUI smoke binary temporarily added automatic settings-window opening and a +quit shortcut to make the test window reachable. These instrumentation changes +were restored before the final build; no test-only behavior remains in source. + +- Windows embedded frontend: mixer loaded; English and Russian startup locale, + Windows styling, version and selected config path displayed correctly. +- SettingsService calls ran through the actual embedded webview. Toggling slider + inversion persisted the temporary configuration in the canonical YAML format. +- OBS draft survived switching to About and back; Revert cleared the draft. + Settings dialog closed and reopened successfully. +- A hand edit changed the active profile while settings stayed open. The GUI + updated, the tray menu was reapplied without deadlock, and a newly configured + global hotkey switched back and persisted the profile. +- The test GUI's quit action reached the normal RequestStop path: serial, OBS, + Discord and the audio finder stopped and the process exited. +- Linux headless runtime connected to PulseAudio, retried an unavailable device, + handled SIGTERM, stopped integrations and exited with status 0. +- Wails dev started Vite first, then built and ran `bin/deej-dev.exe` with the + devserver environment. A CSS edit emitted a Vite HMR update. A Go source edit + rebuilt/restarted using `windows:build:dev:live`, without npm production build. + Test edits were restored; owned dev/Vite processes and temporary bin config + were cleaned up. Browser-visible HMR rendering was not separately inspected. + +## Remaining device/service scenarios + +These require physical hardware, authenticated integrations or a Linux GUI +runtime and are not claimed as passed: + +- Real Arduino disconnect/reconnect and volume changes; serial Quit while a real + active device is delivering values (covered with fake ports/events in tests). +- Real OBS connection and Discord authorization/token persistence; participant + join/rejoin and channel changes. Framed RPC tests cover restoration, throttle, + curves, dedupe invalidation and stale in-flight generation handling. +- Linux GTK4/GTK3 GUI rendering: WSL lacks WebKitGTK/JavaScriptCore runtime libs. +- Full GUI matrix: whole-window close/reopen from tray, rapid edits followed by + whole-window close, notification appearance, duplicate-subscription inspection + and native tray Quit with an active serial device. +- A literal fresh Git checkout was not created because the requested changes are + intentionally uncommitted. Taskfile inclusion/ignore rules and clean headless + conditions were checked separately. + +## Follow-up: Windows toast COM apartment conflict + +A missing-config startup toast exposed RPC_E_CHANGED_MODE (RoInitialize) when +sent from a thread already initialized as STA. Windows notifications now use one +lazy, process-lifetime goroutine locked to an OS thread; registration and every +Push are serialized there because the library caches initialization globally. + +- Regression tests establish STA on the caller, initialize MTA on the worker, + verify thread identity/apartment across later requests, and exercise concurrent + calls and error propagation. `go test -race ./internal/notify` passed. +- `wails3 task test` and `golangci-lint run` passed. +- The Windows GUI release binary was started without computer use with an + explicitly missing temporary config. The missing-config/fatal path ran and + exited promptly; no RoInitialize/toast error was logged. Visual toast appearance + was not inspected. Logs are `bin/verification/notify-fix-*.log`. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 00000000..07e9ee13 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,76 @@ +# Architecture + +The application remains one Go module. `cmd/deej` owns CLI flags, build metadata, +Windows resources and GUI/headless bootstrap. Binaries and installers are output +to `bin/`; `build/config.yml` and `build/tasks/` are source configuration. + +## Core and desktop + +`internal/app` composes configuration, audio discovery, serial transport, mixer, +OBS and Discord. Dependencies are passed to constructors through consumer-owned +interfaces and callbacks. Config, audio, serial and integrations have no dependency +on app, mixer or desktop. App does not import Wails or frontend assets. + +`internal/config.Settings` is the only settings model for YAML, runtime and GUI. +The store publishes immutable snapshots; consumers obtain current settings per +operation. Normalization, migrations and copying remain private. GUI saves reject +invalid settings, file loads correct them. Atomic writes, hot reload debounce, +coalescing and unchanged-reload suppression are shared behavior across profiles +and settings saves. `internal/atomicfile` is also used for Discord token storage. + +`internal/audio` owns audio session interfaces and synchronous lifetime events. +`internal/serial` owns protocol, inversion/noise filtering and port recovery. +`internal/mixer` resolves configuration targets and controls audio/integrations +using narrow interfaces. Audio session information belongs to mixer/audio; +Discord roster/status information belongs to Discord. Desktop converts core +information into DTOs without introducing another settings model. + +OBS and Discord live under `internal/integrations/`. Mixer parses target prefixes; +Discord receives integration-local keys. App assembles the current mapping and +slider positions for Discord restoration after reconnects, participant changes +and reconciliation. Tokens stay beside the selected config and outside YAML. + +`internal/desktop` owns Wails, service bindings, window, tray, events and profile +hotkeys. GUI bootstrap imports it; headless bootstrap does not. The `headless` +build tag removes Wails and embedded frontend dependencies. The runtime +`DEEJ_NO_TRAY_ICON` option skips the tray while retaining GUI-binary dependencies. + +Platform process/window/autorun/file-opening operations live in +`internal/platform`, with low-level Windows APIs in `internal/platform/win`. +`internal/i18n`, `notify`, `icon` and `reconnect` own translations, notifications, +assets and connection recovery respectively. Windows toast registration and sends are +serialized on a dedicated locked OS thread: the toast library caches WinRT +initialization globally, while COM apartments belong to threads. Do not run +its Push on Wails' STA thread or move later sends between Go worker threads. + +## Startup and shutdown + +1. Load initial configuration and resolve the language. +2. Register mixer and finder handlers before discovery. Register independent GUI + subscriptions before starting producers; GUI setup occurs in ApplicationStarted. +3. Start serial, OBS, Discord and configuration watching. +4. Coalesce repeated quit requests. Stop producers while consumers continue to + receive synchronous slider and final serial state events. +5. Stop consumer loops, release audio/finder resources, close GUI and sync logs. + +Each event consumer has its own subscription channel. Sharing a channel would +split delivery instead of broadcasting. Connection Start/Stop operations are +serialized. Each generation retains its dial parameters, has its own failure +channel, and closes its transport to unblock Watch. Late failures from an old +connection cannot tear down the current one. + +## Frontend and generated API + +Mixer components and target helpers live in `frontend/src/features/mixer`; +settings dialog/sections live in `features/settings`. Shared UI stays in +`components/ui`, Titlebar in `components`, and state/profiles/tick in `lib`. +`lib/api.ts` centralizes generated Go API/type imports. + +Generate all bindings together with `wails3 task generate:bindings`, then rebuild +frontend assets. Numeric `Call.ByID` identifiers depend on fully qualified Go +method names, so a package move requires regenerated bindings and real service +calls even when TypeScript compiles. Preserve app-info/locale/platform setup +before mount, subscribe-before-fetch ordering, snapshot conversion and cleanup. + +See [AGENTS.md](../AGENTS.md) for contracts and [verification evidence](architecture-refactor-verification.md) +for completed checks and remaining manual scenarios. diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 00000000..a4bdd6b2 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,50 @@ +# deej settings frontend + +Svelte 5 + Vite settings UI, embedded through `embed.go`. GUI build tasks install +missing dependencies and compile `dist/`; after lockfile changes run +`npm --prefix frontend ci` explicitly. + +## Development + +From the repository root: + +```sh +npm --prefix frontend ci +wails3 task frontend:build +wails3 task dev +``` + +Wails starts Vite first, builds `bin/deej-dev` (`.exe` on Windows), and supplies +`FRONTEND_DEVSERVER_URL` and `WAILS_VITE_PORT`. Frontend edits use HMR; Go edits +rebuild the binary without rerunning the frontend production build. + +For standalone Vite development, run `npm --prefix frontend run dev` and start +the development binary with `FRONTEND_DEVSERVER_URL=http://127.0.0.1:9245`. +Keep `dist/.gitkeep` so embedding works before the first production build. + +## Source layout + +- `src/features/mixer/`: mixer, slider columns, target dialog and target helpers. +- `src/features/settings/`: settings dialog and `sections/`. +- `src/components/`: Titlebar and shared `ui/` components. +- `src/lib/`: state, profiles, tick and `api.ts` for Go bindings/types. +- `messages/`: Paraglide catalogs; generated `src/paraglide/` is ignored. + +Initialize app info, platform and locale before mount. Subscribe before fetching +initial state and clean up on close. Preserve drafts across tabs and flush edits. + +## Generated bindings and checks + +Bindings in `bindings/` are committed. After changing `internal/desktop` service +APIs or `internal/config` models, run from the root: + +```sh +wails3 task generate:bindings +wails3 task frontend:build +npm --prefix frontend run check +``` + +The generator scans `cmd` and `internal` together because it cleans its output. +Import APIs only through `src/lib/api.ts`. Package moves alter `Call.ByID` IDs: +verify real GetAppInfo/GetSettings/temporary-config SaveSettings calls afterwards. +Keep Wails CLI and `@wailsio/runtime` aligned with `go.mod`. diff --git a/frontend/bindings/github.com/nik9play/deej/internal/config/index.ts b/frontend/bindings/github.com/nik9play/deej/internal/config/index.ts new file mode 100644 index 00000000..39980ac7 --- /dev/null +++ b/frontend/bindings/github.com/nik9play/deej/internal/config/index.ts @@ -0,0 +1,16 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +export { + COMSettings, + DiscordSettings, + OBSSettings, + Profile, + Settings, + SliderMappingEntry +} from "./models.js"; + +export type { + HexWord, + SliderMappings +} from "./models.js"; diff --git a/frontend/bindings/github.com/nik9play/deej/internal/config/models.ts b/frontend/bindings/github.com/nik9play/deej/internal/config/models.ts new file mode 100644 index 00000000..f0e7d04d --- /dev/null +++ b/frontend/bindings/github.com/nik9play/deej/internal/config/models.ts @@ -0,0 +1,294 @@ +// 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"; + +/** + * 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); + } +} + +/** + * DiscordSettings describes the Discord RPC integration. deej can't ship + * credentials of its own: Discord gates the rpc.voice.* scopes to an + * application's owner, so the user creates their own application and pastes + * its ID and secret here. The OAuth token that comes out of linking is not + * kept here - it lives in its own file next to the config (see discord.go) + */ +export class DiscordSettings { + "enabled": boolean; + "clientId": string; + "clientSecret": string; + "volumeConversion": boolean; + + /** Creates a new DiscordSettings instance. */ + constructor($$source: Partial = {}) { + if (!("enabled" in $$source)) { + this["enabled"] = false; + } + if (!("clientId" in $$source)) { + this["clientId"] = ""; + } + if (!("clientSecret" in $$source)) { + this["clientSecret"] = ""; + } + if (!("volumeConversion" in $$source)) { + this["volumeConversion"] = false; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new DiscordSettings instance from a string or object. + */ + static createFrom($$source: any = {}): DiscordSettings { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new DiscordSettings($$parsedSource as Partial); + } +} + +/** + * 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; + "volumeConversion": boolean; + + /** 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"] = ""; + } + if (!("volumeConversion" in $$source)) { + this["volumeConversion"] = false; + } + + 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); + } +} + +/** + * Profile is one named slider layout. Only the mapping and its hotkey are + * per-profile; everything else (com, obs, language, ...) stays global. + * normalize guarantees at least one profile exists, so consumers can always + * resolve a mapping + */ +export class Profile { + "name": string; + "hotkey": string; + "sliderMapping": SliderMappings; + + /** Creates a new Profile instance. */ + constructor($$source: Partial = {}) { + if (!("name" in $$source)) { + this["name"] = ""; + } + if (!("hotkey" in $$source)) { + this["hotkey"] = ""; + } + if (!("sliderMapping" in $$source)) { + this["sliderMapping"] = []; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new Profile instance from a string or object. + */ + static createFrom($$source: any = {}): Profile { + const $$createField2_0 = $$createType0; + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + if ("sliderMapping" in $$parsedSource) { + $$parsedSource["sliderMapping"] = $$createField2_0($$parsedSource["sliderMapping"]); + } + return new Profile($$parsedSource as Partial); + } +} + +/** + * Settings is the single source of truth for deej's user-facing + * configuration. The yaml tags define the config file keys, the json tags + * the GUI wire format: a new setting only needs a field here (plus a default + * in defaultSettings and, if needed, a rule in normalize) to reach the file, + * the runtime snapshot and the settings GUI + */ +export class Settings { + "activeProfile": string; + "profiles": Profile[]; + "invertSliders": boolean; + "com": COMSettings; + "noiseReduction": string; + "language": string; + "obs": OBSSettings; + "discord": DiscordSettings; + + /** Creates a new Settings instance. */ + constructor($$source: Partial = {}) { + if (!("activeProfile" in $$source)) { + this["activeProfile"] = ""; + } + if (!("profiles" in $$source)) { + this["profiles"] = []; + } + 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()); + } + if (!("discord" in $$source)) { + this["discord"] = (new DiscordSettings()); + } + + Object.assign(this, $$source); + } + + /** + * Creates a new Settings instance from a string or object. + */ + static createFrom($$source: any = {}): Settings { + const $$createField1_0 = $$createType4; + const $$createField3_0 = $$createType5; + const $$createField6_0 = $$createType6; + const $$createField7_0 = $$createType7; + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + if ("profiles" in $$parsedSource) { + $$parsedSource["profiles"] = $$createField1_0($$parsedSource["profiles"]); + } + if ("com" in $$parsedSource) { + $$parsedSource["com"] = $$createField3_0($$parsedSource["com"]); + } + if ("obs" in $$parsedSource) { + $$parsedSource["obs"] = $$createField6_0($$parsedSource["obs"]); + } + if ("discord" in $$parsedSource) { + $$parsedSource["discord"] = $$createField7_0($$parsedSource["discord"]); + } + return new Settings($$parsedSource as Partial); + } +} + +/** + * 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 = $$createType8; + 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[]; + +// Private type creation functions +var $$createType0 = (function $$initCreateType0(...args: any[]): any { + if ($$createType0 === $$initCreateType0) { + $$createType0 = $$createType2; + } + return $$createType0(...args); +}); +const $$createType1 = SliderMappingEntry.createFrom; +const $$createType2 = $Create.Array($$createType1); +const $$createType3 = Profile.createFrom; +const $$createType4 = $Create.Array($$createType3); +const $$createType5 = COMSettings.createFrom; +const $$createType6 = OBSSettings.createFrom; +const $$createType7 = DiscordSettings.createFrom; +const $$createType8 = $Create.Array($Create.Any); diff --git a/frontend/bindings/github.com/nik9play/deej/internal/desktop/index.ts b/frontend/bindings/github.com/nik9play/deej/internal/desktop/index.ts new file mode 100644 index 00000000..9d4c4602 --- /dev/null +++ b/frontend/bindings/github.com/nik9play/deej/internal/desktop/index.ts @@ -0,0 +1,16 @@ +// 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, + DiscordStatusDTO, + DiscordUserDTO, + SerialPortDTO, + SessionInfoDTO, + StatusDTO +} from "./models.js"; diff --git a/frontend/bindings/github.com/nik9play/deej/internal/desktop/models.ts b/frontend/bindings/github.com/nik9play/deej/internal/desktop/models.ts new file mode 100644 index 00000000..caaad26d --- /dev/null +++ b/frontend/bindings/github.com/nik9play/deej/internal/desktop/models.ts @@ -0,0 +1,257 @@ +// 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; + "platform": 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 (!("platform" in $$source)) { + this["platform"] = ""; + } + 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 $$createField4_0 = $$createType0; + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + if ("specialTargets" in $$parsedSource) { + $$parsedSource["specialTargets"] = $$createField4_0($$parsedSource["specialTargets"]); + } + return new AppInfoDTO($$parsedSource as Partial); + } +} + +/** + * DiscordStatusDTO describes the Discord integration's state for the settings GUI + */ +export class DiscordStatusDTO { + /** + * an account has been authorized + */ + "linked": boolean; + + /** + * the RPC connection is up + */ + "connected": boolean; + + /** + * the voice channel the user is in, if any + */ + "channel": string; + + /** Creates a new DiscordStatusDTO instance. */ + constructor($$source: Partial = {}) { + if (!("linked" in $$source)) { + this["linked"] = false; + } + if (!("connected" in $$source)) { + this["connected"] = false; + } + if (!("channel" in $$source)) { + this["channel"] = ""; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new DiscordStatusDTO instance from a string or object. + */ + static createFrom($$source: any = {}): DiscordStatusDTO { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new DiscordStatusDTO($$parsedSource as Partial); + } +} + +/** + * DiscordUserDTO describes one member of the current voice channel + */ +export class DiscordUserDTO { + "id": string; + "name": string; + + /** Creates a new DiscordUserDTO instance. */ + constructor($$source: Partial = {}) { + if (!("id" in $$source)) { + this["id"] = ""; + } + if (!("name" in $$source)) { + this["name"] = ""; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new DiscordUserDTO instance from a string or object. + */ + static createFrom($$source: any = {}): DiscordUserDTO { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new DiscordUserDTO($$parsedSource as Partial); + } +} + +/** + * 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); + } +} + +/** + * 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 = $$createType1; + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + if ("sliderValues" in $$parsedSource) { + $$parsedSource["sliderValues"] = $$createField2_0($$parsedSource["sliderValues"]); + } + return new StatusDTO($$parsedSource as Partial); + } +} + +// Private type creation functions +const $$createType0 = $Create.Array($Create.Any); +const $$createType1 = $Create.Array($Create.Any); diff --git a/frontend/bindings/github.com/nik9play/deej/internal/desktop/settingsservice.ts b/frontend/bindings/github.com/nik9play/deej/internal/desktop/settingsservice.ts new file mode 100644 index 00000000..eab9e1ad --- /dev/null +++ b/frontend/bindings/github.com/nik9play/deej/internal/desktop/settingsservice.ts @@ -0,0 +1,178 @@ +// 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 config$0 from "../config/models.js"; + +// 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(3464208780).then(($result: any) => { + return $$createType0($result); + }); +} + +/** + * GetAutostart reports whether deej is set to run at system startup + */ +export function GetAutostart(): $CancellablePromise { + return $Call.ByID(543261442); +} + +/** + * GetDiscordStatus returns the Discord integration's live state + */ +export function GetDiscordStatus(): $CancellablePromise<$models.DiscordStatusDTO> { + return $Call.ByID(1349916285).then(($result: any) => { + return $$createType1($result); + }); +} + +/** + * GetDiscordUsers returns the members of the voice channel the user is + * currently in, for slider mapping suggestions. The roster is kept current by + * RPC events, so this serves it as-is; the GUI is told to re-read it by the + * deej:discord event rather than by polling + */ +export function GetDiscordUsers(): $CancellablePromise<$models.DiscordUserDTO[]> { + return $Call.ByID(286149665).then(($result: any) => { + return $$createType3($result); + }); +} + +/** + * GetOBSInputs returns the input names of the connected OBS instance, for + * slider mapping suggestions + */ +export function GetOBSInputs(): $CancellablePromise { + return $Call.ByID(1182271596).then(($result: any) => { + return $$createType4($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(100207160, names).then(($result: any) => { + return $$createType5($result); + }); +} + +/** + * GetProcesses returns the deduplicated, sorted executable names of all + * running processes + */ +export function GetProcesses(): $CancellablePromise { + return $Call.ByID(4095528470).then(($result: any) => { + return $$createType4($result); + }); +} + +/** + * GetSessions returns the current audio sessions with friendly display + * names, for slider mapping suggestions + */ +export function GetSessions(): $CancellablePromise<$models.SessionInfoDTO[]> { + return $Call.ByID(999533824).then(($result: any) => { + return $$createType7($result); + }); +} + +/** + * GetSettings returns the current contents of the user config file + */ +export function GetSettings(): $CancellablePromise { + return $Call.ByID(4264263484).then(($result: any) => { + return $$createType8($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(2307966931).then(($result: any) => { + return $$createType9($result); + }); +} + +/** + * LinkDiscord runs the interactive authorization. Discord shows the user a + * consent dialog, so this blocks until they answer it (or time out) + */ +export function LinkDiscord(): $CancellablePromise { + return $Call.ByID(4285003913); +} + +/** + * ListSerialPorts enumerates the serial ports available on this machine + */ +export function ListSerialPorts(): $CancellablePromise<$models.SerialPortDTO[]> { + return $Call.ByID(931402893).then(($result: any) => { + return $$createType11($result); + }); +} + +/** + * SaveSettings validates and writes the given settings to the user config + * file, applying them immediately + */ +export function SaveSettings(settings: config$0.Settings): $CancellablePromise { + return $Call.ByID(1931480919, settings); +} + +/** + * SetActiveProfile switches to the named profile. It's a targeted write, so a + * switch can't clobber a hand edit that landed since the window was opened + */ +export function SetActiveProfile(name: string): $CancellablePromise { + return $Call.ByID(2957058600, name); +} + +/** + * SetAutostart enables or disables running deej at system startup, applying + * the change immediately + */ +export function SetAutostart(state: boolean): $CancellablePromise { + return $Call.ByID(4116106062, state); +} + +/** + * UnlinkDiscord forgets the stored authorization + */ +export function UnlinkDiscord(): $CancellablePromise { + return $Call.ByID(1159711848); +} + +// Private type creation functions +const $$createType0 = $models.AppInfoDTO.createFrom; +const $$createType1 = $models.DiscordStatusDTO.createFrom; +const $$createType2 = $models.DiscordUserDTO.createFrom; +const $$createType3 = $Create.Array($$createType2); +const $$createType4 = $Create.Array($Create.Any); +const $$createType5 = $Create.Map($Create.Any, $Create.Any); +const $$createType6 = $models.SessionInfoDTO.createFrom; +const $$createType7 = $Create.Array($$createType6); +const $$createType8 = config$0.Settings.createFrom; +const $$createType9 = $models.StatusDTO.createFrom; +const $$createType10 = $models.SerialPortDTO.createFrom; +const $$createType11 = $Create.Array($$createType10); 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..6b7b8ad3 --- /dev/null +++ b/frontend/messages/en.json @@ -0,0 +1,131 @@ +{ + "$schema": "https://inlang.com/schema/inlang-message-format", + "general": "General", + "autostart": "Run at startup", + "autostartHint": "deej will launch at startup", + "connection": "Connection", + "connectionSettings": "Connection settings", + "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.", + "profiles": "Profiles", + "profilesHint": "Each profile has its own slider mapping.", + "profileName": "Name", + "profileHotkey": "Hotkey", + "profileActive": "Active profile", + "setActiveProfile": "Make this profile active", + "switchProfile": "Switch profile", + "manageProfiles": "Manage profiles...", + "addProfile": "Add profile", + "duplicateProfile": "Duplicate profile", + "deleteProfile": "Delete profile", + "newProfileName": "profile {number}", + "hotkeyNone": "Not set", + "hotkeyPressKeys": "Press a key combination...", + "hotkeyClear": "Clear hotkey", + "hotkeyHint": "Click, then press a combination with Ctrl, Alt or Shift.", + "behavior": "Behavior", + "invertSliders": "Invert sliders", + "invertSlidersHint": "Top is 0%, bottom is 100%", + "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.", + "obsVolumeConversion": "Match OBS's volume curve", + "obsVolumeConversionHint": "Recommended. Keeps the physical slider aligned with the fader shown in OBS and gives finer control at low volumes. Turn it off if you prefer volume to rise faster.", + "obsWebsocketHint": "Enable the WebSocket server in OBS first: Tools → WebSocket Server Settings.", + "obsHost": "Address", + "obsPort": "Port", + "obsPassword": "Password", + "discord": "Discord integration", + "discordEnabled": "Enable Discord integration", + "discordHint": "Control the volume of individual people in a voice call, and your own mic and output levels inside Discord.", + "discordVolumeConversion": "Match Discord's volume curve", + "discordVolumeConversionHint": "Recommended. Keeps the physical slider aligned with Discord's on-screen volume control and gives finer control at low volumes. Turn it off if you prefer volume to rise faster.", + "discordSetupHint": "deej don't ship with application token. Create one in the {#developerPortal}Discord Developer Portal{/developerPortal}. Add {#localhost}http://localhost{/localhost} as a redirect URI under OAuth2, then paste its client ID and secret below.", + "copyLocalhost": "Copy http://localhost", + "discordClientId": "Client ID", + "discordClientSecret": "Client secret", + "discordLink": "Link Discord account", + "discordRelink": "Link again", + "discordUnlink": "Unlink", + "discordLinking": "Waiting for you to approve it in Discord...", + "discordLinked": "Account linked", + "discordNotLinked": "No account linked yet", + "discordConnected": "Connected to Discord", + "discordConnecting": "Waiting for Discord...", + "discordSaveFirst": "Save the client ID and secret first.", + "discordLinkError": "Failed to link the Discord account", + "slider": "Slider", + "removeTarget": "Remove target", + "noTargets": "No targets - this slider does nothing", + "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", + "tabDiscord": "Discord", + "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", + "targetDiscordInput": "Discord microphone", + "targetDiscordInputDesc": "Your own mic level inside Discord", + "targetDiscordOutput": "Discord output", + "targetDiscordOutputDesc": "Discord's own output level", + "discordDisabled": "Discord integration is disabled.", + "discordNotConnected": "Not connected to Discord. You can still add a name manually.", + "discordNoVoiceChannel": "Join a voice channel to see who's in it.", + "searchDiscordUsers": "Search people or type a name...", + "discordVoiceChannel": "In {channel}", + "ok": "OK", + "cancel": "Cancel", + "minimize": "Minimize", + "maximize": "Maximize", + "restore": "Restore", + "close": "Close", + "save": "Save", + "revert": "Revert", + "saved": "Settings saved", + "unsavedChanges": "Unsaved changes", + "saveError": "Failed to save settings", + "about": "About", + "configPath": "Configuration file", + "configPathHint": "Can be edited by hand; changes are applied automatically.", + "version": "Version" +} diff --git a/frontend/messages/ru.json b/frontend/messages/ru.json new file mode 100644 index 00000000..6d7cc82a --- /dev/null +++ b/frontend/messages/ru.json @@ -0,0 +1,131 @@ +{ + "$schema": "https://inlang.com/schema/inlang-message-format", + "general": "Общие", + "autostart": "Запускать вместе с системой", + "autostartHint": "Запускать deej вместе с системой", + "connection": "Подключение", + "connectionSettings": "Настройки подключения", + "comPort": "COM-порт", + "comPortAuto": "auto (поиск по USB VID/PID)", + "refreshPorts": "Обновить список портов", + "baudRate": "Скорость (бод)", + "vid": "USB VID", + "pid": "USB PID", + "vidPidHint": "Используется для поиска микшера, когда COM-порт установлен в \"auto\". Шестнадцатеричные значения, например 1A86.", + "profiles": "Профили", + "profilesHint": "У каждого профиля своя раскладка слайдеров.", + "profileName": "Название", + "profileHotkey": "Сочетание клавиш", + "profileActive": "Активный профиль", + "setActiveProfile": "Сделать профиль активным", + "switchProfile": "Переключить профиль", + "manageProfiles": "Управление профилями...", + "addProfile": "Добавить профиль", + "duplicateProfile": "Дублировать профиль", + "deleteProfile": "Удалить профиль", + "newProfileName": "профиль {number}", + "hotkeyNone": "Не задано", + "hotkeyPressKeys": "Нажмите сочетание клавиш...", + "hotkeyClear": "Очистить сочетание клавиш", + "hotkeyHint": "Нажмите на поле, затем сочетание с Ctrl, Alt или Shift.", + "behavior": "Поведение", + "invertSliders": "Инвертировать слайдеры", + "invertSlidersHint": "Верх - 0%, низ - 100%", + "noiseReduction": "Шумоподавление", + "noiseDefault": "обычное (обычное железо)", + "noiseLow": "слабое (отличное железо)", + "noiseHigh": "сильное (шумное железо)", + "noiseNone": "нет (выполняется на устройстве)", + "language": "Язык", + "languageAuto": "Язык системы", + "languageHint": "Применяется после перезапуска deej.", + "obs": "Интеграция с OBS", + "obsEnabled": "Включить интеграцию с OBS Studio через WebSocket", + "obsHint": "Управляйте источниками звука в OBS Studio.", + "obsVolumeConversion": "Учитывать кривую громкости OBS", + "obsVolumeConversionHint": "Рекомендуется. Положение физического ползунка совпадает с фейдером в OBS, а на низкой громкости регулировка становится точнее. Отключите, если громкость должна расти быстрее.", + "obsWebsocketHint": "Сначала включите сервер WebSocket в OBS: Сервис → Настройки сервера WebSocket.", + "obsHost": "Адрес", + "obsPort": "Порт", + "obsPassword": "Пароль", + "discord": "Интеграция с Discord", + "discordEnabled": "Включить интеграцию с Discord", + "discordHint": "Управление громкостью отдельных собеседников в голосовом канале, а также громкостью микрофона и динамика внутри Discord.", + "discordVolumeConversion": "Учитывать кривую громкости Discord", + "discordVolumeConversionHint": "Рекомендуется. Положение физического ползунка совпадает с регулятором в Discord, а на низкой громкости регулировка становится точнее. Отключите, если громкость должна расти быстрее.", + "discordSetupHint": "deej не поставляется со своим токеном приложения. Создайте приложение в {#developerPortal}Discord Developer Portal{/developerPortal}. Добавьте {#localhost}http://localhost{/localhost} в Redirect URI в разделе OAuth2, затем вставьте сюда его Client ID и секрет.", + "copyLocalhost": "Скопировать http://localhost", + "discordClientId": "Client ID", + "discordClientSecret": "Client secret", + "discordLink": "Привязать аккаунт Discord", + "discordRelink": "Привязать заново", + "discordUnlink": "Отвязать", + "discordLinking": "Ожидание подтверждения в Discord...", + "discordLinked": "Аккаунт привязан", + "discordNotLinked": "Аккаунт ещё не привязан", + "discordConnected": "Подключено к Discord", + "discordConnecting": "Ожидание Discord...", + "discordSaveFirst": "Сначала сохраните Client ID и секрет.", + "discordLinkError": "Не удалось привязать аккаунт Discord", + "slider": "Слайдер", + "removeTarget": "Удалить цель", + "noTargets": "Нет целей - этот слайдер ничего не делает", + "settings": "Настройки", + "waitingForDevice": "Ожидание устройства...", + "connected": "Подключено", + "disconnected": "Нет подключения", + "unmapped": "не назначен", + "targetsFor": "Цели для слайдера {number}", + "addTarget": "Добавить цель", + "tabApps": "Приложения", + "tabDevices": "Устройства", + "tabSpecial": "Специальные", + "tabObs": "OBS", + "tabDiscord": "Discord", + "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": "Все приложения, не назначенные другим слайдерам", + "targetDiscordInput": "Микрофон Discord", + "targetDiscordInputDesc": "Уровень вашего микрофона внутри Discord", + "targetDiscordOutput": "Вывод Discord", + "targetDiscordOutputDesc": "Собственный уровень вывода Discord", + "discordDisabled": "Интеграция с Discord отключена.", + "discordNotConnected": "Нет подключения к Discord. Имя можно добавить вручную.", + "discordNoVoiceChannel": "Зайдите в голосовой канал, чтобы увидеть участников.", + "searchDiscordUsers": "Поиск участников или введите имя...", + "discordVoiceChannel": "В канале {channel}", + "ok": "ОК", + "cancel": "Отмена", + "minimize": "Свернуть", + "maximize": "Развернуть", + "restore": "Восстановить", + "close": "Закрыть", + "save": "Сохранить", + "revert": "Отменить", + "saved": "Настройки сохранены", + "unsavedChanges": "Несохранённые изменения", + "saveError": "Не удалось сохранить настройки", + "about": "О программе", + "configPath": "Файл конфигурации", + "configPathHint": "Можно править вручную — изменения применяются автоматически.", + "version": "Версия" +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 00000000..93802baa --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2783 @@ +{ + "name": "deej-frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "deej-frontend", + "version": "0.0.0", + "dependencies": { + "@inlang/paraglide-js": "^2.29.3", + "@inlang/paraglide-js-svelte": "^1.0.4", + "@lucide/svelte": "^1.66.0", + "@wailsio/runtime": "3.1.0", + "bits-ui": "^2.20.3" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^7.3.1", + "@tailwindcss/vite": "^4.3.3", + "@tsconfig/svelte": "^5.0.8", + "svelte": "^5.57.3", + "svelte-check": "^4.7.8", + "tailwindcss": "^4.3.3", + "tslib": "^2.9.1", + "typescript": "^6.0.3", + "vite": "^8.3.5" + } + }, + "node_modules/@bytecodealliance/jco-node-fs": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@bytecodealliance/jco-node-fs/-/jco-node-fs-0.3.2.tgz", + "integrity": "sha512-yxvqQreNcOQdMsEKZomkSqThtkxrAz3f1Cmy8BsV0UHi9saa2/D1KDB14IAXPYwDq/maS6lCzS+MuMQx6QkUgA==", + "license": "(Apache-2.0 WITH LLVM-exception)", + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "@bytecodealliance/jco-node-fs-darwin-arm64": "0.3.2", + "@bytecodealliance/jco-node-fs-darwin-x64": "0.3.2", + "@bytecodealliance/jco-node-fs-linux-arm64-gnu": "0.3.2", + "@bytecodealliance/jco-node-fs-linux-arm64-musl": "0.3.2", + "@bytecodealliance/jco-node-fs-linux-x64-gnu": "0.3.2", + "@bytecodealliance/jco-node-fs-linux-x64-musl": "0.3.2", + "@bytecodealliance/jco-node-fs-win32-arm64-msvc": "0.3.2", + "@bytecodealliance/jco-node-fs-win32-x64-msvc": "0.3.2" + } + }, + "node_modules/@bytecodealliance/jco-node-fs-darwin-arm64": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@bytecodealliance/jco-node-fs-darwin-arm64/-/jco-node-fs-darwin-arm64-0.3.2.tgz", + "integrity": "sha512-WXQq1tRVUWhbK+ImH0G8Kni2AR0sdokLf/KyXRw5P6n4A9ZbgMHOuPfN/PEsYgw4eosuTAonutK0xILk/hA81A==", + "cpu": [ + "arm64" + ], + "license": "(Apache-2.0 WITH LLVM-exception)", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20" + } + }, + "node_modules/@bytecodealliance/jco-node-fs-darwin-x64": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@bytecodealliance/jco-node-fs-darwin-x64/-/jco-node-fs-darwin-x64-0.3.2.tgz", + "integrity": "sha512-Fy/OjHnto7hNE5ksewVkmAMXG/uXyj+RAKFZWQFTzFkp73UyT5vuPjKq3nvx9KolUGWBG37BudEMb4ZAoD9TbQ==", + "cpu": [ + "x64" + ], + "license": "(Apache-2.0 WITH LLVM-exception)", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20" + } + }, + "node_modules/@bytecodealliance/jco-node-fs-linux-arm64-gnu": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@bytecodealliance/jco-node-fs-linux-arm64-gnu/-/jco-node-fs-linux-arm64-gnu-0.3.2.tgz", + "integrity": "sha512-OafRtRu3tfOFQARfkia/LDEqSo2R3yAL1xPwzf4RcRW6JqkOY7qKWsUS5+LPVo/aFaSkMX7F7bdFGpFPmcoZJg==", + "cpu": [ + "arm64" + ], + "license": "(Apache-2.0 WITH LLVM-exception)", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20" + } + }, + "node_modules/@bytecodealliance/jco-node-fs-linux-arm64-musl": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@bytecodealliance/jco-node-fs-linux-arm64-musl/-/jco-node-fs-linux-arm64-musl-0.3.2.tgz", + "integrity": "sha512-JblDiwD7+Y+w0d/tjaNDX4QdGvh5lyuDc+S/jDeT7sYA1caA9yiwIjLa8PC/lm2xudqSxD+Cuy4AosXvl+qI0g==", + "cpu": [ + "arm64" + ], + "license": "(Apache-2.0 WITH LLVM-exception)", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20" + } + }, + "node_modules/@bytecodealliance/jco-node-fs-linux-x64-gnu": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@bytecodealliance/jco-node-fs-linux-x64-gnu/-/jco-node-fs-linux-x64-gnu-0.3.2.tgz", + "integrity": "sha512-6FzmHoLOsa6b4X9AuFLsvlpNF66Dt4sRRftZHaT1I1pYzCWrJkkpgNJhRXxf67ZPmOwBZJT4dan/g752Rx4xRw==", + "cpu": [ + "x64" + ], + "license": "(Apache-2.0 WITH LLVM-exception)", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20" + } + }, + "node_modules/@bytecodealliance/jco-node-fs-linux-x64-musl": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@bytecodealliance/jco-node-fs-linux-x64-musl/-/jco-node-fs-linux-x64-musl-0.3.2.tgz", + "integrity": "sha512-SbAJG2PDV8YAHBESHU9sVSckOitJyvlezdF4CGP5/mOp01JnijVkLVXzGK62yVSfa5TpAybbSd1rk+v36NAW/Q==", + "cpu": [ + "x64" + ], + "license": "(Apache-2.0 WITH LLVM-exception)", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20" + } + }, + "node_modules/@bytecodealliance/jco-node-fs-win32-arm64-msvc": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@bytecodealliance/jco-node-fs-win32-arm64-msvc/-/jco-node-fs-win32-arm64-msvc-0.3.2.tgz", + "integrity": "sha512-nUWYdFysRQJGsIOCk0KAYN2smkNYiHG7rZ7m+0ZXqP+5wqWxKEjkDsORwXiBjdaWFLVww0anZ3SBxrju6P7hXQ==", + "cpu": [ + "arm64" + ], + "license": "(Apache-2.0 WITH LLVM-exception)", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20" + } + }, + "node_modules/@bytecodealliance/jco-node-fs-win32-x64-msvc": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@bytecodealliance/jco-node-fs-win32-x64-msvc/-/jco-node-fs-win32-x64-msvc-0.3.2.tgz", + "integrity": "sha512-RsPj2t8TvltsMWK7/Z7JcVC/IWPMi07/AJkNLiNTCuGTdT/sseqTThJJwhuz3sjFw7dJdxA+bSGT+23y/KT0fw==", + "cpu": [ + "x64" + ], + "license": "(Apache-2.0 WITH LLVM-exception)", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20" + } + }, + "node_modules/@bytecodealliance/jco-transpile": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@bytecodealliance/jco-transpile/-/jco-transpile-0.13.0.tgz", + "integrity": "sha512-9hZ2GMqOlYP1Bf7qQ8v3sCsmkFoXGbgi0HtjHipzrgyk+9GGDzvvbehTGe0oM54rcbKJacvyjpPDn7wtpgzwYA==", + "license": "(Apache-2.0 WITH LLVM-exception)", + "dependencies": { + "@bytecodealliance/preview2-shim": "^0.24.1", + "@bytecodealliance/preview3-shim": "^0.6.0", + "binaryen": "^130.0.0", + "oxc-minify": "^0.136.0" + } + }, + "node_modules/@bytecodealliance/preview2-shim": { + "version": "0.24.1", + "resolved": "https://registry.npmjs.org/@bytecodealliance/preview2-shim/-/preview2-shim-0.24.1.tgz", + "integrity": "sha512-QGZsKD5M76xBhwu3BzLWqYDEpaMX90QWrrfhkQPn2pN9t378koumtdAVH4lr25mqGCZt10YNXkWvyWkw/vJbLQ==", + "license": "(Apache-2.0 WITH LLVM-exception)", + "dependencies": { + "@bytecodealliance/jco-node-fs": "0.3.2" + } + }, + "node_modules/@bytecodealliance/preview3-shim": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@bytecodealliance/preview3-shim/-/preview3-shim-0.6.1.tgz", + "integrity": "sha512-61mEfjfgGmZhEMA0H3waawPKvYd3za9y0vJ7pmQz/9yvm4FR6Ef+cmqhnxDE7ezsM07MY1btP92ymmgsgFwxmg==", + "license": "(Apache-2.0 WITH LLVM-exception)", + "dependencies": { + "@bytecodealliance/jco-node-fs": "0.3.2", + "@bytecodealliance/preview2-shim": "^0.25.0" + } + }, + "node_modules/@bytecodealliance/preview3-shim/node_modules/@bytecodealliance/preview2-shim": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@bytecodealliance/preview2-shim/-/preview2-shim-0.25.0.tgz", + "integrity": "sha512-6ZSDRJ8hJc/febZepzd68z3KzLcOXyCOKtRm8LB2rgAU88xV7ykq6dMb5I4a+sBh1cuV2Yb1159gDzI1q8UsoQ==", + "license": "(Apache-2.0 WITH LLVM-exception)", + "dependencies": { + "@bytecodealliance/jco-node-fs": "0.3.2" + } + }, + "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==", + "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==", + "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==", + "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.29.3", + "resolved": "https://registry.npmjs.org/@inlang/paraglide-js/-/paraglide-js-2.29.3.tgz", + "integrity": "sha512-aI9gT7zGTpr/l8tZM7OXHrgGruZ6jcu2iIqIw8QXKOGOY4akF3u1cIK6EBsttBm+DHI0FGiwCl8xVUjz7kDZHg==", + "license": "MIT", + "dependencies": { + "@inlang/recommend-sherlock": "^0.2.1", + "@inlang/sdk": "^4.0.4", + "commander": "11.1.0", + "consola": "3.4.0", + "jiti": "^2.7.0", + "json5": "2.2.3", + "jsonc-parser": "^3.3.1", + "unplugin": "^2.1.2", + "urlpattern-polyfill": "^10.0.0", + "valibot": "^1.4.2" + }, + "bin": { + "paraglide-js": "bin/run.js" + }, + "peerDependencies": { + "eslint": "^9.0.0 || ^10.0.0", + "typescript": ">=5.6", + "vite": ">=5.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + }, + "typescript": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@inlang/paraglide-js-svelte": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@inlang/paraglide-js-svelte/-/paraglide-js-svelte-1.0.4.tgz", + "integrity": "sha512-dNxkRy/bdZRdSWWEUrg9JxE6bc4h2Kvht8LEpfYNQojHBYGlk44eIQrHU+kEiigQOCmlY2nqx2Wa3OQpLk+dnw==", + "license": "MIT", + "peerDependencies": { + "@inlang/paraglide-js": ">=2.11.0 <3", + "svelte": "^5.0.0" + } + }, + "node_modules/@inlang/recommend-sherlock": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@inlang/recommend-sherlock/-/recommend-sherlock-0.2.1.tgz", + "integrity": "sha512-ckv8HvHy/iTqaVAEKrr+gnl+p3XFNwe5D2+6w6wJk2ORV2XkcRkKOJ/XsTUJbPSiyi4PI+p+T3bqbmNx/rDUlg==", + "license": "MIT", + "dependencies": { + "comment-json": "^4.2.3" + } + }, + "node_modules/@inlang/sdk": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@inlang/sdk/-/sdk-4.1.0.tgz", + "integrity": "sha512-pPlaxyemcFMBcwBx31zbSNWwKORSLc2yv93v0uGCh4QgNh/kBRuLlBxbubmME6UJFsj+5nbao2WDZjz3Fzs+FA==", + "license": "MIT", + "dependencies": { + "@lix-js/sdk": "0.20.1", + "@sinclair/typebox": "^0.31.17", + "kysely": "^0.28.12", + "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.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "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.20.1", + "resolved": "https://registry.npmjs.org/@lix-js/sdk/-/sdk-0.20.1.tgz", + "integrity": "sha512-oscOFP8Cr1My6Wh2KVYC9+aJ1mHs+d7vnD+7PPJaA4L+Y1FR37Sm99SrFGfvUizaj/XtRi2GlDF2IOFt9iXMNQ==", + "license": "MIT", + "dependencies": { + "@bytecodealliance/jco-transpile": "0.13.0", + "binaryen": "130.0.0", + "fflate": "^0.8.3" + } + }, + "node_modules/@lucide/svelte": { + "version": "1.66.0", + "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.66.0.tgz", + "integrity": "sha512-G2qFyutQpgk9TY0QYgqXk9rQtUt8p509mFpQzmTpyc894hfRSFhGNcGAVAUgB8znAyVsp5WTF/2vmCwprnNQ/w==", + "license": "ISC", + "peerDependencies": { + "svelte": "^5" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.5.tgz", + "integrity": "sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" + } + }, + "node_modules/@oxc-minify/binding-android-arm-eabi": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-android-arm-eabi/-/binding-android-arm-eabi-0.136.0.tgz", + "integrity": "sha512-gk1jzky1vLeOcK7w9Ib2RT/UooZ7O21s8YSeXCsy4tzsmguKb3Cbsv/Ra2fKhMp4mKeKCzvC1Xa41OfSBWfWdQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-minify/binding-android-arm64": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-android-arm64/-/binding-android-arm64-0.136.0.tgz", + "integrity": "sha512-5GIYanqud/ohXEcg2dsdn0+eSKXqclw5qlRUkvSM1sKEa4ZQ7hqOk8XPJBNPh3LXXw+Qw85B6jkmOFLKsCHp5w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-minify/binding-darwin-arm64": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-darwin-arm64/-/binding-darwin-arm64-0.136.0.tgz", + "integrity": "sha512-QAMpHKXWOoMCTenSZjQjVT5YmMooujlCKAf1T3PHFl2LuaA6ukQFXba0bgSLgAxRrAeX/itkwi4lzVQXtYmnRg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-minify/binding-darwin-x64": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-darwin-x64/-/binding-darwin-x64-0.136.0.tgz", + "integrity": "sha512-rDnoPj8hztiU8C1VNKuzujh7SgTJlgiUvUCiWQjTnVSLsABsLW9ZWd1l1Ji0VN3UW39wGpptgvahIWTrHnGH/A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-minify/binding-freebsd-x64": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-freebsd-x64/-/binding-freebsd-x64-0.136.0.tgz", + "integrity": "sha512-9fGSgP7uyPLCTjMx9z3AUV4UjnfOVoGTH5JzamLegPwGLJ97Gr1jht1437CRk8ZNabGmxQR6qNn3e+Tox2lswg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-minify/binding-linux-arm-gnueabihf": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.136.0.tgz", + "integrity": "sha512-k0QvZS+x6ROI4O5Ikqn9dDU2dmkEJ46Yh6baZJI/eUk/a1k1GOs9/vyXItLZrKlwYrSgdEx2BFXbKgyd1NyRPw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-minify/binding-linux-arm-musleabihf": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.136.0.tgz", + "integrity": "sha512-iRFxUc8ubotVfuyq3e4LPGbHrjvWd1/B3499a55GueMLc1EDJ0fHzNXJiCx2eMQGfzHtHodjmvrJ2BSvSXBNhQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-minify/binding-linux-arm64-gnu": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.136.0.tgz", + "integrity": "sha512-hbKxNCXJsULrb6EyG5SumzJKuPfP8Y0UQ/TElRgXVUhyyPf6cBPlF8HMq+MAylF98ACBzV8jBp2Mm62lJyTo+w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-minify/binding-linux-arm64-musl": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.136.0.tgz", + "integrity": "sha512-T+u/CGKW0UXemJeVux4ix6rK+ug3bQCUEbu5hFlX9K61KQNd44rOcrM15mDK99L7USaXwmiwHeIkEDvmmyDMSA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-minify/binding-linux-ppc64-gnu": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.136.0.tgz", + "integrity": "sha512-WNPTN2F448tb49BoqjV9IMRzZWa0WB8GHzrB+bnxKQPOyzz07T6rmDKrr4Hjl81ADGjDYSsr1fgXcjyLQFdMdw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-minify/binding-linux-riscv64-gnu": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.136.0.tgz", + "integrity": "sha512-MvcCfu8OeabrmNN9wqhh8Gqizg68RQ3iOvXBJmlLMy5p1PC6az9bAwqOk98lsCmKI4I5LpULekOlfxRYh3o5Kw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-minify/binding-linux-riscv64-musl": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.136.0.tgz", + "integrity": "sha512-rwJXFUFFA0egAULtOOgfW8YhhLhtWrr2qFxftvZ7/U6Xtxiev8i+eCwSNW1RpHiX74QMsJVpRE3ykoefqb1MtQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-minify/binding-linux-s390x-gnu": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.136.0.tgz", + "integrity": "sha512-qizAxHmj7VHm5/D5qiIlvzxAS2tlmadDOJ5XFI7zx/wfnk/+fdk5iVaHOv+C4tDE/4wLQyMoBYVIs6R1LeN91w==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-minify/binding-linux-x64-gnu": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.136.0.tgz", + "integrity": "sha512-gcyLAad0UblkEhGjVfx5Mwq63OSx8r04IbcfwzpZYpHisO/vioMsrs4GkDsQZ2Ejc8cdUk6YMIrf9FwNBzh+Dw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-minify/binding-linux-x64-musl": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-linux-x64-musl/-/binding-linux-x64-musl-0.136.0.tgz", + "integrity": "sha512-QAK6kwBjljX6luYzeppVdRP3gTaFNFfp9iS/V0cCvU43wUZay5xFXBgmzBHYrzZV2MhzURNnXfEZRo9poAxyvA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-minify/binding-openharmony-arm64": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-openharmony-arm64/-/binding-openharmony-arm64-0.136.0.tgz", + "integrity": "sha512-nIGxkTOnN9GwslmooxbZ3Ti2RgbX9QLHy0X9uO0pslOT+4dsNVUawBU/6r4M5cWM+ZdRD2tOF4V+n0l8tS59nQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-minify/binding-wasm32-wasi": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-wasm32-wasi/-/binding-wasm32-wasi-0.136.0.tgz", + "integrity": "sha512-YB3G8AHT6PpDS8/DfjlJrQjywj+/6gCkNBcAJdYzcYbQRIZE6SaTNowiGuZPR1m1jOl1WjcjefniKSmVxzhvRA==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.5" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-minify/binding-win32-arm64-msvc": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.136.0.tgz", + "integrity": "sha512-UVRH3BlNc2rmla+sbSd5xTF/u+esCqZS6VSWPolZybcBh3TlVe1Sf5MOuHQWUaFoyv5uFOvee4hOfS/14nPvZw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-minify/binding-win32-ia32-msvc": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.136.0.tgz", + "integrity": "sha512-zFGkUd82jci0E+isc2O36lVmmXVUDXyEMN2RYIyez+MBNqhdjPLIGx62FCsrJtLKeuiPf/8XkFv977aUtndUng==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-minify/binding-win32-x64-msvc": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.136.0.tgz", + "integrity": "sha512-VHgPwd5xibC9UYxBeq7nURTjY+wxhdumllij6JaCIUVfD9IRcrA66SVlJ5fPNoUXcRp8ZXhJfoIZwVz9wAEGJw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.156.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.156.0.tgz", + "integrity": "sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.16.tgz", + "integrity": "sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.16.tgz", + "integrity": "sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg==", + "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.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.16.tgz", + "integrity": "sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ==", + "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.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.16.tgz", + "integrity": "sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ==", + "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.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.16.tgz", + "integrity": "sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q==", + "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.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.16.tgz", + "integrity": "sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA==", + "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.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.16.tgz", + "integrity": "sha512-FJiLGAblPBMlBEdbOqw6kW6QcXDWAf/oJu7gYs9ZJZwmg+K+/zjFLxPTIY9izJUzxzc24FHvmseyA0IvJSl17w==", + "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.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.16.tgz", + "integrity": "sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg==", + "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.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.16.tgz", + "integrity": "sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA==", + "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.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.16.tgz", + "integrity": "sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg==", + "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.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.16.tgz", + "integrity": "sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w==", + "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.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.16.tgz", + "integrity": "sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA==", + "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.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.16.tgz", + "integrity": "sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.16.tgz", + "integrity": "sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw==", + "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.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.16.tgz", + "integrity": "sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw==", + "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==", + "devOptional": 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/@sveltejs/acorn-typescript": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz", + "integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.3.tgz", + "integrity": "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.3.1.tgz", + "integrity": "sha512-ZPsLN8B1e/En+Ak5s4V7srFDT532oS0qieLsQwu63NGKsS+iAjoO2Js1BochlHlglcU+Pt7WAO3C5Ee+4f6gVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "deepmerge": "^4.3.1", + "magic-string": "^1.0.0", + "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/@sveltejs/vite-plugin-svelte/node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.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.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "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.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "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.4", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", + "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==", + "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/@wailsio/runtime": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@wailsio/runtime/-/runtime-3.1.0.tgz", + "integrity": "sha512-noEAedvRyO8TLCrqhGwzrp+mbMxAIDa2rYmdJQOzvJV9MxgaZeXAJGNBiMiKMTSSy4ESOtQi4VH/5heSzi6ZmA==", + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "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/binaryen": { + "version": "130.0.0", + "resolved": "https://registry.npmjs.org/binaryen/-/binaryen-130.0.0.tgz", + "integrity": "sha512-XDrb+zql0RbFPKgj7MuH9zOc78R3Fa/P/VSGnnpdwYsvNZPWjcMYMdAkKCOQEL2A7yqgjSMTDRFp6gfSDW+/QQ==", + "license": "Apache-2.0", + "bin": { + "wasm-as": "bin/wasm-as", + "wasm-ctor-eval": "bin/wasm-ctor-eval", + "wasm-dis": "bin/wasm-dis", + "wasm-merge": "bin/wasm-merge", + "wasm-metadce": "bin/wasm-metadce", + "wasm-opt": "bin/wasm-opt", + "wasm-reduce": "bin/wasm-reduce", + "wasm-shell": "bin/wasm-shell", + "wasm2js": "bin/wasm2js" + } + }, + "node_modules/bits-ui": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.20.3.tgz", + "integrity": "sha512-KwcmD/DQwzUKk6zxiYhNfBQgYlBCqmUIwHkbeTOEYb+vbx2ZQ3VkbPgnVIXh6RBmS3OqUQOObfP4yZrJCWPLMg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/dom": "^1.8.0", + "esm-env": "^1.2.2", + "runed": "^0.35.1", + "svelte-toolbelt": "^0.10.6", + "tabbable": "^6.5.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/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==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.9.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz", + "integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==", + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz", + "integrity": "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==", + "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.4.0", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.4.0.tgz", + "integrity": "sha512-CQPcneEHw/czj32uwQC52nYnax8H4SXt25FCM+LIwGZ49cMIxpOj3bP8wmzcI+Z0HIy5KkClaPhRLfCySzwHPA==", + "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==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, + "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/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==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "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/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "license": "MIT" + }, + "node_modules/kysely": { + "version": "0.28.19", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.19.tgz", + "integrity": "sha512-sXoKWuQjtorwxDLiws0ZSPGGne/hs+fGHIII7vXquSBFgHwLXgFIHN9tTv3kNdVkJ5d3xtXC84GjjWMYLqdFWg==", + "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.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "devOptional": 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/oxc-minify": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/oxc-minify/-/oxc-minify-0.136.0.tgz", + "integrity": "sha512-u9UvPFTYVwQ/i1kpp/c20S/SbvLaLV0Ngzyhgi0YV4B1SlxM7RgnnePl4vY9N3Z9sTRcozL1MRStI3PlUN9RFA==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-minify/binding-android-arm-eabi": "0.136.0", + "@oxc-minify/binding-android-arm64": "0.136.0", + "@oxc-minify/binding-darwin-arm64": "0.136.0", + "@oxc-minify/binding-darwin-x64": "0.136.0", + "@oxc-minify/binding-freebsd-x64": "0.136.0", + "@oxc-minify/binding-linux-arm-gnueabihf": "0.136.0", + "@oxc-minify/binding-linux-arm-musleabihf": "0.136.0", + "@oxc-minify/binding-linux-arm64-gnu": "0.136.0", + "@oxc-minify/binding-linux-arm64-musl": "0.136.0", + "@oxc-minify/binding-linux-ppc64-gnu": "0.136.0", + "@oxc-minify/binding-linux-riscv64-gnu": "0.136.0", + "@oxc-minify/binding-linux-riscv64-musl": "0.136.0", + "@oxc-minify/binding-linux-s390x-gnu": "0.136.0", + "@oxc-minify/binding-linux-x64-gnu": "0.136.0", + "@oxc-minify/binding-linux-x64-musl": "0.136.0", + "@oxc-minify/binding-openharmony-arm64": "0.136.0", + "@oxc-minify/binding-wasm32-wasi": "0.136.0", + "@oxc-minify/binding-win32-arm64-msvc": "0.136.0", + "@oxc-minify/binding-win32-ia32-msvc": "0.136.0", + "@oxc-minify/binding-win32-x64-msvc": "0.136.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==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.30", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", + "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", + "devOptional": 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.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "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.2.16", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.16.tgz", + "integrity": "sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.156.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.16", + "@rolldown/binding-android-arm64": "1.2.16", + "@rolldown/binding-darwin-arm64": "1.2.16", + "@rolldown/binding-darwin-x64": "1.2.16", + "@rolldown/binding-freebsd-x64": "1.2.16", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.16", + "@rolldown/binding-linux-arm64-gnu": "1.2.16", + "@rolldown/binding-linux-arm64-musl": "1.2.16", + "@rolldown/binding-linux-ppc64-gnu": "1.2.16", + "@rolldown/binding-linux-s390x-gnu": "1.2.16", + "@rolldown/binding-linux-x64-gnu": "1.2.16", + "@rolldown/binding-linux-x64-musl": "1.2.16", + "@rolldown/binding-openharmony-arm64": "1.2.16", + "@rolldown/binding-win32-arm64-msvc": "1.2.16", + "@rolldown/binding-win32-x64-msvc": "1.2.16" + } + }, + "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.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/svelte": { + "version": "5.57.3", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.3.tgz", + "integrity": "sha512-5mAftM3SoDV9X0zdF386F9p2pv8iKOwi/KfBS1XnBAkVCKFzVOLHUoa8iGWAt4buoicLmyWVRpuuD5wplXOQsg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "@jridgewell/sourcemap-codec": "^1.6.0", + "@sveltejs/acorn-typescript": "^1.0.13", + "@types/estree": "^1.0.9", + "acorn": "^8.18.0", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.9.2", + "esm-env": "^1.2.1", + "esrap": "^2.3.6", + "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.8", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.8.tgz", + "integrity": "sha512-laZ49GFHZ3b0z2PWinJWL0syehc/PTR8aHaUaTiBkWJ+4Y38nhxjmXsSz7CbGa3VdKopViVbrBH3bGNqIAI0bQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "^0.2.3", + "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 || ^6.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.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "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==", + "devOptional": 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.9.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.9.1.tgz", + "integrity": "sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "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.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.3.tgz", + "integrity": "sha512-Sc5u1VVln93y1Xnz4R+CunOZc7R9LUcTmhj7Rc7Eg3HVcf7rVn2FOoa812fe0W8p3jD7xeoGSYbfqCGGH2B6xw==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/valibot": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.5.0.tgz", + "integrity": "sha512-nil6AkP2TChWL43Z5uJ6GTxX01CUA+g8LWUM+N/rB9NBbkUMaUsi9PUNzlUPgoKASgmx9f7eGOYpJ04/fSa6FQ==", + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vite": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz", + "integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.30", + "rolldown": "~1.2.14", + "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.7.1 || ^0.8.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/vite/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "devOptional": 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.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "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/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "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/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "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/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "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/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "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/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "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/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "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/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "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/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "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/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "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/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "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/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..f5a845b3 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,31 @@ +{ + "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.29.3", + "@inlang/paraglide-js-svelte": "^1.0.4", + "@lucide/svelte": "^1.66.0", + "@wailsio/runtime": "3.1.0", + "bits-ui": "^2.20.3" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^7.3.1", + "@tailwindcss/vite": "^4.3.3", + "@tsconfig/svelte": "^5.0.8", + "svelte": "^5.57.3", + "svelte-check": "^4.7.8", + "tailwindcss": "^4.3.3", + "tslib": "^2.9.1", + "typescript": "^6.0.3", + "vite": "^8.3.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..11f516fc --- /dev/null +++ b/frontend/src/App.svelte @@ -0,0 +1,53 @@ + + + openSettings()} + onOpenProfiles={() => openSettings("profiles")} + onOpenConnection={() => openSettings("connection")} +/> + +
+ +
+ + + { + targetDialogOpen = false; + openSettings("obs"); + }} + onOpenDiscordSettings={() => { + targetDialogOpen = false; + openSettings("discord"); + }} +/> diff --git a/frontend/src/app.css b/frontend/src/app.css new file mode 100644 index 00000000..1262fca5 --- /dev/null +++ b/frontend/src/app.css @@ -0,0 +1,299 @@ +@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: #e5e5e5; + --slider-fill-surface: #323232; + --slider-handle-surface: #dfdfdf; + --slider-handle-edge: rgb(0 0 0 / 0.35); + --slider-handle-highlight: rgb(255, 255, 255); + --slider-handle-shadow: rgb(0 0 0 / 0.42); + --app-font-sans: sans-serif; + + color-scheme: light dark; +} + +:root[data-platform="windows"] { + --app-font-sans: "Segoe UI Variable Text", sans-serif; +} + +@media (prefers-color-scheme: dark) { + :root { + --ui-surface: #0a0a0a; + --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; + --slider-track-shadow: rgb(0 0 0 / 0.45); + --slider-track-deep-shadow: rgb(0 0 0 / 0.35); + --slider-fill-highlight: rgb(255 255 255 / 0.62); + --slider-fill-shadow: rgb(0 0 0 / 0.34); + --slider-fill-surface: #dedede; + --slider-handle-surface: #272727; + --slider-handle-edge: rgb(0 0 0 / 0.75); + --slider-handle-highlight: rgb(255 255 255 / 0.48); + --slider-handle-shadow: rgb(0 0 0 / 0.7); + } +} + +@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: var(--app-font-sans); +} + +@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:border-accent focus-visible:ring-[3px] focus-visible:ring-accent/20 focus-visible:outline-none; + } + + .label { + @apply text-[13px] font-semibold; + } + + .hint { + @apply text-xs text-muted; + } + + /* macOS-style secondary button: a uniform border plus a rounded overlay + whose faint directional shadow supplies the edge highlight. */ + .btn { + @apply relative rounded-lg border border-edge/40 bg-linear-to-b from-white/65 to-neutral-100/45 px-3.5 py-1.5 text-sm font-medium text-body + shadow-[0_1px_2px_rgb(0_0_0/0.12)] + before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(0.5rem-1px)] + before:shadow-[0_1px_0_rgb(0_0_0/0.08)] + enabled:hover:from-white/55 enabled:hover:to-neutral-200/35 + enabled:active:from-neutral-100/60 enabled:active:to-neutral-200/65 + enabled:active:before:shadow-none focus-visible:outline-2 focus-visible:outline-accent focus-visible:before:shadow-none disabled:opacity-50 + dark:from-white/7 dark:to-white/3 dark:before:shadow-[0_-1px_0_rgb(255_255_255/0.12)] + dark:shadow-[0_1px_2px_rgb(0_0_0/0.45)] + dark:enabled:hover:from-white/10 dark:enabled:hover:to-white/5 + 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 border-neutral-900/65 bg-linear-to-b from-neutral-700/90 to-neutral-900/90 font-semibold text-on-accent + shadow-[0_1px_2px_rgb(0_0_0/0.35)] + before:shadow-[0_-1px_0_rgb(255_255_255/0.18)] + enabled:hover:from-neutral-600/75 enabled:hover:to-neutral-800/75 + enabled:active:from-neutral-900/90 enabled:active:to-neutral-900/90 + enabled:active:shadow-[inset_0_1px_3px_rgb(0_0_0/0.6)] + dark:border-neutral-300/65 dark:from-white/90 dark:to-neutral-300/90 + dark:before:shadow-[0_-1px_0_rgb(255_255_255/0.16)] + dark:shadow-[0_1px_2px_rgb(0_0_0/0.45)] + dark:enabled:hover:from-white/75 dark:enabled:hover:to-neutral-200/75 + dark:enabled:active:from-neutral-300/90 dark:enabled:active:to-neutral-400/90 + 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%); + } + + /* Layered surfaces make the vertical meters read like small physical + faders while keeping the monochrome theme intact. */ + .slider-track { + background: var(--ui-track); + } + + .slider-fill { + background: var(--slider-fill-surface); + } + + /* The cap masks tick marks without adding a gradient beneath the handle. */ + .slider-fill-cap { + background: var(--slider-fill-surface); + } + + .slider-handle { + border: 1px solid var(--slider-handle-edge); + background: var(--slider-handle-surface); + box-shadow: + inset 0 1px var(--slider-handle-highlight), + 0 1px 2px var(--slider-handle-shadow); + } +} + +/* 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/Titlebar.svelte b/frontend/src/components/Titlebar.svelte new file mode 100644 index 00000000..8790a772 --- /dev/null +++ b/frontend/src/components/Titlebar.svelte @@ -0,0 +1,225 @@ + + + +
+ deej + + {#if profiles.length > 0} + +
e.stopPropagation()}> + + + + {app.settings?.activeProfile} + + + + + + {#each profiles as profile (profile.name)} + + {#snippet children({ selected })} + {profile.name} + + {#if profile.hotkey} + + {profile.hotkey} + + {/if} + {#if selected} + + {/if} + + {/snippet} + + {/each} + + +
+ +
+
+
+
+ {/if} + +
+ + e.stopPropagation()} + > + + {app.connected ? m.connected() : m.disconnected()} + + + +
+ {m.comPort()} + {app.comPort || "—"} +
+
+ +
+
+
+
+ +
+ + + + +
+
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..208bd831 --- /dev/null +++ b/frontend/src/components/ui/FieldCombobox.svelte @@ -0,0 +1,109 @@ + + + { + value = selected; + search = ""; + }} + onOpenChange={(open) => { + if (open) search = ""; + }} +> +
+ { + value = e.currentTarget.value; + search = e.currentTarget.value; + }} + onclick={() => { + open = true; + }} + /> + + + +
+ {#if filtered.length > 0} + + + {#each filtered as item (item.value)} + + {#snippet children({ selected })} + {item.value} + + {#if item.hint} + {item.hint} + {/if} + {#if selected} + + {/if} + + {/snippet} + + {/each} + + + {/if} +
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/components/ui/HotkeyInput.svelte b/frontend/src/components/ui/HotkeyInput.svelte new file mode 100644 index 00000000..86489217 --- /dev/null +++ b/frontend/src/components/ui/HotkeyInput.svelte @@ -0,0 +1,98 @@ + + +
+ + (capturing = true)} + onblur={() => (capturing = false)} + {onkeydown} + /> + +
diff --git a/frontend/src/components/ui/SectionActions.svelte b/frontend/src/components/ui/SectionActions.svelte new file mode 100644 index 00000000..7eb42f8a --- /dev/null +++ b/frontend/src/components/ui/SectionActions.svelte @@ -0,0 +1,17 @@ + + +
+ + +
diff --git a/frontend/src/components/ui/SimpleIcon.svelte b/frontend/src/components/ui/SimpleIcon.svelte new file mode 100644 index 00000000..a6d5f3f8 --- /dev/null +++ b/frontend/src/components/ui/SimpleIcon.svelte @@ -0,0 +1,10 @@ + + + diff --git a/frontend/src/features/mixer/Mixer.svelte b/frontend/src/features/mixer/Mixer.svelte new file mode 100644 index 00000000..1c58dc40 --- /dev/null +++ b/frontend/src/features/mixer/Mixer.svelte @@ -0,0 +1,55 @@ + + +
+ {#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/features/mixer/SliderColumn.svelte b/frontend/src/features/mixer/SliderColumn.svelte new file mode 100644 index 00000000..bd57de9e --- /dev/null +++ b/frontend/src/features/mixer/SliderColumn.svelte @@ -0,0 +1,90 @@ + + +
+ {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/features/mixer/TargetDialog.svelte b/frontend/src/features/mixer/TargetDialog.svelte new file mode 100644 index 00000000..15476015 --- /dev/null +++ b/frontend/src/features/mixer/TargetDialog.svelte @@ -0,0 +1,581 @@ + + +{#snippet obsSettingsLink()} + +{/snippet} + +{#snippet discordSettingsLink()} + +{/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: null }, { value: "discord", label: m.tabDiscord(), Icon: null }] as tabItem (tabItem.value)} + + {#if tabItem.value === "obs"} + + {:else if tabItem.value === "discord"} + + {:else} + + {/if} + {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 !app.settings?.discord.enabled} +
+ {m.discordDisabled()} + {@render discordSettingsLink()} +
+ {:else} + { + if (e.key === "Enter") { + e.preventDefault(); + addDiscordFreeText(); + } + }} + /> +
+ {#if discordFreeText !== ""} + + {/if} + + + {@render itemRow(DISCORD_INPUT, specialTargetLabel(DISCORD_INPUT) ?? DISCORD_INPUT, specialTargetDescription(DISCORD_INPUT) ?? "", false, Mic)} + {@render itemRow(DISCORD_OUTPUT, specialTargetLabel(DISCORD_OUTPUT) ?? DISCORD_OUTPUT, specialTargetDescription(DISCORD_OUTPUT) ?? "", false, Volume2)} + + {#if !discordConnected} +
+ {m.discordNotConnected()} + {@render discordSettingsLink()} +
+ {:else} + {#each discordItems as user (user.id)} + {@render itemRow(DISCORD_PREFIX + user.id, user.name, "", false, User)} + {:else} + {#if discordFreeText === ""} +
{m.discordNoVoiceChannel()}
+ {/if} + {/each} + {/if} +
+ {/if} +
+
+
+ +
+ {#if errorText} + {errorText} + {/if} + + +
+
+
+
diff --git a/frontend/src/features/mixer/targets.ts b/frontend/src/features/mixer/targets.ts new file mode 100644 index 00000000..9108a124 --- /dev/null +++ b/frontend/src/features/mixer/targets.ts @@ -0,0 +1,61 @@ +import { m } from "../../paraglide/messages"; +import { app } from "../../lib/state.svelte"; + +export const OBS_PREFIX = "deej.obs:"; +export const DISCORD_PREFIX = "deej.discord:"; +export const DISCORD_INPUT = "deej.discord.input"; +export const DISCORD_OUTPUT = "deej.discord.output"; + +// special target -> localized label/description message functions +const specialTargets: Record string; desc: () => string }> = { + 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 }, + [DISCORD_INPUT]: { label: m.targetDiscordInput, desc: m.targetDiscordInputDesc }, + [DISCORD_OUTPUT]: { label: m.targetDiscordOutput, desc: m.targetDiscordOutputDesc }, +}; + +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)`; + } + if (lower.startsWith(DISCORD_PREFIX)) { + const id = target.slice(DISCORD_PREFIX.length); + return `${app.discordUserNames[id] ?? id} (Discord)`; + } + + const session = app.sessions.find((s) => s.key === lower); + if (session) { + 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/features/settings/SettingsDialog.svelte b/frontend/src/features/settings/SettingsDialog.svelte new file mode 100644 index 00000000..bb083928 --- /dev/null +++ b/frontend/src/features/settings/SettingsDialog.svelte @@ -0,0 +1,263 @@ + + + + + + +
+ {m.settings()} + + + +
+ + {#if settings && comDraft && obsDraft && discordDraft} + + + {#each tabItems as tabItem (tabItem.value)} + + {#if tabItem.value === "obs"} + + {:else if tabItem.value === "discord"} + + {:else} + + {/if} + {tabItem.label} + {#if tabItem.dirty} + + {/if} + + {/each} + + +
+ + + + + + + + save({ com: $state.snapshot(comDraft!) })} + onrevert={() => (comDraft = $state.snapshot(settings!.com))} + /> + + + + save({ obs: $state.snapshot(obsDraft!) })} + onrevert={() => (obsDraft = $state.snapshot(settings!.obs))} + /> + + + save({ discord: $state.snapshot(discordDraft!) })} + onrevert={() => (discordDraft = $state.snapshot(settings!.discord))} + /> + + +
+
+ {/if} + + {#if statusText} +
+ {statusText} +
+ {/if} +
+
+
diff --git a/frontend/src/features/settings/sections/AboutSection.svelte b/frontend/src/features/settings/sections/AboutSection.svelte new file mode 100644 index 00000000..62238c04 --- /dev/null +++ b/frontend/src/features/settings/sections/AboutSection.svelte @@ -0,0 +1,20 @@ + + +
+
+ {m.version()} + {appInfo?.version || "—"} +
+ +
+ {m.configPath()} + + {appInfo?.configPath || "—"} + {m.configPathHint()} +
+
diff --git a/frontend/src/features/settings/sections/BehaviorSection.svelte b/frontend/src/features/settings/sections/BehaviorSection.svelte new file mode 100644 index 00000000..2641658d --- /dev/null +++ b/frontend/src/features/settings/sections/BehaviorSection.svelte @@ -0,0 +1,37 @@ + + +
+
+ settings.invertSliders, (invertSliders) => onsave({ invertSliders })} + /> +
+ +
+ + settings.noiseReduction, (noiseReduction) => onsave({ noiseReduction })} + /> +
+
diff --git a/frontend/src/features/settings/sections/ConnectionSection.svelte b/frontend/src/features/settings/sections/ConnectionSection.svelte new file mode 100644 index 00000000..631c8e28 --- /dev/null +++ b/frontend/src/features/settings/sections/ConnectionSection.svelte @@ -0,0 +1,115 @@ + + + +
{ + event.preventDefault(); + onsave(); + }} +> +
+ +
+
+ +
+ +
+
+ +
+ + String(draft.baudRate || ""), (v) => (draft.baudRate = parseInt(v, 10) || 0)} + /> +
+ + {#if draft.port === "auto"} +
+
+ + +
+
+ + +
+
+
{m.vidPidHint()}
+ {/if} + + + diff --git a/frontend/src/features/settings/sections/DiscordSection.svelte b/frontend/src/features/settings/sections/DiscordSection.svelte new file mode 100644 index 00000000..cd611651 --- /dev/null +++ b/frontend/src/features/settings/sections/DiscordSection.svelte @@ -0,0 +1,211 @@ + + +
{ + event.preventDefault(); + onsave(); + }} +> + +
{m.discordHint()}
+ + {#if draft.enabled} +
+ +
{m.discordVolumeConversionHint()}
+
+ +
+ + {#snippet developerPortal({ children })} + {@render children?.()} + {/snippet} + {#snippet localhost({ children })} + + {/snippet} + +
+
+
+ + +
+
+ + +
+
+ +
+ {#if status?.linked} + + + {:else} + + {/if} + + + {#if linking} + {m.discordLinking()} + {:else if !status?.linked} + + {m.discordNotLinked()} + {:else if status.connected} + + {status.channel ? m.discordVoiceChannel({ channel: status.channel }) : m.discordConnected()} + {:else} + + {m.discordConnecting()} + {/if} + +
+ + {#if dirty} +
{m.discordSaveFirst()}
+ {/if} + + {#if errorText} +
{errorText}
+ {/if} + {/if} + + + diff --git a/frontend/src/features/settings/sections/GeneralSection.svelte b/frontend/src/features/settings/sections/GeneralSection.svelte new file mode 100644 index 00000000..b306827f --- /dev/null +++ b/frontend/src/features/settings/sections/GeneralSection.svelte @@ -0,0 +1,78 @@ + + +
+
+ + settings.language, (language) => onsave({ language })} + /> +
{m.languageHint()}
+
+ + {#if appInfo?.autostartAvailable} +
+ void applyAutostart(checked)} + label={m.autostart()} + /> + {#if autostartError} +
{autostartError}
+ {:else} +
{m.autostartHint()}
+ {/if} +
+ {/if} +
diff --git a/frontend/src/features/settings/sections/ObsSection.svelte b/frontend/src/features/settings/sections/ObsSection.svelte new file mode 100644 index 00000000..89ce3614 --- /dev/null +++ b/frontend/src/features/settings/sections/ObsSection.svelte @@ -0,0 +1,64 @@ + + + +
{ + event.preventDefault(); + onsave(); + }} +> + +
{m.obsHint()}
+ + {#if draft.enabled} +
+ +
{m.obsVolumeConversionHint()}
+
+ +
{m.obsWebsocketHint()}
+
+
+ + +
+
+ + +
+
+ + +
+
+ {/if} + + + diff --git a/frontend/src/features/settings/sections/ProfilesSection.svelte b/frontend/src/features/settings/sections/ProfilesSection.svelte new file mode 100644 index 00000000..aa21b81d --- /dev/null +++ b/frontend/src/features/settings/sections/ProfilesSection.svelte @@ -0,0 +1,122 @@ + + +
+

{m.profilesHint()}

+ +
+ {#each settings.profiles as profile, index (index)} +
+
+ + update(index, { name: event.currentTarget.value })} + /> + + +
+ +
+ {m.profileHotkey()} +
+ profile.hotkey, (hotkey) => update(index, { hotkey })} + /> +
+
+
+ {/each} +
+ + +
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 00000000..28894517 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,2 @@ +export * from "../../bindings/github.com/nik9play/deej/internal/config"; +export * from "../../bindings/github.com/nik9play/deej/internal/desktop"; diff --git a/frontend/src/lib/profiles.ts b/frontend/src/lib/profiles.ts new file mode 100644 index 00000000..1a93c53a --- /dev/null +++ b/frontend/src/lib/profiles.ts @@ -0,0 +1,14 @@ +import type { Profile, Settings, SliderMappings } from "./api"; + +// mirrors Settings.ActiveMapping on the go side: normalized settings always +// resolve by name, the first-profile fallback only covers the brief moment +// before the first config arrives +export function activeProfile(settings: Settings | null): Profile | null { + if (!settings) return null; + + return settings.profiles.find((profile) => profile.name === settings.activeProfile) ?? settings.profiles[0] ?? null; +} + +export function activeMapping(settings: Settings | null): SliderMappings { + return activeProfile(settings)?.sliderMapping ?? []; +} diff --git a/frontend/src/lib/state.svelte.ts b/frontend/src/lib/state.svelte.ts new file mode 100644 index 00000000..d0985e90 --- /dev/null +++ b/frontend/src/lib/state.svelte.ts @@ -0,0 +1,85 @@ +import { Events } from "@wailsio/runtime"; +import { DiscordUserDTO, SessionInfoDTO, Settings, SettingsService } from "./api"; +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 + discordUsers: [] as DiscordUserDTO[], // current voice-channel roster + discordUserNames: {} as Record, // names retained after users leave +}); + +export async function refreshSettings(): Promise { + try { + // snapshot into a plain object so svelte's deep reactivity applies (the + // wails bridge hands back class instances, which aren't made reactive) + app.settings = $state.snapshot(await SettingsService.GetSettings()); + } catch (err) { + console.error("failed to load settings", err); + } +} + +export async function refreshSessions(): Promise { + try { + app.sessions = (await SettingsService.GetSessions()) ?? []; + } catch (err) { + console.error("failed to load sessions", err); + } +} + +export async function refreshDiscordUsers(): Promise { + try { + app.discordUsers = (await SettingsService.GetDiscordUsers()) ?? []; + app.discordUserNames = Object.assign( + {}, + app.discordUserNames, + Object.fromEntries(app.discordUsers.map((user) => [user.id, user.name])), + ); + } catch { + app.discordUsers = []; + } +} + +// init subscribes to live events (before fetching initial state, so nothing +// is missed) and returns a cleanup function for onDestroy +export function init(): () => void { + 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(); + }), + Events.On("deej:discord", () => { + void refreshDiscordUsers(); + }), + ]; + + 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(); + void refreshDiscordUsers(); + + return () => offs.forEach((off) => off()); +} 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..ebda71f9 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,24 @@ +import { mount } from "svelte"; +import App from "./App.svelte"; +import { setLocale } from "./paraglide/runtime"; +import { AppInfoDTO, SettingsService } from "./lib/api"; +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 }); + document.documentElement.dataset.platform = appInfo.platform; +} 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..b6a02a2d 100644 --- a/go.mod +++ b/go.mod @@ -1,44 +1,38 @@ module github.com/nik9play/deej -go 1.25.0 +go 1.26.10 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 + github.com/Microsoft/go-winio v0.6.4 + github.com/andreykaipov/goobs v1.11.1 + github.com/fsnotify/fsnotify v1.10.1 github.com/go-ole/go-ole v1.3.0 - github.com/godbus/dbus/v5 v5.2.2 + github.com/godbus/dbus/v5 v5.2.4 github.com/google/uuid v1.6.0 github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade - github.com/jfreymuth/pulse v0.1.1 + github.com/jfreymuth/pulse v0.1.3 github.com/mitchellh/go-ps v1.0.0 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 - 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 + github.com/nicksnyder/go-i18n/v2 v2.7.0 + github.com/pelletier/go-toml/v2 v2.4.3 + github.com/wailsapp/wails/v3 v3.1.0 + go.bug.st/serial v1.8.0 + go.uber.org/zap v1.28.0 + go.yaml.in/yaml/v3 v3.0.6 + golang.org/x/sys v0.49.0 + golang.org/x/text v0.43.0 ) require ( - github.com/buger/jsonparser v1.1.1 // indirect - github.com/creack/goselect v0.1.3 // indirect - github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/buger/jsonparser v1.6.1 // indirect + github.com/coder/websocket v1.8.15 // indirect + github.com/ebitengine/purego v0.11.2 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/hashicorp/logutils v1.0.0 // 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 + golang.org/x/term v0.47.0 // indirect ) diff --git a/go.sum b/go.sum index 68b5f91f..53fe636e 100644 --- a/go.sum +++ b/go.sum @@ -1,31 +1,24 @@ -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/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/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/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/Microsoft/go-winio v0.6.4 h1:kJF2pamtYCWXf0xAT8sAkwKC0pMl2HYBrRPHAqyUc+g= +github.com/Microsoft/go-winio v0.6.4/go.mod h1:1xl5JXG4ze/m74GH6Dc4Mwxfc3VL5siRVQjIAlHH07U= +github.com/andreykaipov/goobs v1.11.1 h1:73qAE5wK7Dx2G7oT6z2s0rXcAZUy7cDS3VFeaRaSI/I= +github.com/andreykaipov/goobs v1.11.1/go.mod h1:nbeqrqE/6ZoOgWe2vn/+lF7nacPpuwIo+stkQunPxHc= +github.com/buger/jsonparser v1.6.1 h1:I0phFv0PlbLHnM7TZAVjZ2MJ2/eWRTDyuO7GLR98IEs= +github.com/buger/jsonparser v1.6.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/ebitengine/purego v0.11.2 h1:WWiTx8rD8zYdOwSj8O/NiNbsOvSAgJMTlyV1XlbUSeU= +github.com/ebitengine/purego v0.11.2/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= 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/godbus/dbus/v5 v5.2.4 h1:F2gUCy++NMIxGmo9J+z5le/+ZC8W1OgeR51P4i9ZB/U= +github.com/godbus/dbus/v5 v5.2.4/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= 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= @@ -34,12 +27,8 @@ github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= 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= -github.com/jfreymuth/pulse v0.1.1/go.mod h1:cpYspI6YljhkUf1WLXLLDmeaaPFc3CnGLjDZf9dZ4no= -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/jfreymuth/pulse v0.1.3 h1:bc5TdxiB8E+2INnFjFWWgyfgXtz2IyNNNCX+Wt/ZD14= +github.com/jfreymuth/pulse v0.1.3/go.mod h1:cpYspI6YljhkUf1WLXLLDmeaaPFc3CnGLjDZf9dZ4no= 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= @@ -48,53 +37,31 @@ github.com/mmcloughlin/profile v0.1.1 h1:jhDmAqPyebOsVDOCICJoINoLb/AnLBaUw58nFzx github.com/mmcloughlin/profile v0.1.1/go.mod h1:IhHD7q1ooxgwTgjxQYkACGA77oFTDdFVejUS1/tS/qU= github.com/moutend/go-wca v0.3.0 h1:IzhsQ44zBzMdT42xlBjiLSVya9cPYOoKx9E+yXVhFo8= github.com/moutend/go-wca v0.3.0/go.mod h1:7VrPO512jnjFGJ6rr+zOoCfiYjOHRPNfbttJuxAurcw= -github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ= -github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA= +github.com/nicksnyder/go-i18n/v2 v2.7.0 h1:oiVQUmuCeNggvm/fUVb6/R0aP82BDVphZnmVHwut+J0= +github.com/nicksnyder/go-i18n/v2 v2.7.0/go.mod h1:ZMYiWg8ndQSGNk4zbzbyJjrrVjQjuQ8pERFrnBik+c8= github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ= 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/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= -go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A= -go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI= +github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= +github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/wailsapp/wails/v3 v3.1.0 h1:ld0AXSicXiTTsIvmTq3qNn9FzCP8vIHRSJdkRLgaAo8= +github.com/wailsapp/wails/v3 v3.1.0/go.mod h1:tzDY3W1jzN3ksSqfGalTt7K3PwnGfIqtl+IgIXlXz0I= +go.bug.st/serial v1.8.0 h1:ZtnmN8aYXtPlTghwSvDWPHKBHL9TM6oFDa+KpSn4SQE= +go.bug.st/serial v1.8.0/go.mod h1:d0MmS16Qt9b1m06yoYRNUXhRRTJV5Qg2S5EKqQtnayQ= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= -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= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v3 v3.0.6 h1:zcDfvjXEqTQmpjniUBLc91xYJMB2c4QOFxEF/LCEckM= +go.yaml.in/yaml/v3 v3.0.6/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/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= -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= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM= +golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY= +golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= +golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 00000000..3c248c74 --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,209 @@ +// Package app composes the device, audio mixer and integrations without a GUI dependency. +package app + +import ( + "path/filepath" + "sync" + + "github.com/nicksnyder/go-i18n/v2/i18n" + "github.com/nik9play/deej/internal/audio" + "github.com/nik9play/deej/internal/config" + locali18n "github.com/nik9play/deej/internal/i18n" + "github.com/nik9play/deej/internal/integrations/discord" + "github.com/nik9play/deej/internal/integrations/obs" + "github.com/nik9play/deej/internal/mixer" + "github.com/nik9play/deej/internal/notify" + "github.com/nik9play/deej/internal/platform" + "github.com/nik9play/deej/internal/serial" + "go.uber.org/zap" +) + +// App owns the core services and their lifetime. Construct and Initialize before Start. +type App struct { + logger *zap.SugaredLogger + notifier notify.Notifier + config *config.Store + serial *serial.Client + sessions *mixer.Mixer + obs *obs.Client + discord *discord.Client + localizer *i18n.Localizer + resolvedLanguage string + lifecycle sync.Mutex + started bool + stopped bool + stopRequest chan struct{} + requestOnce sync.Once + watcher sync.WaitGroup + stopErr error +} + +// New loads the initial settings and resolves the language before constructing consumers. +func New(logger *zap.SugaredLogger, verbose bool, configPath string) (*App, error) { + d := &App{logger: logger.Named("deej"), stopRequest: make(chan struct{})} + catalog, err := locali18n.New(logger) + if err != nil { + return nil, err + } + d.localizer, err = catalog.SystemLocalizer() + if err != nil { + return nil, err + } + d.notifier, err = notify.NewToastNotifier(logger) + if err != nil { + return nil, err + } + d.config, err = config.New(logger, configPath, d.configOutcome) + if err != nil { + return nil, err + } + if err = d.config.Load(); err != nil { + return nil, err + } + d.localizer, d.resolvedLanguage, err = catalog.Resolve(d.config.Values().Language) + if err != nil { + return nil, err + } + d.serial, err = serial.New(d.config, logger, serial.Options{Verbose: verbose, OnConnected: d.serialConnected, OnDisconnected: d.serialDisconnected}) + if err != nil { + return nil, err + } + d.obs = obs.New(d.config, logger) + d.discord = discord.New(d.config, logger, filepath.Join(filepath.Dir(d.config.Path()), discord.TokenFileName), discordVolumeSource(d.config.Values, d.serial.CurrentSliderPercentValues)) + finder, err := audio.NewSessionFinder(logger) + if err != nil { + _ = d.Stop() + return nil, err + } + d.sessions, err = mixer.New(d.config, d.serial, logger, finder, d.obs, d.discord, platform.GetCurrentWindowProcessNames) + if err != nil { + _ = finder.Release() + _ = d.Stop() + return nil, err + } + return d, nil +} + +// Initialize registers mixer handlers before audio discovery and before serial starts. +func (d *App) Initialize() error { return d.sessions.Initialize() } + +// Start starts producers after all mixer and desktop subscriptions are registered. +func (d *App) Start() { + d.lifecycle.Lock() + defer d.lifecycle.Unlock() + if d.started || d.stopped { + return + } + d.started = true + d.watcher.Go(d.config.WatchConfigFileChanges) + d.serial.Start() + d.obs.Start() + d.discord.Start() +} + +// Wait blocks until a GUI action or process signal requests shutdown. +func (d *App) Wait() { <-d.stopRequest } + +// RequestStop is safe for repeated concurrent quit requests. +func (d *App) RequestStop() { d.requestOnce.Do(func() { close(d.stopRequest) }) } + +// Stop stops producers while their consumers are still receiving, then releases audio. +func (d *App) Stop() error { + d.lifecycle.Lock() + defer d.lifecycle.Unlock() + if d.stopped { + return d.stopErr + } + d.stopped = true + d.RequestStop() + if d.config != nil { + d.config.StopWatchingConfigFile() + d.watcher.Wait() + } + if d.serial != nil { + d.serial.Stop() + } + if d.obs != nil { + d.obs.Stop() + } + if d.discord != nil { + d.discord.Stop() + } + if d.sessions != nil { + d.stopErr = d.sessions.Release() + } + _ = d.logger.Sync() + return d.stopErr +} + +// Config returns the configuration store. +func (d *App) Config() *config.Store { return d.config } + +// Serial returns the slider transport. +func (d *App) Serial() *serial.Client { return d.serial } + +// Mixer returns the audio router. +func (d *App) Mixer() *mixer.Mixer { return d.sessions } + +// OBS returns the OBS controller. +func (d *App) OBS() *obs.Client { return d.obs } + +// Discord returns the Discord controller. +func (d *App) Discord() *discord.Client { return d.discord } + +// Localizer returns the resolved notification catalog. +func (d *App) Localizer() *i18n.Localizer { return d.localizer } + +// Language returns the resolved language. +func (d *App) Language() string { return d.resolvedLanguage } + +// Notifier returns the system notification sink. +func (d *App) Notifier() notify.Notifier { return d.notifier } + +// discordVolumeSource reads fresh snapshots whenever Discord restores volumes. +func discordVolumeSource(settings func() *config.Settings, sliderValues func() []float32) func() []discord.TargetVolume { + return func() []discord.TargetVolume { + values := sliderValues() + var result []discord.TargetVolume + for _, entry := range settings().ActiveMapping() { + if entry.Slider < 0 || entry.Slider >= len(values) { + continue + } + for _, target := range entry.Targets { + if key, ok := mixer.DiscordTargetKey(target); ok { + result = append(result, discord.TargetVolume{Target: key, Percent: values[entry.Slider]}) + } + } + } + return result + } +} +func (d *App) text(id, other string, data map[string]string) string { + return d.localizer.MustLocalize(&i18n.LocalizeConfig{DefaultMessage: &i18n.Message{ID: id, Other: other}, TemplateData: data}) +} +func (d *App) configOutcome(out config.Outcome) { + var title, body string + switch out.Kind { + case config.Missing: + title = d.text("ConfigNotFoundTitle", "Can't find configuration!", nil) + body = d.text("ConfigNotFoundDescription", "{{.FilePath}} must be in the same directory as deej. Please re-launch.", map[string]string{"FilePath": out.Path}) + case config.ReadError: + title = d.text("ConfigErrorTitle", "Error loading configuration!", nil) + body = d.text("ConfigErrorDescription", "Please check deej's logs for more details.", nil) + case config.Invalid: + title = d.text("ConfigInvalidTitle", "Invalid configuration!", nil) + body = d.text("ConfigInvalidDescription", "Please make sure {{.FilePath}} is in a valid YAML format.", map[string]string{"FilePath": out.Path}) + case config.Reloaded: + title = d.text("ConfigReloadTitle", "Configuration reloaded!", nil) + body = d.text("ConfigReloadDescription", "Your changes have been applied.", nil) + default: + return + } + d.notifier.Notify(title, body) +} +func (d *App) serialConnected(port string) { + d.notifier.Notify(d.text("ComPortConnectedNotificationTitle", "Connected to {{.ComPort}}.", map[string]string{"ComPort": port}), d.text("ComPortConnectedNotificationDescription", "Succesfully connected to deej.", nil)) +} +func (d *App) serialDisconnected(port string) { + d.notifier.Notify(d.text("ComPortDisconnectedNotificationTitle", "Disconnected from {{.ComPort}} due to an error.", map[string]string{"ComPort": port}), d.text("ComPortDisconnectedNotificationDescription", "Trying to reconnect.", nil)) +} diff --git a/internal/app/app_test.go b/internal/app/app_test.go new file mode 100644 index 00000000..49d5f16d --- /dev/null +++ b/internal/app/app_test.go @@ -0,0 +1,180 @@ +package app + +import ( + "errors" + "os" + "path/filepath" + "reflect" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/nik9play/deej/internal/audio" + "github.com/nik9play/deej/internal/config" + "github.com/nik9play/deej/internal/integrations/discord" + "github.com/nik9play/deej/internal/integrations/obs" + "github.com/nik9play/deej/internal/mixer" + "github.com/nik9play/deej/internal/serial" + "go.uber.org/zap" +) + +func TestDiscordVolumeSourceUsesCurrentProfileAndSliderReadings(t *testing.T) { + settings := &config.Settings{ + ActiveProfile: "active", + Profiles: []config.Profile{ + {Name: "inactive", SliderMapping: config.SliderMappings{{Slider: 0, Targets: []string{"deej.discord:wrong-profile"}}}}, + {Name: "active", SliderMapping: config.SliderMappings{ + {Slider: 0, Targets: []string{"master", "DEEJ.DISCORD.INPUT", "deej.discord: Nikita "}}, + {Slider: 1, Targets: []string{"Deej.Discord.Output", "discord.exe", "deej.obs:Music"}}, + {Slider: -1, Targets: []string{"deej.discord:negative"}}, + {Slider: 99, Targets: []string{"deej.discord:missing-slider"}}, + }}, + }, + } + values := []float32{0.25, 0.75} + currentVolumes := discordVolumeSource(func() *config.Settings { return settings }, func() []float32 { return values }) + want := []discord.TargetVolume{ + {Target: discord.InputTarget, Percent: values[0]}, + {Target: discord.UserTargetPrefix + "nikita", Percent: values[0]}, + {Target: discord.OutputTarget, Percent: values[1]}, + } + if got := currentVolumes(); !reflect.DeepEqual(got, want) { + t.Fatalf("mapped volumes = %#v, want %#v", got, want) + } + values = []float32{0.9, 0.75} + want[0].Percent, want[1].Percent = 0.9, 0.9 + if got := currentVolumes(); !reflect.DeepEqual(got, want) { + t.Fatalf("restoration captured an old slider snapshot: %#v", got) + } + updated := *settings + updated.ActiveProfile = "inactive" + settings = &updated + if got := currentVolumes(); !reflect.DeepEqual(got, []discord.TargetVolume{{Target: "user:wrong-profile", Percent: 0.9}}) { + t.Fatalf("restoration captured an old profile: %#v", got) + } + values = nil + if got := currentVolumes(); len(got) != 0 { + t.Fatalf("sliders without a reading must not be restored: %#v", got) + } +} + +type lifecycleFinder struct { + startErr error + releaseErr error + releases atomic.Int32 + onRelease func() +} + +func (f *lifecycleFinder) Start(audio.SessionEventHandler) error { return f.startErr } +func (f *lifecycleFinder) Release() error { + f.releases.Add(1) + if f.onRelease != nil { + f.onRelease() + } + return f.releaseErr +} + +// Compose the real core services against a missing port and fake audio finder. +// This exercises their subscription shutdown without device or audio access. +func testCore(t *testing.T, finder *lifecycleFinder) *App { + t.Helper() + logger := zap.NewNop().Sugar() + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte("com:\n port: deej-test-nonexistent-port\n"), 0600); err != nil { + t.Fatal(err) + } + store, err := config.New(logger, path, nil) + if err != nil { + t.Fatal(err) + } + if err := store.Load(); err != nil { + t.Fatal(err) + } + sliders, err := serial.New(store, logger, serial.Options{}) + if err != nil { + t.Fatal(err) + } + core := &App{ + logger: logger, config: store, serial: sliders, stopRequest: make(chan struct{}), + obs: obs.New(store, logger), discord: discord.New(store, logger, filepath.Join(filepath.Dir(path), discord.TokenFileName), nil), + } + core.sessions, err = mixer.New(store, sliders, logger, finder, core.obs, core.discord, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = core.Stop() }) + return core +} + +func TestConcurrentStopWaitsForResourcesAndReturnsSameError(t *testing.T) { + entered, release := make(chan struct{}), make(chan struct{}) + wantErr := errors.New("finder release failed") + finder := &lifecycleFinder{releaseErr: wantErr, onRelease: func() { close(entered); <-release }} + core := testCore(t, finder) + var releaseOnce sync.Once + finishRelease := func() { releaseOnce.Do(func() { close(release) }) } + t.Cleanup(finishRelease) + if err := core.Initialize(); err != nil { + t.Fatal(err) + } + core.Start() + results := make(chan error, 9) + go func() { results <- core.Stop() }() + select { + case <-entered: + case <-time.After(2 * time.Second): + t.Fatal("shutdown did not reach audio release") + } + var requests sync.WaitGroup + for range 8 { + requests.Go(func() { core.RequestStop(); results <- core.Stop() }) + } + select { + case err := <-results: + t.Fatalf("Stop returned before audio release completed: %v", err) + default: + } + finishRelease() + for range 9 { + select { + case err := <-results: + if !errors.Is(err, wantErr) { + t.Errorf("Stop error = %v, want %v", err, wantErr) + } + case <-time.After(2 * time.Second): + t.Fatal("a concurrent Stop remained blocked") + } + } + requests.Wait() + if got := finder.releases.Load(); got != 1 { + t.Fatalf("audio resources released %d times", got) + } +} + +func TestStopAfterFailedInitializationReleasesConsumersAndPreventsStart(t *testing.T) { + wantErr := errors.New("audio discovery failed") + finder := &lifecycleFinder{startErr: wantErr} + core := testCore(t, finder) + if err := core.Initialize(); !errors.Is(err, wantErr) { + t.Fatalf("Initialize error = %v, want %v", err, wantErr) + } + finished := make(chan error, 1) + go func() { finished <- core.Stop() }() + select { + case err := <-finished: + if err != nil { + t.Fatal(err) + } + case <-time.After(2 * time.Second): + t.Fatal("failed startup left a consumer blocked") + } + core.Wait() + if got := finder.releases.Load(); got != 1 { + t.Fatalf("failed startup released finder %d times", got) + } + core.Start() // No earlier Start can mask the stopped-state guard. + if core.started { + t.Fatal("Start accepted after failed initialization was stopped") + } +} diff --git a/pkg/deej/logger.go b/internal/app/logger.go similarity index 93% rename from pkg/deej/logger.go rename to internal/app/logger.go index f24f9059..adb61586 100644 --- a/pkg/deej/logger.go +++ b/internal/app/logger.go @@ -1,4 +1,4 @@ -package deej +package app import ( "fmt" @@ -6,7 +6,6 @@ import ( "path/filepath" "time" - "github.com/nik9play/deej/pkg/deej/util" "go.uber.org/zap" "go.uber.org/zap/zapcore" ) @@ -31,7 +30,7 @@ func NewLogger(buildType string) (*zap.SugaredLogger, error) { // release: info and above, log to file only (no UI) if buildType == buildTypeRelease { - if err := util.EnsureDirExists(logDirectory); err != nil { + if err := os.MkdirAll(logDirectory, 0755); err != nil { return nil, fmt.Errorf("ensure log directory exists: %w", err) } diff --git a/pkg/deej/panic.go b/internal/app/panic.go similarity index 82% rename from pkg/deej/panic.go rename to internal/app/panic.go index 9feba0d9..5a592569 100644 --- a/pkg/deej/panic.go +++ b/internal/app/panic.go @@ -1,15 +1,13 @@ -package deej +package app import ( "bytes" "fmt" - "io/ioutil" + "os" "path/filepath" "runtime/debug" "time" - - "github.com/nik9play/deej/pkg/deej/util" ) const ( @@ -31,8 +29,8 @@ Stack trace: ` ) -func (d *Deej) recoverFromPanic() { - r := recover() +// ReportCrash records a recovered panic and requests coordinated shutdown. +func (d *App) ReportCrash(r any) { if r == nil { return @@ -49,7 +47,7 @@ func (d *Deej) recoverFromPanic() { logDirectory := filepath.Join(filepath.Dir(ex), "logs") // that would suck - if err := util.EnsureDirExists(logDirectory); err != nil { + if err := os.MkdirAll(logDirectory, 0755); err != nil { panic(fmt.Errorf("ensure crashlog dir exists: %w", err)) } @@ -57,7 +55,7 @@ func (d *Deej) recoverFromPanic() { crashlogPath := filepath.Join(logDirectory, fmt.Sprintf(crashlogFilename, now.Format(crashlogTimestampFormat))) // that would REALLY suck - if err := ioutil.WriteFile(crashlogPath, crashlogBytes.Bytes(), os.ModePerm); err != nil { + if err := os.WriteFile(crashlogPath, crashlogBytes.Bytes(), os.ModePerm); err != nil { panic(fmt.Errorf("can't even write the crashlog file contents: %w", err)) } @@ -69,7 +67,5 @@ func (d *Deej) recoverFromPanic() { fmt.Sprintf("More details in %s", crashlogPath)) // bye :( - d.signalStop() - d.logger.Errorw("Quitting", "exitCode", 1) - os.Exit(1) + d.RequestStop() } diff --git a/internal/atomicfile/write.go b/internal/atomicfile/write.go new file mode 100644 index 00000000..65551dea --- /dev/null +++ b/internal/atomicfile/write.go @@ -0,0 +1,37 @@ +package atomicfile + +import ( + "os" + "path/filepath" +) + +// Write writes data to a temp file in the target's directory and +// renames it over the target, so a crash mid-write can't corrupt the config. +// The config holds the OBS websocket password, so it stays owner-only: the +// mode comes from os.CreateTemp, which creates at 0600, and rename preserves +// it - don't widen it +func Write(path string, data []byte) error { + tmp, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".tmp-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + _ = os.Remove(tmpPath) + return err + } + + if err := tmp.Close(); err != nil { + _ = os.Remove(tmpPath) + return err + } + + if err := os.Rename(tmpPath, path); err != nil { + _ = os.Remove(tmpPath) + return err + } + + return nil +} diff --git a/internal/atomicfile/write_test.go b/internal/atomicfile/write_test.go new file mode 100644 index 00000000..26c3f4f0 --- /dev/null +++ b/internal/atomicfile/write_test.go @@ -0,0 +1,47 @@ +package atomicfile + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestWriteReplacesDestinationAndKeepsOwnerPermissions(t *testing.T) { + path := filepath.Join(t.TempDir(), "settings.yaml") + if err := os.WriteFile(path, []byte("old"), 0644); err != nil { + t.Fatal(err) + } + if err := Write(path, []byte("new")); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) + if err != nil || string(data) != "new" { + t.Fatalf("replacement = %q, %v", data, err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if runtime.GOOS != "windows" && info.Mode().Perm() != 0600 { + t.Fatalf("permissions = %o", info.Mode().Perm()) + } +} + +func TestFailedRenameRemovesTemporaryFile(t *testing.T) { + dir := t.TempDir() + destination := filepath.Join(dir, "directory") + if err := os.Mkdir(destination, 0700); err != nil { + t.Fatal(err) + } + if err := Write(destination, []byte("new")); err == nil { + t.Fatal("expected rename failure") + } + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 || entries[0].Name() != "directory" { + t.Fatalf("temporary files leaked: %v", entries) + } +} diff --git a/pkg/deej/session.go b/internal/audio/session.go similarity index 64% rename from pkg/deej/session.go rename to internal/audio/session.go index 2ce15cf2..18ec4c03 100644 --- a/pkg/deej/session.go +++ b/internal/audio/session.go @@ -1,4 +1,4 @@ -package deej +package audio import ( "strings" @@ -6,6 +6,13 @@ import ( "go.uber.org/zap" ) +// Standard device and system targets shared by discovery and the mixer. +const ( + MasterSessionName = "master" + SystemSessionName = "system" + InputSessionName = "mic" +) + // Session represents a single addressable audio session type Session interface { GetVolume() float32 @@ -17,11 +24,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,21 +55,33 @@ 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 + return SystemSessionName } if s.master { diff --git a/pkg/deej/session_finder.go b/internal/audio/session_finder.go similarity index 98% rename from pkg/deej/session_finder.go rename to internal/audio/session_finder.go index f6b6058e..1e370aa9 100644 --- a/pkg/deej/session_finder.go +++ b/internal/audio/session_finder.go @@ -1,4 +1,4 @@ -package deej +package audio // SessionEventHandler receives session add/remove events from a SessionFinder type SessionEventHandler func(event SessionEvent) diff --git a/pkg/deej/session_finder_linux.go b/internal/audio/session_finder_linux.go similarity index 95% rename from pkg/deej/session_finder_linux.go rename to internal/audio/session_finder_linux.go index 8a2fed76..8c7f2b18 100644 --- a/pkg/deej/session_finder_linux.go +++ b/internal/audio/session_finder_linux.go @@ -1,4 +1,4 @@ -package deej +package audio import ( "errors" @@ -10,7 +10,7 @@ import ( "github.com/jfreymuth/pulse/proto" "go.uber.org/zap" - "github.com/nik9play/deej/pkg/reconnect" + "github.com/nik9play/deej/internal/reconnect" ) const ( @@ -51,6 +51,9 @@ func (c paConn) request(req proto.RequestArgs, rpl proto.Reply) error { } type paSessionFinder struct { + lifecycle sync.Mutex + released bool + workers sync.WaitGroup logger *zap.SugaredLogger sessionLogger *zap.SugaredLogger @@ -77,7 +80,8 @@ type paSessionFinder struct { stopCh chan struct{} } -func newSessionFinder(logger *zap.SugaredLogger) (SessionFinder, error) { +// NewSessionFinder constructs the platform audio-session discovery service. +func NewSessionFinder(logger *zap.SugaredLogger) (SessionFinder, error) { sf := &paSessionFinder{ logger: logger.Named("session_finder"), sessionLogger: logger.Named("sessions"), @@ -103,6 +107,11 @@ func newSessionFinder(logger *zap.SugaredLogger) (SessionFinder, error) { // Begins session discovery, delivering events synchronously to handler. func (sf *paSessionFinder) Start(handler SessionEventHandler) error { + sf.lifecycle.Lock() + defer sf.lifecycle.Unlock() + if sf.released { + return errors.New("session finder already released") + } if sf.started { return errors.New("session finder already started") } @@ -111,7 +120,7 @@ func (sf *paSessionFinder) Start(handler SessionEventHandler) error { // the worker must be running before the first connection is made, since // onUp queues the initial enumeration on it - go sf.eventWorker() + sf.workers.Go(sf.eventWorker) sf.reconnector.Start() return nil @@ -407,12 +416,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() @@ -625,8 +640,16 @@ func (sf *paSessionFinder) notify(event SessionEvent) { } func (sf *paSessionFinder) Release() error { + sf.lifecycle.Lock() + defer sf.lifecycle.Unlock() + if sf.released { + return nil + } + sf.released = true sf.reconnector.Stop() close(sf.stopCh) + sf.workers.Wait() + sf.clearSessions() sf.logger.Debug("Released PA session finder") return nil diff --git a/pkg/deej/session_finder_windows.go b/internal/audio/session_finder_windows.go similarity index 96% rename from pkg/deej/session_finder_windows.go rename to internal/audio/session_finder_windows.go index 8582b1b3..cdbffa42 100644 --- a/pkg/deej/session_finder_windows.go +++ b/internal/audio/session_finder_windows.go @@ -1,4 +1,4 @@ -package deej +package audio import ( "context" @@ -16,10 +16,13 @@ import ( wca "github.com/moutend/go-wca/pkg/wca" "go.uber.org/zap" - "github.com/nik9play/deej/pkg/win" + "github.com/nik9play/deej/internal/platform/win" ) type wcaSessionFinder struct { + lifecycle sync.Mutex + released bool + workers sync.WaitGroup logger *zap.SugaredLogger sessionLogger *zap.SugaredLogger @@ -96,7 +99,8 @@ const ( masterInputSessionID = "master_input" ) -func newSessionFinder(logger *zap.SugaredLogger) (SessionFinder, error) { +// NewSessionFinder constructs the platform audio-session discovery service. +func NewSessionFinder(logger *zap.SugaredLogger) (SessionFinder, error) { ctx, cancel := context.WithCancel(context.Background()) sf := &wcaSessionFinder{ @@ -117,13 +121,18 @@ func newSessionFinder(logger *zap.SugaredLogger) (SessionFinder, error) { // Start begins session discovery, delivering events synchronously to handler func (sf *wcaSessionFinder) Start(handler SessionEventHandler) error { + sf.lifecycle.Lock() + defer sf.lifecycle.Unlock() + if sf.released { + return errors.New("session finder already released") + } if sf.started { return errors.New("session finder already started") } sf.started = true sf.handler = handler - go sf.sessionFinderWorker(sf.workerCtx) + sf.workers.Go(func() { sf.sessionFinderWorker(sf.workerCtx) }) return nil } @@ -347,7 +356,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 +641,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 +660,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 +669,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) @@ -679,7 +688,14 @@ func (sf *wcaSessionFinder) getMasterSession(mmDevice *wca.IMMDevice, key string func (sf *wcaSessionFinder) Release() error { // the worker goroutine owns all COM objects (including the device // enumerator) and cleans them up when the context is cancelled + sf.lifecycle.Lock() + defer sf.lifecycle.Unlock() + if sf.released { + return nil + } + sf.released = true sf.workerCancel() + sf.workers.Wait() sf.logger.Debug("Released WCA session finder instance") return nil @@ -757,7 +773,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 +812,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/internal/audio/session_linux.go similarity index 89% rename from pkg/deej/session_linux.go rename to internal/audio/session_linux.go index 07a6f0ac..a020688d 100644 --- a/pkg/deej/session_linux.go +++ b/internal/audio/session_linux.go @@ -1,4 +1,4 @@ -package deej +package audio import ( "fmt" @@ -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()) @@ -66,9 +68,9 @@ func newMasterSession( ) *masterSession { var key string if isOutput { - key = masterSessionName + key = MasterSessionName } else { - key = inputSessionName + key = InputSessionName } return newNamedMasterSession(logger, conn, streamIndex, streamChannels, isOutput, 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) @@ -208,18 +213,18 @@ func (s *masterSession) String() string { return fmt.Sprintf(sessionStringFormat, s.humanReadableDesc, s.GetVolume()) } -func createChannelVolumes(channels byte, volume float32) []uint32 { - volumes := make([]uint32, channels) +func createChannelVolumes(channels byte, volume float32) proto.ChannelVolumes { + volumes := make(proto.ChannelVolumes, channels) for i := range volumes { - volumes[i] = uint32(volume * maxVolume) + volumes[i] = proto.Volume(volume * maxVolume) } return volumes } -func parseChannelVolumes(volumes []uint32) float32 { - var level uint32 +func parseChannelVolumes(volumes proto.ChannelVolumes) float32 { + var level proto.Volume for _, volume := range volumes { level += volume diff --git a/pkg/deej/session_windows.go b/internal/audio/session_windows.go similarity index 91% rename from pkg/deej/session_windows.go rename to internal/audio/session_windows.go index 1821cffc..ce18381a 100644 --- a/pkg/deej/session_windows.go +++ b/internal/audio/session_windows.go @@ -1,4 +1,4 @@ -package deej +package audio import ( "errors" @@ -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/internal/platform" ) var errNoSuchProcess = errors.New("no such process") @@ -51,7 +53,7 @@ func newWCASession( // special treatment for system sounds session if pid == 0 { s.system = true - s.name = systemSessionName + s.name = SystemSessionName s.humanReadableDesc = "system sounds" } else { @@ -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 := platform.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/internal/config/defaults.go b/internal/config/defaults.go new file mode 100644 index 00000000..3f10a56f --- /dev/null +++ b/internal/config/defaults.go @@ -0,0 +1,34 @@ +package config + +// defaultProfileName is the profile a config without any gets, and the one a +// legacy top-level slider_mapping is migrated into +const defaultProfileName = "default" + +func defaultSettings() Settings { + return Settings{ + // the active profile is left to normalize, which resolves an empty + // value to the first profile - so a config that only has profiles: + // (and no active_profile:) isn't reported as pointing at a missing one + Profiles: []Profile{{Name: defaultProfileName, SliderMapping: SliderMappings{}}}, + Language: "auto", + + COM: COMSettings{ + Port: "auto", + BaudRate: 9600, + + // ch340 chip + VID: "1A86", + PID: "7523", + }, + + OBS: OBSSettings{ + Host: "localhost", + Port: 4455, + VolumeConversion: true, + }, + Discord: DiscordSettings{ + // Preserve Discord's displayed volume curve unless explicitly disabled. + VolumeConversion: true, + }, + } +} diff --git a/internal/config/integration_defaults_test.go b/internal/config/integration_defaults_test.go new file mode 100644 index 00000000..217da497 --- /dev/null +++ b/internal/config/integration_defaults_test.go @@ -0,0 +1,38 @@ +package config + +import "testing" + +func TestNormalizeDiscordRequiresCredentials(t *testing.T) { + settings := defaultSettings() + settings.Discord = DiscordSettings{Enabled: true} + + problems := settings.normalize() + if len(problems) != 2 { + t.Fatalf("expected both credentials to be reported, got %v", problems) + } + if settings.Discord.Enabled { + t.Error("enabling without credentials should leave the integration off") + } + + // a client secret pasted into the client id field is caught up front + settings = defaultSettings() + settings.Discord = DiscordSettings{Enabled: true, ClientID: "not-a-snowflake", ClientSecret: "secret"} + + if problems := settings.normalize(); len(problems) != 1 { + t.Fatalf("expected the non-numeric client id to be reported, got %v", problems) + } + + // the valid case is left alone, whitespace aside + settings = defaultSettings() + settings.Discord = DiscordSettings{Enabled: true, ClientID: " 1234567890 ", ClientSecret: " secret "} + + if problems := settings.normalize(); len(problems) != 0 { + t.Fatalf("expected no problems, got %v", problems) + } + if settings.Discord.ClientID != "1234567890" || settings.Discord.ClientSecret != "secret" { + t.Errorf("credentials should be trimmed, got %+v", settings.Discord) + } + if !settings.Discord.Enabled { + t.Error("valid credentials should stay enabled") + } +} diff --git a/internal/config/legacy.go b/internal/config/legacy.go new file mode 100644 index 00000000..f47f83b7 --- /dev/null +++ b/internal/config/legacy.go @@ -0,0 +1,53 @@ +package config + +import ( + "errors" + + "go.yaml.in/yaml/v3" +) + +// applyLegacyKeys fills settings from the flat keys older config files used: +// the com ones (com_port, baud_rate, com_vid, com_pid) and the top-level +// slider_mapping that predates profiles. Loads call it before the main decode, +// so an explicit com:/profiles: section overrides these. Saves only ever write +// the new form, so this is read-only compatibility +func applyLegacyKeys(data []byte, s *Settings) { + var legacy struct { + Port *string `yaml:"com_port"` + BaudRate *int `yaml:"baud_rate"` + VID *HexWord `yaml:"com_vid"` + PID *HexWord `yaml:"com_pid"` + SliderMapping *SliderMappings `yaml:"slider_mapping"` + } + + // a type error still decodes the well-typed keys; only a structural parse + // error (which the main decode reports too) means nothing usable came back + if err := yaml.Unmarshal(data, &legacy); err != nil && !isYAMLTypeError(err) { + return + } + + if legacy.Port != nil { + s.COM.Port = *legacy.Port + } + if legacy.BaudRate != nil { + s.COM.BaudRate = *legacy.BaudRate + } + if legacy.VID != nil { + s.COM.VID = *legacy.VID + } + if legacy.PID != nil { + s.COM.PID = *legacy.PID + } + + // a pre-profiles config: read the single mapping as the default profile. + // yaml replaces a slice wholesale, so a profiles: section in the same file + // still wins when the main decode runs + if legacy.SliderMapping != nil { + s.Profiles = []Profile{{Name: defaultProfileName, SliderMapping: *legacy.SliderMapping}} + } +} + +func isYAMLTypeError(err error) bool { + var typeErr *yaml.TypeError + return errors.As(err, &typeErr) +} diff --git a/internal/config/mapping.go b/internal/config/mapping.go new file mode 100644 index 00000000..8a69ae97 --- /dev/null +++ b/internal/config/mapping.go @@ -0,0 +1,178 @@ +package config + +import ( + "cmp" + "fmt" + "slices" + "strconv" + + "go.yaml.in/yaml/v3" +) + +// ActiveMapping returns the slider mapping deej is currently driving. It's the +// only mapping lookup on the hot path, so it stays a plain scan over the (very +// short) profile list +func (s *Settings) ActiveMapping() SliderMappings { + for i := range s.Profiles { + if s.Profiles[i].Name == s.ActiveProfile { + return s.Profiles[i].SliderMapping + } + } + + // normalized settings always resolve above; fall back to the first profile + // so an unnormalized snapshot still does something sensible + if len(s.Profiles) > 0 { + return s.Profiles[0].SliderMapping + } + + return nil +} + +// SliderMappingEntry is one slider's targets, JSON-friendly for the GUI +type SliderMappingEntry struct { + Slider int `json:"slider"` + Targets []string `json:"targets"` +} + +// SliderMappings is the slider_mapping config key. In the file it's a YAML +// mapping of slider index to a single target or a list of targets +type SliderMappings []SliderMappingEntry + +// UnmarshalYAML is deliberately lenient: hand-edited entries it can't make +// sense of are skipped rather than failing the whole config load. Strictness +// for GUI saves comes from Settings.normalize instead +func (sm *SliderMappings) UnmarshalYAML(node *yaml.Node) error { + entries := SliderMappings{} + + if node.Kind == yaml.MappingNode { + for i := 0; i+1 < len(node.Content); i += 2 { + keyNode, valueNode := node.Content[i], node.Content[i+1] + + slider, err := strconv.Atoi(keyNode.Value) + if err != nil { + continue + } + + targets := []string{} + switch valueNode.Kind { + case yaml.ScalarNode: + if valueNode.Tag != "!!null" && valueNode.Value != "" { + targets = append(targets, valueNode.Value) + } + case yaml.SequenceNode: + for _, item := range valueNode.Content { + if item.Kind == yaml.ScalarNode && item.Tag != "!!null" { + targets = append(targets, item.Value) + } + } + } + + entries = append(entries, SliderMappingEntry{Slider: slider, Targets: targets}) + } + } + + *sm = entries + return nil +} + +// MarshalYAML builds the slider_mapping value: single targets are written +// as plain scalars, multiple targets as sequences, matching the style of +// the example config. Every save runs Settings.normalize first, so the +// entries are already sorted, deduped and free of empty targets +func (sm SliderMappings) MarshalYAML() (any, error) { + mapping := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} + + for _, entry := range sm { + if len(entry.Targets) == 0 { + continue + } + + var value *yaml.Node + if len(entry.Targets) == 1 { + value = yamlStringNode(entry.Targets[0]) + } else { + value = &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"} + for _, target := range entry.Targets { + value.Content = append(value.Content, yamlStringNode(target)) + } + } + + mapping.Content = append(mapping.Content, yamlIntNode(entry.Slider), value) + } + + return mapping, nil +} + +// Get returns the targets mapped to a slider index. The mapping is small +// (one entry per physical slider) and sanitized, so a linear scan is fine +func (sm SliderMappings) Get(slider int) ([]string, bool) { + for _, entry := range sm { + if entry.Slider == slider { + return slices.Clone(entry.Targets), true + } + } + + return nil, false +} + +// String is a compact form for logs +func (sm SliderMappings) String() string { + targets := 0 + for _, entry := range sm { + targets += len(entry.Targets) + } + + return fmt.Sprintf("<%d sliders mapped to %d targets>", len(sm), targets) +} + +func (sm SliderMappings) clone() SliderMappings { + out := make(SliderMappings, len(sm)) + for i, entry := range sm { + out[i] = SliderMappingEntry{Slider: entry.Slider, Targets: slices.Clone(entry.Targets)} + } + + return out +} + +func yamlStringNode(value string) *yaml.Node { + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value} +} + +func yamlIntNode(value int) *yaml.Node { + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!int", Value: strconv.Itoa(value)} +} + +// normalized returns the canonical form of a mapping and the list of values +// that were invalid: negative and duplicate slider indexes are reported and +// dropped, empty targets (and entries left with none, so the snapshot matches +// what a save writes) are dropped silently, and the result is sorted by +// slider index +func (sm SliderMappings) normalized() (SliderMappings, []string) { + var problems []string + + mapping := SliderMappings{} + seenSliders := map[int]bool{} + + for _, entry := range sm { + if entry.Slider < 0 { + problems = append(problems, fmt.Sprintf("slider index must not be negative: %d", entry.Slider)) + continue + } + if seenSliders[entry.Slider] { + problems = append(problems, fmt.Sprintf("duplicate slider index: %d", entry.Slider)) + continue + } + seenSliders[entry.Slider] = true + + targets := slices.DeleteFunc(slices.Clone(entry.Targets), func(t string) bool { return t == "" }) + if len(targets) == 0 { + continue + } + + mapping = append(mapping, SliderMappingEntry{Slider: entry.Slider, Targets: targets}) + } + + slices.SortFunc(mapping, func(a, b SliderMappingEntry) int { return cmp.Compare(a.Slider, b.Slider) }) + + return mapping, problems +} diff --git a/internal/config/model.go b/internal/config/model.go new file mode 100644 index 00000000..388446ae --- /dev/null +++ b/internal/config/model.go @@ -0,0 +1,145 @@ +package config + +import ( + "fmt" + "strconv" + "strings" + + "go.yaml.in/yaml/v3" +) + +// Settings is the single source of truth for deej's user-facing +// configuration. The yaml tags define the config file keys, the json tags +// the GUI wire format: a new setting only needs a field here (plus a default +// in defaultSettings and, if needed, a rule in normalize) to reach the file, +// the runtime snapshot and the settings GUI +type Settings struct { + ActiveProfile string `yaml:"active_profile" json:"activeProfile"` + Profiles []Profile `yaml:"profiles" json:"profiles"` + InvertSliders bool `yaml:"invert_sliders" json:"invertSliders"` + COM COMSettings `yaml:"com" json:"com"` + NoiseReduction string `yaml:"noise_reduction,omitempty" json:"noiseReduction"` + Language string `yaml:"language" json:"language"` + OBS OBSSettings `yaml:"obs" json:"obs"` + Discord DiscordSettings `yaml:"discord" json:"discord"` +} + +// Profile is one named slider layout. Only the mapping and its hotkey are +// per-profile; everything else (com, obs, language, ...) stays global. +// normalize guarantees at least one profile exists, so consumers can always +// resolve a mapping +type Profile struct { + Name string `yaml:"name" json:"name"` + Hotkey string `yaml:"hotkey,omitempty" json:"hotkey"` + SliderMapping SliderMappings `yaml:"slider_mapping" json:"sliderMapping"` +} + +// clone returns a deep copy, safe to hand out and mutate without touching the +// live snapshot +func (s Settings) clone() Settings { + out := s + + out.Profiles = make([]Profile, len(s.Profiles)) + for i, profile := range s.Profiles { + profile.SliderMapping = profile.SliderMapping.clone() + out.Profiles[i] = profile + } + + return out +} + +// 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"` +} + +// 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"` + VolumeConversion bool `yaml:"volume_conversion" json:"volumeConversion"` +} + +// DiscordSettings describes the Discord RPC integration. deej can't ship +// credentials of its own: Discord gates the rpc.voice.* scopes to an +// application's owner, so the user creates their own application and pastes +// its ID and secret here. The OAuth token that comes out of linking is not +// kept here - it lives in its own file next to the config (see discord.go) +type DiscordSettings struct { + Enabled bool `yaml:"enabled" json:"enabled"` + ClientID string `yaml:"client_id" json:"clientId"` + ClientSecret string `yaml:"client_secret" json:"clientSecret"` + VolumeConversion bool `yaml:"volume_conversion" json:"volumeConversion"` +} + +// HexWord is a 16-bit value carried as a hex string (e.g. "1A86"), the form +// both the config file and the GUI use. An empty value means "use the +// built-in default" +type HexWord string + +// Value returns the numeric form; ok is false for empty or malformed values +func (h HexWord) Value() (uint16, bool) { + trimmed := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(string(h))), "0x") + + parsed, err := strconv.ParseUint(trimmed, 16, 16) + if err != nil { + return 0, false + } + + return uint16(parsed), true +} + +// canonical returns the canonical 4-digit hex form. A blank value maps to the +// default (blank means "use the built-in default") with ok=true. ok is false +// only for a non-blank value that isn't valid hex; the default is returned in +// that case too, so callers always get a usable value +func (h HexWord) canonical(def HexWord) (HexWord, bool) { + if strings.TrimSpace(string(h)) == "" { + return def, true + } + + parsed, ok := h.Value() + if !ok { + return def, false + } + + return HexWord(fmt.Sprintf("%04X", parsed)), true +} + +// UnmarshalYAML accepts both YAML integers (0x1A86, 6790) and hex strings +// ("1A86", "0x1A86") +func (h *HexWord) UnmarshalYAML(node *yaml.Node) error { + var number uint64 + if err := node.Decode(&number); err == nil { + if number > 0xFFFF { + return fmt.Errorf("must be a 16-bit value: %s", node.Value) + } + + *h = HexWord(fmt.Sprintf("%04X", number)) + return nil + } + + var str string + if err := node.Decode(&str); err != nil { + return err + } + + *h = HexWord(str) + return nil +} + +// MarshalYAML writes the value as a 0x-prefixed YAML integer +func (h HexWord) MarshalYAML() (any, error) { + value, ok := h.Value() + if !ok { + // sanitized settings never hit this; keep whatever the value is + return string(h), nil + } + + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!int", Value: fmt.Sprintf("0x%04X", value)}, nil +} diff --git a/internal/config/model_test.go b/internal/config/model_test.go new file mode 100644 index 00000000..361c8526 --- /dev/null +++ b/internal/config/model_test.go @@ -0,0 +1,659 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "go.uber.org/zap" +) + +func newTestConfig(t *testing.T, contents string) *Store { + 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 := New(zap.NewNop().Sugar(), configPath, nil) + if err != nil { + t.Fatalf("create config: %v", err) + } + + if err := cc.Load(); 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: "" +` + +// TestSaveUserSettingsRoundTrip touches every field of Settings, so a save +// that drops or mangles one shows up here +func TestSaveUserSettingsRoundTrip(t *testing.T) { + cc := newTestConfig(t, testConfigContents) + + settings := cc.UserSettings() + settings.COM.Port = "COM7" + settings.COM.BaudRate = 115200 + settings.COM.VID = "2341" + settings.COM.PID = "0043" + settings.InvertSliders = true + settings.NoiseReduction = "high" + settings.Language = "ru" + settings.OBS.Enabled = true + settings.OBS.Host = "192.168.1.5" + settings.OBS.Port = 4456 + settings.OBS.Password = "secret" + settings.Profiles = []Profile{ + {Name: "default", SliderMapping: SliderMappings{ + {Slider: 0, Targets: []string{"master"}}, + {Slider: 2, Targets: []string{"discord.exe", "spotify.exe"}}, + }}, + {Name: "gaming", Hotkey: "Ctrl+Alt+2", SliderMapping: SliderMappings{ + {Slider: 0, Targets: []string{"game.exe"}}, + }}, + } + settings.ActiveProfile = "default" + + if err := cc.SaveUserSettings(settings); err != nil { + t.Fatalf("save settings: %v", err) + } + + // the file must carry the config's own key names, not just the values + data, err := os.ReadFile(cc.configPath) + if err != nil { + t.Fatalf("read saved config: %v", err) + } + saved := string(data) + + for _, want := range []string{ + "port: COM7", + "invert_sliders: true", + "noise_reduction: high", + "discord.exe", + "active_profile: default", + "name: gaming", + "hotkey: Ctrl+Alt+2", + } { + if !strings.Contains(saved, want) { + t.Errorf("saved config missing %q:\n%s", want, saved) + } + } + + // every value must survive the write -> read cycle, applied immediately + got := cc.UserSettings() + if got.COM.Port != "COM7" || got.COM.BaudRate != 115200 || + got.COM.VID != "2341" || got.COM.PID != "0043" || + !got.InvertSliders || got.NoiseReduction != "high" || got.Language != "ru" || + !got.OBS.Enabled || got.OBS.Host != "192.168.1.5" || + got.OBS.Port != 4456 || got.OBS.Password != "secret" { + t.Errorf("round trip mismatch: %+v", got) + } + + if len(got.Profiles) != 2 || got.Profiles[1].Name != "gaming" || got.Profiles[1].Hotkey != "Ctrl+Alt+2" { + t.Fatalf("profiles did not survive the round trip: %+v", got.Profiles) + } + + if len(got.ActiveMapping()) != 2 { + t.Fatalf("expected 2 mapping entries, got %v", got.ActiveMapping()) + } + targets, ok := got.ActiveMapping().Get(2) + if !ok || len(targets) != 2 { + t.Errorf("multi-target mapping not applied, got %v", targets) + } +} + +func TestLoadReadsLegacySliderMapping(t *testing.T) { + // a pre-profiles config: the top-level mapping becomes the default profile + cc := newTestConfig(t, testConfigContents) + + values := cc.Values() + if len(values.Profiles) != 1 || values.Profiles[0].Name != defaultProfileName { + t.Fatalf("expected a single %q profile, got %+v", defaultProfileName, values.Profiles) + } + if values.ActiveProfile != defaultProfileName { + t.Errorf("ActiveProfile = %q, expected %q", values.ActiveProfile, defaultProfileName) + } + + targets, ok := values.ActiveMapping().Get(1) + if !ok || len(targets) != 2 { + t.Errorf("legacy mapping not migrated, got %v", targets) + } +} + +func TestProfilesWinOverLegacySliderMapping(t *testing.T) { + cc := newTestConfig(t, ` +slider_mapping: + 0: master +profiles: + - name: gaming + slider_mapping: + 0: game.exe + - name: music + slider_mapping: + 0: spotify.exe +`) + + values := cc.Values() + if len(values.Profiles) != 2 { + t.Fatalf("expected the profiles section to replace the legacy mapping, got %+v", values.Profiles) + } + + // no active_profile in the file: the first profile wins, silently + if values.ActiveProfile != "gaming" { + t.Errorf("ActiveProfile = %q, expected gaming", values.ActiveProfile) + } + if targets, _ := values.ActiveMapping().Get(0); len(targets) != 1 || targets[0] != "game.exe" { + t.Errorf("active mapping = %v, expected game.exe", targets) + } +} + +func TestSaveMigratesSliderMappingToProfiles(t *testing.T) { + cc := newTestConfig(t, testConfigContents) + + if err := cc.SaveUserSettings(cc.UserSettings()); err != nil { + t.Fatalf("save settings: %v", err) + } + + data, err := os.ReadFile(cc.configPath) + if err != nil { + t.Fatalf("read saved config: %v", err) + } + saved := string(data) + + // the legacy key lives at the top level, so it would start a line + // unindented (a profile's own slider_mapping is indented) + for line := range strings.SplitSeq(saved, "\n") { + if strings.HasPrefix(line, "slider_mapping:") { + t.Errorf("saved config still contains the legacy top-level mapping:\n%s", saved) + } + } + if !strings.Contains(saved, "profiles:") || !strings.Contains(saved, "name: default") { + t.Errorf("saved config missing the migrated profile:\n%s", saved) + } +} + +func TestSetActiveProfile(t *testing.T) { + cc := newTestConfig(t, ` +active_profile: default +profiles: + - name: default + slider_mapping: + 0: master + - name: gaming + slider_mapping: + 0: game.exe +`) + + if err := cc.SetActiveProfile("unknown"); err == nil { + t.Error("expected an error for an unknown profile") + } + + reloaded := cc.SubscribeToChanges() + + // resolves case-insensitively, and stores the profile's own spelling + if err := cc.SetActiveProfile("GAMING"); err != nil { + t.Fatalf("set active profile: %v", err) + } + if cc.Values().ActiveProfile != "gaming" { + t.Errorf("ActiveProfile = %q, expected gaming", cc.Values().ActiveProfile) + } + if targets, _ := cc.Values().ActiveMapping().Get(0); len(targets) != 1 || targets[0] != "game.exe" { + t.Errorf("active mapping = %v, expected game.exe", targets) + } + + select { + case <-reloaded: + default: + t.Error("expected a reload notification") + } + + // a hotkey can be pressed repeatedly: switching to the active profile must + // not rewrite the file + before, err := os.Stat(cc.configPath) + if err != nil { + t.Fatalf("stat config: %v", err) + } + if err := cc.SetActiveProfile("gaming"); err != nil { + t.Fatalf("re-set active profile: %v", err) + } + after, err := os.Stat(cc.configPath) + if err != nil { + t.Fatalf("stat config: %v", err) + } + if !before.ModTime().Equal(after.ModTime()) { + t.Error("switching to the already-active profile rewrote the config file") + } +} + +func TestSaveUserSettingsNotifiesConsumers(t *testing.T) { + cc := newTestConfig(t, testConfigContents) + + // the channel is buffered and the save notifies synchronously, so the + // signal is already waiting by the time SaveUserSettings returns + reloaded := cc.SubscribeToChanges() + + if err := cc.SaveUserSettings(cc.UserSettings()); err != nil { + t.Fatalf("save settings: %v", err) + } + + select { + case <-reloaded: + default: + 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()); 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.SplitSeq(saved, "\n") { + for _, stale := range []string{"com_port:", "baud_rate:", "com_vid:", "com_pid:"} { + if strings.HasPrefix(line, stale) { + t.Errorf("saved config still contains legacy key %q:\n%s", stale, saved) + } + } + } + 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 and falls back to its default; the rest of + // the file must still apply, through the com section and the legacy keys + // alike (the two take separate decode paths) + for _, tc := range []struct { + name string + contents string + }{ + {"com section", "com:\n baud_rate: [what]\n port: COM9\n"}, + {"legacy keys", "baud_rate: [what]\ncom_port: COM9\n"}, + } { + t.Run(tc.name, func(t *testing.T) { + values := newTestConfig(t, tc.contents).Values() + + 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 TestLoadEmptyConfigUsesDefaults(t *testing.T) { + settings := newTestConfig(t, "").Values() + if settings.COM.Port != "auto" { + t.Errorf("COM.Port = %q, expected auto", settings.COM.Port) + } + if !settings.OBS.VolumeConversion || !settings.Discord.VolumeConversion { + t.Errorf("volume conversion should default on: OBS=%v, Discord=%v", settings.OBS.VolumeConversion, settings.Discord.VolumeConversion) + } +} + +func TestSettingsNormalize(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}, + ActiveProfile: "default", + Profiles: []Profile{ + {Name: "default", Hotkey: "Ctrl+Alt+1", SliderMapping: SliderMappings{ + {Slider: 0, Targets: []string{"master"}}, + }}, + }, + } + + v := valid + if problems := v.normalize(); len(problems) != 0 { + t.Errorf("valid settings reported problems: %v", problems) + } + + // empty VID/PID mean "use the built-in default": no problem, and they + // canonicalize to the default value + emptyVIDPID := valid + emptyVIDPID.COM.VID = "" + emptyVIDPID.COM.PID = "" + if problems := emptyVIDPID.normalize(); len(problems) != 0 { + t.Errorf("empty VID/PID reported problems: %v", problems) + } + if emptyVIDPID.COM.VID != defaultSettings().COM.VID || emptyVIDPID.COM.PID != defaultSettings().COM.PID { + t.Errorf("empty VID/PID not defaulted: %q/%q", emptyVIDPID.COM.VID, emptyVIDPID.COM.PID) + } + + // an entry whose targets are all empty is dropped, not reported: the + // snapshot has to match what a save writes to the file + emptyTargets := valid + emptyTargets.Profiles = []Profile{{Name: "default", SliderMapping: SliderMappings{ + {Slider: 0, Targets: []string{"master"}}, + {Slider: 1, Targets: []string{""}}, + {Slider: 2, Targets: nil}, + }}} + if problems := emptyTargets.normalize(); len(problems) != 0 { + t.Errorf("empty targets reported problems: %v", problems) + } + if mapping := emptyTargets.ActiveMapping(); len(mapping) != 1 || mapping[0].Slider != 0 { + t.Errorf("target-less entries not dropped: %+v", mapping) + } + + // a config with no profiles at all gets an empty default, silently + noProfiles := valid + noProfiles.Profiles = nil + noProfiles.ActiveProfile = "" + if problems := noProfiles.normalize(); len(problems) != 0 { + t.Errorf("missing profiles reported problems: %v", problems) + } + if len(noProfiles.Profiles) != 1 || noProfiles.ActiveProfile != defaultProfileName { + t.Errorf("missing profiles not defaulted: %+v", noProfiles) + } + + cases := []struct { + 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.Profiles = []Profile{{Name: "default", SliderMapping: SliderMappings{ + {Slider: -1, Targets: []string{"a"}}, + }}} + }}, + {"duplicate slider", func(s *Settings) { + s.Profiles = []Profile{{Name: "default", SliderMapping: SliderMappings{ + {Slider: 1, Targets: []string{"a"}}, + {Slider: 1, Targets: []string{"b"}}, + }}} + }}, + {"unnamed profile", func(s *Settings) { + s.Profiles = []Profile{{Name: " "}} + s.ActiveProfile = "" + }}, + {"duplicate profile name", func(s *Settings) { + s.Profiles = []Profile{{Name: "default"}, {Name: "DEFAULT"}} + }}, + {"unknown active profile", func(s *Settings) { s.ActiveProfile = "gaming" }}, + {"hotkey without a modifier", func(s *Settings) { + s.Profiles = []Profile{{Name: "default", Hotkey: "a"}} + }}, + {"hotkey with an unknown modifier", func(s *Settings) { + s.Profiles = []Profile{{Name: "default", Hotkey: "Hyper+F5"}} + }}, + {"duplicate hotkey", func(s *Settings) { + s.Profiles = []Profile{ + {Name: "default", Hotkey: "Ctrl+Alt+1"}, + {Name: "gaming", Hotkey: "ctrl+alt+1"}, + } + }}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + settings := valid + tc.mutate(&settings) + if problems := settings.normalize(); len(problems) == 0 { + t.Error("expected normalize to report a problem") + } + }) + } +} + +func TestSaveUserSettingsRejectsInvalid(t *testing.T) { + cc := newTestConfig(t, testConfigContents) + + settings := cc.UserSettings() + settings.COM.BaudRate = -1 + + if err := cc.SaveUserSettings(settings); 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 *Store) { + t.Helper() + + done := make(chan struct{}) + go func() { + cc.WatchConfigFileChanges() + 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); 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): + } +} + +func TestSaveDoesNotMutatePublishedSnapshot(t *testing.T) { + cc := newTestConfig(t, testConfigContents) + before := cc.Values() + settings := *before + settings.COM.BaudRate = -1 + settings.Profiles = append([]Profile(nil), before.Profiles...) + settings.Profiles[0].Name = " default " + if err := cc.SaveUserSettings(settings); err == nil { + t.Fatal("expected invalid save") + } + if settings.Profiles[0].Name != " default " { + t.Fatal("save mutated caller profiles") + } + if cc.Values() != before || before.Profiles[0].Name != "default" { + t.Fatal("save mutated published snapshot") + } +} + +func TestMappingLookupDoesNotExposeTargets(t *testing.T) { + cc := newTestConfig(t, testConfigContents) + targets, _ := cc.Values().ActiveMapping().Get(1) + targets[0] = "changed.exe" + again, _ := cc.Values().ActiveMapping().Get(1) + if again[0] != "chrome.exe" { + t.Fatal("lookup exposed mutable snapshot targets") + } +} + +func TestStopWatchingIsIdempotent(t *testing.T) { + cc := newTestConfig(t, testConfigContents) + cc.StopWatchingConfigFile() + cc.StopWatchingConfigFile() +} diff --git a/internal/config/normalize.go b/internal/config/normalize.go new file mode 100644 index 00000000..448e8a15 --- /dev/null +++ b/internal/config/normalize.go @@ -0,0 +1,223 @@ +package config + +import ( + "fmt" + "slices" + "strings" +) + +// findProfileName resolves a profile name case-insensitively, returning it in +// the spelling the profile actually uses +func findProfileName(profiles []Profile, name string) (string, bool) { + for _, profile := range profiles { + if strings.EqualFold(profile.Name, name) { + return profile.Name, true + } + } + + return "", false +} + +var validNoiseReductionLevels = []string{"", "low", "default", "high", "none"} +var validLanguages = []string{"auto", "en", "ru"} + +// normalize canonicalizes the settings in place and returns the list of +// values that were invalid and had to be replaced with a default (or +// dropped). It is the single rule set behind both config policies: GUI saves +// reject when the returned list is non-empty; config-file loads apply the +// fixes and log the list. Silent canonicalizations (blank VID/PID -> default, +// empty OBS host -> default, dropping empty targets, sorting the mapping) are +// not reported +func (s *Settings) normalize() []string { + var problems []string + defaults := defaultSettings() + + if s.COM.Port == "" { + problems = append(problems, "com port must not be empty") + s.COM.Port = defaults.COM.Port + } + + if s.COM.BaudRate <= 0 { + problems = append(problems, fmt.Sprintf("baud rate must be positive, got %d", s.COM.BaudRate)) + s.COM.BaudRate = defaults.COM.BaudRate + } + + // a blank VID/PID means "use the built-in default" and is not a problem; + // only a non-blank value that isn't valid hex is + if vid, ok := s.COM.VID.canonical(defaults.COM.VID); ok { + s.COM.VID = vid + } else { + problems = append(problems, fmt.Sprintf("com vid %q is not a 16-bit hex value", string(s.COM.VID))) + s.COM.VID = vid + } + + if pid, ok := s.COM.PID.canonical(defaults.COM.PID); ok { + s.COM.PID = pid + } else { + problems = append(problems, fmt.Sprintf("com pid %q is not a 16-bit hex value", string(s.COM.PID))) + s.COM.PID = pid + } + + if !slices.Contains(validNoiseReductionLevels, s.NoiseReduction) { + problems = append(problems, fmt.Sprintf("invalid noise reduction level: %q", s.NoiseReduction)) + s.NoiseReduction = defaults.NoiseReduction + } + + if !slices.Contains(validLanguages, s.Language) { + problems = append(problems, fmt.Sprintf("invalid language: %q", s.Language)) + s.Language = defaults.Language + } + + if s.OBS.Port < 1 || s.OBS.Port > 65535 { + problems = append(problems, fmt.Sprintf("obs port must be between 1 and 65535, got %d", s.OBS.Port)) + s.OBS.Port = defaults.OBS.Port + } + + if s.OBS.Host == "" { + s.OBS.Host = defaults.OBS.Host + } + + problems = append(problems, s.normalizeDiscord()...) + problems = append(problems, s.normalizeProfiles()...) + + return problems +} + +// normalizeDiscord canonicalizes the Discord credentials. The integration +// can't do anything without both of them, so enabling it without them is +// reported rather than left as a connection that quietly never happens +func (s *Settings) normalizeDiscord() []string { + var problems []string + + s.Discord.ClientID = strings.TrimSpace(s.Discord.ClientID) + s.Discord.ClientSecret = strings.TrimSpace(s.Discord.ClientSecret) + + if !s.Discord.Enabled { + return nil + } + + switch { + case s.Discord.ClientID == "": + problems = append(problems, "discord client id must not be empty") + + // a client id is a snowflake; catching a pasted client *secret* here saves + // a confusing round trip through discord's OAuth error responses + case !isDigits(s.Discord.ClientID): + problems = append(problems, fmt.Sprintf("discord client id must be numeric, got %q", s.Discord.ClientID)) + s.Discord.ClientID = "" + } + + if s.Discord.ClientSecret == "" { + problems = append(problems, "discord client secret must not be empty") + } + + // leave the file self-consistent: enabled without usable credentials would + // otherwise sit there looking like it should be working + if len(problems) > 0 { + s.Discord.Enabled = false + } + + return problems +} + +func isDigits(value string) bool { + return value != "" && strings.IndexFunc(value, func(r rune) bool { return r < '0' || r > '9' }) < 0 +} + +// normalizeProfiles canonicalizes the profile list and the active profile. +// There is always at least one profile afterwards, and ActiveProfile always +// names one of them +func (s *Settings) normalizeProfiles() []string { + var problems []string + + profiles := make([]Profile, 0, len(s.Profiles)) + seenNames := map[string]bool{} + seenHotkeys := map[string]bool{} + + for i, profile := range s.Profiles { + profile.Name = strings.TrimSpace(profile.Name) + if profile.Name == "" { + generated := fmt.Sprintf("profile %d", i+1) + problems = append(problems, fmt.Sprintf("profile %d has no name, using %q", i+1, generated)) + profile.Name = generated + } + + if seenNames[strings.ToLower(profile.Name)] { + problems = append(problems, fmt.Sprintf("duplicate profile name: %q", profile.Name)) + continue + } + seenNames[strings.ToLower(profile.Name)] = true + + profile.Hotkey = strings.TrimSpace(profile.Hotkey) + if profile.Hotkey != "" { + switch { + case !validHotkey(profile.Hotkey): + problems = append(problems, fmt.Sprintf("invalid hotkey for profile %q: %q", profile.Name, profile.Hotkey)) + profile.Hotkey = "" + + // the OS would reject the second registration anyway; dropping it + // here keeps the file honest about what's actually bound + case seenHotkeys[strings.ToLower(profile.Hotkey)]: + problems = append(problems, fmt.Sprintf("duplicate hotkey %q on profile %q", profile.Hotkey, profile.Name)) + profile.Hotkey = "" + + default: + seenHotkeys[strings.ToLower(profile.Hotkey)] = true + } + } + + mapping, mappingProblems := profile.SliderMapping.normalized() + for _, problem := range mappingProblems { + problems = append(problems, fmt.Sprintf("profile %q: %s", profile.Name, problem)) + } + profile.SliderMapping = mapping + + profiles = append(profiles, profile) + } + + // a config with no profiles at all (or one whose every profile was dropped + // as a duplicate) gets an empty default, so consumers never see none + if len(profiles) == 0 { + profiles = append(profiles, Profile{Name: defaultProfileName, SliderMapping: SliderMappings{}}) + } + s.Profiles = profiles + + s.ActiveProfile = strings.TrimSpace(s.ActiveProfile) + if resolved, ok := findProfileName(profiles, s.ActiveProfile); ok { + s.ActiveProfile = resolved + } else { + // an empty value just means "no preference", only a name that doesn't + // resolve is worth reporting + if s.ActiveProfile != "" { + problems = append(problems, fmt.Sprintf("unknown active profile: %q", s.ActiveProfile)) + } + s.ActiveProfile = profiles[0].Name + } + + return problems +} + +// validHotkeyModifiers mirrors the modifiers wails accepts in an accelerator +// (see application.modifierMap). The key itself is left to wails to validate, +// which reports an unsupported one when it registers the shortcut +var validHotkeyModifiers = []string{ + "cmdorctrl", "cmd", "command", "ctrl", "optionoralt", "alt", "option", "shift", "super", +} + +// validHotkey reports whether a profile hotkey looks like a wails accelerator +// with at least one modifier. The modifier is not optional: a bare key would +// be grabbed system-wide, so deej would swallow it in every other application +func validHotkey(hotkey string) bool { + components := strings.Split(hotkey, "+") + if len(components) < 2 { + return false + } + + for _, modifier := range components[:len(components)-1] { + if !slices.Contains(validHotkeyModifiers, strings.ToLower(strings.TrimSpace(modifier))) { + return false + } + } + + return strings.TrimSpace(components[len(components)-1]) != "" +} diff --git a/internal/config/store.go b/internal/config/store.go new file mode 100644 index 00000000..1709fd0b --- /dev/null +++ b/internal/config/store.go @@ -0,0 +1,265 @@ +package config + +import ( + "bytes" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + + "go.uber.org/zap" + "go.yaml.in/yaml/v3" + + "github.com/nik9play/deej/internal/atomicfile" +) + +// Store provides application-wide access to configuration fields, +// as well as loading/file watching logic for deej's configuration file +type Store struct { + current atomic.Pointer[Settings] + + logger *zap.SugaredLogger + onOutcome func(Outcome) + + stopWatcher chan struct{} + stopOnce sync.Once + + consumersLock sync.Mutex + reloadConsumers []chan bool + + // serializes the read-modify-write cycles of Load and SaveUserSettings + lock sync.Mutex + + configPath string +} + +// Values returns the current immutable snapshot of the configuration. +// Callers that read multiple fields should grab one snapshot and use it +// throughout, so all values belong to the same config generation. The +// returned snapshot must not be mutated +func (cc *Store) Values() *Settings { + return cc.current.Load() +} + +// New creates a configuration store. Notifications are reported through onOutcome. +func New(logger *zap.SugaredLogger, configPath string, onOutcome func(Outcome)) (*Store, error) { + logger = logger.Named("config") + + ex, err := os.Executable() + if err != nil { + return nil, fmt.Errorf("get executable dir: %w", err) + } + + // set config path to exe dir, if custom path is not provided + if configPath == "" { + configPath = filepath.Join(filepath.Dir(ex), "config.yaml") + } + + cc := &Store{ + logger: logger, + onOutcome: onOutcome, + reloadConsumers: []chan bool{}, + stopWatcher: make(chan struct{}), + configPath: configPath, + } + + logger.Debug("Created config instance") + + return cc, nil +} + +// Load reads deej's config file from disk and tries to parse it +func (cc *Store) Load() error { + cc.lock.Lock() + defer cc.lock.Unlock() + + return cc.loadLocked() +} + +func (cc *Store) loadLocked() error { + cc.logger.Debugw("Loading config", "path", cc.configPath) + + // make sure it exists + if info, err := os.Stat(cc.configPath); errors.Is(err, os.ErrNotExist) || (err == nil && info.IsDir()) { + cc.logger.Warnw("Config file not found", "path", cc.configPath) + + cc.report(Missing, nil) + + return fmt.Errorf("config file doesn't exist: %s", cc.configPath) + } + + data, err := os.ReadFile(cc.configPath) + if err != nil { + cc.logger.Warnw("Failed to read user config", "error", err) + + cc.report(ReadError, err) + + return fmt.Errorf("read user config: %w", err) + } + + // missing keys keep the defaults they were initialized with. Legacy flat + // com keys are applied first so an explicit com: section overrides them + settings := defaultSettings() + applyLegacyKeys(data, &settings) + if err := yaml.Unmarshal(data, &settings); err != nil { + + // a *yaml.TypeError means the file parsed, but some values have the + // 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) + + cc.report(Invalid, err) + + return fmt.Errorf("parse user config: %w", err) + } + } + + if problems := settings.normalize(); len(problems) > 0 { + cc.logger.Warnw("Config had invalid values, replaced with defaults", "problems", problems) + } + + cc.current.Store(&settings) + + cc.logger.Info("Loaded config successfully") + cc.logger.Infow("Config values", + "activeProfile", settings.ActiveProfile, + "profiles", len(settings.Profiles), + "sliderMapping", settings.ActiveMapping(), + "comPort", settings.COM.Port, + "baudRate", settings.COM.BaudRate, + "invertSliders", settings.InvertSliders) + + return nil +} + +// 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 *Store) SubscribeToChanges() chan bool { + cc.consumersLock.Lock() + defer cc.consumersLock.Unlock() + + c := make(chan bool, 1) + cc.reloadConsumers = append(cc.reloadConsumers, c) + + return c +} + +// UserSettings returns the current contents of the user config file +func (cc *Store) UserSettings() Settings { + return cc.Values().clone() +} + +// SetActiveProfile switches to the named profile and persists the choice, so +// it survives a restart. It's the one-field write behind the profile hotkeys, +// the tray menu and the titlebar dropdown; everything else goes through +// SaveUserSettings +func (cc *Store) SetActiveProfile(name string) error { + cc.lock.Lock() + defer cc.lock.Unlock() + settings := cc.Values().clone() + + resolved, ok := findProfileName(settings.Profiles, name) + if !ok { + return fmt.Errorf("unknown profile: %q", name) + } + + // a hotkey can be pressed repeatedly; don't rewrite the config file (and + // wake every consumer) when nothing would change + if settings.ActiveProfile == resolved { + return nil + } + + settings.ActiveProfile = resolved + + if err := cc.saveAndReload(settings); err != nil { + return err + } + + cc.onConfigReloaded() + + return nil +} + +// 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 *Store) SaveUserSettings(settings Settings) error { + settings = settings.clone() + // normalize both canonicalizes (blank VID/PID -> defaults, mapping sorted + // and filtered) and reports invalid values; a GUI save must be rejected + // rather than silently corrected + if problems := settings.normalize(); len(problems) > 0 { + return fmt.Errorf("invalid settings: %s", strings.Join(problems, "; ")) + } + + cc.lock.Lock() + defer cc.lock.Unlock() + if err := cc.saveAndReload(settings); err != nil { + return err + } + + cc.onConfigReloaded() + + return nil +} + +func (cc *Store) saveAndReload(settings Settings) error { + + var buf bytes.Buffer + encoder := yaml.NewEncoder(&buf) + encoder.SetIndent(2) + if err := encoder.Encode(&settings); err != nil { + return fmt.Errorf("marshal config for save: %w", err) + } + if err := encoder.Close(); err != nil { + return fmt.Errorf("marshal config for save: %w", err) + } + out := buf.Bytes() + + if err := atomicfile.Write(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(); err != nil { + return fmt.Errorf("load config after save: %w", err) + } + + return nil +} + +// Path returns the configuration file path, resolved relative to the executable by default. +func (cc *Store) Path() string { return cc.configPath } + +func (cc *Store) report(kind OutcomeKind, err error) { + if cc.onOutcome != nil { + cc.onOutcome(Outcome{Kind: kind, Path: cc.configPath, Err: err}) + } +} + +// OutcomeKind identifies a configuration load result requiring user feedback. +type OutcomeKind int + +const ( + Missing OutcomeKind = iota + ReadError + Invalid + Reloaded +) + +// Outcome describes loading feedback without depending on desktop or localization. +type Outcome struct { + Kind OutcomeKind + Path string + Err error +} diff --git a/internal/config/watch.go b/internal/config/watch.go new file mode 100644 index 00000000..fb91556f --- /dev/null +++ b/internal/config/watch.go @@ -0,0 +1,126 @@ +package config + +import ( + "path/filepath" + "reflect" + "strings" + "time" + + "github.com/fsnotify/fsnotify" +) + +// 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 + +// WatchConfigFileChanges starts watching for configuration file changes +// and attempts reloading the config when they happen +func (cc *Store) WatchConfigFileChanges() { + cc.logger.Debugw("Starting to watch user config file for changes", "path", cc.configPath) + + 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 + } + + // trailing-edge debounce timer, armed on every relevant event. Go 1.23+ + // timer channels are unbuffered, so Stop/Reset never leave a stale tick + // behind and the usual drain dance isn't needed + debounce := time.NewTimer(time.Hour) + debounce.Stop() + defer debounce.Stop() + + for { + select { + case event, ok := <-watcher.Events: + if !ok { + return + } + + if !strings.EqualFold(filepath.Clean(event.Name), filepath.Clean(cc.configPath)) { + continue + } + + if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Rename) == 0 { + continue + } + + debounce.Reset(watchDebounceDelay) + + case err, ok := <-watcher.Errors: + if !ok { + return + } + cc.logger.Warnw("Config file watcher error", "error", err) + + case <-debounce.C: + cc.handleConfigFileChange() + + case <-cc.stopWatcher: + cc.logger.Debug("Stopping user config file watcher") + return + } + } +} + +func (cc *Store) handleConfigFileChange() { + cc.logger.Debug("Config file modified, attempting reload") + + cc.lock.Lock() + previous := cc.current.Load() + + if err := cc.loadLocked(); err != nil { + cc.lock.Unlock() + cc.logger.Warnw("Failed to reload config file", "error", err) + return + } + + // a GUI save already loaded, applied and notified synchronously; the file + // event it triggers just reloads identical content. Skip the redundant + // toast and consumer notification whenever the config didn't actually + // change - this also covers a hand edit that only touched comments or + // whitespace + if previous != nil && reflect.DeepEqual(previous, cc.current.Load()) { + cc.lock.Unlock() + cc.logger.Debug("Config unchanged after reload, skipping notification") + return + } + + cc.lock.Unlock() + cc.logger.Info("Reloaded config successfully") + + cc.report(Reloaded, nil) + + cc.onConfigReloaded() +} + +// StopWatchingConfigFile signals our filesystem watcher to stop +func (cc *Store) StopWatchingConfigFile() { + cc.stopOnce.Do(func() { close(cc.stopWatcher) }) +} + +func (cc *Store) onConfigReloaded() { + cc.logger.Debug("Notifying consumers about configuration reload") + + cc.consumersLock.Lock() + defer cc.consumersLock.Unlock() + + for _, consumer := range cc.reloadConsumers { + // 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/internal/desktop/desktop.go b/internal/desktop/desktop.go new file mode 100644 index 00000000..55e39f11 --- /dev/null +++ b/internal/desktop/desktop.go @@ -0,0 +1,114 @@ +//go:build !headless + +// Package desktop hosts the settings UI, tray menu and global shortcuts. +package desktop + +import ( + "github.com/nicksnyder/go-i18n/v2/i18n" + "github.com/nik9play/deej/internal/config" + "github.com/nik9play/deej/internal/integrations/discord" + "github.com/nik9play/deej/internal/integrations/obs" + "github.com/nik9play/deej/internal/mixer" + "github.com/nik9play/deej/internal/notify" + "github.com/nik9play/deej/internal/serial" + "github.com/wailsapp/wails/v3/pkg/application" + "go.uber.org/zap" + "sync" + "sync/atomic" +) + +// Options supplies services and lifecycle operations from the bootstrap. +type Options struct { + Config *config.Store + Serial *serial.Client + Mixer *mixer.Mixer + OBS *obs.Client + Discord *discord.Client + Logger *zap.SugaredLogger + Notifier notify.Notifier + Localizer *i18n.Localizer + Language string + Version string + Start func() + Wait func() + Stop func() error + RequestStop func() +} + +// Desktop owns Wails resources and independent subscriptions to core events. +type Desktop struct { + config *config.Store + serial *serial.Client + sessions *mixer.Mixer + obs *obs.Client + discord *discord.Client + logger *zap.SugaredLogger + notifier notify.Notifier + localizer *i18n.Localizer + resolvedLanguage, version string + app *application.App + settingsLock sync.Mutex + shutdownDone chan struct{} + eventsDone chan struct{} + eventWorkers sync.WaitGroup + runtimeWorkers sync.WaitGroup + runtimeMu sync.Mutex + runtimeStarted bool + closing atomic.Bool + eventsOnce sync.Once + allowQuit atomic.Bool + profileHotkeys map[string]string + start, wait, requestStop func() + stop func() error +} + +// New prepares the shell; Run must execute on the main thread. +func New(o Options) *Desktop { + return &Desktop{ + config: o.Config, serial: o.Serial, sessions: o.Mixer, obs: o.OBS, discord: o.Discord, logger: o.Logger, notifier: o.Notifier, localizer: o.Localizer, + resolvedLanguage: o.Language, version: o.Version, start: o.Start, wait: o.Wait, stop: o.Stop, requestStop: o.RequestStop, eventsDone: make(chan struct{}), profileHotkeys: map[string]string{}, + } +} +func (d *Desktop) stopEvents() { + d.eventsOnce.Do(func() { close(d.eventsDone) }) + d.eventWorkers.Wait() +} +func (d *Desktop) runCore() { + d.runCoreWithQuit(func() { + // Quit uses a synchronous main-thread dispatch. Queue it without blocking + // this worker so Run can still join us if its native loop already ended. + application.InvokeAsync(func() { + if !d.closing.Load() { + d.app.Quit() + } + }) + }) +} + +func (d *Desktop) runCoreWithQuit(quit func()) { + d.start() + d.wait() + if err := d.stop(); err != nil { + d.logger.Warnw("Failed to stop core", "error", err) + } + // Serial emits its final disconnected event during Stop; retain every consumer until then. + d.stopEvents() + d.allowQuit.Store(true) + if !d.closing.Load() { + quit() + } +} + +// cleanupCore also covers failure before ApplicationStarted. Prevent later +// callbacks from creating consumers or starting a core after cleanup has begun. +func (d *Desktop) cleanupCore() { + d.runtimeMu.Lock() + d.closing.Store(true) + d.runtimeMu.Unlock() + d.requestStop() + if err := d.stop(); err != nil { + d.logger.Warnw("Failed to stop core", "error", err) + } + d.stopEvents() + d.runtimeWorkers.Wait() +} diff --git a/internal/desktop/dto.go b/internal/desktop/dto.go new file mode 100644 index 00000000..930614c9 --- /dev/null +++ b/internal/desktop/dto.go @@ -0,0 +1,60 @@ +//go:build !headless + +package desktop + +import "github.com/nik9play/deej/internal/mixer" + +// 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"` + Platform string `json:"platform"` + 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 +} + +// DiscordUserDTO describes one member of the current voice channel +type DiscordUserDTO struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// DiscordStatusDTO describes the Discord integration's state for the settings GUI +type DiscordStatusDTO struct { + Linked bool `json:"linked"` // an account has been authorized + Connected bool `json:"connected"` // the RPC connection is up + Channel string `json:"channel"` // the voice channel the user is in, if any +} + +func sessionDTOs(in []mixer.SessionInfo) []SessionInfoDTO { + out := make([]SessionInfoDTO, 0, len(in)) + for _, s := range in { + out = append(out, SessionInfoDTO{Key: s.Key, DisplayName: s.DisplayName, IsDevice: s.IsDevice, IsInput: s.IsInput}) + } + return out +} diff --git a/internal/desktop/events.go b/internal/desktop/events.go new file mode 100644 index 00000000..f434649e --- /dev/null +++ b/internal/desktop/events.go @@ -0,0 +1,121 @@ +//go:build !headless + +package desktop + +import ( + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/events" +) + +// 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 + eventDiscord = "deej:discord" // no payload; the discord voice roster changed +) + +func (d *Desktop) subscribeEvents(statusInfo, sessionsInfo *application.MenuItem, setTooltip, setValuesInfo, rebuildProfilesMenu func()) { + app := d.app + app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) { + d.runtimeMu.Lock() + defer d.runtimeMu.Unlock() + if d.closing.Load() || d.runtimeStarted { + return + } + d.runtimeStarted = true + d.logger.Debug("Tray instance ready") + + application.InvokeAsync(func() { + if !d.closing.Load() { + setTooltip() + } + }) + + sliderMovedChannel := d.serial.SubscribeToSliderMoveEvents() + stateChangeChannel := d.serial.SubscribeToStateChangeEvent() + sessionCountChangeChannel := d.sessions.SubscribeToSessionCountChange() + configReloadedChannel := d.config.SubscribeToChanges() + discordRosterChannel := d.discord.SubscribeToRosterChange() + + emitState := func() { + app.Event.Emit(eventState, map[string]any{ + "connected": d.serial.GetState(), + "comPort": d.serial.CurrentComPort(), + }) + } + + // wait on things to happen; menu item mutations must run on the wails main thread + d.eventWorkers.Go(func() { + for { + select { + case <-d.eventsDone: + return + // slider moved + case <-sliderMovedChannel: + if d.closing.Load() { + continue + } + application.InvokeAsync(func() { + if !d.closing.Load() { + setTooltip() + setValuesInfo() + } + }) + app.Event.Emit(eventSliders, d.serial.CurrentSliderPercentValues()) + + // connection state changed + case <-stateChangeChannel: + if d.closing.Load() { + continue + } + application.InvokeAsync(func() { + if d.closing.Load() { + return + } + setTooltip() + setValuesInfo() + statusInfo.SetLabel(getStatusItemTitle(d)) + }) + emitState() + app.Event.Emit(eventSliders, d.serial.CurrentSliderPercentValues()) + + // someone joined or left the discord voice channel + case <-discordRosterChannel: + if d.closing.Load() { + continue + } + app.Event.Emit(eventDiscord) + + // session count changed + case <-sessionCountChangeChannel: + if d.closing.Load() { + continue + } + application.InvokeAsync(func() { + if !d.closing.Load() { + sessionsInfo.SetLabel(getSessionsCountString(d)) + } + }) + app.Event.Emit(eventSessions) + + // config applied (GUI save or manual edit) + case <-configReloadedChannel: + if d.closing.Load() { + continue + } + rebuildProfilesMenu() + d.syncProfileHotkeys() + app.Event.Emit(eventConfig) + emitState() + app.Event.Emit(eventSliders, d.serial.CurrentSliderPercentValues()) + } + } + }) + + // actually start the main runtime + d.runtimeWorkers.Go(d.runCore) + }) + +} diff --git a/internal/desktop/hotkeys.go b/internal/desktop/hotkeys.go new file mode 100644 index 00000000..8c8efac7 --- /dev/null +++ b/internal/desktop/hotkeys.go @@ -0,0 +1,49 @@ +//go:build !headless + +package desktop + +import ( + "maps" +) + +func (d *Desktop) syncProfileHotkeys() { + logger := d.logger + next := map[string]string{} + for _, profile := range d.config.Values().Profiles { + if profile.Hotkey != "" { + next[profile.Hotkey] = profile.Name + } + } + + // switching profiles reloads the config too, and the hotkey set is + // unchanged then - don't churn the OS registrations over it + if maps.Equal(d.profileHotkeys, next) { + return + } + + if err := d.app.GlobalShortcut.UnregisterAll(); err != nil { + logger.Warnw("Failed to unregister profile hotkeys", "error", err) + } + + for hotkey, name := range next { + if err := d.app.GlobalShortcut.Register(hotkey, func() { + logger.Infow("Profile hotkey pressed, switching profile", "profile", name) + + if err := d.config.SetActiveProfile(name); err != nil { + logger.Warnw("Failed to switch profile", "profile", name, "error", err) + return + } + + // a global hotkey fires while deej is invisible, so the toast + // is the only feedback the user gets + d.notifier.Notify(getProfileSwitchedText(d, name)) + }); err != nil { + // usually another application already owns the combination. + // The entry stays in the set so an unrelated reload doesn't + // retry (and re-churn) it; editing the hotkey does + logger.Warnw("Failed to register profile hotkey", "hotkey", hotkey, "profile", name, "error", err) + } + } + + d.profileHotkeys = next +} diff --git a/internal/desktop/lifecycle_test.go b/internal/desktop/lifecycle_test.go new file mode 100644 index 00000000..faf4f051 --- /dev/null +++ b/internal/desktop/lifecycle_test.go @@ -0,0 +1,122 @@ +//go:build !headless + +package desktop + +import ( + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "go.uber.org/zap" +) + +func awaitDesktop(t *testing.T, signal <-chan struct{}, description string) { + t.Helper() + select { + case <-signal: + case <-time.After(2 * time.Second): + t.Fatal(description) + } +} + +func TestCoreShutdownKeepsEventConsumersUntilFinalState(t *testing.T) { + started, requested, quit, consumerDone := make(chan struct{}), make(chan struct{}), make(chan struct{}), make(chan struct{}) + states := make(chan bool) + var received atomic.Int32 + var requestOnce sync.Once + d := New(Options{ + Logger: zap.NewNop().Sugar(), + Start: func() { close(started) }, + Wait: func() { <-requested }, + RequestStop: func() { requestOnce.Do(func() { close(requested) }) }, + Stop: func() error { + states <- false // Serial's synchronous final disconnected event. + return errors.New("audio release failed") + }, + }) + d.eventWorkers.Go(func() { + defer close(consumerDone) + for { + select { + case <-states: + received.Add(1) + case <-d.eventsDone: + return + } + } + }) + d.runtimeWorkers.Go(func() { d.runCoreWithQuit(func() { close(quit) }) }) + awaitDesktop(t, started, "core was not started") + d.requestStop() + awaitDesktop(t, quit, "shutdown could not deliver its final state event") + d.runtimeWorkers.Wait() + awaitDesktop(t, consumerDone, "event consumer survived shutdown") + if !d.allowQuit.Load() || received.Load() != 1 { + t.Fatalf("quit allowed=%v, final state count=%d", d.allowQuit.Load(), received.Load()) + } + var repeated sync.WaitGroup + for range 8 { + repeated.Go(d.stopEvents) + } + repeated.Wait() +} + +func TestRunFailureCleanupCoordinatesWithQuitRequest(t *testing.T) { + started, requested, consumerDone, cleaned := make(chan struct{}), make(chan struct{}), make(chan struct{}), make(chan struct{}) + states := make(chan bool) + var requestOnce, stopOnce sync.Once + var quits atomic.Int32 + d := New(Options{ + Logger: zap.NewNop().Sugar(), + Start: func() { close(started) }, + Wait: func() { <-requested }, + RequestStop: func() { requestOnce.Do(func() { close(requested) }) }, + Stop: func() error { + stopOnce.Do(func() { + states <- false + }) + return nil + }, + }) + d.eventWorkers.Go(func() { + defer close(consumerDone) + for { + select { + case <-states: + case <-d.eventsDone: + return + } + } + }) + d.runtimeWorkers.Go(func() { d.runCoreWithQuit(func() { quits.Add(1) }) }) + awaitDesktop(t, started, "core was not started") + go func() { d.cleanupCore(); close(cleaned) }() + awaitDesktop(t, cleaned, "Run failure cleanup raced shutdown and left a blocked worker") + awaitDesktop(t, consumerDone, "cleanup did not stop event consumers") + if quits.Load() != 0 || !d.closing.Load() { + t.Fatalf("late GUI quits=%d, closing=%v", quits.Load(), d.closing.Load()) + } +} + +func TestRunFailureBeforeApplicationStartedStillStopsCore(t *testing.T) { + var starts, stops atomic.Int32 + requested := make(chan struct{}) + d := New(Options{ + Logger: zap.NewNop().Sugar(), + Start: func() { starts.Add(1) }, + Wait: func() { <-requested }, + RequestStop: func() { close(requested) }, + Stop: func() error { stops.Add(1); return nil }, + }) + d.cleanupCore() + if starts.Load() != 0 || stops.Load() != 1 || !d.closing.Load() { + t.Fatalf("early failure started=%d, stopped=%d, closing=%v", starts.Load(), stops.Load(), d.closing.Load()) + } + select { + case <-d.eventsDone: + default: + t.Fatal("early failure did not close event lifetime") + } +} diff --git a/internal/desktop/settings_service.go b/internal/desktop/settings_service.go new file mode 100644 index 00000000..dcc301ed --- /dev/null +++ b/internal/desktop/settings_service.go @@ -0,0 +1,270 @@ +//go:build !headless + +package desktop + +import ( + "encoding/base64" + "errors" + "maps" + "runtime" + "sort" + "strings" + "sync" + + "github.com/nik9play/deej/internal/audio" + "github.com/nik9play/deej/internal/config" + "github.com/nik9play/deej/internal/serial" + + "github.com/nik9play/deej/internal/platform" +) + +// SettingsService exposes configuration APIs to the settings GUI frontend +type SettingsService struct { + d *Desktop + + iconMu sync.Mutex + iconCache map[string]string +} + +func newSettingsService(d *Desktop) *SettingsService { + return &SettingsService{d: d, iconCache: make(map[string]string)} +} + +// GetSettings returns the current contents of the user config file +func (s *SettingsService) GetSettings() config.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 config.Settings) error { + return s.d.config.SaveUserSettings(settings) +} + +// SetActiveProfile switches to the named profile. It's a targeted write, so a +// switch can't clobber a hand edit that landed since the window was opened +func (s *SettingsService) SetActiveProfile(name string) error { + return s.d.config.SetActiveProfile(name) +} + +// 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.Path(), + ResolvedLanguage: s.d.resolvedLanguage, + Platform: runtime.GOOS, + SpecialTargets: []string{ + audio.MasterSessionName, + audio.SystemSessionName, + audio.InputSessionName, + "deej.current", + "deej.current.fullscreen", + "deej.unmapped", + }, + AutostartAvailable: runtime.GOOS == "windows", + } +} + +// GetAutostart reports whether deej is set to run at system startup +func (s *SettingsService) GetAutostart() bool { + return platform.GetAutostartState() +} + +// SetAutostart enables or disables running deej at system startup, applying +// the change immediately +func (s *SettingsService) SetAutostart(state bool) error { + return platform.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 sessionDTOs(s.d.sessions.SessionInfos()) +} + +// GetProcesses returns the deduplicated, sorted executable names of all +// running processes +func (s *SettingsService) GetProcesses() ([]string, error) { + pidsByName, err := platform.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 := platform.GetProcessImagePath(uint32(pid)) + if err != nil { + continue + } + if _, tried := triedPaths[path]; tried { + continue + } + triedPaths[path] = struct{}{} + + if pngBytes, err := platform.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 !platform.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 := platform.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() +} + +// GetDiscordStatus returns the Discord integration's live state +func (s *SettingsService) GetDiscordStatus() DiscordStatusDTO { + if s.d.discord == nil { + return DiscordStatusDTO{} + } + + status := s.d.discord.Status() + + return DiscordStatusDTO{Linked: status.Linked, Connected: status.Connected, Channel: status.Channel} +} + +// GetDiscordUsers returns the members of the voice channel the user is +// currently in, for slider mapping suggestions. The roster is kept current by +// RPC events, so this serves it as-is; the GUI is told to re-read it by the +// deej:discord event rather than by polling +func (s *SettingsService) GetDiscordUsers() ([]DiscordUserDTO, error) { + if s.d.discord == nil { + return nil, errors.New("discord integration is not initialized") + } + + users := s.d.discord.VoiceUsers() + result := make([]DiscordUserDTO, 0, len(users)) + for _, u := range users { + result = append(result, DiscordUserDTO{ID: u.ID, Name: u.Name}) + } + return result, nil +} + +// LinkDiscord runs the interactive authorization. Discord shows the user a +// consent dialog, so this blocks until they answer it (or time out) +func (s *SettingsService) LinkDiscord() error { + if s.d.discord == nil { + return errors.New("discord integration is not initialized") + } + + return s.d.discord.Link() +} + +// UnlinkDiscord forgets the stored authorization +func (s *SettingsService) UnlinkDiscord() error { + if s.d.discord == nil { + return errors.New("discord integration is not initialized") + } + + return s.d.discord.Unlink() +} + +// ListSerialPorts enumerates the serial ports available on this machine +func (s *SettingsService) ListSerialPorts() ([]SerialPortDTO, error) { + ports, err := serial.ListPorts() + 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/internal/desktop/settings_service_test.go b/internal/desktop/settings_service_test.go new file mode 100644 index 00000000..9892e5a0 --- /dev/null +++ b/internal/desktop/settings_service_test.go @@ -0,0 +1,112 @@ +//go:build windows && !headless + +package desktop + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "os" + "path/filepath" + "regexp" + "strconv" + "testing" + + "github.com/nik9play/deej/internal/config" + "github.com/wailsapp/wails/v3/pkg/application" + "go.uber.org/zap" +) + +// Exercise the generated numeric IDs through Wails' real JSON dispatcher. Merely +// compiling TS against regenerated model names does not validate Call.ByID. +func TestGeneratedSettingsBindings(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(path, []byte("slider_mapping:\n 0: master\nlanguage: en\n"), 0600); err != nil { + t.Fatal(err) + } + store, err := config.New(zap.NewNop().Sugar(), path, nil) + if err != nil { + t.Fatal(err) + } + if err = store.Load(); err != nil { + t.Fatal(err) + } + service := newSettingsService(&Desktop{config: store, version: "Version test", resolvedLanguage: "en"}) + // Bindings.Add uses the application's logger; no window or native event loop is needed. + application.New(application.Options{DisableDefaultSignalHandler: true, Logger: slog.New(slog.NewTextHandler(io.Discard, nil))}) + bindings := application.NewBindings(nil, nil) + if err = bindings.Add(application.NewService(service)); err != nil { + t.Fatal(err) + } + generated, err := os.ReadFile("../../frontend/bindings/github.com/nik9play/deej/internal/desktop/settingsservice.ts") + if err != nil { + t.Fatal(err) + } + ids := map[string]uint32{} + pattern := regexp.MustCompile(`(?s)export function (\w+)\([^\n]*\n\s*return \$Call.ByID\((\d+)`) + for _, match := range pattern.FindAllSubmatch(generated, -1) { + id, parseErr := strconv.ParseUint(string(match[2]), 10, 32) + if parseErr != nil { + t.Fatal(parseErr) + } + method := bindings.GetByID(uint32(id)) + if method == nil || method.Name != string(match[1]) { + t.Fatalf("stale generated binding %s: %d", match[1], id) + } + ids[string(match[1])] = uint32(id) + } + if len(ids) < 10 { + t.Fatalf("expected all service bindings, got %d", len(ids)) + } + call := func(name string, args ...json.RawMessage) (any, error) { + t.Helper() + method := bindings.GetByID(ids[name]) + if method == nil { + t.Fatalf("missing %s binding", name) + } + return method.Call(context.Background(), args) + } + info, err := call("GetAppInfo") + if err != nil { + t.Fatal(err) + } + if got := info.(AppInfoDTO); got.ConfigPath != path || got.Version != "Version test" || got.ResolvedLanguage != "en" { + t.Fatalf("app info: %+v", got) + } + result, err := call("GetSettings") + if err != nil { + t.Fatal(err) + } + settings := result.(config.Settings) + settings.Profiles[0].Name = "saved" + settings.ActiveProfile = "saved" + payload, err := json.Marshal(settings) + if err != nil { + t.Fatal(err) + } + if _, err = call("SaveSettings", payload); err != nil { + t.Fatal(err) + } + if err = store.Load(); err != nil { + t.Fatal(err) + } + result, err = call("GetSettings") + if err != nil { + t.Fatal(err) + } + if got := result.(config.Settings); got.ActiveProfile != "saved" || got.Profiles[0].Name != "saved" { + t.Fatalf("save did not persist: %+v", got) + } + // A rejected save can be corrected and retried through the same bridge. + settings.COM.BaudRate = -1 + payload, _ = json.Marshal(settings) + if _, err = call("SaveSettings", payload); err == nil { + t.Fatal("invalid settings accepted") + } + settings.COM.BaudRate = 9600 + payload, _ = json.Marshal(settings) + if _, err = call("SaveSettings", payload); err != nil { + t.Fatal(err) + } +} diff --git a/internal/desktop/tray.go b/internal/desktop/tray.go new file mode 100644 index 00000000..58ad6709 --- /dev/null +++ b/internal/desktop/tray.go @@ -0,0 +1,374 @@ +//go:build !headless + +package desktop + +import ( + "io/fs" + "log/slog" + "runtime" + + "strconv" + "strings" + + "github.com/nicksnyder/go-i18n/v2/i18n" + "github.com/wailsapp/wails/v3/pkg/application" + + "github.com/nik9play/deej/frontend" + "github.com/nik9play/deej/internal/icon" + "github.com/nik9play/deej/internal/platform" +) + +const settingsWindowName = "deej-settings" + +func getConfigItemText(d *Desktop) (string, string) { + configTitle := d.localizer.MustLocalize(&i18n.LocalizeConfig{ + DefaultMessage: &i18n.Message{ + ID: "EditConfigTitle", + Other: "Edit configuration", + }, + }) + configDescription := d.localizer.MustLocalize(&i18n.LocalizeConfig{ + DefaultMessage: &i18n.Message{ + ID: "EditConfigDescription", + Other: "Open config file with notepad", + }, + }) + + return configTitle, configDescription +} + +func getSettingsItemText(d *Desktop) (string, string) { + configTitle := d.localizer.MustLocalize(&i18n.LocalizeConfig{ + DefaultMessage: &i18n.Message{ + ID: "SettingsTitle", + Other: "Settings", + }, + }) + configDescription := d.localizer.MustLocalize(&i18n.LocalizeConfig{ + DefaultMessage: &i18n.Message{ + ID: "SettingsDescription", + Other: "Settings", + }, + }) + + return configTitle, configDescription +} + +func getOpenSettingsItemText(d *Desktop) (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 *Desktop) (string, string) { + configTitle := d.localizer.MustLocalize(&i18n.LocalizeConfig{ + DefaultMessage: &i18n.Message{ + ID: "AutostartTitle", + Other: "Run at startup", + }, + }) + configDescription := d.localizer.MustLocalize(&i18n.LocalizeConfig{ + DefaultMessage: &i18n.Message{ + ID: "AutostartDescription", + Other: "deej will launch at startup", + }, + }) + + return configTitle, configDescription +} + +func getProfilesItemText(d *Desktop) (string, string) { + profilesTitle := d.localizer.MustLocalize(&i18n.LocalizeConfig{ + DefaultMessage: &i18n.Message{ + ID: "ProfilesTitle", + Other: "Profiles", + }, + }) + profilesDescription := d.localizer.MustLocalize(&i18n.LocalizeConfig{ + DefaultMessage: &i18n.Message{ + ID: "ProfilesDescription", + Other: "Switch the active slider mapping", + }, + }) + + return profilesTitle, profilesDescription +} + +func getProfileSwitchedText(d *Desktop, name string) (string, string) { + switchedTitle := d.localizer.MustLocalize(&i18n.LocalizeConfig{ + DefaultMessage: &i18n.Message{ + ID: "ProfileSwitchedTitle", + Other: "Profile switched", + }, + }) + switchedDescription := d.localizer.MustLocalize(&i18n.LocalizeConfig{ + DefaultMessage: &i18n.Message{ + ID: "ProfileSwitchedDescription", + Other: "{{.Profile}} is now active.", + }, + TemplateData: map[string]string{ + "Profile": name, + }, + }) + + return switchedTitle, switchedDescription +} + +func getQuitItemText(d *Desktop) (string, string) { + quitTitle := d.localizer.MustLocalize(&i18n.LocalizeConfig{ + DefaultMessage: &i18n.Message{ + ID: "QuitTitle", + Other: "Quit", + }, + }) + quitDescription := d.localizer.MustLocalize(&i18n.LocalizeConfig{ + DefaultMessage: &i18n.Message{ + ID: "QuitDescription", + Other: "Stop deej and quit", + }, + }) + + return quitTitle, quitDescription +} + +func getStatusItemTitle(d *Desktop) string { + var title string + + if d.serial.GetState() { + title = d.localizer.MustLocalize(&i18n.LocalizeConfig{ + DefaultMessage: &i18n.Message{ + ID: "StatusTrueTitle", + Other: "Connected to {{.ComPort}}", + }, + TemplateData: map[string]string{ + "ComPort": d.serial.CurrentComPort(), + }, + }) + } else { + title = d.localizer.MustLocalize(&i18n.LocalizeConfig{ + DefaultMessage: &i18n.Message{ + ID: "StatusFalseTitle", + Other: "Waiting for device...", + }, + }) + } + + return title +} + +func getValuesString(d *Desktop) string { + values := d.serial.CurrentSliderValues() + strs := make([]string, len(values)) + for i, num := range values { + strs[i] = strconv.FormatFloat((float64(num)/1023.0)*100, 'f', 0, 32) + } + return strings.Join(strs, " | ") +} + +func getSessionsCountString(d *Desktop) string { + count := d.sessions.SessionCount() + return d.localizer.MustLocalize(&i18n.LocalizeConfig{ + DefaultMessage: &i18n.Message{ + ID: "AudioSessionsCount", + One: "{{.Count}} audio session", + Other: "{{.Count}} audio sessions", + }, + TemplateData: map[string]interface{}{ + "Count": count, + }, + PluralCount: count, + }) +} + +func (d *Desktop) Run() error { + logger := d.logger.Named("tray") + + d.shutdownDone = make(chan struct{}) + defer d.cleanupCore() + + 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)), + }, + ShouldQuit: func() bool { d.requestStop(); return d.allowQuit.Load() }, + PostShutdown: func() { close(d.shutdownDone) }, + LogLevel: slog.LevelError, + }) + d.app = app + + 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) + } + tray.SetTooltip(title) + } + + menu := app.NewMenu() + + settingsTitle, _ := getSettingsItemText(d) + settings := menu.AddSubmenu(settingsTitle) + + openSettingsTitle, _ := getOpenSettingsItemText(d) + settings.Add(openSettingsTitle).OnClick(func(*application.Context) { + logger.Info("Open settings menu item clicked, opening settings window") + + d.openSettingsWindow() + }) + + configTitle, _ := getConfigItemText(d) + settings.Add(configTitle).OnClick(func(*application.Context) { + logger.Info("Edit config menu item clicked, opening config for editing") + + if err := platform.OpenExternal(logger, d.config.Path()); err != nil { + logger.Warnw("Failed to open config file for editing", "error", err) + } + }) + + if runtime.GOOS != "linux" { + autostartTitle, _ := getAutostartItemText(d) + settings.AddCheckbox(autostartTitle, platform.GetAutostartState()).OnClick(func(ctx *application.Context) { + if err := platform.SetAutostartState(ctx.ClickedMenuItem().Checked()); err != nil { + logger.Warnw("Failed to set autostart state", "error", err) + } + }) + } + + profilesTitle, _ := getProfilesItemText(d) + profilesMenu := menu.AddSubmenu(profilesTitle) + + // the profile list follows the config, so the submenu is rebuilt from + // scratch on every reload. Menu mutations must run on the wails main thread + rebuildProfilesMenu := func() { + settings := d.config.Values() + + profilesMenu.Clear() + for _, profile := range settings.Profiles { + name := profile.Name + + // a tab right-aligns what follows it in a win32 menu, which is how + // native menus lay their shortcuts out. SetAccelerator would do the + // same, but it also reorders the modifiers and binds the shortcut a + // second time whenever a window has focus. On the linux tray the tab + // is just whitespace + label := name + if profile.Hotkey != "" { + label += "\t" + profile.Hotkey + } + + profilesMenu.AddRadio(label, name == settings.ActiveProfile).OnClick(func(*application.Context) { + logger.Infow("Profile menu item clicked, switching profile", "profile", name) + + if err := d.config.SetActiveProfile(name); err != nil { + logger.Warnw("Failed to switch profile", "profile", name, "error", err) + } + }) + } + + // the tray builds a native menu out of the item tree when it's handed + // one, so rebuilding the tree isn't enough - the menu has to be set + // again. SetMenu hops to the main thread itself, so this must not run + // on it (InvokeSync from the main thread would deadlock) + tray.SetMenu(menu) + } + rebuildProfilesMenu() + + d.syncProfileHotkeys() + + menu.AddSeparator() + + statusInfo := menu.Add(getStatusItemTitle(d)).SetEnabled(false) + + valuesInfo := menu.Add("...").SetEnabled(false).SetHidden(true) + + setValuesInfo := func() { + if d.serial.GetState() { + valuesInfo.SetLabel(getValuesString(d)) + valuesInfo.SetHidden(false) + } else { + valuesInfo.SetHidden(true) + } + } + + sessionsInfo := menu.Add(getSessionsCountString(d)).SetEnabled(false) + + if d.version != "" { + menu.Add(d.version).SetEnabled(false) + } + + menu.AddSeparator() + + quitTitle, _ := getQuitItemText(d) + menu.Add(quitTitle).OnClick(func(*application.Context) { + logger.Info("Quit menu item clicked, stopping") + + d.requestStop() + }) + + tray.SetMenu(menu) + + tray.OnDoubleClick(func() { + d.openSettingsWindow() + }) + + d.subscribeEvents(statusInfo, sessionsInfo, setTooltip, setValuesInfo, rebuildProfilesMenu) + + // start the tray icon + logger.Debug("Running in tray") + + return app.Run() +} + +// 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 *Desktop) openSettingsWindow() { + d.settingsLock.Lock() + defer d.settingsLock.Unlock() + + if win, ok := d.app.Window.GetByName(settingsWindowName); ok { + win.Restore() + win.Focus() + return + } + + settingsTitle, _ := getSettingsItemText(d) + + d.app.Window.NewWithOptions(application.WebviewWindowOptions{ + Name: settingsWindowName, + Title: "deej - " + settingsTitle, + Width: 880, + Height: 620, + MinWidth: 800, + MinHeight: 600, + Frameless: true, + URL: "/", + }) +} diff --git a/internal/i18n/catalog.go b/internal/i18n/catalog.go new file mode 100644 index 00000000..158d08ea --- /dev/null +++ b/internal/i18n/catalog.go @@ -0,0 +1,51 @@ +// Package i18n owns embedded Go translations and language resolution. +package i18n + +import ( + "embed" + "fmt" + "github.com/jeandeaual/go-locale" + "github.com/nicksnyder/go-i18n/v2/i18n" + "github.com/pelletier/go-toml/v2" + "go.uber.org/zap" + "golang.org/x/text/language" +) + +//go:embed lang/active.*.toml +var langFS embed.FS + +// Catalog resolves system and configured languages against embedded translations. +type Catalog struct { + bundle *i18n.Bundle + logger *zap.SugaredLogger +} + +// New loads the bundled translations. +func New(logger *zap.SugaredLogger) (*Catalog, error) { + bundle := i18n.NewBundle(language.English) + bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) + if _, err := bundle.LoadMessageFileFS(langFS, "lang/active.ru.toml"); err != nil { + return nil, fmt.Errorf("load message file: %w", err) + } + return &Catalog{bundle: bundle, logger: logger}, nil +} + +// SystemLocalizer provides initial feedback before configuration has loaded. +func (c *Catalog) SystemLocalizer() (*i18n.Localizer, error) { + localizer, _, err := c.Resolve("auto") + return localizer, err +} + +// Resolve selects the configured language or detects the system language. +// English messages supplied by callers provide the fallback. +func (c *Catalog) Resolve(lang string) (*i18n.Localizer, string, error) { + if lang == "auto" { + var err error + lang, err = locale.GetLanguage() + if err != nil { + return nil, "", fmt.Errorf("get system locale: %w", err) + } + } + c.logger.Infof("Selected language: %s", lang) + return i18n.NewLocalizer(c.bundle, lang, "en"), lang, nil +} diff --git a/pkg/deej/lang/active.en.toml b/internal/i18n/lang/active.en.toml similarity index 82% rename from pkg/deej/lang/active.en.toml rename to internal/i18n/lang/active.en.toml index c9a057f2..9e41192e 100644 --- a/pkg/deej/lang/active.en.toml +++ b/internal/i18n/lang/active.en.toml @@ -14,6 +14,12 @@ 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" +ProfileSwitchedDescription = "{{.Profile}} is now active." +ProfileSwitchedTitle = "Profile switched" +ProfilesDescription = "Switch the active slider mapping" +ProfilesTitle = "Profiles" QuitDescription = "Stop deej and quit" QuitTitle = "Quit" SettingsDescription = "Settings" diff --git a/pkg/deej/lang/active.ru.toml b/internal/i18n/lang/active.ru.toml similarity index 88% rename from pkg/deej/lang/active.ru.toml rename to internal/i18n/lang/active.ru.toml index da8373ab..448704e5 100644 --- a/pkg/deej/lang/active.ru.toml +++ b/internal/i18n/lang/active.ru.toml @@ -69,6 +69,24 @@ other = "Редактировать файл конфигурации" hash = "sha1-8139ad1d0afcd3f4a2d34b1cafb1c4a1e8a51825" other = "Редактировать конфигурацию" +[OpenSettingsDescription] +other = "Открыть окно настроек" + +[OpenSettingsTitle] +other = "Открыть настройки" + +[ProfileSwitchedDescription] +other = "Профиль {{.Profile}} активен." + +[ProfileSwitchedTitle] +other = "Профиль переключён" + +[ProfilesDescription] +other = "Переключить активную раскладку слайдеров" + +[ProfilesTitle] +other = "Профили" + [QuitDescription] hash = "sha1-2683afe6d5eb3d1a51548bd95d2ea0af240e381f" other = "Остановить deej и выйти" diff --git a/pkg/icon/assets/edit-config.ico b/internal/icon/assets/edit-config.ico similarity index 100% rename from pkg/icon/assets/edit-config.ico rename to internal/icon/assets/edit-config.ico diff --git a/pkg/icon/assets/logo.bmp b/internal/icon/assets/logo.bmp similarity index 100% rename from pkg/icon/assets/logo.bmp rename to internal/icon/assets/logo.bmp diff --git a/pkg/icon/assets/logo.ico b/internal/icon/assets/logo.ico similarity index 100% rename from pkg/icon/assets/logo.ico rename to internal/icon/assets/logo.ico diff --git a/pkg/icon/assets/logo.png b/internal/icon/assets/logo.png similarity index 100% rename from pkg/icon/assets/logo.png rename to internal/icon/assets/logo.png diff --git a/pkg/icon/assets/refresh-sessions.ico b/internal/icon/assets/refresh-sessions.ico similarity index 100% rename from pkg/icon/assets/refresh-sessions.ico rename to internal/icon/assets/refresh-sessions.ico diff --git a/pkg/icon/assets/tray-icon.ico b/internal/icon/assets/tray-icon.ico similarity index 100% rename from pkg/icon/assets/tray-icon.ico rename to internal/icon/assets/tray-icon.ico diff --git a/internal/icon/assets/tray-icon.png b/internal/icon/assets/tray-icon.png new file mode 100644 index 00000000..35bf7abd Binary files /dev/null and b/internal/icon/assets/tray-icon.png differ diff --git a/pkg/icon/icon.go b/internal/icon/icon.go similarity index 100% rename from pkg/icon/icon.go rename to internal/icon/icon.go diff --git a/pkg/icon/icon_linux.go b/internal/icon/icon_linux.go similarity index 100% rename from pkg/icon/icon_linux.go rename to internal/icon/icon_linux.go diff --git a/pkg/icon/icon_windows.go b/internal/icon/icon_windows.go similarity index 55% rename from pkg/icon/icon_windows.go rename to internal/icon/icon_windows.go index 48e526ef..a1bdf0c6 100644 --- a/pkg/icon/icon_windows.go +++ b/internal/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 +// 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.ico var TrayDeejLogo []byte diff --git a/internal/integrations/discord/auth.go b/internal/integrations/discord/auth.go new file mode 100644 index 00000000..a4912609 --- /dev/null +++ b/internal/integrations/discord/auth.go @@ -0,0 +1,211 @@ +package discord + +import ( + "encoding/json" + "errors" + "fmt" + "github.com/nik9play/deej/internal/atomicfile" + "github.com/nik9play/deej/internal/config" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" +) + +// Link runs the one interactive step of the integration: Discord shows the +// user a consent dialog, and approving it hands back an authorization code +// that deej exchanges for a token it can reuse from now on. This is deliberately +// not part of dial - a retry every few seconds would mean a popup every few +// seconds - so it lives behind the settings window's button +func (d *Client) Link() error { + cfg := d.settings.Values().Discord + + if cfg.ClientID == "" || cfg.ClientSecret == "" { + return errors.New("save a Discord client ID and secret first") + } + + conn, err := d.open(cfg) + if err != nil { + return err + } + defer func() { _ = conn.Close() }() + + data, err := conn.requestWithTimeout("AUTHORIZE", map[string]any{ + "client_id": cfg.ClientID, + "scopes": discordScopes, + }, discordAuthorizeTimeout) + if err != nil { + return fmt.Errorf("authorize: %w", err) + } + + var authorized struct { + Code string `json:"code"` + } + if err := json.Unmarshal(data, &authorized); err != nil || authorized.Code == "" { + return errors.New("discord returned no authorization code") + } + + token, err := exchangeToken(cfg, url.Values{ + "grant_type": {"authorization_code"}, + "code": {authorized.Code}, + "redirect_uri": {discordRedirectURI}, + }) + if err != nil { + return err + } + + if err := d.storeToken(token); err != nil { + return err + } + + d.logger.Info("Linked Discord account") + d.reconnector.Reconnect(errors.New("account linked")) + + return nil +} + +// Unlink forgets the stored token, which also drops the connection +func (d *Client) Unlink() error { + d.token.Store(nil) + + if err := os.Remove(d.tokenPath()); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("remove Discord token: %w", err) + } + + d.logger.Info("Unlinked Discord account") + d.reconnector.Reconnect(errors.New("account unlinked")) + + return nil +} + +// discordToken is the OAuth token deej persists between runs. It's kept beside +// the config file rather than inside it: a token written by the backend would +// otherwise race the settings window's whole-document saves, and every write +// would churn the config watcher +type discordToken struct { + ClientID string `json:"client_id"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresAt time.Time `json:"expires_at"` +} + +// expired reports whether the token needs refreshing, a minute early so one +// doesn't lapse midway through a request +func (t *discordToken) expired() bool { + return time.Now().After(t.ExpiresAt.Add(-time.Minute)) +} + +func (d *Client) tokenPath() string { + return d.tokenFile +} + +func (d *Client) loadToken() { + data, err := os.ReadFile(d.tokenPath()) + if err != nil { + return + } + + var token discordToken + if err := json.Unmarshal(data, &token); err != nil { + d.logger.Warnw("Ignoring malformed Discord token file", "path", d.tokenPath(), "error", err) + return + } + + if token.AccessToken == "" || token.ClientID != d.settings.Values().Discord.ClientID { + return + } + + d.token.Store(&token) + d.logger.Debug("Loaded stored Discord token") +} + +// storeToken persists the token next to the config file. writeFileAtomic +// creates at 0600 and the rename preserves it, which is exactly what a +// credential file needs +func (d *Client) storeToken(token *discordToken) error { + data, err := json.Marshal(token) + if err != nil { + return fmt.Errorf("marshal Discord token: %w", err) + } + + if err := atomicfile.Write(d.tokenPath(), data); err != nil { + return fmt.Errorf("write Discord token: %w", err) + } + + d.token.Store(token) + + return nil +} + +func (d *Client) ensureFreshToken(cfg config.DiscordSettings, token *discordToken) (*discordToken, error) { + if !token.expired() { + return token, nil + } + + if token.RefreshToken == "" { + return nil, errors.New("the stored Discord token expired and can't be refreshed, link again") + } + + d.logger.Debug("Discord token expired, refreshing") + + refreshed, err := exchangeToken(cfg, url.Values{ + "grant_type": {"refresh_token"}, + "refresh_token": {token.RefreshToken}, + }) + if err != nil { + return nil, fmt.Errorf("refresh Discord token: %w", err) + } + + if err := d.storeToken(refreshed); err != nil { + return nil, err + } + + return refreshed, nil +} + +// exchangeToken posts to Discord's OAuth token endpoint, for both the initial +// authorization code and later refreshes +func exchangeToken(cfg config.DiscordSettings, form url.Values) (*discordToken, error) { + form.Set("client_id", cfg.ClientID) + form.Set("client_secret", cfg.ClientSecret) + + resp, err := discordHTTPClient.PostForm(discordTokenURL, form) + if err != nil { + return nil, fmt.Errorf("token request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<16)) + if err != nil { + return nil, fmt.Errorf("read token response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + // the body carries Discord's own error description (and echoes nothing + // secret), which is far more useful in the settings window than a bare + // status code + return nil, fmt.Errorf("token request failed (%s): %s", resp.Status, strings.TrimSpace(string(body))) + } + + var parsed struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + } + if err := json.Unmarshal(body, &parsed); err != nil { + return nil, fmt.Errorf("parse token response: %w", err) + } + + if parsed.AccessToken == "" { + return nil, errors.New("the token response had no access token") + } + + return &discordToken{ + ClientID: cfg.ClientID, + AccessToken: parsed.AccessToken, + RefreshToken: parsed.RefreshToken, + ExpiresAt: time.Now().Add(time.Duration(parsed.ExpiresIn) * time.Second), + }, nil +} diff --git a/internal/integrations/discord/client.go b/internal/integrations/discord/client.go new file mode 100644 index 00000000..bd44ddc8 --- /dev/null +++ b/internal/integrations/discord/client.go @@ -0,0 +1,396 @@ +package discord + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "sync" + "sync/atomic" + "time" + + "github.com/nik9play/deej/internal/config" + "github.com/nik9play/deej/internal/reconnect" + "go.uber.org/zap" +) + +const ( + discordRetryDelay = 5 * time.Second + + // the roster is kept current by VOICE_STATE_* events, so this is only the + // safety net that repairs it if one is ever missed or dropped + discordReconcileInterval = 30 * time.Second + + // how many roster events may queue before they're dropped. Dropping is + // safe: the reconcile pass above repairs whatever was missed, and never + // blocking here is what keeps the read loop from deadlocking against the + // event handler's own requests + discordEventBufferSize = 64 + + // minimum spacing between volume commands. A slider sweep produces tens of + // events a second, which would trip Discord's RPC rate limit if each one + // became a request + discordFlushInterval = 100 * time.Millisecond + + // how long to wait for the reply to a single RPC command + discordCommandTimeout = 10 * time.Second + + // authorizing means clicking a button in the Discord client, so this one + // waits on a human rather than on software + discordAuthorizeTimeout = 2 * time.Minute + + discordTokenURL = "https://discord.com/api/oauth2/token" + + // deej never receives this redirect - the authorization code comes back + // over the IPC connection - but the token exchange still has to echo a URI + // the application has registered + discordRedirectURI = "http://localhost" + + // TokenFileName is the credential file kept beside the user configuration. + TokenFileName = "discord_token.json" +) + +// the scopes deej asks for: rpc to talk to the client at all, rpc.voice.read +// to see who's in the voice channel, rpc.voice.write to set volumes +var discordScopes = []string{"rpc", "rpc.voice.read", "rpc.voice.write"} + +// IPC frame opcodes +const ( + discordOpHandshake uint32 = 0 + discordOpFrame uint32 = 1 + discordOpClose uint32 = 2 + discordOpPing uint32 = 3 + discordOpPong uint32 = 4 +) + +// pending volume keys, also the throttle's map keys +const ( + discordKeyInput = "input" + discordKeyOutput = "output" + discordKeyUser = "user:" +) + +var discordHTTPClient = &http.Client{Timeout: 15 * time.Second} + +// Client controls volumes inside the Discord client over its local RPC +// connection: the volume of individual people in a voice channel, and the +// user's own input and output levels. Mapping the discord.exe process covers +// neither of those, which is the whole reason this exists +type Client struct { + settings SettingsProvider + tokenFile string + currentVolumes func() []TargetVolume + lifecycle sync.Mutex + wg sync.WaitGroup + logger *zap.SugaredLogger + + reconnector *reconnect.Reconnector[*discordConn] + + token atomic.Pointer[discordToken] + roster atomic.Pointer[discordRoster] + + // volumeLock guards the throttle's two maps: pending is what the slider + // handler wrote, lastSent is what actually reached Discord + volumeLock sync.Mutex + pending map[string]float32 + lastSent map[string]float32 + volumeGeneration uint64 + + // every roster write happens on the event loop, so the roster pointer + // above needs no lock of its own + events chan discordEvent + reconcile chan struct{} + + rosterChangeChannel chan struct{} + + stopChannel chan struct{} +} + +// discordEvent is one RPC event, moved off the read loop so the handler can +// issue its own requests without deadlocking against it +type discordEvent struct { + name string + data json.RawMessage +} + +func New(settings SettingsProvider, logger *zap.SugaredLogger, tokenPath string, currentVolumes func() []TargetVolume) *Client { + logger = logger.Named("discord") + + d := &Client{ + settings: settings, + tokenFile: tokenPath, + currentVolumes: currentVolumes, + logger: logger, + pending: map[string]float32{}, + lastSent: map[string]float32{}, + events: make(chan discordEvent, discordEventBufferSize), + reconcile: make(chan struct{}, 1), + rosterChangeChannel: make(chan struct{}, 1), + } + + d.reconnector = reconnect.New(reconnect.Options[*discordConn]{ + Logger: logger, + + // without a stored token the only way forward is the interactive + // authorization, which must not happen on a retry loop + Enabled: func() bool { + cfg := d.settings.Values().Discord + return cfg.Enabled && cfg.ClientID != "" && d.token.Load() != nil + }, + + Dial: d.dial, + Watch: d.watch, + Close: d.close, + OnUp: d.onUp, + OnDown: func(err error) { + d.logger.Warnw("Discord connection error, reconnecting...", "error", err) + }, + Backoff: func(int) time.Duration { return discordRetryDelay }, + }) + + logger.Debug("Created Discord client instance") + + return d +} + +func (d *Client) Start() { + d.lifecycle.Lock() + defer d.lifecycle.Unlock() + if d.stopChannel != nil { + return + } + d.stopChannel = make(chan struct{}) + d.logger.Info("Discord client starting") + + // NewClient is constructed before the initial config load. + d.loadToken() + d.setupOnConfigReload() + + d.reconnector.Start() + + // the event loop issues blocking requests of its own, so it doesn't share + // a goroutine with the volume flush + stop := d.stopChannel + d.wg.Go(func() { discordTickLoop(stop, discordFlushInterval, d.flush) }) + d.wg.Go(func() { d.eventLoop(stop) }) +} + +func (d *Client) Stop() { + d.lifecycle.Lock() + defer d.lifecycle.Unlock() + if d.stopChannel == nil { + return + } + close(d.stopChannel) + d.reconnector.Stop() + d.wg.Wait() + d.stopChannel = nil + d.logger.Info("Discord client stopped") +} + +func (d *Client) IsConnected() bool { + return d.reconnector.Connected() +} + +func discordTickLoop(stopChannel <-chan struct{}, interval time.Duration, job func()) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-stopChannel: + return + case <-ticker.C: + job() + } + } +} + +// Status reports what the settings window shows: whether an account has been +// linked, whether the connection is up, and the voice channel deej can see +func (d *Client) Status() Status { + status := Status{Linked: d.token.Load() != nil, Connected: d.reconnector.Connected()} + + if roster := d.roster.Load(); roster != nil { + status.Channel = roster.channel + } + + return status +} + +// dial establishes an authenticated connection using the stored token, +// without touching any client state - the reconnector decides whether the +// result is adopted +func (d *Client) dial() (*discordConn, error) { + cfg := d.settings.Values().Discord + + token := d.token.Load() + if token == nil { + return nil, errors.New("no linked Discord account") + } + + // refresh before opening anything, so an expired token doesn't cost a + // connection that's about to be rejected + token, err := d.ensureFreshToken(cfg, token) + if err != nil { + return nil, err + } + + conn, err := d.open(cfg) + if err != nil { + return nil, err + } + + if _, err := conn.request("AUTHENTICATE", map[string]any{"access_token": token.AccessToken}); err != nil { + _ = conn.Close() + return nil, fmt.Errorf("authenticate: %w", err) + } + + return conn, nil +} + +// open connects to the Discord client and completes the handshake, leaving a +// connection that can carry commands but hasn't authenticated yet +func (d *Client) open(cfg config.DiscordSettings) (*discordConn, error) { + rw, err := dialDiscordIPC() + if err != nil { + return nil, err + } + + conn := newDiscordConn(rw, cfg, d.queueEvent) + + handshake, err := json.Marshal(map[string]any{"v": 1, "client_id": cfg.ClientID}) + if err != nil { + _ = conn.Close() + return nil, err + } + + if err := conn.write(discordOpHandshake, handshake); err != nil { + _ = conn.Close() + return nil, fmt.Errorf("handshake: %w", err) + } + + select { + case <-conn.ready: + return conn, nil + + // the connection failed before it was ever adopted, so taking its error + // here doesn't strand a Watch + case <-conn.done: + _ = conn.Close() + return nil, fmt.Errorf("handshake: %w", conn.doneErr) + + case <-time.After(discordCommandTimeout): + _ = conn.Close() + return nil, errors.New("timed out waiting for the Discord handshake") + } +} + +// watch waits for this connection's read loop to finish, which happens either +// on a connection error or because Close was called +func (d *Client) watch(conn *discordConn, errChannel chan<- error) { + <-conn.done + + select { + case errChannel <- conn.doneErr: + default: + } +} + +func (d *Client) close(conn *discordConn) { + _ = conn.Close() + // The reconnector clears Current before Close. Reconcile on its own loop + // so a disconnect cannot race a roster update from an RPC event. + d.requestReconcile() + d.logger.Info("Disconnected from Discord") +} + +func (d *Client) onUp(conn *discordConn) { + // re-check the snapshot now that the connection is adopted + if d.settings.Values().Discord != conn.cfg { + d.logger.Debug("Discord config changed while connecting, triggering reconnect") + d.reconnector.Reconnect(errors.New("config changed during dial")) + return + } + + // a fresh connection knows nothing about what deej last sent + d.invalidateVolumes() + + d.logger.Info("Connected to Discord") + + // the event loop subscribes and reads the initial roster; it must not run + // here, since this is the reconnector's goroutine + d.requestReconcile() +} + +func (d *Client) requestReconcile() { + select { + case d.reconcile <- struct{}{}: + default: + // one is already queued, which does the same work + } +} + +// setupOnConfigReload triggers a reconnect when the Discord credentials change +func (d *Client) setupOnConfigReload() { + configReloadedChannel := d.settings.SubscribeToChanges() + previous := d.settings.Values().Discord + + stop := d.stopChannel + d.wg.Go(func() { + for { + select { + case <-stop: + return + case <-configReloadedChannel: + } + current := d.settings.Values().Discord + + credentialsChanged := discordCredentialsChanged(previous, current) + if credentialsChanged { + d.token.Store(nil) + if err := os.Remove(d.tokenPath()); err != nil && !errors.Is(err, os.ErrNotExist) { + d.logger.Warnw("Failed to remove Discord token after credentials changed", "error", err) + } + } + previous = current + + conn, ok := d.reconnector.Current() + if !ok { + if credentialsChanged { + d.reconnector.Reconnect(errors.New("discord credentials changed")) + } + continue + } + + if current != conn.cfg { + d.logger.Debug("Discord config changed, triggering reconnect") + d.reconnector.Reconnect(errors.New("config changed")) + } + } + }) +} + +func discordCredentialsChanged(before config.DiscordSettings, after config.DiscordSettings) bool { + return before.ClientID != after.ClientID || before.ClientSecret != after.ClientSecret +} + +// SettingsProvider supplies live immutable settings and coalesced reload signals. +type SettingsProvider interface { + Values() *config.Settings + SubscribeToChanges() chan bool +} + +// User is a participant in the current Discord voice channel. +type User struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// Status describes account authorization, connection and the selected voice channel. +type Status struct { + Linked bool `json:"linked"` + Connected bool `json:"connected"` + Channel string `json:"channel"` +} diff --git a/internal/integrations/discord/client_test.go b/internal/integrations/discord/client_test.go new file mode 100644 index 00000000..21724dfc --- /dev/null +++ b/internal/integrations/discord/client_test.go @@ -0,0 +1,366 @@ +package discord + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "math" + "path/filepath" + "slices" + "testing" + "time" + + "github.com/nik9play/deej/internal/config" + "go.uber.org/zap" +) + +func newTestClient() *Client { + return &Client{ + pending: map[string]float32{}, + lastSent: map[string]float32{}, + } +} + +type testSettings struct { + values *config.Settings + changes chan bool +} + +func (p *testSettings) Values() *config.Settings { return p.values } +func (p *testSettings) SubscribeToChanges() chan bool { return p.changes } +func TestNewClientBeforeConfigLoad(t *testing.T) { + client := New(&testSettings{}, zap.NewNop().Sugar(), filepath.Join(t.TempDir(), TokenFileName), nil) + if client == nil { + t.Fatal("expected Discord client") + } +} + +func TestWriteDiscordFrame(t *testing.T) { + var buf bytes.Buffer + + body := []byte(`{"cmd":"AUTHENTICATE","nonce":"abc"}`) + if err := writeDiscordFrame(&buf, discordOpFrame, body); err != nil { + t.Fatalf("write frame: %v", err) + } + + // Opcode 1 and a 36-byte body, both encoded as little-endian uint32s. + want := append([]byte{1, 0, 0, 0, 36, 0, 0, 0}, body...) + if got := buf.Bytes(); !bytes.Equal(got, want) { + t.Errorf("frame = %x, want %x", got, want) + } +} + +// the transport is a stream, so a frame can arrive in pieces +func TestDiscordFrameReadsAcrossChunks(t *testing.T) { + body := bytes.Repeat([]byte("x"), 5000) + // A wire fixture independent of writeDiscordFrame: opcode 1, length 0x1388. + wire := append([]byte{1, 0, 0, 0, 0x88, 0x13, 0, 0}, body...) + + // hand the reader one byte at a time + opcode, got, err := readDiscordFrame(iotest(wire)) + if err != nil { + t.Fatalf("read frame: %v", err) + } + + if opcode != 1 { + t.Errorf("opcode = %d, want 1", opcode) + } + if !bytes.Equal(got, body) { + t.Errorf("body length = %d, want %d", len(got), len(body)) + } +} + +// oneByteReader hands out a single byte per Read, the worst case io.ReadFull +// has to cope with +type oneByteReader struct { + data []byte +} + +func iotest(data []byte) io.Reader { + return &oneByteReader{data: data} +} + +func (r *oneByteReader) Read(p []byte) (int, error) { + if len(r.data) == 0 { + return 0, io.EOF + } + if len(p) == 0 { + return 0, nil + } + + p[0] = r.data[0] + r.data = r.data[1:] + + return 1, nil +} + +func TestDiscordFrameRejectsOversizedLength(t *testing.T) { + // A length of 1 MiB + 1 must fail before trying to read the absent body. + header := []byte{1, 0, 0, 0, 1, 0, 0x10, 0} + const wantError = "frame too large: 1048577 bytes" + if _, _, err := readDiscordFrame(bytes.NewReader(header)); err == nil || err.Error() != wantError { + t.Fatalf("oversized frame error = %v, want %s", err, wantError) + } +} + +func TestDiscordVolume(t *testing.T) { + cases := []struct { + percent float32 + want float64 + }{ + {0, 0}, + {0.5, 50}, + {1, 100}, + {0.335, 34}, // rounded, not truncated + {-1, 0}, // clamped + {2, 100}, + } + + for _, testCase := range cases { + if got := discordVolume(testCase.percent); got != testCase.want { + t.Errorf("discordVolume(%v) = %v, want %v", testCase.percent, got, testCase.want) + } + } +} + +func TestDiscordUserVolume(t *testing.T) { + cases := []struct { + percent float32 + convert bool + want float64 + }{ + {0, true, 0}, + {0.01, true, 0.0001}, + {0.02, true, 0.0008}, + {0.5, true, 12.5}, + {1, true, 100}, + {0.5, false, 50}, + {-1, false, 0}, // clamped + {2, false, 100}, + } + + for _, testCase := range cases { + if got := discordUserVolume(testCase.percent, testCase.convert); math.Abs(got-testCase.want) > 1e-9 { + t.Errorf("discordUserVolume(%v, %v) = %v, want %v", testCase.percent, testCase.convert, got, testCase.want) + } + } +} + +func TestDiscordReplyDataUnwrapsError(t *testing.T) { + data, err := discordReplyData(discordFrame{Data: json.RawMessage(`{"ok":true}`)}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(data) != `{"ok":true}` { + t.Errorf("data = %s", data) + } + + _, err = discordReplyData(discordFrame{ + Evt: "ERROR", + Data: json.RawMessage(`{"code":4006,"message":"Not authenticated"}`), + }) + if err == nil { + t.Fatal("expected an error for an ERROR frame") + } + if got := err.Error(); got != "discord rpc error 4006: Not authenticated" { + t.Errorf("error = %q", got) + } +} + +func TestDiscordConnectionFailureWakesEveryRequest(t *testing.T) { + conn := &discordConn{ + rw: &testDiscordTransport{}, + pending: map[string]chan discordFrame{}, + done: make(chan struct{}), + } + + results := make(chan error, 2) + for range 2 { + go func() { + _, err := conn.requestWithTimeout("TEST", nil, 5*time.Second) + results <- err + }() + } + + deadline := time.Now().Add(time.Second) + for { + conn.pendingLock.Lock() + pending := len(conn.pending) + conn.pendingLock.Unlock() + if pending == 2 { + break + } + if time.Now().After(deadline) { + t.Fatal("requests did not become pending") + } + time.Sleep(time.Millisecond) + } + + want := errors.New("connection lost") + conn.fail(want) + for range 2 { + select { + case err := <-results: + if !errors.Is(err, want) { + t.Errorf("request error = %v, want %v", err, want) + } + case <-time.After(time.Second): + t.Fatal("request remained blocked after connection failure") + } + } +} + +func TestDiscordVoiceStateSubscriptionFailureIsReported(t *testing.T) { + done := make(chan struct{}) + close(done) + + conn := &discordConn{ + rw: &testDiscordTransport{}, + pending: map[string]chan discordFrame{}, + done: done, + doneErr: errors.New("connection lost"), + } + client := newTestClient() + client.logger = zap.NewNop().Sugar() + + if client.resubscribeVoiceStates(conn, "", "channel") { + t.Fatal("failed subscriptions must not be reported as successful") + } +} + +type testDiscordTransport struct { + bytes.Buffer +} + +func (*testDiscordTransport) Close() error { return nil } + +func TestDiscordThrottleSkipsUnchangedValues(t *testing.T) { + client := newTestClient() + + client.pending[discordKeyOutput] = 0.5 + client.markSent(client.takePending()) + + // the same value again is a no-op... + client.pending[discordKeyOutput] = 0.5 + if todo := client.takePending(); len(todo) != 0 { + t.Errorf("unchanged value should not be resent, got %v", todo) + } + + // ...but a different one still goes out + client.pending[discordKeyOutput] = 0.6 + if todo := client.takePending(); todo[discordKeyOutput] != 0.6 { + t.Errorf("changed value should be sent, got %v", todo) + } +} + +// the roster is maintained incrementally from VOICE_STATE_* events, so the +// folding has to add, update, remove and keep the list sorted +func TestDiscordApplyVoiceStateEvent(t *testing.T) { + client := newTestClient() + client.roster.Store(&discordRoster{id: "c1", channel: "General"}) + + names := func() []string { + roster := client.roster.Load() + out := make([]string, 0, len(roster.users)) + for _, user := range roster.users { + out = append(out, user.Name+"="+user.ID) + } + return out + } + + apply := func(name string, payload string) { + client.applyVoiceStateEvent(discordEvent{name: name, data: json.RawMessage(payload)}) + } + + apply("VOICE_STATE_CREATE", `{"user":{"id":"2","username":"zoe"}}`) + apply("VOICE_STATE_CREATE", `{"user":{"id":"1","username":"adam"}}`) + + if got := names(); !slices.Equal(got, []string{"adam=1", "zoe=2"}) { + t.Fatalf("after two joins: %v, want them sorted by name", got) + } + + // a nickname wins over the global name, and an update replaces in place + apply("VOICE_STATE_UPDATE", `{"nick":"Bob","user":{"id":"2","username":"zoe","global_name":"Zoe"}}`) + + if got := names(); !slices.Equal(got, []string{"adam=1", "Bob=2"}) { + t.Fatalf("after a rename: %v", got) + } + + apply("VOICE_STATE_DELETE", `{"user":{"id":"1","username":"adam"}}`) + + if got := names(); !slices.Equal(got, []string{"Bob=2"}) { + t.Fatalf("after a leave: %v", got) + } + + // the channel identity survives the folding + if roster := client.roster.Load(); roster.id != "c1" || roster.channel != "General" { + t.Errorf("channel lost: %+v", roster) + } + + // events for someone already gone, and malformed ones, change nothing + apply("VOICE_STATE_DELETE", `{"user":{"id":"404","username":"ghost"}}`) + apply("VOICE_STATE_CREATE", `not json`) + apply("VOICE_STATE_CREATE", `{"user":{}}`) + + if got := names(); !slices.Equal(got, []string{"Bob=2"}) { + t.Errorf("roster should be unchanged: %v", got) + } +} + +// an event arriving before the first reconcile has no roster to fold into, +// and must not invent one out of a single member +func TestDiscordVoiceStateEventWithoutRoster(t *testing.T) { + client := newTestClient() + + client.applyVoiceStateEvent(discordEvent{ + name: "VOICE_STATE_CREATE", + data: json.RawMessage(`{"user":{"id":"1","username":"adam"}}`), + }) + + if roster := client.roster.Load(); roster != nil { + t.Errorf("expected no roster, got %+v", roster) + } +} + +func TestDiscordResolveUser(t *testing.T) { + client := newTestClient() + client.roster.Store(&discordRoster{ + channel: "General", + users: []User{{ID: "123", Name: "Nikita"}}, + }) + + // case-insensitive display name match + if id, ok := client.resolveUser("nikita"); !ok || id != "123" { + t.Errorf("resolveUser(nikita) = %q, %v; want 123, true", id, ok) + } + + // an all-digit target is a user id, and works with nobody in the roster + if id, ok := client.resolveUser("987654321"); !ok || id != "987654321" { + t.Errorf("resolveUser(987654321) = %q, %v; want it passed through", id, ok) + } + + if _, ok := client.resolveUser("someone else"); ok { + t.Error("expected a miss for a user who isn't in the channel") + } +} + +func TestDiscordCredentialsChanged(t *testing.T) { + base := config.DiscordSettings{Enabled: true, ClientID: "123", ClientSecret: "secret"} + + if discordCredentialsChanged(base, base) { + t.Fatal("unchanged credentials should keep the linked account") + } + + changedID := base + changedID.ClientID = "456" + if !discordCredentialsChanged(base, changedID) { + t.Fatal("changing the client ID should invalidate the linked account") + } + + changedSecret := base + changedSecret.ClientSecret = "new-secret" + if !discordCredentialsChanged(base, changedSecret) { + t.Fatal("changing the client secret should invalidate the linked account") + } +} diff --git a/internal/integrations/discord/ipc_other.go b/internal/integrations/discord/ipc_other.go new file mode 100644 index 00000000..6eeff506 --- /dev/null +++ b/internal/integrations/discord/ipc_other.go @@ -0,0 +1,31 @@ +//go:build !windows + +package discord + +import ( + "cmp" + "io" + "net" + "os" + "path/filepath" +) + +func discordIPCPaths() []string { + root := cmp.Or(os.Getenv("XDG_RUNTIME_DIR"), os.Getenv("TMPDIR"), "/tmp") + + // flatpak and snap installs put their socket in a sandbox subdirectory + dirs := []string{"", "app/com.discordapp.Discord", "app/com.discordapp.DiscordCanary", "snap.discord"} + + paths := make([]string, 0, len(dirs)) + for _, dir := range dirs { + paths = append(paths, filepath.Join(root, dir, "discord-ipc-")) + } + + return paths +} + +// openDiscordIPC connects to one candidate socket. A unix socket supports +// concurrent reads and writes, which is what the read loop relies on +func openDiscordIPC(path string) (io.ReadWriteCloser, error) { + return net.Dial("unix", path) +} diff --git a/internal/integrations/discord/ipc_windows.go b/internal/integrations/discord/ipc_windows.go new file mode 100644 index 00000000..e725e8b6 --- /dev/null +++ b/internal/integrations/discord/ipc_windows.go @@ -0,0 +1,29 @@ +package discord + +import ( + "io" + "time" + + "github.com/Microsoft/go-winio" +) + +// how long to wait for the named pipe to accept a connection +const discordPipeDialTimeout = 2 * time.Second + +func discordIPCPaths() []string { + return []string{`\\.\pipe\discord-ipc-`} +} + +// openDiscordIPC connects to one candidate pipe. +// +// This deliberately does not use os.OpenFile: that opens the pipe as a +// synchronous handle, and Windows serializes I/O on one of those - a blocking +// ReadFile from the read loop makes a concurrent WriteFile on the same handle +// wait behind it, which deadlocks the moment deej sends a command while +// waiting for a reply. winio opens the pipe overlapped, which is what makes +// concurrent reads and writes safe +func openDiscordIPC(path string) (io.ReadWriteCloser, error) { + timeout := discordPipeDialTimeout + + return winio.DialPipe(path, &timeout) +} diff --git a/internal/integrations/discord/restoration_test.go b/internal/integrations/discord/restoration_test.go new file mode 100644 index 00000000..7195c0d6 --- /dev/null +++ b/internal/integrations/discord/restoration_test.go @@ -0,0 +1,195 @@ +package discord + +import ( + "encoding/json" + "maps" + "net" + "reflect" + "sync" + "testing" + "time" + + "github.com/nik9play/deej/internal/config" + "github.com/nik9play/deej/internal/reconnect" + "go.uber.org/zap" +) + +// The RPC peer exercises reconciliation and flushes through real framed +// requests, without requiring a running Discord client or private app fixtures. +func testRPCClient(t *testing.T, reply func(discordFrame) json.RawMessage) *Client { + t.Helper() + rw, peer := net.Pipe() + go func() { + defer func() { _ = peer.Close() }() + for { + _, body, err := readDiscordFrame(peer) + if err != nil { + return + } + var request discordFrame + if err := json.Unmarshal(body, &request); err != nil { + return + } + response := discordFrame{Cmd: request.Cmd, Nonce: request.Nonce, Data: reply(request)} + body, err = json.Marshal(response) + if err != nil || writeDiscordFrame(peer, discordOpFrame, body) != nil { + return + } + } + }() + logger := zap.NewNop().Sugar() + client := New(&testSettings{values: &config.Settings{}}, logger, "", nil) + conn := newDiscordConn(rw, config.DiscordSettings{}, client.queueEvent) + ready := make(chan struct{}) + client.reconnector = reconnect.New(reconnect.Options[*discordConn]{ + Logger: logger, + Dial: func() (*discordConn, error) { return conn, nil }, + Watch: client.watch, + Close: client.close, + OnUp: func(*discordConn) { close(ready) }, + }) + client.reconnector.Start() + t.Cleanup(client.reconnector.Stop) + select { + case <-ready: + case <-time.After(time.Second): + t.Fatal("RPC peer was not connected") + } + return client +} + +func TestDiscordThrottleCollapsesBurst(t *testing.T) { + requests := make(chan discordFrame, 8) + client := testRPCClient(t, func(request discordFrame) json.RawMessage { + requests <- request + return json.RawMessage(`{}`) + }) + for _, percent := range []float32{0.1, 0.2, 0.3, 0.4} { + client.SetVolume(InputTarget, percent) + } + if got := len(requests); got != 0 { + t.Fatalf("burst sent %d requests before flush", got) + } + client.flush() + if got := len(requests); got != 1 { + t.Fatalf("flush sent %d requests, want 1", got) + } + request := <-requests + wantArgs := map[string]any{"input": map[string]any{"volume": float64(40)}} + if request.Cmd != "SET_VOICE_SETTINGS" || !reflect.DeepEqual(request.Args, wantArgs) { + t.Errorf("request = %s %+v, want SET_VOICE_SETTINGS %+v", request.Cmd, request.Args, wantArgs) + } + if len(client.pending) != 0 { + t.Errorf("pending should be drained, got %d entries", len(client.pending)) + } +} + +func TestReconciliationRestoresVolumesWithoutSliderMovement(t *testing.T) { + var mu sync.Mutex + channel := json.RawMessage(`{"id":"one","name":"First","voice_states":[{"user":{"id":"1","username":"Nikita"}}]}`) + client := testRPCClient(t, func(request discordFrame) json.RawMessage { + if request.Cmd == "GET_SELECTED_VOICE_CHANNEL" { + mu.Lock() + defer mu.Unlock() + return channel + } + return json.RawMessage(`{}`) + }) + client.currentVolumes = func() []TargetVolume { + return []TargetVolume{{Target: "user:nikita", Percent: 0.25}} + } + var subscribedConn *discordConn + var subscribedChannel string + reconcile := func() { client.reconcileRoster(&subscribedConn, &subscribedChannel) } + assertRestored := func() { + t.Helper() + pending := client.takePending() + if !maps.Equal(pending, map[string]float32{"user:nikita": 0.25}) { + t.Fatalf("current volume was not restored: %v", pending) + } + client.markSent(pending) + } + reconcile() + assertRestored() + reconcile() + if pending := client.takePending(); len(pending) != 0 { + t.Fatalf("unchanged roster should retain deduplication: %v", pending) + } + mu.Lock() + channel = json.RawMessage(`{"id":"two","name":"Second","voice_states":[{"user":{"id":"1","username":"Nikita"}}]}`) + mu.Unlock() + reconcile() + assertRestored() + mu.Lock() + channel = json.RawMessage(`{"id":"two","name":"Second","voice_states":[]}`) + mu.Unlock() + reconcile() + client.takePending() + mu.Lock() + channel = json.RawMessage(`{"id":"two","name":"Second","voice_states":[{"user":{"id":"1","username":"Nikita"}}]}`) + mu.Unlock() + reconcile() + assertRestored() +} + +func TestVoiceJoinAndRejoinReplayCurrentCallbackValues(t *testing.T) { + client := newTestClient() + percent := float32(0.25) + client.currentVolumes = func() []TargetVolume { return []TargetVolume{{Target: "user:nikita", Percent: percent}} } + client.roster.Store(&discordRoster{id: "channel"}) + join := discordEvent{name: "VOICE_STATE_CREATE", data: json.RawMessage(`{"user":{"id":"1","username":"Nikita"}}`)} + client.applyVoiceStateEvent(join) + client.markSent(client.takePending()) + client.applyVoiceStateEvent(discordEvent{name: "VOICE_STATE_DELETE", data: join.data}) + percent = 0.75 + client.applyVoiceStateEvent(join) + if got := client.takePending(); !maps.Equal(got, map[string]float32{"user:nikita": percent}) { + t.Fatalf("rejoin did not replay the current slider value: %v", got) + } +} + +func TestOldFlushCannotSuppressRestoration(t *testing.T) { + requested, respond := make(chan struct{}), make(chan struct{}) + client := testRPCClient(t, func(request discordFrame) json.RawMessage { + if request.Cmd == "SET_VOICE_SETTINGS" { + close(requested) + <-respond + } + return json.RawMessage(`{}`) + }) + client.pending[InputTarget] = 0.5 + finished := make(chan struct{}) + go func() { client.flush(); close(finished) }() + select { + case <-requested: + case <-time.After(time.Second): + t.Fatal("volume request was not sent") + } + client.invalidateVolumes() + client.currentVolumes = func() []TargetVolume { return []TargetVolume{{Target: InputTarget, Percent: 0.5}} } + client.replayMappedSliderValues() + close(respond) + select { + case <-finished: + case <-time.After(time.Second): + t.Fatal("flush did not complete") + } + if got := client.takePending(); !maps.Equal(got, map[string]float32{InputTarget: 0.5}) { + t.Fatalf("old request suppressed the restored value: %v", got) + } +} + +func TestConcurrentStartStopWithoutAuthorization(t *testing.T) { + client := New(&testSettings{values: &config.Settings{}}, zap.NewNop().Sugar(), "", nil) + var wg sync.WaitGroup + for range 8 { + wg.Go(func() { client.Start(); client.Start(); client.Stop(); client.Stop() }) + } + finished := make(chan struct{}) + go func() { wg.Wait(); close(finished) }() + select { + case <-finished: + case <-time.After(2 * time.Second): + t.Fatal("concurrent lifecycle requests did not finish") + } +} diff --git a/internal/integrations/discord/roster.go b/internal/integrations/discord/roster.go new file mode 100644 index 00000000..5870401e --- /dev/null +++ b/internal/integrations/discord/roster.go @@ -0,0 +1,298 @@ +package discord + +import ( + "cmp" + "encoding/json" + "fmt" + "slices" + "strings" + "time" +) + +// SubscribeToRosterChange returns a channel that signals whenever the voice +// channel roster changes. Signals are coalesced - consumers should re-read +// VoiceUsers rather than count them +func (d *Client) SubscribeToRosterChange() <-chan struct{} { + return d.rosterChangeChannel +} + +func (d *Client) notifyRosterChange() { + select { + case d.rosterChangeChannel <- struct{}{}: + default: + // a signal is already pending, which says everything it needs to + } +} + +// VoiceUsers returns the members of the voice channel the user is currently +// in, for the settings window's target picker +func (d *Client) VoiceUsers() []User { + roster := d.roster.Load() + if roster == nil { + return nil + } + + return slices.Clone(roster.users) +} + +// discordRoster is a snapshot of the current voice channel +type discordRoster struct { + id string // channel id, the scope the voice state subscriptions use + channel string // channel name, for display + users []User +} + +// discordVoiceState is one member's entry, both as VOICE_STATE_* events carry +// it and as GET_SELECTED_VOICE_CHANNEL nests it under voice_states +type discordVoiceState struct { + Nick string `json:"nick"` + User struct { + ID string `json:"id"` + Username string `json:"username"` + GlobalName string `json:"global_name"` + } `json:"user"` +} + +func (s discordVoiceState) toUser() User { + // the per-server nickname is what the user sees in the client, so it's + // what they'd reach for when writing a mapping + return User{ + ID: s.User.ID, + Name: cmp.Or(s.Nick, s.User.GlobalName, s.User.Username), + } +} + +// queueEvent hands an RPC event to the event loop. It is called from a +// connection's read loop, so it drops rather than blocks when the buffer is +// full - the periodic reconcile repairs whatever gets dropped +func (d *Client) queueEvent(evt string, data json.RawMessage) { + select { + case d.events <- discordEvent{name: evt, data: data}: + default: + d.logger.Debugw("Discord event buffer full, dropping event", "event", evt) + } +} + +// eventLoop owns the roster. Every write to it happens here - from RPC events, +// from the periodic reconcile, and from a connection coming up - so the roster +// needs no lock beyond the atomic pointer readers use +func (d *Client) eventLoop(stopChannel <-chan struct{}) { + defer func() { + d.roster.Store(nil) + d.notifyRosterChange() + }() + ticker := time.NewTicker(discordReconcileInterval) + defer ticker.Stop() + + // the connection the current subscriptions belong to, and the channel + // those subscriptions are scoped to. A new connection carries neither, + // which is why the connection's identity is what's tracked here + var subscribedConn *discordConn + var subscribedChannel string + + for { + select { + case <-stopChannel: + return + + case <-d.reconcile: + d.reconcileRoster(&subscribedConn, &subscribedChannel) + + case <-ticker.C: + d.reconcileRoster(&subscribedConn, &subscribedChannel) + + case event := <-d.events: + // changing voice channel moves the subscriptions with it, which + // the reconcile pass handles along with reading the new roster + if event.name == "VOICE_CHANNEL_SELECT" { + d.reconcileRoster(&subscribedConn, &subscribedChannel) + continue + } + + d.applyVoiceStateEvent(event) + } + } +} + +// applyVoiceStateEvent folds one VOICE_STATE_* event into the roster +func (d *Client) applyVoiceStateEvent(event discordEvent) { + var state discordVoiceState + if err := json.Unmarshal(event.data, &state); err != nil || state.User.ID == "" { + return + } + + roster := d.roster.Load() + if roster == nil { + return + } + + user := state.toUser() + + users := slices.Clone(roster.users) + index := slices.IndexFunc(users, func(existing User) bool { return existing.ID == user.ID }) + joined := index < 0 && event.name != "VOICE_STATE_DELETE" + + switch event.name { + case "VOICE_STATE_DELETE": + if index < 0 { + return + } + users = slices.Delete(users, index, index+1) + + default: // CREATE and UPDATE both mean "this is the member's current state" + if index >= 0 { + if users[index] == user { + return + } + users[index] = user + } else { + users = append(users, user) + } + } + + sortDiscordUsers(users) + + d.roster.Store(&discordRoster{id: roster.id, channel: roster.channel, users: users}) + d.notifyRosterChange() + if joined { + // Discord may have discarded a user's override while they were away. + d.invalidateVolumes() + d.replayMappedSliderValues() + } +} + +// reconcileRoster re-reads the voice channel from scratch and re-points the +// subscriptions at it. It runs on connect, whenever the user changes voice +// channel, and periodically to repair anything a dropped event missed +func (d *Client) reconcileRoster(subscribedConn **discordConn, subscribedChannel *string) { + conn, ok := d.reconnector.Current() + if !ok { + *subscribedConn = nil + *subscribedChannel = "" + + if d.roster.Swap(nil) != nil { + d.notifyRosterChange() + } + + return + } + + // a new connection carries none of the old one's subscriptions + if conn != *subscribedConn { + *subscribedConn = conn + *subscribedChannel = "" + + if _, err := conn.requestEvent("SUBSCRIBE", "VOICE_CHANNEL_SELECT", nil); err != nil { + d.logger.Debugw("Failed to subscribe to Discord voice channel changes", "error", err) + } + } + + channel, err := d.readVoiceChannel(conn) + if err != nil { + d.logger.Debugw("Failed to read Discord voice channel", "error", err) + return + } + if current, connected := d.reconnector.Current(); !connected || current != conn { + return + } + + if channel.id != *subscribedChannel { + if d.resubscribeVoiceStates(conn, *subscribedChannel, channel.id) { + *subscribedChannel = channel.id + } + } + + previous := d.roster.Load() + // A changed channel, or a join recovered by reconciliation, may have lost + // Discord's per-user overrides even when the physical sliders did not move. + if rosterNeedsReplay(previous, channel) { + d.invalidateVolumes() + } + d.roster.Store(channel) + d.replayMappedSliderValues() + + if previous == nil || previous.channel != channel.channel || !slices.Equal(previous.users, channel.users) { + d.notifyRosterChange() + } +} + +func rosterNeedsReplay(previous, current *discordRoster) bool { + if previous == nil || previous.id != current.id { + return true + } + for _, user := range current.users { + if !slices.ContainsFunc(previous.users, func(old User) bool { return old.ID == user.ID }) { + return true + } + } + return false +} + +// readVoiceChannel fetches the full current voice channel state +func (d *Client) readVoiceChannel(conn *discordConn) (*discordRoster, error) { + data, err := conn.request("GET_SELECTED_VOICE_CHANNEL", nil) + if err != nil { + return nil, err + } + + // null when the user isn't in a voice channel + var channel *struct { + ID string `json:"id"` + Name string `json:"name"` + VoiceStates []discordVoiceState `json:"voice_states"` + } + + if err := json.Unmarshal(data, &channel); err != nil { + return nil, fmt.Errorf("parse voice channel: %w", err) + } + + if channel == nil { + return &discordRoster{}, nil + } + + roster := &discordRoster{id: channel.ID, channel: channel.Name} + for _, state := range channel.VoiceStates { + roster.users = append(roster.users, state.toUser()) + } + + sortDiscordUsers(roster.users) + + return roster, nil +} + +// resubscribeVoiceStates moves the per-channel event subscriptions from one +// voice channel to another; either id may be empty, meaning "not in a channel" +func (d *Client) resubscribeVoiceStates(conn *discordConn, from string, to string) bool { + events := []string{"VOICE_STATE_CREATE", "VOICE_STATE_UPDATE", "VOICE_STATE_DELETE"} + subscribed := true + + for _, name := range events { + if from != "" { + if _, err := conn.requestEvent("UNSUBSCRIBE", name, map[string]any{"channel_id": from}); err != nil { + d.logger.Debugw("Failed to unsubscribe from Discord voice states", "event", name, "error", err) + } + } + + if to != "" { + if _, err := conn.requestEvent("SUBSCRIBE", name, map[string]any{"channel_id": to}); err != nil { + d.logger.Debugw("Failed to subscribe to Discord voice states", "event", name, "error", err) + subscribed = false + } + } + } + + return subscribed +} + +func sortDiscordUsers(users []User) { + slices.SortFunc(users, func(a, b User) int { + // case-insensitively, or a plain byte compare would file every + // capitalised display name ahead of every lowercase one + if diff := cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)); diff != 0 { + return diff + } + + // discord display names are not unique, so keep the order stable + return cmp.Compare(a.ID, b.ID) + }) +} diff --git a/internal/integrations/discord/rpc.go b/internal/integrations/discord/rpc.go new file mode 100644 index 00000000..d09b8acb --- /dev/null +++ b/internal/integrations/discord/rpc.go @@ -0,0 +1,274 @@ +package discord + +import ( + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "github.com/google/uuid" + "github.com/nik9play/deej/internal/config" + "io" + "strings" + "sync" + "time" +) + +// discordConn is one Discord IPC connection generation, carrying the config +// snapshot it was opened with so config reloads can detect credential changes +type discordConn struct { + rw io.ReadWriteCloser + cfg config.DiscordSettings + + writeLock sync.Mutex + + // pending correlates replies to the callers waiting on them, by nonce + pendingLock sync.Mutex + pending map[string]chan discordFrame + + // onEvent receives subscribed RPC events. It is called from the read loop, + // so it must never block + onEvent func(evt string, data json.RawMessage) + + ready chan struct{} + readyOnce sync.Once + + // done is closed when the read loop exits, broadcasting the stored error + // to every caller waiting on this connection + done chan struct{} + doneOnce sync.Once + doneErr error +} + +func newDiscordConn(rw io.ReadWriteCloser, cfg config.DiscordSettings, onEvent func(evt string, data json.RawMessage)) *discordConn { + conn := &discordConn{ + rw: rw, + cfg: cfg, + onEvent: onEvent, + pending: map[string]chan discordFrame{}, + ready: make(chan struct{}), + done: make(chan struct{}), + } + + go conn.readLoop() + + return conn +} + +// Close releases the transport, which unblocks the read loop and in turn any +// Watch waiting on it +func (c *discordConn) Close() error { + return c.rw.Close() +} + +// discordFrame is the envelope every RPC command, reply and event shares +type discordFrame struct { + Cmd string `json:"cmd"` + Nonce string `json:"nonce,omitempty"` + Evt string `json:"evt,omitempty"` + Args any `json:"args,omitempty"` + Data json.RawMessage `json:"data,omitempty"` +} + +func (c *discordConn) request(cmd string, args any) (json.RawMessage, error) { + return c.roundTrip(discordFrame{Cmd: cmd, Args: args}, discordCommandTimeout) +} + +// requestEvent issues SUBSCRIBE and UNSUBSCRIBE, the two commands that name +// their event in evt rather than in args +func (c *discordConn) requestEvent(cmd string, evt string, args any) (json.RawMessage, error) { + return c.roundTrip(discordFrame{Cmd: cmd, Evt: evt, Args: args}, discordCommandTimeout) +} + +func (c *discordConn) requestWithTimeout(cmd string, args any, timeout time.Duration) (json.RawMessage, error) { + return c.roundTrip(discordFrame{Cmd: cmd, Args: args}, timeout) +} + +func (c *discordConn) roundTrip(frame discordFrame, timeout time.Duration) (json.RawMessage, error) { + cmd := frame.Cmd + nonce := uuid.NewString() + frame.Nonce = nonce + + body, err := json.Marshal(frame) + if err != nil { + return nil, fmt.Errorf("marshal %s: %w", cmd, err) + } + + // buffered, so the read loop never blocks handing over a reply nobody is + // waiting for anymore + replyChannel := make(chan discordFrame, 1) + + c.pendingLock.Lock() + c.pending[nonce] = replyChannel + c.pendingLock.Unlock() + + defer func() { + c.pendingLock.Lock() + delete(c.pending, nonce) + c.pendingLock.Unlock() + }() + + if err := c.write(discordOpFrame, body); err != nil { + return nil, err + } + + select { + case reply := <-replyChannel: + return discordReplyData(reply) + + case <-c.done: + return nil, c.doneErr + + case <-time.After(timeout): + return nil, fmt.Errorf("timed out waiting for a reply to %s", cmd) + } +} + +// discordReplyData unwraps a reply, turning an ERROR frame into a Go error +func discordReplyData(reply discordFrame) (json.RawMessage, error) { + if reply.Evt != "ERROR" { + return reply.Data, nil + } + + var rpcErr struct { + Code int `json:"code"` + Message string `json:"message"` + } + if err := json.Unmarshal(reply.Data, &rpcErr); err == nil && rpcErr.Message != "" { + return nil, fmt.Errorf("discord rpc error %d: %s", rpcErr.Code, rpcErr.Message) + } + + return nil, errors.New("discord rpc error") +} + +func (c *discordConn) write(opcode uint32, body []byte) error { + c.writeLock.Lock() + defer c.writeLock.Unlock() + + return writeDiscordFrame(c.rw, opcode, body) +} + +func (c *discordConn) fail(err error) { + c.doneOnce.Do(func() { + c.doneErr = err + close(c.done) + }) +} + +// readLoop is the connection's only reader: it hands replies to the callers +// waiting on their nonce and answers pings, until the transport fails or is +// closed +func (c *discordConn) readLoop() { + for { + opcode, body, err := readDiscordFrame(c.rw) + if err != nil { + c.fail(fmt.Errorf("read frame: %w", err)) + return + } + + switch opcode { + case discordOpPing: + // the client wants its own payload echoed back + if err := c.write(discordOpPong, body); err != nil { + c.fail(err) + return + } + continue + + case discordOpClose: + c.fail(fmt.Errorf("discord closed the connection: %s", strings.TrimSpace(string(body)))) + return + + case discordOpFrame: + + default: + continue + } + + var frame discordFrame + if err := json.Unmarshal(body, &frame); err != nil { + continue + } + + // the handshake is acknowledged with an event rather than a reply, so + // it can't be correlated by nonce like everything else + if frame.Cmd == "DISPATCH" && frame.Evt == "READY" { + c.readyOnce.Do(func() { close(c.ready) }) + continue + } + + if frame.Nonce == "" { + // a subscribed event. onEvent must not block: it's handing work to + // a goroutine that issues requests this very loop has to answer + if frame.Cmd == "DISPATCH" && frame.Evt != "" && c.onEvent != nil { + c.onEvent(frame.Evt, frame.Data) + } + + continue + } + + c.pendingLock.Lock() + replyChannel, waiting := c.pending[frame.Nonce] + delete(c.pending, frame.Nonce) + c.pendingLock.Unlock() + + if waiting { + replyChannel <- frame + } + } +} + +// discordMaxFrameSize caps what a single frame may claim, so a desynced stream +// can't make deej allocate wildly +const discordMaxFrameSize = 1 << 20 + +// writeDiscordFrame writes one IPC frame: a little-endian opcode and length, +// then the JSON body +func writeDiscordFrame(w io.Writer, opcode uint32, body []byte) error { + frame := make([]byte, 8, 8+len(body)) + binary.LittleEndian.PutUint32(frame[0:4], opcode) + binary.LittleEndian.PutUint32(frame[4:8], uint32(len(body))) + + if _, err := w.Write(append(frame, body...)); err != nil { + return fmt.Errorf("write frame: %w", err) + } + + return nil +} + +func readDiscordFrame(r io.Reader) (uint32, []byte, error) { + header := make([]byte, 8) + if _, err := io.ReadFull(r, header); err != nil { + return 0, nil, err + } + + opcode := binary.LittleEndian.Uint32(header[0:4]) + length := binary.LittleEndian.Uint32(header[4:8]) + + if length > discordMaxFrameSize { + return 0, nil, fmt.Errorf("frame too large: %d bytes", length) + } + + body := make([]byte, length) + if _, err := io.ReadFull(r, body); err != nil { + return 0, nil, err + } + + return opcode, body, nil +} + +// dialDiscordIPC opens the Discord client's local RPC transport. Discord takes +// the first free slot of ten, so all of them get probed +func dialDiscordIPC() (io.ReadWriteCloser, error) { + for _, base := range discordIPCPaths() { + for i := range 10 { + rw, err := openDiscordIPC(fmt.Sprintf("%s%d", base, i)) + if err != nil { + continue + } + + return rw, nil + } + } + + return nil, errors.New("no Discord IPC socket found (is Discord running?)") +} diff --git a/internal/integrations/discord/volume.go b/internal/integrations/discord/volume.go new file mode 100644 index 00000000..12703935 --- /dev/null +++ b/internal/integrations/discord/volume.go @@ -0,0 +1,222 @@ +package discord + +import ( + "fmt" + "maps" + "math" + "strings" +) + +// SetVolume queues a value for a Discord-local input, output, or user: target. +func (d *Client) SetVolume(target string, percent float32) { + if key, ok := localTargetKey(target); ok { + d.setVolume(key, percent) + } +} + +func localTargetKey(target string) (string, bool) { + target = strings.ToLower(target) + switch { + case target == InputTarget || target == OutputTarget: + return target, true + case strings.HasPrefix(target, UserTargetPrefix): + return UserTargetPrefix + strings.TrimSpace(strings.TrimPrefix(target, UserTargetPrefix)), true + default: + return "", false + } +} + +// SetInputVolume sets the user's own microphone level inside Discord +func (d *Client) SetInputVolume(percent float32) { + d.setVolume(discordKeyInput, percent) +} + +// SetOutputVolume sets Discord's own output level +func (d *Client) SetOutputVolume(percent float32) { + d.setVolume(discordKeyOutput, percent) +} + +// SetUserVolume sets the volume of one person in the current voice channel. +// The name is matched case-insensitively against the roster; an all-digit +// name is taken as a user ID +func (d *Client) SetUserVolume(name string, percent float32) { + d.setVolume(discordKeyUser+strings.ToLower(strings.TrimSpace(name)), percent) +} + +// Targets within Discord use local keys; config target parsing belongs to the mixer. +const ( + InputTarget = "input" + OutputTarget = "output" + UserTargetPrefix = "user:" +) + +// TargetVolume is a current physical slider value for a Discord-local target. +type TargetVolume struct { + Target string + Percent float32 +} + +// replayMappedSliderValues restores per-connection overrides after the roster is current. +func (d *Client) replayMappedSliderValues() { + if d.currentVolumes == nil { + return + } + values := d.currentVolumes() + d.volumeLock.Lock() + defer d.volumeLock.Unlock() + for _, value := range values { + if key, ok := localTargetKey(value.Target); ok { + d.pending[key] = value.Percent + } + } +} + +// setVolume records the latest value for a target, which the flush loop picks +// up. Values arriving while disconnected are dropped rather than queued: a +// slider position from ten minutes ago shouldn't be applied on reconnect +func (d *Client) setVolume(key string, percent float32) { + if !d.IsConnected() { + return + } + + d.volumeLock.Lock() + defer d.volumeLock.Unlock() + + d.pending[key] = percent +} + +// flush sends the values collected since the last tick, one request per +// target, skipping the ones Discord already has +func (d *Client) flush() { + conn, ok := d.reconnector.Current() + if !ok { + return + } + + // the requests below block, so they happen outside the lock + d.volumeLock.Lock() + generation := d.volumeGeneration + pending := d.takePendingLocked() + d.volumeLock.Unlock() + sent := map[string]float32{} + for key, percent := range pending { + if err := d.send(conn, key, percent); err != nil { + d.logger.Debugw("Failed to set Discord volume", "target", key, "error", err) + continue + } + + sent[key] = percent + } + + d.volumeLock.Lock() + defer d.volumeLock.Unlock() + if current, connected := d.reconnector.Current(); connected && current == conn && generation == d.volumeGeneration { + maps.Copy(d.lastSent, sent) + } +} + +func (d *Client) invalidateVolumes() { + d.volumeLock.Lock() + defer d.volumeLock.Unlock() + clear(d.lastSent) + d.volumeGeneration++ +} + +// takePending swaps out everything collected since the last flush, dropping +// the targets Discord already has at that value +func (d *Client) takePending() map[string]float32 { + d.volumeLock.Lock() + defer d.volumeLock.Unlock() + return d.takePendingLocked() +} + +func (d *Client) takePendingLocked() map[string]float32 { + todo := make(map[string]float32, len(d.pending)) + for key, percent := range d.pending { + if last, sent := d.lastSent[key]; !sent || last != percent { + todo[key] = percent + } + } + + clear(d.pending) + + return todo +} + +func (d *Client) markSent(sent map[string]float32) { + if len(sent) == 0 { + return + } + + d.volumeLock.Lock() + defer d.volumeLock.Unlock() + + maps.Copy(d.lastSent, sent) +} + +func (d *Client) send(conn *discordConn, key string, percent float32) error { + switch key { + case discordKeyInput: + _, err := conn.request("SET_VOICE_SETTINGS", map[string]any{"input": map[string]any{"volume": discordVolume(percent)}}) + return err + + case discordKeyOutput: + _, err := conn.request("SET_VOICE_SETTINGS", map[string]any{"output": map[string]any{"volume": discordVolume(percent)}}) + return err + } + + name := strings.TrimPrefix(key, discordKeyUser) + + userID, ok := d.resolveUser(name) + if !ok { + return fmt.Errorf("no user %q in the current voice channel", name) + } + + cfg := d.settings.Values().Discord + _, err := conn.request("SET_USER_VOICE_SETTINGS", map[string]any{ + "user_id": userID, + "volume": discordUserVolume(percent, cfg.VolumeConversion), + }) + + return err +} + +// discordVolume converts a 0.0-1.0 slider position to Discord's linear input +// and output voice-settings scale. +func discordVolume(percent float32) float64 { + return math.Round(min(max(float64(percent), 0), 1) * 100) +} + +// discordUserVolume maps a 0.0-1.0 slider position onto Discord's participant +// 0-100 participant-volume range. When conversion is enabled, the RPC value +// uses the inverse of Discord's cube-root display curve. +func discordUserVolume(percent float32, convert bool) float64 { + position := min(max(float64(percent), 0), 1) + if convert { + position = position * position * position + } + + return position * 100 +} + +// resolveUser maps a mapping target to a Discord user ID. An all-digit target +// is taken as an ID directly, so someone who renames (or whose display name +// deej can't see) can still be pinned in the config +func (d *Client) resolveUser(name string) (string, bool) { + if name != "" && strings.IndexFunc(name, func(r rune) bool { return r < '0' || r > '9' }) < 0 { + return name, true + } + + roster := d.roster.Load() + if roster == nil { + return "", false + } + + for _, user := range roster.users { + if strings.EqualFold(user.Name, name) { + return user.ID, true + } + } + + return "", false +} diff --git a/pkg/deej/obs.go b/internal/integrations/obs/client.go similarity index 53% rename from pkg/deej/obs.go rename to internal/integrations/obs/client.go index a148ae82..247221bd 100644 --- a/pkg/deej/obs.go +++ b/internal/integrations/obs/client.go @@ -1,15 +1,18 @@ -package deej +package obs import ( "errors" "fmt" + "sort" + "sync" "time" "github.com/andreykaipov/goobs" "github.com/andreykaipov/goobs/api/requests/inputs" "go.uber.org/zap" - "github.com/nik9play/deej/pkg/reconnect" + "github.com/nik9play/deej/internal/config" + "github.com/nik9play/deej/internal/reconnect" ) const obsRetryDelay = 5 * time.Second @@ -18,27 +21,30 @@ const obsRetryDelay = 5 * time.Second // was dialed with so config reloads can detect parameter changes type obsConn struct { client *goobs.Client - cfg OBSConfig + cfg config.OBSSettings } -type OBSClient struct { - deej *Deej - logger *zap.SugaredLogger +type Client struct { + settings SettingsProvider + lifecycle sync.Mutex + stop chan struct{} + wg sync.WaitGroup + logger *zap.SugaredLogger reconnector *reconnect.Reconnector[obsConn] } -func NewOBSClient(deej *Deej, logger *zap.SugaredLogger) *OBSClient { +func New(settings SettingsProvider, logger *zap.SugaredLogger) *Client { logger = logger.Named("obs") - o := &OBSClient{ - deej: deej, - logger: logger, + o := &Client{ + settings: settings, + logger: logger, } o.reconnector = reconnect.New(reconnect.Options[obsConn]{ Logger: logger, - Enabled: func() bool { return o.deej.config.Values().OBSConfig.Enabled }, + Enabled: func() bool { return o.settings.Values().OBS.Enabled }, Dial: o.dial, Watch: o.watch, Close: o.close, @@ -51,39 +57,58 @@ func NewOBSClient(deej *Deej, logger *zap.SugaredLogger) *OBSClient { logger.Debug("Created OBS client instance") - o.setupOnConfigReload() - return o } -func (o *OBSClient) Start() { +func (o *Client) Start() { + o.lifecycle.Lock() + defer o.lifecycle.Unlock() + if o.stop != nil { + return + } + o.stop = make(chan struct{}) + o.setupOnConfigReload() o.logger.Info("OBS client starting") o.reconnector.Start() } -func (o *OBSClient) Stop() { +func (o *Client) Stop() { + o.lifecycle.Lock() + defer o.lifecycle.Unlock() + if o.stop == nil { + return + } + close(o.stop) o.reconnector.Stop() + o.wg.Wait() + o.stop = nil o.logger.Info("OBS client stopped") } -func (o *OBSClient) IsConnected() bool { +func (o *Client) IsConnected() bool { return o.reconnector.Connected() } // obsVolumeMulFromPercent converts a 0.0-1.0 slider position to an OBS volume -// multiplier using the cubic curve OBS's own mixer faders use -func obsVolumeMulFromPercent(percent float32) float64 { - p := float64(percent) - return p * p * p +// multiplier. Conversion uses the cubic curve OBS's own mixer faders use; +// when disabled, the physical slider maps linearly to the multiplier. +func obsVolumeMulFromPercent(percent float32, convert bool) float64 { + position := min(max(float64(percent), 0), 1) + if convert { + return position * position * position + } + + return position } -func (o *OBSClient) SetInputVolume(inputName string, percent float32) error { +func (o *Client) SetInputVolume(inputName string, percent float32) error { conn, ok := o.reconnector.Current() if !ok { return fmt.Errorf("not connected to OBS") } - vol := obsVolumeMulFromPercent(percent) + convert := o.settings.Values().OBS.VolumeConversion + vol := obsVolumeMulFromPercent(percent, convert) _, err := conn.client.Inputs.SetInputVolume(&inputs.SetInputVolumeParams{ InputName: &inputName, InputVolumeMul: &vol, @@ -98,9 +123,32 @@ func (o *OBSClient) SetInputVolume(inputName string, percent float32) error { return nil } -func (o *OBSClient) onUp(conn obsConn) { +// ListInputs returns the sorted names of all inputs in the connected OBS +// instance, used by the settings GUI for target suggestions +func (o *Client) 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 *Client) onUp(conn obsConn) { // re-check the snapshot now that the connection is adopted - if o.deej.config.Values().OBSConfig != conn.cfg { + if o.settings.Values().OBS != conn.cfg { o.logger.Debug("OBS config changed while connecting, triggering reconnect") o.reconnector.Reconnect(errors.New("config changed during dial")) return @@ -111,8 +159,8 @@ 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 +func (o *Client) dial() (obsConn, error) { + cfg := o.settings.Values().OBS address := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) o.logger.Debugw("Attempting OBS connection", "address", address) @@ -133,7 +181,7 @@ func (o *OBSClient) dial() (obsConn, error) { // watch drains a single connection's incoming events to detect // disconnection; Disconnect closes the events channel, which ends the loop -func (o *OBSClient) watch(conn obsConn, errChannel chan<- error) { +func (o *Client) watch(conn obsConn, errChannel chan<- error) { for range conn.client.IncomingEvents { //nolint:revive // draining only } @@ -143,31 +191,42 @@ func (o *OBSClient) watch(conn obsConn, errChannel chan<- error) { } } -func (o *OBSClient) close(conn obsConn) { +func (o *Client) close(conn obsConn) { _ = conn.client.Disconnect() o.logger.Info("Disconnected from OBS") } // setupOnConfigReload triggers a reconnect when // the OBS connection parameters change -func (o *OBSClient) setupOnConfigReload() { - configReloadedChannel := o.deej.config.SubscribeToChanges() +func (o *Client) setupOnConfigReload() { + configReloadedChannel := o.settings.SubscribeToChanges() - go func() { + stop := o.stop + o.wg.Go(func() { for { - <-configReloadedChannel + select { + case <-stop: + return + case <-configReloadedChannel: + } conn, ok := o.reconnector.Current() if !ok { continue } - cfg := o.deej.config.Values().OBSConfig + cfg := o.settings.Values().OBS if cfg != conn.cfg { o.logger.Debug("OBS config changed, triggering reconnect") o.reconnector.Reconnect(errors.New("config changed")) } } - }() + }) +} + +// SettingsProvider supplies current settings and independent reload subscriptions. +type SettingsProvider interface { + Values() *config.Settings + SubscribeToChanges() chan bool } diff --git a/internal/integrations/obs/client_test.go b/internal/integrations/obs/client_test.go new file mode 100644 index 00000000..030248c7 --- /dev/null +++ b/internal/integrations/obs/client_test.go @@ -0,0 +1,28 @@ +package obs + +import ( + "math" + "testing" +) + +func TestOBSVolumeMulFromPercent(t *testing.T) { + cases := []struct { + percent float32 + convert bool + want float64 + }{ + {0, true, 0}, + {0.5, true, 0.125}, + {1, true, 1}, + {0.5, false, 0.5}, + {-1, false, 0}, + {2, false, 1}, + } + + for _, testCase := range cases { + got := obsVolumeMulFromPercent(testCase.percent, testCase.convert) + if math.Abs(got-testCase.want) > 1e-9 { + t.Errorf("obsVolumeMulFromPercent(%v, %v) = %v, want %v", testCase.percent, testCase.convert, got, testCase.want) + } + } +} diff --git a/internal/mixer/discord_routing_test.go b/internal/mixer/discord_routing_test.go new file mode 100644 index 00000000..f6820e5d --- /dev/null +++ b/internal/mixer/discord_routing_test.go @@ -0,0 +1,30 @@ +package mixer + +import "testing" + +// the slider handler has to route discord targets away from the audio session +// path, and leave everything else alone +func TestDiscordTargetRouting(t *testing.T) { + sessions := &Mixer{} + + handled := []string{ + "deej.discord.input", + "deej.discord.output", + "deej.discord:Nikita", + "DEEJ.DISCORD:Nikita", // targets are case-insensitive + } + + for _, target := range handled { + if !sessions.applySpecialTargetAction(target, 0.5) { + t.Errorf("%q should be handled as a discord target", target) + } + } + + notHandled := []string{"discord.exe", "master", "deej.current", "deej.discordant"} + + for _, target := range notHandled { + if sessions.applySpecialTargetAction(target, 0.5) { + t.Errorf("%q should fall through to the audio session path", target) + } + } +} diff --git a/internal/mixer/mixer.go b/internal/mixer/mixer.go new file mode 100644 index 00000000..df7432c9 --- /dev/null +++ b/internal/mixer/mixer.go @@ -0,0 +1,351 @@ +// Package mixer routes slider positions to audio and integration targets. +package mixer + +import ( + "fmt" + "sort" + "sync" + + "github.com/nik9play/deej/internal/audio" + "github.com/nik9play/deej/internal/config" + "github.com/nik9play/deej/internal/serial" + "go.uber.org/zap" +) + +// SettingsProvider supplies the latest immutable settings and reload signals. +type SettingsProvider interface { + Values() *config.Settings + SubscribeToChanges() chan bool +} + +// SliderSource broadcasts slider movements to independent consumers. +type SliderSource interface { + SubscribeToSliderMoveEvents() chan serial.SliderMoveEvent +} + +// OBSController controls named OBS inputs. +type OBSController interface { + IsConnected() bool + SetInputVolume(string, float32) error +} + +// DiscordController accepts integration-local target keys. +type DiscordController interface{ SetVolume(string, float32) } + +// SessionInfo is a snapshot of the available audio targets. +type SessionInfo struct { + Key string + DisplayName string + IsDevice bool + IsInput bool +} + +type Mixer struct { + settings SettingsProvider + sliders SliderSource + obs OBSController + discord DiscordController + foreground func(bool) ([]string, error) + done chan struct{} + workers sync.WaitGroup + stopOnce sync.Once + logger *zap.SugaredLogger + + m map[string][]audio.Session + lock sync.Locker + + sessionFinder audio.SessionFinder + + unmappedSessions []audio.Session + + // channel for notifying about session count changes + sessionCountChangeChan chan struct{} +} + +const ( + masterSessionName = audio.MasterSessionName // master device volume + systemSessionName = audio.SystemSessionName // system sounds volume + inputSessionName = audio.InputSessionName // microphone input level + + // some targets need to be transformed before their correct audio sessions can be accessed. + // this prefix identifies those targets to ensure they don't contradict with another similarly-named process + specialTargetTransformPrefix = "deej." + + // obs targets are handled directly via OBS WebSocket API + obsTargetPrefix = "deej.obs:" + + // discord targets are handled directly via Discord's local RPC connection. + // mapping the discord.exe process covers none of these: they reach inside + // Discord rather than at its audio session + discordUserTargetPrefix = "deej.discord:" // one person in the current voice channel + discordInputTarget = "deej.discord.input" // own microphone level + discordOutputTarget = "deej.discord.output" + + // targets the currently active window (Windows-only, experimental) + specialTargetCurrentWindow = "current" + + // targets the currently active fullscreen window (Windows-only, experimental) + specialTargetCurrentFullscreenWindow = "current.fullscreen" + + // targets all currently unmapped sessions (experimental) + specialTargetAllUnmapped = "unmapped" +) + +func New(settings SettingsProvider, sliders SliderSource, logger *zap.SugaredLogger, sessionFinder audio.SessionFinder, obs OBSController, discord DiscordController, foreground func(bool) ([]string, error)) (*Mixer, error) { + logger = logger.Named("sessions") + + m := &Mixer{ + settings: settings, sliders: sliders, obs: obs, discord: discord, foreground: foreground, done: make(chan struct{}), + logger: logger, + m: make(map[string][]audio.Session), + lock: &sync.Mutex{}, + sessionFinder: sessionFinder, + sessionCountChangeChan: make(chan struct{}, 1), + } + + logger.Debug("Created session map instance") + + return m, nil +} + +func (m *Mixer) SubscribeToSessionCountChange() <-chan struct{} { + return m.sessionCountChangeChan +} + +func (m *Mixer) notifySessionCountChange() { + select { + case m.sessionCountChangeChan <- struct{}{}: + default: + // channel already has a pending notification + } +} + +func (m *Mixer) Initialize() error { + m.setupOnSliderMove() + m.setupOnConfigReload() + + // the handler must be registered before the finder starts discovering + // sessions, otherwise startup enumeration events would be lost + if err := m.sessionFinder.Start(m.handleSessionEvent); err != nil { + return fmt.Errorf("start session finder: %w", err) + } + + return nil +} + +func (m *Mixer) Release() error { + m.stopOnce.Do(func() { close(m.done) }) + m.workers.Wait() + if err := m.sessionFinder.Release(); err != nil { + m.logger.Warnw("Failed to release session finder during session map release", "error", err) + return fmt.Errorf("release session finder during release: %w", err) + } + + return nil +} + +func (m *Mixer) setupOnSliderMove() { + sliderEventsChannel := m.sliders.SubscribeToSliderMoveEvents() + + m.workers.Go(func() { + for { + select { + case event := <-sliderEventsChannel: + m.handleSliderMoveEvent(event) + case <-m.done: + return + } + } + }) +} + +// setupOnConfigReload recomputes the unmapped session list whenever the config +// is reloaded, since edits to the slider mapping change which sessions count +// as unmapped +func (m *Mixer) setupOnConfigReload() { + configReloadedChannel := m.settings.SubscribeToChanges() + + m.workers.Go(func() { + for { + select { + case <-configReloadedChannel: + m.refreshUnmappedSessions() + case <-m.done: + return + } + } + }) +} + +// refreshUnmappedSessions rebuilds the unmapped session list from the current +// session map and slider mapping +func (m *Mixer) refreshUnmappedSessions() { + m.lock.Lock() + defer m.lock.Unlock() + + m.unmappedSessions = nil + + for _, sessions := range m.m { + for _, session := range sessions { + if !m.sessionMapped(session) { + m.unmappedSessions = append(m.unmappedSessions, session) + } + } + } + + m.logger.Debugw("Refreshed unmapped sessions after config reload", "count", len(m.unmappedSessions)) +} + +// handleSessionEvent is called synchronously by the session finder, +// potentially from multiple goroutines +func (m *Mixer) handleSessionEvent(event audio.SessionEvent) { + switch event.Type { + case audio.SessionEventAdded: + m.handleSessionAdded(event) + case audio.SessionEventRemoved: + m.handleSessionRemoved(event) + } +} + +func (m *Mixer) handleSessionAdded(event audio.SessionEvent) { + m.logger.Debugw("audio.Session added event received", "session", event.Session) + + // add to the map and track as unmapped in one locked block, so a + // concurrent refreshUnmappedSessions can't observe the session in the map + // and track it a second time + m.lock.Lock() + + m.addLocked(event.Session) + + if !m.sessionMapped(event.Session) { + m.logger.Debugw("Tracking unmapped session from event", "session", event.Session) + m.unmappedSessions = append(m.unmappedSessions, event.Session) + } + + m.lock.Unlock() + + m.notifySessionCountChange() +} + +func (m *Mixer) handleSessionRemoved(event audio.SessionEvent) { + if event.Session == nil { + return + } + + m.logger.Debugw("audio.Session removed event received", "key", event.Session.Key()) + + // the finder releases the session as soon as this handler returns. taking + // the lock here guarantees the session is unreachable by then: it's out of + // the map, and no setSessionVolumes call is still using it + m.lock.Lock() + + // Remove from the main map + m.removeSessionLocked(event.Session) + + // Remove from unmapped sessions if present + for i, unmapped := range m.unmappedSessions { + if unmapped == event.Session { + m.unmappedSessions = append(m.unmappedSessions[:i], m.unmappedSessions[i+1:]...) + break + } + } + + m.lock.Unlock() + + m.notifySessionCountChange() +} + +// removeSession removes a specific session from the map +// removeSessionLocked removes a specific session from the map. m.lock must be held +func (m *Mixer) removeSessionLocked(session audio.Session) { + key := session.Key() + sessions, ok := m.m[key] + if !ok { + return + } + + // Find and remove the specific session + for i, s := range sessions { + if s == session { + m.m[key] = append(sessions[:i], sessions[i+1:]...) + break + } + } + + // Remove the key entirely if no sessions left + if len(m.m[key]) == 0 { + delete(m.m, key) + } +} + +// 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 +} + +// addLocked adds a session to the map. m.lock must be held +func (m *Mixer) addLocked(value audio.Session) { + key := value.Key() + + existing, ok := m.m[key] + if !ok { + m.m[key] = []audio.Session{value} + } else { + m.m[key] = append(existing, value) + } +} + +func (m *Mixer) SessionCount() int { + m.lock.Lock() + defer m.lock.Unlock() + + count := 0 + for _, sessions := range m.m { + count += len(sessions) + } + + 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 *Mixer) SessionInfos() []SessionInfo { + m.lock.Lock() + defer m.lock.Unlock() + + infos := make([]SessionInfo, 0, len(m.m)) + for key, sessions := range m.m { + info := SessionInfo{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 *Mixer) String() string { + return fmt.Sprintf("<%d audio sessions>", m.SessionCount()) +} diff --git a/internal/mixer/mixer_test.go b/internal/mixer/mixer_test.go new file mode 100644 index 00000000..c5a440ad --- /dev/null +++ b/internal/mixer/mixer_test.go @@ -0,0 +1,391 @@ +package mixer + +import ( + "errors" + "os" + "path/filepath" + "reflect" + "slices" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/nik9play/deej/internal/audio" + "github.com/nik9play/deej/internal/config" + "github.com/nik9play/deej/internal/serial" + "go.uber.org/zap" +) + +type fakeSettings struct { + value atomic.Pointer[config.Settings] + mu sync.Mutex + changes []chan bool +} + +func settingsWithMapping(mapping config.SliderMappings) *fakeSettings { + f := &fakeSettings{} + f.value.Store(&config.Settings{ActiveProfile: "default", Profiles: []config.Profile{{Name: "default", SliderMapping: mapping}}}) + return f +} + +func (f *fakeSettings) Values() *config.Settings { return f.value.Load() } +func (f *fakeSettings) SubscribeToChanges() chan bool { + f.mu.Lock() + defer f.mu.Unlock() + ch := make(chan bool, 1) + f.changes = append(f.changes, ch) + return ch +} + +type fakeSliders struct { + events chan serial.SliderMoveEvent + subscriptions int +} + +func (f *fakeSliders) SubscribeToSliderMoveEvents() chan serial.SliderMoveEvent { + f.subscriptions++ + return f.events +} +func (f *fakeSliders) send(t *testing.T, slider int, value float32) { + t.Helper() + select { + case f.events <- serial.SliderMoveEvent{SliderID: slider, PercentValue: value}: + case <-time.After(2 * time.Second): + t.Fatal("mixer did not receive slider event") + } +} + +type fakeSession struct { + key, name string + device, input bool + mu sync.Mutex + volume float32 + updates chan float32 + setting, continueSet chan struct{} + released atomic.Bool +} + +func newSession(key string) *fakeSession { + return &fakeSession{key: key, updates: make(chan float32, 16)} +} +func (s *fakeSession) Key() string { return s.key } +func (s *fakeSession) DisplayName() string { return s.name } +func (s *fakeSession) IsDevice() bool { return s.device } +func (s *fakeSession) IsInput() bool { return s.input } +func (s *fakeSession) GetVolume() float32 { s.mu.Lock(); defer s.mu.Unlock(); return s.volume } +func (s *fakeSession) SetVolume(v float32) error { + if s.setting != nil { + close(s.setting) + <-s.continueSet + } + s.mu.Lock() + s.volume = v + s.mu.Unlock() + s.updates <- v + return nil +} +func (s *fakeSession) Release() { s.released.Store(true) } + +type fakeFinder struct { + mu sync.Mutex + sessions map[audio.Session]bool + handler audio.SessionEventHandler + onStart func() +} + +func newFinder(sessions ...*fakeSession) *fakeFinder { + f := &fakeFinder{sessions: map[audio.Session]bool{}} + for _, s := range sessions { + f.sessions[s] = true + } + return f +} +func (f *fakeFinder) Start(handler audio.SessionEventHandler) error { + f.handler = handler + if f.onStart != nil { + f.onStart() + } + for session := range f.sessions { + handler(audio.SessionEvent{Type: audio.SessionEventAdded, Session: session}) + } + return nil +} +func (f *fakeFinder) remove(session audio.Session) { + f.mu.Lock() + present := f.sessions[session] + delete(f.sessions, session) + f.mu.Unlock() + if present { + f.handler(audio.SessionEvent{Type: audio.SessionEventRemoved, Session: session}) + session.Release() + } +} +func (f *fakeFinder) Release() error { + f.mu.Lock() + sessions := make([]audio.Session, 0, len(f.sessions)) + for s := range f.sessions { + sessions = append(sessions, s) + } + f.mu.Unlock() + for _, s := range sessions { + f.remove(s) + } + return nil +} + +type volumeCall struct { + target string + value float32 +} +type fakeOBS struct { + connected bool + calls chan volumeCall +} + +func (f *fakeOBS) IsConnected() bool { return f.connected } +func (f *fakeOBS) SetInputVolume(target string, v float32) error { + f.calls <- volumeCall{target, v} + return nil +} + +type fakeDiscord struct{ calls chan volumeCall } + +func (f *fakeDiscord) SetVolume(target string, v float32) { f.calls <- volumeCall{target, v} } + +func makeMixer(t *testing.T, settings SettingsProvider, finder *fakeFinder, obs OBSController, discord DiscordController, foreground func(bool) ([]string, error)) (*Mixer, *fakeSliders) { + t.Helper() + sliders := &fakeSliders{events: make(chan serial.SliderMoveEvent)} + m, err := New(settings, sliders, zap.NewNop().Sugar(), finder, obs, discord, foreground) + if err != nil { + t.Fatal(err) + } + if err = m.Initialize(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := m.Release(); err != nil { + t.Error(err) + } + }) + return m, sliders +} + +func receive[T any](t *testing.T, ch <-chan T) T { + t.Helper() + select { + case value := <-ch: + return value + case <-time.After(2 * time.Second): + t.Fatal("expected mixer output") + var zero T + return zero + } +} + +func eventually(t *testing.T, check func() bool) { + t.Helper() + deadline := time.NewTimer(2 * time.Second) + defer deadline.Stop() + tick := time.NewTicker(time.Millisecond) + defer tick.Stop() + for { + if check() { + return + } + select { + case <-deadline.C: + t.Fatal("mixer state did not settle") + case <-tick.C: + } + } +} + +func TestSessionMappedIgnoresMixedCaseSpecialTarget(t *testing.T) { + settings := settingsWithMapping(config.SliderMappings{{Slider: 0, Targets: []string{"DEEJ.DISCORD:Nikita"}}}) + m, _ := makeMixer(t, settings, newFinder(), nil, nil, nil) + if m.sessionMapped(newSession("other.exe")) { + t.Fatal("Discord target should not map an audio session") + } +} + +func TestSpecialTargetsRouteToIntegrationControllers(t *testing.T) { + settings := settingsWithMapping(config.SliderMappings{{Slider: 0, Targets: []string{ + "DEEJ.OBS:Desktop Audio", "DEEJ.DISCORD:Nikita", "deej.discord.input", "deej.discord.output", + }}}) + obs := &fakeOBS{connected: true, calls: make(chan volumeCall, 4)} + discord := &fakeDiscord{calls: make(chan volumeCall, 4)} + _, sliders := makeMixer(t, settings, newFinder(), obs, discord, nil) + sliders.send(t, 0, .42) + if got := receive(t, obs.calls); got != (volumeCall{"Desktop Audio", .42}) { + t.Fatalf("OBS call = %+v", got) + } + for _, target := range []string{"user:nikita", "input", "output"} { + if got := receive(t, discord.calls); got != (volumeCall{target, .42}) { + t.Fatalf("Discord call = %+v, want %s", got, target) + } + } +} + +func TestForegroundTargetsUseRequestedWindowAndDeduplicateProcesses(t *testing.T) { + for _, fullscreen := range []bool{false, true} { + t.Run(map[bool]string{false: "current", true: "fullscreen"}[fullscreen], func(t *testing.T) { + target := "deej.current" + if fullscreen { + target += ".fullscreen" + } + settings := settingsWithMapping(config.SliderMappings{{Slider: 0, Targets: []string{target}}}) + browser, child := newSession("chrome.exe"), newSession("child.exe") + foreground := func(wantFullscreen bool) ([]string, error) { + if wantFullscreen != fullscreen { + t.Errorf("fullscreen = %v, want %v", wantFullscreen, fullscreen) + } + return []string{"Chrome.EXE", "CHROME.exe", "child.exe"}, nil + } + m, sliders := makeMixer(t, settings, newFinder(browser, child), nil, nil, foreground) + if got := m.resolveTarget(target); !slices.Equal(got, []string{"chrome.exe", "child.exe"}) { + t.Fatalf("foreground targets = %v", got) + } + sliders.send(t, 0, .35) + if got := receive(t, browser.updates); got != .35 { + t.Fatalf("browser volume = %v", got) + } + if got := receive(t, child.updates); got != .35 { + t.Fatalf("child volume = %v", got) + } + }) + } +} + +func TestForegroundLookupFailureIsIgnored(t *testing.T) { + settings := settingsWithMapping(config.SliderMappings{{Slider: 0, Targets: []string{"deej.current"}}}) + m, _ := makeMixer(t, settings, newFinder(), nil, nil, func(bool) ([]string, error) { return nil, errors.New("no foreground window") }) + if got := m.resolveTarget("deej.current"); got != nil { + t.Fatalf("failed lookup resolved %v", got) + } +} + +func TestUnmappedExcludesNamedTargetsAndDeviceSessions(t *testing.T) { + settings := settingsWithMapping(config.SliderMappings{{Slider: 0, Targets: []string{"deej.unmapped"}}, {Slider: 1, Targets: []string{"mapped.exe"}}}) + other, mapped := newSession("other.exe"), newSession("mapped.exe") + master, system, mic, device := newSession("master"), newSession("system"), newSession("mic"), newSession("speakers") + device.device = true + m, sliders := makeMixer(t, settings, newFinder(other, mapped, master, system, mic, device), nil, nil, nil) + if got := m.resolveTarget("deej.unmapped"); !reflect.DeepEqual(got, []string{"other.exe"}) { + t.Fatalf("unmapped = %v", got) + } + sliders.send(t, 0, .61) + if got := receive(t, other.updates); got != .61 { + t.Fatalf("unmapped volume = %v", got) + } + for _, s := range []*fakeSession{mapped, master, system, mic, device} { + if got := s.GetVolume(); got != 0 { + t.Errorf("excluded session %s volume = %v", s.Key(), got) + } + } +} + +func TestProfileReloadUsesFreshMappingForStationaryEvent(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + data := []byte("active_profile: first\nprofiles:\n - name: first\n slider_mapping:\n 0: first.exe\n 1: deej.unmapped\n - name: second\n slider_mapping:\n 0: second.exe\n 1: deej.unmapped\n") + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatal(err) + } + store, err := config.New(zap.NewNop().Sugar(), path, nil) + if err != nil { + t.Fatal(err) + } + if err = store.Load(); err != nil { + t.Fatal(err) + } + first, second := newSession("first.exe"), newSession("second.exe") + m, sliders := makeMixer(t, store, newFinder(first, second), nil, nil, nil) + sliders.send(t, 0, .73) + receive(t, first.updates) + if err = store.SetActiveProfile("second"); err != nil { + t.Fatal(err) + } + eventually(t, func() bool { return slices.Equal(m.resolveTarget("deej.unmapped"), []string{"first.exe"}) }) + // serial's reload test verifies reset/re-emission of this stationary value; + // this boundary verifies routing and cache refresh against the real store. + sliders.send(t, 0, .73) + if got := receive(t, second.updates); got != .73 { + t.Fatalf("new profile volume = %v", got) + } + sliders.send(t, 1, .2) + if got := receive(t, first.updates); got != .2 { + t.Fatalf("newly unmapped volume = %v", got) + } +} + +func TestSessionRemovalWaitsForActiveVolumeChange(t *testing.T) { + settings := settingsWithMapping(config.SliderMappings{{Slider: 0, Targets: []string{"busy.exe"}}}) + session := newSession("busy.exe") + session.setting, session.continueSet = make(chan struct{}), make(chan struct{}) + finder := newFinder(session) + m, sliders := makeMixer(t, settings, finder, nil, nil, nil) + sliders.send(t, 0, .8) + receive(t, session.setting) + removing, removed := make(chan struct{}), make(chan struct{}) + go func() { close(removing); finder.remove(session); close(removed) }() + receive(t, removing) + select { + case <-removed: + t.Fatal("session was removed while SetVolume was active") + case <-time.After(20 * time.Millisecond): + } + if session.released.Load() { + t.Fatal("session was released while SetVolume was active") + } + close(session.continueSet) + receive(t, removed) + if !session.released.Load() { + t.Fatal("finder did not release removed session") + } + if m.SessionCount() != 0 { + t.Fatal("removed session remains reachable") + } + sliders.send(t, 0, .2) + if got := session.GetVolume(); got != .8 { + t.Fatalf("removed session volume changed to %v", got) + } +} + +func TestInitializeSubscribesBeforeDiscoveryAndReleaseStopsConsumers(t *testing.T) { + settings := settingsWithMapping(nil) + sliders := &fakeSliders{events: make(chan serial.SliderMoveEvent)} + session := newSession("startup.exe") + finder := newFinder(session) + finder.onStart = func() { + if sliders.subscriptions != 1 { + t.Error("slider subscription missing before discovery") + } + settings.mu.Lock() + count := len(settings.changes) + settings.mu.Unlock() + if count != 1 { + t.Error("config subscription missing before discovery") + } + } + m, err := New(settings, sliders, zap.NewNop().Sugar(), finder, nil, nil, nil) + if err != nil { + t.Fatal(err) + } + if err = m.Initialize(); err != nil { + t.Fatal(err) + } + if m.SessionCount() != 1 { + t.Fatal("startup enumeration event was lost") + } + if err = m.Release(); err != nil { + t.Fatal(err) + } + if !session.released.Load() || m.SessionCount() != 0 { + t.Fatal("Release did not synchronously release sessions") + } + select { + case sliders.events <- serial.SliderMoveEvent{}: + t.Fatal("slider consumer still receiving after Release") + default: + } +} diff --git a/internal/mixer/targets.go b/internal/mixer/targets.go new file mode 100644 index 00000000..f7a0bbe5 --- /dev/null +++ b/internal/mixer/targets.go @@ -0,0 +1,211 @@ +package mixer + +import ( + "github.com/nik9play/deej/internal/audio" + "github.com/nik9play/deej/internal/serial" + "slices" + "strings" +) + +// 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 *Mixer) sessionMapped(session audio.Session) bool { + + // count master/system/mic as mapped + if slices.Contains([]string{masterSessionName, systemSessionName, inputSessionName}, session.Key()) { + return true + } + + // count device sessions as mapped + if session.IsDevice() { + return true + } + + // look through the actual mappings + for _, entry := range m.settings.Values().ActiveMapping() { + for _, target := range entry.Targets { + target = strings.ToLower(target) + + // ignore special transforms + if m.targetHasSpecialTransform(target) { + continue + } + + resolvedTargets := m.resolveTarget(target) + if len(resolvedTargets) == 0 { + continue + } + target = resolvedTargets[0] + + if target == session.Key() { + return true + } + } + } + + return false +} + +func (m *Mixer) handleSliderMoveEvent(event serial.SliderMoveEvent) { + + // get the targets mapped to this slider from the active profile + targets, ok := m.settings.Values().ActiveMapping().Get(event.SliderID) + + // if slider not found in config, silently ignore + if !ok { + return + } + + // for each possible target for this slider... + for _, target := range targets { + + // handle special action targets (OBS, etc.) that don't map to audio sessions + if m.applySpecialTargetAction(target, event.PercentValue) { + continue + } + + // resolve the target name by cleaning it up and applying any special transformations. + // depending on the transformation applied, this can result in more than one target name + resolvedTargets := m.resolveTarget(target) + + // for each resolved target... + for _, resolvedTarget := range resolvedTargets { + m.setSessionVolumes(resolvedTarget, event.PercentValue) + } + } +} + +// setSessionVolumes adjusts the volume of every session matching the target. +// it holds the lock for the duration of the adjustment so a session can't be +// removed (and released) while its volume is being set +func (m *Mixer) setSessionVolumes(target string, volume float32) { + m.lock.Lock() + defer m.lock.Unlock() + + sessions, ok := m.m[target] + + // no sessions matching this target - move on + if !ok { + return + } + + // iterate all matching sessions and adjust the volume of each one + for _, session := range sessions { + if session.GetVolume() != volume { + if err := session.SetVolume(volume); err != nil { + m.logger.Warnw("Failed to set target session volume", "error", err) + } + } + } +} + +// applySpecialTargetAction handles targets that control external systems rather than audio sessions +// (e.g. OBS and Discord). +// Returns true if the target was handled, false if it should be treated as a normal audio target. +func (m *Mixer) applySpecialTargetAction(target string, volume float32) bool { + lowerTarget := strings.ToLower(target) + discordKey, isDiscordTarget := DiscordTargetKey(target) + + switch { + case strings.HasPrefix(lowerTarget, obsTargetPrefix): + inputName := target[len(obsTargetPrefix):] + m.handleOBSTarget(inputName, volume) + return true + + case isDiscordTarget: + if m.discord != nil { + m.discord.SetVolume(discordKey, volume) + } + return true + } + + return false +} + +func (m *Mixer) handleOBSTarget(inputName string, volume float32) { + if m.obs == nil || !m.obs.IsConnected() { + return + } + + if err := m.obs.SetInputVolume(inputName, volume); err != nil { + m.logger.Debugw("Failed to set OBS input volume", "input", inputName, "error", err) + } +} + +func (m *Mixer) targetHasSpecialTransform(target string) bool { + return strings.HasPrefix(target, specialTargetTransformPrefix) +} + +func (m *Mixer) resolveTarget(target string) []string { + + // start by ignoring the case + target = strings.ToLower(target) + + // look for any special targets first, by examining the prefix + if m.targetHasSpecialTransform(target) { + return m.applyTargetTransform(strings.TrimPrefix(target, specialTargetTransformPrefix)) + } + + return []string{target} +} + +func (m *Mixer) applyTargetTransform(specialTargetName string) []string { + checkFullscreen := false + + // select the transformation based on its name + switch specialTargetName { + + // get current active fullscreen window + case specialTargetCurrentFullscreenWindow: + checkFullscreen = true + fallthrough + + // get current active window + case specialTargetCurrentWindow: + currentWindowProcessNames, err := m.foreground(checkFullscreen) + + // silently ignore errors here, as this is on deej's "hot path" (and it could just mean the user's running linux) + if err != nil { + return nil + } + + // we could have gotten a non-lowercase names from that, so let's ensure we return ones that are lowercase + for targetIdx, target := range currentWindowProcessNames { + currentWindowProcessNames[targetIdx] = strings.ToLower(target) + } + + // remove dupes, preserving order + return uniqueStrings(currentWindowProcessNames) + + // get currently unmapped sessions + case specialTargetAllUnmapped: + // the unmapped session list is mutated by the session event and config + // reload handlers, so it must be read under the lock + m.lock.Lock() + targetKeys := make([]string, len(m.unmappedSessions)) + for sessionIdx, session := range m.unmappedSessions { + targetKeys[sessionIdx] = session.Key() + } + m.lock.Unlock() + + return targetKeys + } + + return nil +} + +// DiscordTargetKey parses config syntax into a Discord-local target key. +func DiscordTargetKey(target string) (string, bool) { + lower := strings.ToLower(target) + switch { + case lower == discordInputTarget: + return "input", true + case lower == discordOutputTarget: + return "output", true + case strings.HasPrefix(lower, discordUserTargetPrefix): + return "user:" + strings.TrimSpace(lower[len(discordUserTargetPrefix):]), true + default: + return "", false + } +} diff --git a/pkg/notify/notify.go b/internal/notify/notify.go similarity index 86% rename from pkg/notify/notify.go rename to internal/notify/notify.go index 8679bd62..7bd445e3 100644 --- a/pkg/notify/notify.go +++ b/internal/notify/notify.go @@ -4,8 +4,7 @@ import ( "os" "path/filepath" - "github.com/nik9play/deej/pkg/deej/util" - "github.com/nik9play/deej/pkg/icon" + "github.com/nik9play/deej/internal/icon" "go.uber.org/zap" ) @@ -36,19 +35,18 @@ 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) - if !util.FileExists(appIconPath) { + if info, err := os.Stat(appIconPath); err != nil || info.IsDir() { tn.logger.Debugw("Deej icon file missing, creating", "path", appIconPath) f, err := os.Create(appIconPath) if err != nil { tn.logger.Errorw("Failed to create toast notification icon", "error", err) + return "" } if _, err = f.Write(icon.TrayDeejLogo); err != nil { diff --git a/pkg/notify/notify_appdata_windows.go b/internal/notify/notify_appdata_windows.go similarity index 90% rename from pkg/notify/notify_appdata_windows.go rename to internal/notify/notify_appdata_windows.go index d1d59463..899d651c 100644 --- a/pkg/notify/notify_appdata_windows.go +++ b/internal/notify/notify_appdata_windows.go @@ -119,9 +119,6 @@ func setAppDataFunc(data AppDataCustom) error { // value is the literal value. func writeStringValue(path, name, value string) error { // always rewrite registry values - // if keyExists(path, name) { - // return nil - // } key, _, err := registry.CreateKey(registry.CURRENT_USER, path, registry.SET_VALUE) if err != nil { return fmt.Errorf("opening registry key: %s: %w", path, err) @@ -134,17 +131,3 @@ func writeStringValue(path, name, value string) error { } return nil } - -// keyExists returns true if the key exists. -func keyExists(path, name string) bool { - key, err := registry.OpenKey(registry.CURRENT_USER, path, registry.READ) - if err != nil { - return false - } - defer key.Close() - v, _, err := key.GetStringValue(name) - if err != nil { - return false - } - return v != "" -} diff --git a/pkg/notify/notify_linux.go b/internal/notify/notify_linux.go similarity index 100% rename from pkg/notify/notify_linux.go rename to internal/notify/notify_linux.go diff --git a/pkg/notify/notify_windows.go b/internal/notify/notify_windows.go similarity index 52% rename from pkg/notify/notify_windows.go rename to internal/notify/notify_windows.go index f8def99a..255c2c2d 100644 --- a/pkg/notify/notify_windows.go +++ b/internal/notify/notify_windows.go @@ -3,7 +3,9 @@ package notify import ( "fmt" "os" + "runtime" "strings" + "sync" "git.sr.ht/~jackmordaunt/go-toast/v2" "github.com/google/uuid" @@ -49,7 +51,44 @@ func initalize(appIconPath, appName string) error { return nil } +// The toast library caches WinRT initialization globally, although COM apartments +// belong to OS threads. Keep initialization, registration and every Push on one +// process-lifetime thread, separate from Wails' STA and audio COM threads. +var toastThread = sync.OnceValue(func() chan<- toastRequest { + requests, _ := startToastThread() + return requests +}) + +type toastRequest struct { + run func() error + result chan error +} + +func startToastThread() (chan<- toastRequest, <-chan struct{}) { + requests := make(chan toastRequest) + done := make(chan struct{}) + go func() { + runtime.LockOSThread() + // Do not return a COM-initialized thread to Go's pool. The production worker + // lives until process exit; when a test closes requests, Go discards its thread. + defer close(done) + for request := range requests { + request.result <- request.run() + } + }() + return requests, done +} + func Notify(title, message, appIconPath, appName string) error { + result := make(chan error, 1) + toastThread() <- toastRequest{ + run: func() error { return pushToast(title, message, appIconPath, appName) }, + result: result, + } + return <-result +} + +func pushToast(title, message, appIconPath, appName string) error { err := initalize(appIconPath, appName) if err != nil { return fmt.Errorf("initialize toast: %w", err) diff --git a/internal/platform/processes.go b/internal/platform/processes.go new file mode 100644 index 00000000..7db264db --- /dev/null +++ b/internal/platform/processes.go @@ -0,0 +1,28 @@ +package platform + +import ( + ps "github.com/mitchellh/go-ps" + "strings" +) + +// 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 +} diff --git a/internal/platform/procicon_linux.go b/internal/platform/procicon_linux.go new file mode 100644 index 00000000..97f1610f --- /dev/null +++ b/internal/platform/procicon_linux.go @@ -0,0 +1,15 @@ +package platform + +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/internal/platform/procicon_windows.go b/internal/platform/procicon_windows.go new file mode 100644 index 00000000..35a2a007 --- /dev/null +++ b/internal/platform/procicon_windows.go @@ -0,0 +1,111 @@ +package platform + +import ( + "bytes" + "errors" + "fmt" + "image" + "image/png" + + "golang.org/x/sys/windows" + + "github.com/nik9play/deej/internal/platform/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/internal/platform/procinfo_windows.go b/internal/platform/procinfo_windows.go new file mode 100644 index 00000000..1b4a0057 --- /dev/null +++ b/internal/platform/procinfo_windows.go @@ -0,0 +1,92 @@ +package platform + +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/internal/platform/util.go b/internal/platform/util.go new file mode 100644 index 00000000..f0e81251 --- /dev/null +++ b/internal/platform/util.go @@ -0,0 +1,48 @@ +package platform + +import ( + "fmt" + "os" + "os/signal" + "syscall" + + "go.uber.org/zap" +) + +// OpenExternal opens a file using the default associated program +func OpenExternal(logger *zap.SugaredLogger, filename string) error { + command := getOpenExternalCommand(filename) + + if err := command.Run(); err != nil { + logger.Warnw("Failed to open file", + "filename", filename, + "error", err) + return fmt.Errorf("open file proc: %w", err) + } + + return nil +} + +// SetupCloseHandler creates a 'listener' on a new goroutine which will notify the +// program if it receives an interrupt from the OS +func SetupCloseHandler() chan os.Signal { + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt, syscall.SIGTERM) + + return c +} + +// GetCurrentWindowProcessNames returns the process names (including extension, if applicable) +// of the current foreground window. This includes child processes belonging to the window. +// This is currently only implemented for Windows +func GetCurrentWindowProcessNames(checkFullscreen bool) ([]string, error) { + return getCurrentWindowProcessNames(checkFullscreen) +} + +func GetAutostartState() bool { + return getAutostartState() +} + +func SetAutostartState(state bool) error { + return setAutostartState(state) +} diff --git a/pkg/deej/util/util_linux.go b/internal/platform/util_linux.go similarity index 95% rename from pkg/deej/util/util_linux.go rename to internal/platform/util_linux.go index d2fdf6dd..67e8427f 100644 --- a/pkg/deej/util/util_linux.go +++ b/internal/platform/util_linux.go @@ -1,4 +1,4 @@ -package util +package platform import ( "errors" diff --git a/pkg/deej/util/util_windows.go b/internal/platform/util_windows.go similarity index 94% rename from pkg/deej/util/util_windows.go rename to internal/platform/util_windows.go index cae3ae97..338e560e 100644 --- a/pkg/deej/util/util_windows.go +++ b/internal/platform/util_windows.go @@ -1,4 +1,4 @@ -package util +package platform import ( "fmt" @@ -12,7 +12,7 @@ import ( "unsafe" "github.com/mitchellh/go-ps" - "github.com/nik9play/deej/pkg/win" + "github.com/nik9play/deej/internal/platform/win" "golang.org/x/sys/windows" "golang.org/x/sys/windows/registry" ) @@ -88,7 +88,9 @@ func getCurrentWindowProcessNames(checkFullscreen bool) ([]string, error) { var ownerPID uint32 // get its PID and put it in our window info struct - windows.GetWindowThreadProcessId(hwnd, &ownerPID) + if _, err := windows.GetWindowThreadProcessId(hwnd, &ownerPID); err != nil { + return nil, fmt.Errorf("get foreground process: %w", err) + } // check for system PID (0) if ownerPID == 0 { @@ -170,8 +172,8 @@ func isWindowFullscreen(hwnd windows.HWND) bool { exStyle := win.GetWindowLongPtr(hwnd, win.GWL_EXSTYLE) // I doubt that this check is necessary - return !((style&(win.WS_DLGFRAME|win.WS_THICKFRAME)) != 0 || - (exStyle&(win.WS_EX_WINDOWEDGE|win.WS_EX_TOOLWINDOW)) != 0) + return style&(win.WS_DLGFRAME|win.WS_THICKFRAME) == 0 && + exStyle&(win.WS_EX_WINDOWEDGE|win.WS_EX_TOOLWINDOW) == 0 } const registryValue = "deej" diff --git a/internal/platform/win/icon_windows.go b/internal/platform/win/icon_windows.go new file mode 100644 index 00000000..bb6bf605 --- /dev/null +++ b/internal/platform/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/pkg/win/syscalls_windows.go b/internal/platform/win/syscalls_windows.go similarity index 91% rename from pkg/win/syscalls_windows.go rename to internal/platform/win/syscalls_windows.go index d6c1bfc9..814d9070 100644 --- a/pkg/win/syscalls_windows.go +++ b/internal/platform/win/syscalls_windows.go @@ -19,6 +19,7 @@ var ( procGetWindowLong = moduser32.NewProc("GetWindowLongPtrW") ) +//nolint:revive // Preserve the documented Win32 constant names at the syscall boundary. const ( QUNS_NOT_PRESENT = 1 QUNS_BUSY = 2 @@ -29,6 +30,7 @@ const ( QUNS_APP = 7 ) +//nolint:revive // Preserve the documented Win32 constant names at the syscall boundary. const ( GWL_EXSTYLE = -20 GWL_STYLE = -16 @@ -45,6 +47,7 @@ const ( ) // Window style constants +//nolint:revive // Preserve the documented Win32 constant names at the syscall boundary. const ( WS_OVERLAPPED = 0x00000000 WS_POPUP = 0x80000000 @@ -75,6 +78,7 @@ const ( ) // Extended window style constants +//nolint:revive // Preserve the documented Win32 constant names at the syscall boundary. const ( WS_EX_DLGMODALFRAME = 0x00000001 WS_EX_NOPARENTNOTIFY = 0x00000004 @@ -104,6 +108,7 @@ const ( WS_EX_NOACTIVATE = 0x08000000 ) +//nolint:revive // Preserve the documented Win32 constant names at the syscall boundary. const ( MONITOR_DEFAULTTONULL = 0x0 MONITOR_DEFAULTTOPRIMARY = 0x1 diff --git a/pkg/win/wca_callbacks_windows.go b/internal/platform/win/wca_callbacks_windows.go similarity index 92% rename from pkg/win/wca_callbacks_windows.go rename to internal/platform/win/wca_callbacks_windows.go index cbe523f9..1070c6ad 100644 --- a/pkg/win/wca_callbacks_windows.go +++ b/internal/platform/win/wca_callbacks_windows.go @@ -309,11 +309,11 @@ func (asn *IAudioSessionNotification) ToWCA() *wca.IAudioSessionNotification { // IMMNotificationClientCallback contains the callback functions for device notifications // This is a fixed version of go-wca's implementation that correctly passes dwNewState type IMMNotificationClientCallback struct { - OnDeviceStateChanged func(pwstrDeviceId string, dwNewState uint32) error - OnDeviceAdded func(pwstrDeviceId string) error - OnDeviceRemoved func(pwstrDeviceId string) error + OnDeviceStateChanged func(deviceID string, dwNewState uint32) error + OnDeviceAdded func(deviceID string) error + OnDeviceRemoved func(deviceID string) error OnDefaultDeviceChanged func(flow wca.EDataFlow, role wca.ERole, pwstrDefaultDeviceId string) error - OnPropertyValueChanged func(pwstrDeviceId string, key uint64) error + OnPropertyValueChanged func(deviceID string, key uint64) error } // IMMNotificationClient is a COM callback interface for device notifications @@ -357,14 +357,14 @@ func mmncRelease(this uintptr) int64 { return int64(mmnc.refCount) } -func mmncOnDeviceStateChanged(this uintptr, pwstrDeviceId uintptr, dwNewState uintptr) int64 { +func mmncOnDeviceStateChanged(this uintptr, deviceID uintptr, dwNewState uintptr) int64 { mmnc := (*IMMNotificationClient)(unsafe.Pointer(this)) if mmnc.callback.OnDeviceStateChanged == nil { return ole.S_OK } - device := wca.LPCWSTRToString(pwstrDeviceId, 1024) + device := wca.LPCWSTRToString(deviceID, 1024) // Fixed: pass actual dwNewState instead of hardcoded 0 if err := mmnc.callback.OnDeviceStateChanged(device, uint32(dwNewState)); err != nil { @@ -374,14 +374,14 @@ func mmncOnDeviceStateChanged(this uintptr, pwstrDeviceId uintptr, dwNewState ui return ole.S_OK } -func mmncOnDeviceAdded(this uintptr, pwstrDeviceId uintptr) int64 { +func mmncOnDeviceAdded(this uintptr, deviceID uintptr) int64 { mmnc := (*IMMNotificationClient)(unsafe.Pointer(this)) if mmnc.callback.OnDeviceAdded == nil { return ole.S_OK } - device := wca.LPCWSTRToString(pwstrDeviceId, 1024) + device := wca.LPCWSTRToString(deviceID, 1024) if err := mmnc.callback.OnDeviceAdded(device); err != nil { return ole.E_FAIL @@ -390,14 +390,14 @@ func mmncOnDeviceAdded(this uintptr, pwstrDeviceId uintptr) int64 { return ole.S_OK } -func mmncOnDeviceRemoved(this uintptr, pwstrDeviceId uintptr) int64 { +func mmncOnDeviceRemoved(this uintptr, deviceID uintptr) int64 { mmnc := (*IMMNotificationClient)(unsafe.Pointer(this)) if mmnc.callback.OnDeviceRemoved == nil { return ole.S_OK } - device := wca.LPCWSTRToString(pwstrDeviceId, 1024) + device := wca.LPCWSTRToString(deviceID, 1024) if err := mmnc.callback.OnDeviceRemoved(device); err != nil { return ole.E_FAIL @@ -406,14 +406,14 @@ func mmncOnDeviceRemoved(this uintptr, pwstrDeviceId uintptr) int64 { return ole.S_OK } -func mmncOnDefaultDeviceChanged(this uintptr, flow, role uint64, pwstrDeviceId uintptr) int64 { +func mmncOnDefaultDeviceChanged(this uintptr, flow, role uint64, deviceID uintptr) int64 { mmnc := (*IMMNotificationClient)(unsafe.Pointer(this)) if mmnc.callback.OnDefaultDeviceChanged == nil { return ole.S_OK } - device := wca.LPCWSTRToString(pwstrDeviceId, 1024) + device := wca.LPCWSTRToString(deviceID, 1024) if err := mmnc.callback.OnDefaultDeviceChanged(wca.EDataFlow(flow), wca.ERole(role), device); err != nil { return ole.E_FAIL @@ -422,14 +422,14 @@ func mmncOnDefaultDeviceChanged(this uintptr, flow, role uint64, pwstrDeviceId u return ole.S_OK } -func mmncOnPropertyValueChanged(this uintptr, pwstrDeviceId uintptr, key uintptr) int64 { +func mmncOnPropertyValueChanged(this uintptr, deviceID uintptr, key uintptr) int64 { mmnc := (*IMMNotificationClient)(unsafe.Pointer(this)) if mmnc.callback.OnPropertyValueChanged == nil { return ole.S_OK } - device := wca.LPCWSTRToString(pwstrDeviceId, 1024) + device := wca.LPCWSTRToString(deviceID, 1024) // Fixed: pass actual key instead of hardcoded 0 if err := mmnc.callback.OnPropertyValueChanged(device, uint64(key)); err != nil { @@ -456,8 +456,8 @@ func (mmnc *IMMNotificationClient) ToWCA() *wca.IMMNotificationClient { // GetDevice calls IMMDeviceEnumerator::GetDevice directly via vtable, // working around go-wca's unimplemented stub that always returns E_NOTIMPL. -func GetDevice(mmde *wca.IMMDeviceEnumerator, pwstrId string, ppDevice **wca.IMMDevice) error { - idPtr, err := syscall.UTF16PtrFromString(pwstrId) +func GetDevice(mmde *wca.IMMDeviceEnumerator, deviceID string, ppDevice **wca.IMMDevice) error { + idPtr, err := syscall.UTF16PtrFromString(deviceID) if err != nil { return err } diff --git a/pkg/reconnect/reconnect.go b/internal/reconnect/reconnect.go similarity index 97% rename from pkg/reconnect/reconnect.go rename to internal/reconnect/reconnect.go index 5cab785e..30c6b515 100644 --- a/pkg/reconnect/reconnect.go +++ b/internal/reconnect/reconnect.go @@ -69,8 +69,7 @@ type Options[C any] struct { // Reconnector maintains at most one live connection of type C, redialing // with backoff whenever the connection drops. // -// Start, Stop and the Reconnector's construction must happen on one -// goroutine (or be otherwise serialized); all other methods are safe to call +// Lifecycle operations are serialized internally; all methods are safe to call // from any goroutine. type Reconnector[C any] struct { opts Options[C] @@ -83,6 +82,7 @@ type Reconnector[C any] struct { hasConn bool errChannel chan error + lifecycle sync.Mutex stopChannel chan struct{} wg sync.WaitGroup } @@ -107,6 +107,11 @@ func New[C any](opts Options[C]) *Reconnector[C] { // Start launches the manager loop. func (r *Reconnector[C]) Start() { + r.lifecycle.Lock() + defer r.lifecycle.Unlock() + if r.stopChannel != nil { + return + } stopChannel := make(chan struct{}) r.stopChannel = stopChannel @@ -118,6 +123,8 @@ func (r *Reconnector[C]) Start() { // Stop tears down the current connection (if any) and waits for the manager // loop and its Watch to exit. It never waits for a dial in flight. func (r *Reconnector[C]) Stop() { + r.lifecycle.Lock() + defer r.lifecycle.Unlock() if r.stopChannel == nil { return } diff --git a/pkg/reconnect/reconnect_test.go b/internal/reconnect/reconnect_test.go similarity index 95% rename from pkg/reconnect/reconnect_test.go rename to internal/reconnect/reconnect_test.go index ac93e1ac..8f5dae44 100644 --- a/pkg/reconnect/reconnect_test.go +++ b/internal/reconnect/reconnect_test.go @@ -2,6 +2,7 @@ package reconnect import ( "errors" + "sync" "testing" "time" ) @@ -390,3 +391,21 @@ func TestRestartAfterStop(t *testing.T) { stopWithDeadline(t, f.r) } + +func TestConcurrentLifecycleIsSerialized(t *testing.T) { + f := newFakeConnector(nil, true) + var callers sync.WaitGroup + for range 24 { + callers.Go(func() { f.r.Start() }) + } + callers.Wait() + for range 24 { + callers.Go(func() { f.r.Stop() }) + } + done := make(chan struct{}) + go func() { callers.Wait(); close(done) }() + expectRecv(t, done, "concurrent stops") + // A fresh run must also stop cleanly after all concurrent requests finish. + f.r.Start() + f.r.Stop() +} diff --git a/internal/serial/client.go b/internal/serial/client.go new file mode 100644 index 00000000..fa21279f --- /dev/null +++ b/internal/serial/client.go @@ -0,0 +1,365 @@ +package serial + +import ( + "bufio" + "errors" + "fmt" + "slices" + "strconv" + "strings" + "sync" + "sync/atomic" + + transport "go.bug.st/serial" + "go.bug.st/serial/enumerator" + + "go.uber.org/zap" + + "github.com/nik9play/deej/internal/config" + "github.com/nik9play/deej/internal/reconnect" +) + +// serialConn is one serial connection generation, carrying the port name it +// resolved to and the connection parameters it was opened with so config +// reloads can detect changes +type serialConn struct { + port transport.Port + comPort string + connInfo config.COMSettings + adopted *atomic.Bool +} + +// Client manages hardware connections and synchronous event subscriptions. +type Client struct { + settings SettingsProvider + options Options + lifecycle sync.Mutex + stopped bool + configDone chan struct{} + configWG sync.WaitGroup + consumersLock sync.Mutex + logger *zap.SugaredLogger + + reconnector *reconnect.Reconnector[serialConn] + + stateLock sync.Mutex + lastKnownNumSliders int + currentSliderValues []int + + sliderMoveConsumers []chan SliderMoveEvent + stateChangeConsumers []chan bool +} + +var ErrNoSerialPorts = errors.New("no serial ports found") +var ErrAutoPortNotFound = errors.New("can't autodetect com port") + +// SliderMoveEvent represents a single slider move captured by deej +type SliderMoveEvent struct { + SliderID int + PercentValue float32 +} + +// New constructs a serial client and subscribes to settings changes. +// Stop must be called even when Start has not been called. +func New(settings SettingsProvider, logger *zap.SugaredLogger, options Options) (*Client, error) { + logger = logger.Named("serial") + + sio := &Client{ + settings: settings, + options: options, + configDone: make(chan struct{}), + logger: logger, + sliderMoveConsumers: []chan SliderMoveEvent{}, + stateChangeConsumers: []chan bool{}, + } + + sio.reconnector = reconnect.New(reconnect.Options[serialConn]{ + Logger: logger, + Dial: sio.dial, + Watch: sio.watch, + Close: sio.close, + OnUp: sio.onUp, + OnDown: sio.onDown, + }) + + logger.Debug("Created serial i/o instance") + + // respond to config changes + sio.setupOnConfigReload() + + return sio, nil +} + +// dial resolves the com port (autodetecting by VID/PID if configured) and +// opens it +func (sio *Client) dial() (serialConn, error) { + config := sio.settings.Values() + + 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") + + ports, err := enumerator.GetDetailedPortsList() + + if err != nil { + sio.logger.Errorw("Failed to enumarate serial ports, retrying", "err", err) + return serialConn{}, ErrNoSerialPorts + } + if len(ports) == 0 { + sio.logger.Debug("No serial ports found, retrying") + return serialConn{}, ErrNoSerialPorts + } + for _, port := range ports { + sio.logger.Debugf("Found port: %s", port.Name) + if port.IsUSB { + sio.logger.Debugf(" USB ID %s:%s", port.VID, port.PID) + + vid, _ := strconv.ParseUint(port.VID, 16, 16) + pid, _ := strconv.ParseUint(port.PID, 16, 16) + + 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 + break + } + + } + } + + if comPort == "auto" { + sio.logger.Debug("COM port not found, retrying") + return serialConn{}, ErrAutoPortNotFound + } + } + + mode := transport.Mode{ + BaudRate: config.COM.BaudRate, + DataBits: 8, + StopBits: transport.OneStopBit, + } + + sio.logger.Debugw("Attempting serial connection", + "comPort", comPort, + "baudRate", mode.BaudRate) + + port, err := transport.Open(comPort, &mode) + + if err != nil { + // might need a user notification here, TBD + sio.logger.Debugw("Failed to open serial connection", "error", err) + return serialConn{}, fmt.Errorf("open serial connection: %w", err) + } + + // actually, this sets timeout to 0x7FFFFFFE instead of 0xFFFFFFFE + // to make serial chip work properly. + // see https://github.com/arduino/serial-monitor/issues/112 + err = port.SetReadTimeout(transport.NoTimeout) + if err != nil { + sio.logger.Warnw("Failed to set read timeout", "error", err) + + // close the port before bailing, otherwise the handle stays open + // and every future open attempt fails with access denied + if closeErr := port.Close(); closeErr != nil { + sio.logger.Warnw("Failed to close serial connection", "error", closeErr) + } + + return serialConn{}, fmt.Errorf("set read timeout: %w", err) + } + + return serialConn{ + port: port, + comPort: comPort, + connInfo: config.COM, + adopted: new(atomic.Bool), + }, nil +} + +func (sio *Client) onUp(conn serialConn) { + if sio.settings.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 + } + + sio.sendStateChangeEvent(true) + + sio.logger.Named(strings.ToLower(conn.comPort)).Infow("Connected") + + if conn.adopted != nil { + conn.adopted.Store(true) + } + if sio.options.OnConnected != nil { + sio.options.OnConnected(conn.comPort) + } + +} + +func (sio *Client) onDown(err error) { + sio.logger.Warnw("Read line error", "err", err) + sio.logger.Warn("Closing serial port") + + if sio.options.OnDisconnected != nil { + sio.options.OnDisconnected(sio.CurrentComPort()) + } + +} + +func (sio *Client) close(conn serialConn) { + if err := conn.port.Close(); err != nil { + sio.logger.Warnw("Failed to close serial connection", "error", err) + } else { + sio.logger.Info("Serial connection closed") + } + + // Late dial results were never published, so closing them must not emit + // events after Stop has let the consumers exit. + if conn.adopted != nil && conn.adopted.Swap(false) { + sio.sendStateChangeEvent(false) + } +} + +func (sio *Client) 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 *Client) CurrentComPort() string { + if conn, ok := sio.reconnector.Current(); ok { + return conn.comPort + } + + return sio.settings.Values().COM.Port +} + +func (sio *Client) CurrentSliderValues() []int { + sio.stateLock.Lock() + defer sio.stateLock.Unlock() + + return slices.Clone(sio.currentSliderValues) +} + +// Start attempts to connect to our arduino chip +func (sio *Client) Start() { + sio.lifecycle.Lock() + defer sio.lifecycle.Unlock() + if sio.stopped { + return + } + config := sio.settings.Values() + sio.logger.Infow("Trying serial connection", + "port", config.COM.Port, + "vid", string(config.COM.VID), + "pid", string(config.COM.PID), + ) + + sio.reconnector.Start() +} + +// Stop terminates the client and its settings worker. Consumers must keep +// receiving events until Stop returns. Subscription channels remain open. +func (sio *Client) Stop() { + sio.lifecycle.Lock() + defer sio.lifecycle.Unlock() + if sio.stopped { + return + } + sio.stopped = true + close(sio.configDone) + sio.configWG.Wait() + sio.reconnector.Stop() + sio.logger.Info("Serial stopped") +} + +// SubscribeToSliderMoveEvents returns an unbuffered channel that receives +// a sliderMoveEvent struct every time a slider moves +func (sio *Client) SubscribeToSliderMoveEvents() chan SliderMoveEvent { + ch := make(chan SliderMoveEvent) + sio.consumersLock.Lock() + sio.sliderMoveConsumers = append(sio.sliderMoveConsumers, ch) + sio.consumersLock.Unlock() + + return ch +} + +func (sio *Client) SubscribeToStateChangeEvent() chan bool { + ch := make(chan bool) + sio.consumersLock.Lock() + sio.stateChangeConsumers = append(sio.stateChangeConsumers, ch) + sio.consumersLock.Unlock() + + return ch +} + +func (sio *Client) sendStateChangeEvent(state bool) { + sio.consumersLock.Lock() + consumers := slices.Clone(sio.stateChangeConsumers) + sio.consumersLock.Unlock() + for _, consumer := range consumers { + consumer <- state + } +} + +func (sio *Client) setupOnConfigReload() { + configReloadedChannel := sio.settings.SubscribeToChanges() + + sio.configWG.Go(func() { + for { + select { + case <-sio.configDone: + return + case <-configReloadedChannel: + } + + // reset the slider count so the next line re-emits all values + sio.stateLock.Lock() + sio.lastKnownNumSliders = 0 + sio.stateLock.Unlock() + + config := sio.settings.Values() + + // if connection params have changed, ask the reconnector to + // renew the connection. when disconnected every dial attempt + // reads the current config anyway + conn, ok := sio.reconnector.Current() + if !ok { + continue + } + + 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")) + } + } + }) +} + +// watch reads lines off a single connection until it fails; closing the port +// unblocks the pending read +func (sio *Client) watch(conn serialConn, errChannel chan<- error) { + logger := sio.logger.Named(strings.ToLower(conn.comPort)) + + reader := bufio.NewReader(conn.port) + for { + line, err := reader.ReadString('\n') + if err != nil { + // non-blocking send: Reconnect may have already filled this + // generation's buffer, and nothing drains it after teardown + select { + case errChannel <- fmt.Errorf("read error: %w", err): + default: + } + return + } + + if sio.options.Verbose { + logger.Debugw("Read new line", "line", line) + } + + sio.handleLine(logger, line) + } +} diff --git a/internal/serial/client_test.go b/internal/serial/client_test.go new file mode 100644 index 00000000..92bb1909 --- /dev/null +++ b/internal/serial/client_test.go @@ -0,0 +1,128 @@ +package serial + +import ( + "io" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/nik9play/deej/internal/config" + "github.com/nik9play/deej/internal/reconnect" + transport "go.bug.st/serial" + "go.uber.org/zap" +) + +// Embedding the transport interface leaves unused operations out of the fake. +type blockingPort struct { + transport.Port + closed chan struct{} + once sync.Once +} + +func (p *blockingPort) Read([]byte) (int, error) { + <-p.closed + return 0, io.EOF +} + +func (p *blockingPort) Close() error { + p.once.Do(func() { close(p.closed) }) + return nil +} + +func testConnectedClient(t *testing.T, dial func() (serialConn, error)) *Client { + t.Helper() + settings := &fakeSettings{value: &config.Settings{}, changes: make(chan bool)} + client, err := New(settings, zap.NewNop().Sugar(), Options{}) + if err != nil { + t.Fatal(err) + } + client.reconnector = reconnect.New(reconnect.Options[serialConn]{ + Dial: dial, Watch: client.watch, Close: client.close, OnUp: client.onUp, + }) + t.Cleanup(client.Stop) + return client +} + +func waitForSignal(t *testing.T, signal <-chan struct{}) { + t.Helper() + select { + case <-signal: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for serial shutdown") + } +} + +func TestStopDeliversDisconnectedBeforeConsumersExit(t *testing.T) { + port := &blockingPort{closed: make(chan struct{})} + client := testConnectedClient(t, func() (serialConn, error) { + return serialConn{port: port, adopted: new(atomic.Bool)}, nil + }) + states := client.SubscribeToStateChangeEvent() + client.Start() + select { + case connected := <-states: + if !connected { + t.Fatal("first state should be connected") + } + case <-time.After(2 * time.Second): + t.Fatal("connection did not start") + } + stopped := make(chan struct{}) + go func() { client.Stop(); close(stopped) }() + select { + case connected := <-states: + if connected { + t.Fatal("Stop should report disconnected") + } + case <-time.After(2 * time.Second): + t.Fatal("Stop did not deliver disconnected") + } + waitForSignal(t, stopped) + // A repeated Stop must neither close channels twice nor block. + client.Stop() +} + +func TestLateDialCloseDoesNotSendAfterStop(t *testing.T) { + port := &blockingPort{closed: make(chan struct{})} + dialStarted, dialFinish := make(chan struct{}), make(chan struct{}) + client := testConnectedClient(t, func() (serialConn, error) { + close(dialStarted) + <-dialFinish + return serialConn{port: port, adopted: new(atomic.Bool)}, nil + }) + // There is deliberately no receiver once Stop returns. + client.SubscribeToStateChangeEvent() + client.Start() + waitForSignal(t, dialStarted) + stopped := make(chan struct{}) + go func() { client.Stop(); close(stopped) }() + waitForSignal(t, stopped) + close(dialFinish) + waitForSignal(t, port.closed) +} + +func TestConfigReloadReemitsStationarySliders(t *testing.T) { + provider := &fakeSettings{value: &config.Settings{}, changes: make(chan bool)} + client, err := New(provider, zap.NewNop().Sugar(), Options{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(client.Stop) + events := make(chan SliderMoveEvent, 4) + client.sliderMoveConsumers = append(client.sliderMoveConsumers, events) + client.handleLine(client.logger, "512|1023\r\n") + drainEvents(events) + client.handleLine(client.logger, "512|1023\r\n") + if got := drainEvents(events); len(got) != 0 { + t.Fatalf("unchanged line emitted %d events before reload", len(got)) + } + // The second unbuffered send synchronizes with completion of the first + // reset, just as two coalesced reload generations would. + provider.changes <- true + provider.changes <- true + client.handleLine(client.logger, "512|1023\r\n") + if got := drainEvents(events); len(got) != 2 { + t.Fatalf("unchanged line emitted %d events after reload, want 2", len(got)) + } +} diff --git a/internal/serial/noise.go b/internal/serial/noise.go new file mode 100644 index 00000000..a74b9d98 --- /dev/null +++ b/internal/serial/noise.go @@ -0,0 +1,28 @@ +package serial + +import "math" + +// normalizeScalar rounds the physical slider to two decimal places. +func normalizeScalar(v float32) float32 { + return float32(math.Round(float64(v)*100) / 100) +} + +func significantlyDifferent(oldValue, newValue int, level string) bool { + threshold := 10 + switch level { + case "high": + threshold = 20 + case "low": + threshold = 5 + case "none": + threshold = 1 + } + if (newValue < 10 || newValue > 1013) && threshold > 5 { + threshold = 5 + } + difference := oldValue - newValue + if difference < 0 { + difference = -difference + } + return difference >= threshold +} diff --git a/internal/serial/options.go b/internal/serial/options.go new file mode 100644 index 00000000..b888cfd1 --- /dev/null +++ b/internal/serial/options.go @@ -0,0 +1,18 @@ +// Package serial connects to the hardware mixer and broadcasts normalized slider events. +package serial + +import "github.com/nik9play/deej/internal/config" + +// SettingsProvider supplies current snapshots and coalesced change signals. +type SettingsProvider interface { + Values() *config.Settings + SubscribeToChanges() chan bool +} + +// Options provides diagnostics and localized connection notifications. +// Callbacks execute on the connection goroutine and should return promptly. +type Options struct { + Verbose bool + OnConnected func(port string) + OnDisconnected func(port string) +} diff --git a/internal/serial/ports.go b/internal/serial/ports.go new file mode 100644 index 00000000..21cfba6c --- /dev/null +++ b/internal/serial/ports.go @@ -0,0 +1,28 @@ +package serial + +import "go.bug.st/serial/enumerator" + +// PortInfo describes a serial port independently of the transport library. +type PortInfo struct { + Name string + IsUSB bool + VID string + PID string + Product string +} + +// ListPorts enumerates the serial ports available on this machine. +func ListPorts() ([]PortInfo, error) { + ports, err := enumerator.GetDetailedPortsList() + if err != nil { + return nil, err + } + result := make([]PortInfo, 0, len(ports)) + for _, port := range ports { + result = append(result, PortInfo{ + Name: port.Name, IsUSB: port.IsUSB, VID: port.VID, PID: port.PID, + Product: port.Product, + }) + } + return result, nil +} diff --git a/internal/serial/protocol.go b/internal/serial/protocol.go new file mode 100644 index 00000000..82401fcd --- /dev/null +++ b/internal/serial/protocol.go @@ -0,0 +1,127 @@ +package serial + +import ( + "regexp" + "slices" + "strconv" + "strings" + + "go.uber.org/zap" +) + +var expectedLinePattern = regexp.MustCompile(`^\d{1,4}(\|\d{1,4})*\r\n$`) + +// CurrentSliderPercentValues returns the current slider values as the 0..1 +// scalars the sessions receive (normalized and inversion-applied, mirroring +// handleLine) +func (sio *Client) CurrentSliderPercentValues() []float32 { + config := sio.settings.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 := normalizeScalar(float32(raw) / 1023.0) + if config.InvertSliders { + value = 1 - value + } + values[i] = value + } + + return values +} + +func (sio *Client) handleLine(logger *zap.SugaredLogger, line string) { + // this function receives an unsanitized line which is guaranteed to end with LF, + // but most lines will end with CRLF. it may also have garbage instead of + // deej-formatted values, so we must check for that! just ignore bad ones + if !expectedLinePattern.MatchString(line) { + return + } + + // trim the suffix + line = strings.TrimSuffix(line, "\r\n") + + // split on pipe (|), this gives a slice of numerical strings between "0" and "1023" + splitLine := strings.Split(line, "|") + numSliders := len(splitLine) + + // turns out the first line could come out dirty sometimes (i.e. "4558|925|41|643|220") + // so let's check the first number for correctness just in case + if firstNumber, _ := strconv.Atoi(splitLine[0]); firstNumber > 1023 { + logger.Debugw("Got malformed line from serial, ignoring", "line", line) + return + } + + config := sio.settings.Values() + + sio.stateLock.Lock() + + // update our slider count, if needed - this will send slider move events for all + if numSliders != sio.lastKnownNumSliders { + logger.Infow("Detected sliders", "amount", numSliders) + sio.lastKnownNumSliders = numSliders + sio.currentSliderValues = make([]int, numSliders) + + // reset everything to be an impossible value to force the slider move event later + for idx := range sio.currentSliderValues { + sio.currentSliderValues[idx] = -1023 + } + } + + // for each slider: + moveEvents := []SliderMoveEvent{} + for sliderIdx, stringValue := range splitLine { + + // convert string values to integers ("1023" -> 1023) + number, _ := strconv.Atoi(stringValue) + + // map the value from raw to a "dirty" float between 0 and 1 (e.g. 0.15451...) + dirtyFloat := float32(number) / 1023.0 + + // normalize it to an actual volume scalar between 0.0 and 1.0 with 2 points of precision + normalizedScalar := normalizeScalar(dirtyFloat) + + // if sliders are inverted, take the complement of 1.0 + if config.InvertSliders { + normalizedScalar = 1 - normalizedScalar + } + + // check if it changes the desired state (could just be a jumpy raw slider value) + if significantlyDifferent(sio.currentSliderValues[sliderIdx], number, config.NoiseReduction) { + + // if it does, update the saved value and create a move event + sio.currentSliderValues[sliderIdx] = number + + moveEvents = append(moveEvents, SliderMoveEvent{ + SliderID: sliderIdx, + PercentValue: normalizedScalar, + }) + + if sio.options.Verbose { + logger.Debugw("Slider moved", "event", moveEvents[len(moveEvents)-1]) + } + } + } + + // release the lock before delivering events, since sends can block on consumers + sio.stateLock.Unlock() + + // deliver move events if there are any, towards all potential consumers + if len(moveEvents) > 0 { + sio.consumersLock.Lock() + consumers := slices.Clone(sio.sliderMoveConsumers) + sio.consumersLock.Unlock() + for _, consumer := range consumers { + for _, moveEvent := range moveEvents { + consumer <- moveEvent + } + } + } +} diff --git a/pkg/deej/serial_test.go b/internal/serial/protocol_test.go similarity index 81% rename from pkg/deej/serial_test.go rename to internal/serial/protocol_test.go index 61a6603f..52202aa8 100644 --- a/pkg/deej/serial_test.go +++ b/internal/serial/protocol_test.go @@ -1,24 +1,19 @@ -package deej +package serial import ( + "github.com/nik9play/deej/internal/config" "testing" "go.uber.org/zap" ) -// newTestSerialIO builds a SerialIO with just enough wiring for handleLine, +// newTestClient builds a Client with just enough wiring for handleLine, // plus a buffered channel that captures emitted slider move events. -func newTestSerialIO(invertSliders bool, noiseReductionLevel string) (*SerialIO, chan SliderMoveEvent) { - config := &CanonicalConfig{} - config.current.Store(&ConfigValues{ - InvertSliders: invertSliders, - NoiseReductionLevel: noiseReductionLevel, - }) - - sio := &SerialIO{ - deej: &Deej{config: config}, - logger: zap.NewNop().Sugar(), - } +func newTestClient(invertSliders bool, noiseReductionLevel string) (*Client, chan SliderMoveEvent) { + provider := &fakeSettings{value: &config.Settings{ + InvertSliders: invertSliders, NoiseReduction: noiseReductionLevel, + }} + sio := &Client{settings: provider, logger: zap.NewNop().Sugar()} events := make(chan SliderMoveEvent, 64) sio.sliderMoveConsumers = append(sio.sliderMoveConsumers, events) @@ -39,7 +34,7 @@ func drainEvents(ch chan SliderMoveEvent) []SliderMoveEvent { } func TestHandleLineParsesValidLine(t *testing.T) { - sio, ch := newTestSerialIO(false, "") + sio, ch := newTestClient(false, "") sio.handleLine(sio.logger, "0|512|1023\r\n") @@ -78,7 +73,7 @@ func TestHandleLineIgnoresMalformedLines(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - sio, ch := newTestSerialIO(false, "") + sio, ch := newTestClient(false, "") sio.handleLine(sio.logger, tt.line) @@ -90,7 +85,7 @@ func TestHandleLineIgnoresMalformedLines(t *testing.T) { } func TestHandleLineNoiseReduction(t *testing.T) { - sio, ch := newTestSerialIO(false, "") + sio, ch := newTestClient(false, "") // first line always emits (values initialized to an impossible -1023) sio.handleLine(sio.logger, "512|512\r\n") @@ -116,7 +111,7 @@ func TestHandleLineNoiseReduction(t *testing.T) { } func TestHandleLineNoiseReductionNone(t *testing.T) { - sio, ch := newTestSerialIO(false, "none") + sio, ch := newTestClient(false, "none") sio.handleLine(sio.logger, "512\r\n") drainEvents(ch) @@ -129,7 +124,7 @@ func TestHandleLineNoiseReductionNone(t *testing.T) { } func TestHandleLineEdgeSnapping(t *testing.T) { - sio, ch := newTestSerialIO(false, "") + sio, ch := newTestClient(false, "") sio.handleLine(sio.logger, "1013\r\n") drainEvents(ch) @@ -147,7 +142,7 @@ func TestHandleLineEdgeSnapping(t *testing.T) { } func TestHandleLineSliderCountChange(t *testing.T) { - sio, ch := newTestSerialIO(false, "") + sio, ch := newTestClient(false, "") sio.handleLine(sio.logger, "512\r\n") if events := drainEvents(ch); len(events) != 1 { @@ -167,3 +162,11 @@ func TestHandleLineSliderCountChange(t *testing.T) { t.Errorf("lastKnownNumSliders = %d, expected 2", sio.lastKnownNumSliders) } } + +type fakeSettings struct { + value *config.Settings + changes chan bool +} + +func (f *fakeSettings) Values() *config.Settings { return f.value } +func (f *fakeSettings) SubscribeToChanges() chan bool { return f.changes } diff --git a/pkg/deej/cmd/rsrc_windows_amd64.syso b/pkg/deej/cmd/rsrc_windows_amd64.syso deleted file mode 100644 index 6e434d8c..00000000 Binary files a/pkg/deej/cmd/rsrc_windows_amd64.syso and /dev/null differ diff --git a/pkg/deej/cmd/rsrc_windows_arm64.syso b/pkg/deej/cmd/rsrc_windows_arm64.syso deleted file mode 100644 index 0d37edf5..00000000 Binary files a/pkg/deej/cmd/rsrc_windows_arm64.syso and /dev/null differ diff --git a/pkg/deej/cmd/winres/winres.json b/pkg/deej/cmd/winres/winres.json deleted file mode 100644 index 2689ce35..00000000 --- a/pkg/deej/cmd/winres/winres.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "RT_GROUP_ICON": { - "APP": { - "0000": "../../../icon/assets/logo.ico" - } - }, - "RT_MANIFEST": { - "#1": { - "0409": { - "identity": { - "name": "deej", - "version": "1.0.0.0" - }, - "description": "", - "minimum-os": "win7", - "execution-level": "as invoker", - "ui-access": false, - "auto-elevate": false, - "dpi-awareness": "per monitor v2", - "disable-window-filtering": false, - "high-resolution-scrolling-aware": false, - "ultra-high-resolution-scrolling-aware": false, - "long-path-aware": false, - "printer-driver-isolation": false, - "gdi-scaling": false, - "segment-heap": false, - "use-common-controls-v6": true - } - } - } -} diff --git a/pkg/deej/config.go b/pkg/deej/config.go deleted file mode 100644 index d628461c..00000000 --- a/pkg/deej/config.go +++ /dev/null @@ -1,408 +0,0 @@ -package deej - -import ( - "fmt" - "os" - "path/filepath" - "strings" - "sync/atomic" - "time" - - "github.com/fsnotify/fsnotify" - "github.com/nicksnyder/go-i18n/v2/i18n" - "github.com/spf13/viper" - "go.uber.org/zap" - - "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 - - OBSConfig OBSConfig -} - -// CanonicalConfig provides application-wide access to configuration fields, -// as well as loading/file watching logic for deej's configuration file -type CanonicalConfig struct { - current atomic.Pointer[ConfigValues] - - logger *zap.SugaredLogger - notifier notify.Notifier - stopWatcherChannel chan bool - watcherStopped atomic.Bool - - reloadConsumers []chan bool - - userConfig *viper.Viper - internalConfig *viper.Viper - - configPath string -} - -// Values returns the current immutable snapshot of the configuration. -// Callers that read multiple fields should grab one snapshot and use it -// throughout, so all values belong to the same config generation -func (cc *CanonicalConfig) Values() *ConfigValues { - 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}) - - return emptyMap -}() - -// NewConfig creates a config instance for the deej object and sets up viper instances for deej's config files -func NewConfig(logger *zap.SugaredLogger, notifier notify.Notifier, configPath string) (*CanonicalConfig, error) { - logger = logger.Named("config") - - ex, err := os.Executable() - if err != nil { - return nil, fmt.Errorf("get executable dir: %w", err) - } - - // set config path to exe dir, if custom path is not provided - if configPath == "" { - 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, - } - - // 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 -func (cc *CanonicalConfig) Load(localizer *i18n.Localizer) error { - cc.logger.Debugw("Loading config", "path", cc.configPath) - - // make sure it exists - if !util.FileExists(cc.configPath) { - cc.logger.Warnw("Config file not found", "path", cc.configPath) - - configNotFoundTitle := localizer.MustLocalize(&i18n.LocalizeConfig{ - DefaultMessage: &i18n.Message{ - ID: "ConfigNotFoundTitle", - Other: "Can't find configuration!", - }, - }) - configNotFoundDescription := localizer.MustLocalize(&i18n.LocalizeConfig{ - DefaultMessage: &i18n.Message{ - ID: "ConfigNotFoundDescription", - Other: "{{.FilePath}} must be in the same directory as deej. Please re-launch.", - }, - TemplateData: map[string]string{ - "FilePath": cc.configPath, - }, - }) - cc.notifier.Notify(configNotFoundTitle, configNotFoundDescription) - - 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) - - // 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", - Other: "Invalid configuration!", - }, - }) - configInvalidDescription := localizer.MustLocalize(&i18n.LocalizeConfig{ - DefaultMessage: &i18n.Message{ - ID: "ConfigInvalidDescription", - Other: "Please make sure {{.FilePath}} is in a valid YAML format.", - }, - TemplateData: map[string]string{ - "FilePath": cc.configPath, - }, - }) - 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) - } - - // 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") - } - - // 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 := cc.Values() - cc.logger.Info("Loaded config successfully") - cc.logger.Infow("Config values", - "sliderMapping", values.SliderMapping, - "connectionInfo", values.ConnectionInfo, - "invertSliders", values.InvertSliders) - - return nil -} - -// SubscribeToChanges allows external components to receive updates when the config is reloaded -func (cc *CanonicalConfig) SubscribeToChanges() chan bool { - c := make(chan bool) - cc.reloadConsumers = append(cc.reloadConsumers, c) - - return c -} - -// WatchConfigFileChanges starts watching for configuration file changes -// and attempts reloading the config when they happen -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) { - - // and attempt reload if appropriate - cc.logger.Debugw("Config file modified, attempting reload", "event", event) - - // wait a bit to let the editor actually flush the new file contents to disk - time.Sleep(delayBetweenEventAndReload) - - if err := cc.Load(localizer); err != nil { - cc.logger.Warnw("Failed to reload config file", "error", err) - } else { - cc.logger.Info("Reloaded config successfully") - - 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.onConfigReloaded() - } - - // don't forget to update the time - lastAttemptedReload = now - } - } - }) - cc.userConfig.WatchConfig() - - // wait till they stop us - <-cc.stopWatcherChannel - cc.logger.Debug("Stopping user config file watcher") -} - -// StopWatchingConfigFile signals our filesystem watcher to stop -func (cc *CanonicalConfig) StopWatchingConfigFile() { - cc.watcherStopped.Store(true) - cc.stopWatcherChannel <- true -} - -func (cc *CanonicalConfig) populateFromVipers() error { - - 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 - } - - values.InvertSliders = cc.userConfig.GetBool(configKeyInvertSliders) - values.NoiseReductionLevel = cc.userConfig.GetString(configKeyNoiseReductionLevel) - values.Language = cc.userConfig.GetString(configKeyLanguage) - - userConfigVID := cc.userConfig.GetUint64(configKeyComVID) - userConfigPID := cc.userConfig.GetUint64(configKeyComPID) - - values.AutoSearchVIDPID = VIDPID{VID: userConfigVID, PID: userConfigPID} - - 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) - - cc.current.Store(values) - - 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") - - return nil -} - -func (cc *CanonicalConfig) onConfigReloaded() { - cc.logger.Debug("Notifying consumers about configuration reload") - - for _, consumer := range cc.reloadConsumers { - consumer <- true - } -} diff --git a/pkg/deej/deej.go b/pkg/deej/deej.go deleted file mode 100644 index 599d6999..00000000 --- a/pkg/deej/deej.go +++ /dev/null @@ -1,245 +0,0 @@ -// Package deej provides a machine-side client that pairs with an Arduino -// chip to form a tactile, physical volume control system/ -package deej - -import ( - "embed" - "fmt" - "os" - - "go.uber.org/zap" - "golang.org/x/text/language" - - "github.com/jeandeaual/go-locale" - "github.com/nicksnyder/go-i18n/v2/i18n" - "github.com/nik9play/deej/pkg/deej/util" - "github.com/pelletier/go-toml/v2" - - "github.com/nik9play/deej/pkg/notify" -) - -const ( - - // when this is set to anything, deej won't use a tray icon - envNoTray = "DEEJ_NO_TRAY_ICON" -) - -// Deej is the main entity managing access to all sub-components -type Deej struct { - logger *zap.SugaredLogger - notifier notify.Notifier - config *CanonicalConfig - serial *SerialIO - sessions *sessionMap - obs *OBSClient - bundle *i18n.Bundle - localizer *i18n.Localizer - - stopChannel chan bool - version string - verbose bool -} - -//go:embed lang/active.*.toml -var langFS embed.FS - -// NewDeej creates a Deej instance -func NewDeej(logger *zap.SugaredLogger, verbose bool, configPath string) (*Deej, error) { - logger = logger.Named("deej") - - bundle := i18n.NewBundle(language.English) - bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal) - _, err := bundle.LoadMessageFileFS(langFS, "lang/active.ru.toml") - - if err != nil { - logger.Errorw("Failed to open ru message file", "error", err) - return nil, fmt.Errorf("load message file: %w", err) - } - - notifier, err := notify.NewToastNotifier(logger) - if err != nil { - logger.Errorw("Failed to create ToastNotifier", "error", err) - return nil, fmt.Errorf("create new ToastNotifier: %w", err) - } - - config, err := NewConfig(logger, notifier, configPath) - if err != nil { - logger.Errorw("Failed to create Config", "error", err) - return nil, fmt.Errorf("create new Config: %w", err) - } - - d := &Deej{ - logger: logger, - notifier: notifier, - config: config, - stopChannel: make(chan bool), - verbose: verbose, - bundle: bundle, - } - - serial, err := NewSerialIO(d, logger) - if err != nil { - logger.Errorw("Failed to create SerialIO", "error", err) - return nil, fmt.Errorf("create new SerialIO: %w", err) - } - - d.serial = serial - - sessionFinder, err := newSessionFinder(logger) - if err != nil { - logger.Errorw("Failed to create SessionFinder", "error", err) - return nil, fmt.Errorf("create new SessionFinder: %w", err) - } - - sessions, err := newSessionMap(d, logger, sessionFinder) - if err != nil { - logger.Errorw("Failed to create sessionMap", "error", err) - return nil, fmt.Errorf("create new sessionMap: %w", err) - } - - d.sessions = sessions - - d.obs = NewOBSClient(d, logger) - - logger.Debug("Created deej instance") - - return d, nil -} - -// Initialize sets up components and starts to run in the background -func (d *Deej) Initialize() error { - d.logger.Debug("Initializing") - - // create temp initialLocalizer because we don't know the language yet - initialLocalizer, err := d.GetSystemLocalizer() - if err != nil { - return err - } - - // load the config for the first time - if err := d.config.Load(initialLocalizer); err != nil { - d.logger.Errorw("Failed to load config during initialization", "error", err) - return fmt.Errorf("load config during init: %w", err) - } - - if err := d.updateLocalizer(); err != nil { - d.logger.Errorw("Failed to update localizer", "error", err) - return fmt.Errorf("update localizer: %w", err) - } - - // initialize the session map - if err := d.sessions.initialize(); err != nil { - d.logger.Errorw("Failed to initialize session map", "error", err) - return fmt.Errorf("init session map: %w", err) - } - - // decide whether to run with/without tray - if _, noTraySet := os.LookupEnv(envNoTray); noTraySet { - - d.logger.Debugw("Running without tray icon", "reason", "envvar set") - - // run in main thread while waiting on ctrl+C - d.setupInterruptHandler() - d.run() - - } else { - d.setupInterruptHandler() - d.initializeTray(d.run) - } - - return nil -} - -func (d *Deej) GetSystemLocalizer() (*i18n.Localizer, error) { - lang, err := locale.GetLanguage() - if err != nil { - return nil, fmt.Errorf("get system locale: %w", err) - } - return i18n.NewLocalizer(d.bundle, lang, "en"), nil -} - -func (d *Deej) updateLocalizer() error { - lang := d.config.Values().Language - if lang == "auto" { - var err error - lang, err = locale.GetLanguage() - - if err != nil { - d.logger.Errorw("Failed to get system locale", "error", err) - return fmt.Errorf("get system locale: %w", err) - } - } - d.logger.Infof("Selected language: %s", lang) - d.localizer = i18n.NewLocalizer(d.bundle, lang, "en") - - return nil -} - -// SetVersion causes deej to add a version string to its tray menu if called before Initialize -func (d *Deej) SetVersion(version string) { - d.version = version -} - -// Verbose returns a boolean indicating whether deej is running in verbose mode -func (d *Deej) Verbose() bool { - return d.verbose -} - -func (d *Deej) setupInterruptHandler() { - interruptChannel := util.SetupCloseHandler() - - go func() { - signal := <-interruptChannel - d.logger.Debugw("Interrupted", "signal", signal) - d.signalStop() - }() -} - -func (d *Deej) run() { - d.logger.Info("Run loop starting") - - // watch the config file for changes - go d.config.WatchConfigFileChanges(d.localizer) - - // connect to the arduino - d.serial.Start() - - d.obs.Start() - - // wait until stopped (gracefully) - <-d.stopChannel - d.logger.Debug("Stop channel signaled, terminating") - - if err := d.stop(); err != nil { - d.logger.Warnw("Failed to stop deej", "error", err) - os.Exit(1) - } - // exit with 0 - os.Exit(0) -} - -func (d *Deej) signalStop() { - d.logger.Debug("Signalling stop channel") - d.stopChannel <- true -} - -func (d *Deej) stop() error { - d.logger.Info("Stopping") - - d.config.StopWatchingConfigFile() - d.serial.Stop() - d.obs.Stop() - - // release the session map - if err := d.sessions.release(); err != nil { - d.logger.Errorw("Failed to release session map", "error", err) - return fmt.Errorf("release session map: %w", err) - } - - d.stopTray() - - // attempt to sync on exit - this won't necessarily work but can't harm - _ = d.logger.Sync() - - return nil -} diff --git a/pkg/deej/serial.go b/pkg/deej/serial.go deleted file mode 100644 index 0871109f..00000000 --- a/pkg/deej/serial.go +++ /dev/null @@ -1,439 +0,0 @@ -package deej - -import ( - "bufio" - "errors" - "fmt" - "regexp" - "slices" - "strconv" - "strings" - "sync" - - "go.bug.st/serial" - "go.bug.st/serial/enumerator" - - "github.com/nicksnyder/go-i18n/v2/i18n" - "go.uber.org/zap" - - "github.com/nik9play/deej/pkg/deej/util" - "github.com/nik9play/deej/pkg/reconnect" -) - -// serialConn is one serial connection generation, carrying the port name it -// resolved to and the connection parameters it was opened with so config -// reloads can detect changes -type serialConn struct { - port serial.Port - comPort string - connInfo ConnectionInfo -} - -// SerialIO provides a deej-aware abstraction layer to managing serial I/O -type SerialIO struct { - deej *Deej - logger *zap.SugaredLogger - - reconnector *reconnect.Reconnector[serialConn] - - stateLock sync.Mutex - comPortToUse string - lastKnownNumSliders int - currentSliderValues []int - - sliderMoveConsumers []chan SliderMoveEvent - stateChangeConsumers []chan bool -} - -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 - PercentValue float32 -} - -var expectedLinePattern = regexp.MustCompile(`^\d{1,4}(\|\d{1,4})*\r\n$`) - -// NewSerialIO creates a SerialIO instance that uses the provided deej -// instance's connection info to establish communications with the arduino chip -func NewSerialIO(deej *Deej, logger *zap.SugaredLogger) (*SerialIO, error) { - logger = logger.Named("serial") - - sio := &SerialIO{ - deej: deej, - logger: logger, - sliderMoveConsumers: []chan SliderMoveEvent{}, - stateChangeConsumers: []chan bool{}, - } - - sio.reconnector = reconnect.New(reconnect.Options[serialConn]{ - Logger: logger, - Dial: sio.dial, - Watch: sio.watch, - Close: sio.close, - OnUp: sio.onUp, - OnDown: sio.onDown, - }) - - logger.Debug("Created serial i/o instance") - - // respond to config changes - sio.setupOnConfigReload() - - return sio, nil -} - -// dial resolves the com port (autodetecting by VID/PID if configured) and -// opens it -func (sio *SerialIO) dial() (serialConn, error) { - config := sio.deej.config.Values() - - comPort := config.ConnectionInfo.COMPort - allowedVIDPID := config.AutoSearchVIDPID - - if comPort == "auto" { - sio.logger.Debugw("Trying to autodetect serial port") - - ports, err := enumerator.GetDetailedPortsList() - - if err != nil { - sio.logger.Errorw("Failed to enumarate serial ports, retrying", "err", err) - return serialConn{}, ErrNoSerialPorts - } - if len(ports) == 0 { - sio.logger.Debug("No serial ports found, retrying") - return serialConn{}, ErrNoSerialPorts - } - for _, port := range ports { - sio.logger.Debugf("Found port: %s", port.Name) - if port.IsUSB { - sio.logger.Debugf(" USB ID %s:%s", port.VID, port.PID) - - vid, _ := strconv.ParseUint(port.VID, 16, 16) - pid, _ := strconv.ParseUint(port.PID, 16, 16) - - if vid == allowedVIDPID.VID && pid == allowedVIDPID.PID { - sio.logger.Debugw("Found COM port", "com", port.Name, "vid", port.VID, "pid", port.PID) - - comPort = port.Name - break - } - - } - } - - if comPort == "auto" { - sio.logger.Debug("COM port not found, retrying") - return serialConn{}, ErrAutoPortNotFound - } - } - - mode := serial.Mode{ - BaudRate: config.ConnectionInfo.BaudRate, - DataBits: 8, - StopBits: serial.OneStopBit, - } - - sio.logger.Debugw("Attempting serial connection", - "comPort", comPort, - "baudRate", mode.BaudRate) - - port, err := serial.Open(comPort, &mode) - - if err != nil { - // might need a user notification here, TBD - sio.logger.Debugw("Failed to open serial connection", "error", err) - return serialConn{}, fmt.Errorf("open serial connection: %w", err) - } - - // actually, this sets timeout to 0x7FFFFFFE instead of 0xFFFFFFFE - // to make serial chip work properly. - // see https://github.com/arduino/serial-monitor/issues/112 - err = port.SetReadTimeout(serial.NoTimeout) - if err != nil { - sio.logger.Warnw("Failed to set read timeout", "error", err) - - // close the port before bailing, otherwise the handle stays open - // and every future open attempt fails with access denied - if closeErr := port.Close(); closeErr != nil { - sio.logger.Warnw("Failed to close serial connection", "error", closeErr) - } - - return serialConn{}, fmt.Errorf("set read timeout: %w", err) - } - - return serialConn{ - port: port, - comPort: comPort, - connInfo: config.ConnectionInfo, - }, 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 { - sio.logger.Info("Connection parameters changed while connecting, renewing connection") - sio.reconnector.Reconnect(errors.New("connection parameters changed during dial")) - return - } - - sio.sendStateChangeEvent(true) - - sio.logger.Named(strings.ToLower(conn.comPort)).Infow("Connected") - - connectedTitle := sio.deej.localizer.MustLocalize(&i18n.LocalizeConfig{ - DefaultMessage: &i18n.Message{ - ID: "ComPortConnectedNotificationTitle", - Other: "Connected to {{.ComPort}}.", - }, - TemplateData: map[string]string{ - "ComPort": conn.comPort, - }, - }) - connectedDescription := sio.deej.localizer.MustLocalize(&i18n.LocalizeConfig{ - DefaultMessage: &i18n.Message{ - ID: "ComPortConnectedNotificationDescription", - Other: "Succesfully connected to deej.", - }, - }) - sio.deej.notifier.Notify(connectedTitle, connectedDescription) -} - -func (sio *SerialIO) onDown(err error) { - sio.logger.Warnw("Read line error", "err", err) - sio.logger.Warn("Closing serial port") - - disconnectedTitle := sio.deej.localizer.MustLocalize(&i18n.LocalizeConfig{ - DefaultMessage: &i18n.Message{ - ID: "ComPortDisconnectedNotificationTitle", - Other: "Disconnected from {{.ComPort}} due to an error.", - }, - TemplateData: map[string]string{ - "ComPort": sio.CurrentComPort(), - }, - }) - disconnectedDescription := sio.deej.localizer.MustLocalize(&i18n.LocalizeConfig{ - DefaultMessage: &i18n.Message{ - ID: "ComPortDisconnectedNotificationDescription", - Other: "Trying to reconnect.", - }, - }) - sio.deej.notifier.Notify(disconnectedTitle, disconnectedDescription) -} - -func (sio *SerialIO) close(conn serialConn) { - if err := conn.port.Close(); err != nil { - sio.logger.Warnw("Failed to close serial connection", "error", err) - } else { - sio.logger.Info("Serial connection closed") - } - - sio.sendStateChangeEvent(false) -} - -func (sio *SerialIO) GetState() bool { - return sio.reconnector.Connected() -} - -func (sio *SerialIO) CurrentComPort() string { - sio.stateLock.Lock() - defer sio.stateLock.Unlock() - - return sio.comPortToUse -} - -func (sio *SerialIO) CurrentSliderValues() []int { - sio.stateLock.Lock() - defer sio.stateLock.Unlock() - - return slices.Clone(sio.currentSliderValues) -} - -// 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), - ) - - sio.reconnector.Start() -} - -// Stop signals us to shut down our serial connection, if one is active -func (sio *SerialIO) Stop() { - sio.reconnector.Stop() - sio.logger.Info("Serial stopped") -} - -// SubscribeToSliderMoveEvents returns an unbuffered channel that receives -// a sliderMoveEvent struct every time a slider moves -func (sio *SerialIO) SubscribeToSliderMoveEvents() chan SliderMoveEvent { - ch := make(chan SliderMoveEvent) - sio.sliderMoveConsumers = append(sio.sliderMoveConsumers, ch) - - return ch -} - -func (sio *SerialIO) SubscribeToStateChangeEvent() chan bool { - ch := make(chan bool) - sio.stateChangeConsumers = append(sio.stateChangeConsumers, ch) - - return ch -} - -func (sio *SerialIO) sendStateChangeEvent(state bool) { - for _, consumer := range sio.stateChangeConsumers { - consumer <- state - } -} - -func (sio *SerialIO) setupOnConfigReload() { - configReloadedChannel := sio.deej.config.SubscribeToChanges() - - go func() { - for { - <-configReloadedChannel - - // reset the slider count so the next line re-emits all values - sio.stateLock.Lock() - sio.lastKnownNumSliders = 0 - sio.stateLock.Unlock() - - // 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 - conn, ok := sio.reconnector.Current() - if !ok { - continue - } - - config := sio.deej.config.Values() - - if config.ConnectionInfo != conn.connInfo { - sio.logger.Info("Detected change in connection parameters, attempting to renew connection") - sio.reconnector.Reconnect(errors.New("connection parameters changed")) - } - } - }() -} - -// watch reads lines off a single connection until it fails; closing the port -// unblocks the pending read -func (sio *SerialIO) watch(conn serialConn, errChannel chan<- error) { - logger := sio.logger.Named(strings.ToLower(conn.comPort)) - - reader := bufio.NewReader(conn.port) - for { - line, err := reader.ReadString('\n') - if err != nil { - // non-blocking send: Reconnect may have already filled this - // generation's buffer, and nothing drains it after teardown - select { - case errChannel <- fmt.Errorf("read error: %w", err): - default: - } - return - } - - if sio.deej.Verbose() { - logger.Debugw("Read new line", "line", line) - } - - sio.handleLine(logger, line) - } -} - -func (sio *SerialIO) handleLine(logger *zap.SugaredLogger, line string) { - // this function receives an unsanitized line which is guaranteed to end with LF, - // but most lines will end with CRLF. it may also have garbage instead of - // deej-formatted values, so we must check for that! just ignore bad ones - if !expectedLinePattern.MatchString(line) { - return - } - - // trim the suffix - line = strings.TrimSuffix(line, "\r\n") - - // split on pipe (|), this gives a slice of numerical strings between "0" and "1023" - splitLine := strings.Split(line, "|") - numSliders := len(splitLine) - - // turns out the first line could come out dirty sometimes (i.e. "4558|925|41|643|220") - // so let's check the first number for correctness just in case - if firstNumber, _ := strconv.Atoi(splitLine[0]); firstNumber > 1023 { - logger.Debugw("Got malformed line from serial, ignoring", "line", line) - return - } - - config := sio.deej.config.Values() - - sio.stateLock.Lock() - - // update our slider count, if needed - this will send slider move events for all - if numSliders != sio.lastKnownNumSliders { - logger.Infow("Detected sliders", "amount", numSliders) - sio.lastKnownNumSliders = numSliders - sio.currentSliderValues = make([]int, numSliders) - - // reset everything to be an impossible value to force the slider move event later - for idx := range sio.currentSliderValues { - sio.currentSliderValues[idx] = -1023 - } - } - - // for each slider: - moveEvents := []SliderMoveEvent{} - for sliderIdx, stringValue := range splitLine { - - // convert string values to integers ("1023" -> 1023) - number, _ := strconv.Atoi(stringValue) - - // map the value from raw to a "dirty" float between 0 and 1 (e.g. 0.15451...) - dirtyFloat := float32(number) / 1023.0 - - // normalize it to an actual volume scalar between 0.0 and 1.0 with 2 points of precision - normalizedScalar := util.NormalizeScalar(dirtyFloat) - - // if sliders are inverted, take the complement of 1.0 - if config.InvertSliders { - normalizedScalar = 1 - normalizedScalar - } - - // 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 it does, update the saved value and create a move event - sio.currentSliderValues[sliderIdx] = number - - moveEvents = append(moveEvents, SliderMoveEvent{ - SliderID: sliderIdx, - PercentValue: normalizedScalar, - }) - - if sio.deej.Verbose() { - logger.Debugw("Slider moved", "event", moveEvents[len(moveEvents)-1]) - } - } - } - - // release the lock before delivering events, since sends can block on consumers - sio.stateLock.Unlock() - - // deliver move events if there are any, towards all potential consumers - if len(moveEvents) > 0 { - for _, consumer := range sio.sliderMoveConsumers { - for _, moveEvent := range moveEvents { - consumer <- moveEvent - } - } - } -} diff --git a/pkg/deej/session_map.go b/pkg/deej/session_map.go deleted file mode 100644 index ef87b976..00000000 --- a/pkg/deej/session_map.go +++ /dev/null @@ -1,452 +0,0 @@ -package deej - -import ( - "fmt" - "strings" - "sync" - - "github.com/nik9play/deej/pkg/deej/util" - "github.com/thoas/go-funk" - "go.uber.org/zap" -) - -type sessionMap struct { - deej *Deej - logger *zap.SugaredLogger - - m map[string][]Session - lock sync.Locker - - sessionFinder SessionFinder - - unmappedSessions []Session - - // channel for notifying about session count changes - sessionCountChangeChan chan struct{} -} - -const ( - masterSessionName = "master" // master device volume - systemSessionName = "system" // system sounds volume - inputSessionName = "mic" // microphone input level - - // some targets need to be transformed before their correct audio sessions can be accessed. - // this prefix identifies those targets to ensure they don't contradict with another similarly-named process - specialTargetTransformPrefix = "deej." - - // obs targets are handled directly via OBS WebSocket API - obsTargetPrefix = "deej.obs:" - - // targets the currently active window (Windows-only, experimental) - specialTargetCurrentWindow = "current" - - // targets the currently active fullscreen window (Windows-only, experimental) - specialTargetCurrentFullscreenWindow = "current.fullscreen" - - // targets all currently unmapped sessions (experimental) - specialTargetAllUnmapped = "unmapped" -) - -func newSessionMap(deej *Deej, logger *zap.SugaredLogger, sessionFinder SessionFinder) (*sessionMap, error) { - logger = logger.Named("sessions") - - m := &sessionMap{ - deej: deej, - logger: logger, - m: make(map[string][]Session), - lock: &sync.Mutex{}, - sessionFinder: sessionFinder, - sessionCountChangeChan: make(chan struct{}, 1), - } - - logger.Debug("Created session map instance") - - return m, nil -} - -func (m *sessionMap) SubscribeToSessionCountChange() <-chan struct{} { - return m.sessionCountChangeChan -} - -func (m *sessionMap) notifySessionCountChange() { - select { - case m.sessionCountChangeChan <- struct{}{}: - default: - // channel already has a pending notification - } -} - -func (m *sessionMap) initialize() error { - m.setupOnSliderMove() - m.setupOnConfigReload() - - // the handler must be registered before the finder starts discovering - // sessions, otherwise startup enumeration events would be lost - if err := m.sessionFinder.Start(m.handleSessionEvent); err != nil { - return fmt.Errorf("start session finder: %w", err) - } - - return nil -} - -func (m *sessionMap) release() error { - if err := m.sessionFinder.Release(); err != nil { - m.logger.Warnw("Failed to release session finder during session map release", "error", err) - return fmt.Errorf("release session finder during release: %w", err) - } - - return nil -} - -func (m *sessionMap) setupOnSliderMove() { - sliderEventsChannel := m.deej.serial.SubscribeToSliderMoveEvents() - - go func() { - for { - event := <-sliderEventsChannel - m.handleSliderMoveEvent(event) - } - }() -} - -// setupOnConfigReload recomputes the unmapped session list whenever the config -// is reloaded, since edits to the slider mapping change which sessions count -// as unmapped -func (m *sessionMap) setupOnConfigReload() { - configReloadedChannel := m.deej.config.SubscribeToChanges() - - go func() { - for range configReloadedChannel { - m.refreshUnmappedSessions() - } - }() -} - -// refreshUnmappedSessions rebuilds the unmapped session list from the current -// session map and slider mapping -func (m *sessionMap) refreshUnmappedSessions() { - m.lock.Lock() - defer m.lock.Unlock() - - m.unmappedSessions = nil - - for _, sessions := range m.m { - for _, session := range sessions { - if !m.sessionMapped(session) { - m.unmappedSessions = append(m.unmappedSessions, session) - } - } - } - - m.logger.Debugw("Refreshed unmapped sessions after config reload", "count", len(m.unmappedSessions)) -} - -// handleSessionEvent is called synchronously by the session finder, -// potentially from multiple goroutines -func (m *sessionMap) handleSessionEvent(event SessionEvent) { - switch event.Type { - case SessionEventAdded: - m.handleSessionAdded(event) - case SessionEventRemoved: - m.handleSessionRemoved(event) - } -} - -func (m *sessionMap) handleSessionAdded(event SessionEvent) { - m.logger.Debugw("Session added event received", "session", event.Session) - - // add to the map and track as unmapped in one locked block, so a - // concurrent refreshUnmappedSessions can't observe the session in the map - // and track it a second time - m.lock.Lock() - - m.addLocked(event.Session) - - if !m.sessionMapped(event.Session) { - m.logger.Debugw("Tracking unmapped session from event", "session", event.Session) - m.unmappedSessions = append(m.unmappedSessions, event.Session) - } - - m.lock.Unlock() - - m.notifySessionCountChange() -} - -func (m *sessionMap) handleSessionRemoved(event SessionEvent) { - if event.Session == nil { - return - } - - m.logger.Debugw("Session removed event received", "key", event.Session.Key()) - - // the finder releases the session as soon as this handler returns. taking - // the lock here guarantees the session is unreachable by then: it's out of - // the map, and no setSessionVolumes call is still using it - m.lock.Lock() - - // Remove from the main map - m.removeSessionLocked(event.Session) - - // Remove from unmapped sessions if present - for i, unmapped := range m.unmappedSessions { - if unmapped == event.Session { - m.unmappedSessions = append(m.unmappedSessions[:i], m.unmappedSessions[i+1:]...) - break - } - } - - m.lock.Unlock() - - m.notifySessionCountChange() -} - -// removeSession removes a specific session from the map -func (m *sessionMap) removeSession(session Session) { - m.lock.Lock() - defer m.lock.Unlock() - - m.removeSessionLocked(session) -} - -// removeSessionLocked removes a specific session from the map. m.lock must be held -func (m *sessionMap) removeSessionLocked(session Session) { - key := session.Key() - sessions, ok := m.m[key] - if !ok { - return - } - - // Find and remove the specific session - for i, s := range sessions { - if s == session { - m.m[key] = append(sessions[:i], sessions[i+1:]...) - break - } - } - - // Remove the key entirely if no sessions left - if len(m.m[key]) == 0 { - delete(m.m, key) - } -} - -// 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()) { - return true - } - - // count device sessions as mapped - if session.IsDevice() { - return true - } - - matchFound := false - - // look through the actual mappings - m.deej.config.Values().SliderMapping.iterate(func(_ int, targets []string) { - for _, target := range targets { - - // ignore special transforms - if m.targetHasSpecialTransform(target) { - continue - } - - // safe to assume this has a single element because we made sure there's no special transform - target = m.resolveTarget(target)[0] - - if target == session.Key() { - matchFound = true - return - } - } - }) - - return matchFound -} - -func (m *sessionMap) handleSliderMoveEvent(event SliderMoveEvent) { - - // get the targets mapped to this slider from the config - targets, ok := m.deej.config.Values().SliderMapping.get(event.SliderID) - - // if slider not found in config, silently ignore - if !ok { - return - } - - // for each possible target for this slider... - for _, target := range targets { - - // handle special action targets (OBS, etc.) that don't map to audio sessions - if m.applySpecialTargetAction(target, event.PercentValue) { - continue - } - - // resolve the target name by cleaning it up and applying any special transformations. - // depending on the transformation applied, this can result in more than one target name - resolvedTargets := m.resolveTarget(target) - - // for each resolved target... - for _, resolvedTarget := range resolvedTargets { - m.setSessionVolumes(resolvedTarget, event.PercentValue) - } - } -} - -// setSessionVolumes adjusts the volume of every session matching the target. -// it holds the lock for the duration of the adjustment so a session can't be -// removed (and released) while its volume is being set -func (m *sessionMap) setSessionVolumes(target string, volume float32) { - m.lock.Lock() - defer m.lock.Unlock() - - sessions, ok := m.m[target] - - // no sessions matching this target - move on - if !ok { - return - } - - // iterate all matching sessions and adjust the volume of each one - for _, session := range sessions { - if session.GetVolume() != volume { - if err := session.SetVolume(volume); err != nil { - m.logger.Warnw("Failed to set target session volume", "error", err) - } - } - } -} - -// applySpecialTargetAction handles targets that control external systems rather than audio sessions -// (e.g. OBS, and potentially Discord or others in the future). -// Returns true if the target was handled, false if it should be treated as a normal audio target. -func (m *sessionMap) applySpecialTargetAction(target string, volume float32) bool { - switch { - case strings.HasPrefix(strings.ToLower(target), obsTargetPrefix): - inputName := target[len(obsTargetPrefix):] - m.handleOBSTarget(inputName, volume) - return true - } - - return false -} - -func (m *sessionMap) handleOBSTarget(inputName string, volume float32) { - if m.deej.obs == nil || !m.deej.obs.IsConnected() { - return - } - - if err := m.deej.obs.SetInputVolume(inputName, volume); err != nil { - m.logger.Debugw("Failed to set OBS input volume", "input", inputName, "error", err) - } -} - -func (m *sessionMap) targetHasSpecialTransform(target string) bool { - return strings.HasPrefix(target, specialTargetTransformPrefix) -} - -func (m *sessionMap) resolveTarget(target string) []string { - - // start by ignoring the case - target = strings.ToLower(target) - - // look for any special targets first, by examining the prefix - if m.targetHasSpecialTransform(target) { - return m.applyTargetTransform(strings.TrimPrefix(target, specialTargetTransformPrefix)) - } - - return []string{target} -} - -func (m *sessionMap) applyTargetTransform(specialTargetName string) []string { - checkFullscreen := false - - // select the transformation based on its name - switch specialTargetName { - - // get current active fullscreen window - case specialTargetCurrentFullscreenWindow: - checkFullscreen = true - fallthrough - - // get current active window - case specialTargetCurrentWindow: - currentWindowProcessNames, err := util.GetCurrentWindowProcessNames(checkFullscreen) - - // silently ignore errors here, as this is on deej's "hot path" (and it could just mean the user's running linux) - if err != nil { - return nil - } - - // we could have gotten a non-lowercase names from that, so let's ensure we return ones that are lowercase - for targetIdx, target := range currentWindowProcessNames { - currentWindowProcessNames[targetIdx] = strings.ToLower(target) - } - - // remove dupes - return funk.UniqString(currentWindowProcessNames) - - // get currently unmapped sessions - case specialTargetAllUnmapped: - // the unmapped session list is mutated by the session event and config - // reload handlers, so it must be read under the lock - m.lock.Lock() - targetKeys := make([]string, len(m.unmappedSessions)) - for sessionIdx, session := range m.unmappedSessions { - targetKeys[sessionIdx] = session.Key() - } - m.lock.Unlock() - - return targetKeys - } - - return nil -} - -func (m *sessionMap) add(value Session) { - m.lock.Lock() - defer m.lock.Unlock() - - m.addLocked(value) -} - -// addLocked adds a session to the map. m.lock must be held -func (m *sessionMap) addLocked(value Session) { - key := value.Key() - - existing, ok := m.m[key] - if !ok { - m.m[key] = []Session{value} - } else { - m.m[key] = append(existing, value) - } -} - -func (m *sessionMap) get(key string) ([]Session, bool) { - m.lock.Lock() - defer m.lock.Unlock() - - value, ok := m.m[key] - return value, ok -} - -func (m *sessionMap) getSessionCount() int { - m.lock.Lock() - defer m.lock.Unlock() - - count := 0 - for _, sessions := range m.m { - count += len(sessions) - } - - return count -} - -func (m *sessionMap) String() string { - return fmt.Sprintf("<%d audio sessions>", m.getSessionCount()) -} diff --git a/pkg/deej/slider_map.go b/pkg/deej/slider_map.go deleted file mode 100644 index 3238a5c2..00000000 --- a/pkg/deej/slider_map.go +++ /dev/null @@ -1,92 +0,0 @@ -package deej - -import ( - "fmt" - "strconv" - "sync" - - "github.com/thoas/go-funk" -) - -type sliderMap struct { - m map[int][]string - lock sync.Locker -} - -func newSliderMap() *sliderMap { - return &sliderMap{ - m: make(map[int][]string), - lock: &sync.Mutex{}, - } -} - -func sliderMapFromConfigs(userMapping map[string][]string, internalMapping map[string][]string) *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) - } - - return resultMap -} - -func (m *sliderMap) iterate(f func(int, []string)) { - m.lock.Lock() - defer m.lock.Unlock() - - for key, value := range m.m { - f(key, value) - } -} - -func (m *sliderMap) get(key int) ([]string, bool) { - m.lock.Lock() - defer m.lock.Unlock() - - value, ok := m.m[key] - return value, ok -} - -func (m *sliderMap) set(key int, value []string) { - m.lock.Lock() - defer m.lock.Unlock() - - m.m[key] = value -} - -func (m *sliderMap) String() string { - m.lock.Lock() - defer m.lock.Unlock() - - sliderCount := 0 - targetCount := 0 - - for _, value := range m.m { - sliderCount++ - targetCount += len(value) - } - - return fmt.Sprintf("<%d sliders mapped to %d targets>", sliderCount, targetCount) -} diff --git a/pkg/deej/tray.go b/pkg/deej/tray.go deleted file mode 100644 index a94ade8d..00000000 --- a/pkg/deej/tray.go +++ /dev/null @@ -1,265 +0,0 @@ -package deej - -import ( - "strconv" - "strings" - - "fyne.io/systray" - "github.com/nicksnyder/go-i18n/v2/i18n" - - "github.com/nik9play/deej/pkg/deej/util" - "github.com/nik9play/deej/pkg/icon" -) - -func getConfigItemText(d *Deej) (string, string) { - configTitle := d.localizer.MustLocalize(&i18n.LocalizeConfig{ - DefaultMessage: &i18n.Message{ - ID: "EditConfigTitle", - Other: "Edit configuration", - }, - }) - configDescription := d.localizer.MustLocalize(&i18n.LocalizeConfig{ - DefaultMessage: &i18n.Message{ - ID: "EditConfigDescription", - Other: "Open config file with notepad", - }, - }) - - return configTitle, configDescription -} - -func getSettingsItemText(d *Deej) (string, string) { - configTitle := d.localizer.MustLocalize(&i18n.LocalizeConfig{ - DefaultMessage: &i18n.Message{ - ID: "SettingsTitle", - Other: "Settings", - }, - }) - configDescription := d.localizer.MustLocalize(&i18n.LocalizeConfig{ - DefaultMessage: &i18n.Message{ - ID: "SettingsDescription", - Other: "Settings", - }, - }) - - return configTitle, configDescription -} - -func getAutostartItemText(d *Deej) (string, string) { - configTitle := d.localizer.MustLocalize(&i18n.LocalizeConfig{ - DefaultMessage: &i18n.Message{ - ID: "AutostartTitle", - Other: "Run at startup", - }, - }) - configDescription := d.localizer.MustLocalize(&i18n.LocalizeConfig{ - DefaultMessage: &i18n.Message{ - ID: "AutostartDescription", - Other: "deej will launch at startup", - }, - }) - - return configTitle, configDescription -} - -func getQuitItemText(d *Deej) (string, string) { - quitTitle := d.localizer.MustLocalize(&i18n.LocalizeConfig{ - DefaultMessage: &i18n.Message{ - ID: "QuitTitle", - Other: "Quit", - }, - }) - quitDescription := d.localizer.MustLocalize(&i18n.LocalizeConfig{ - DefaultMessage: &i18n.Message{ - ID: "QuitDescription", - Other: "Stop deej and quit", - }, - }) - - return quitTitle, quitDescription -} - -func getStatusItemTitle(d *Deej) string { - var title string - - if d.serial.GetState() { - title = d.localizer.MustLocalize(&i18n.LocalizeConfig{ - DefaultMessage: &i18n.Message{ - ID: "StatusTrueTitle", - Other: "Connected to {{.ComPort}}", - }, - TemplateData: map[string]string{ - "ComPort": d.serial.CurrentComPort(), - }, - }) - } else { - title = d.localizer.MustLocalize(&i18n.LocalizeConfig{ - DefaultMessage: &i18n.Message{ - ID: "StatusFalseTitle", - Other: "Waiting for device...", - }, - }) - } - - return title -} - -func getValuesString(d *Deej) string { - values := d.serial.CurrentSliderValues() - strs := make([]string, len(values)) - for i, num := range values { - strs[i] = strconv.FormatFloat((float64(num)/1023.0)*100, 'f', 0, 32) - } - return strings.Join(strs, " | ") -} - -func getSessionsCountString(d *Deej) string { - count := d.sessions.getSessionCount() - return d.localizer.MustLocalize(&i18n.LocalizeConfig{ - DefaultMessage: &i18n.Message{ - ID: "AudioSessionsCount", - One: "{{.Count}} audio session", - Other: "{{.Count}} audio sessions", - }, - TemplateData: map[string]interface{}{ - "Count": count, - }, - PluralCount: count, - }) -} - -func (d *Deej) initializeTray(onDone func()) { - logger := d.logger.Named("tray") - - onReady := func() { - logger.Debug("Tray instance ready") - - systray.SetTemplateIcon(icon.TrayDeejLogo, icon.TrayDeejLogo) - - systray.SetTooltip("deej") - - setTooltip := func() { - title := "deej\n" + getStatusItemTitle(d) - if d.serial.GetState() { - title += "\n" + getValuesString(d) - } - systray.SetTooltip(title) - } - setTooltip() - - settingsTitle, settingsDescription := getSettingsItemText(d) - settings := systray.AddMenuItem(settingsTitle, settingsDescription) - settings.SetIcon(icon.EditConfigIcon) - - configTitle, configDescription := getConfigItemText(d) - editConfig := settings.AddSubMenuItem(configTitle, configDescription) - - autostartTitle, autostartDescription := getAutostartItemText(d) - autostart := settings.AddSubMenuItemCheckbox(autostartTitle, autostartDescription, util.GetAutostartState()) - - if util.Linux() { - autostart.Hide() - } - - systray.AddSeparator() - - statusInfo := systray.AddMenuItem(getStatusItemTitle(d), "") - statusInfo.Disable() - - valuesInfo := systray.AddMenuItem("...", "") - valuesInfo.Disable() - valuesInfo.Hide() - - setValuesInfo := func() { - if d.serial.GetState() { - valuesInfo.SetTitle(getValuesString(d)) - valuesInfo.Show() - } else { - valuesInfo.Hide() - } - } - setValuesInfo() - - sessionsInfo := systray.AddMenuItem(getSessionsCountString(d), "") - sessionsInfo.Disable() - - setSessionsInfo := func() { - sessionsInfo.SetTitle(getSessionsCountString(d)) - } - - if d.version != "" { - versionInfo := systray.AddMenuItem(d.version, "") - versionInfo.Disable() - } - - systray.AddSeparator() - - quitTitle, quitDescription := getQuitItemText(d) - quit := systray.AddMenuItem(quitTitle, quitDescription) - - sliderMovedChannel := d.serial.SubscribeToSliderMoveEvents() - stateChangeChannel := d.serial.SubscribeToStateChangeEvent() - sessionCountChangeChannel := d.sessions.SubscribeToSessionCountChange() - - // wait on things to happen - go func() { - for { - select { - // slider moved - case <-sliderMovedChannel: - setTooltip() - setValuesInfo() - - // connection state changed - case <-stateChangeChannel: - setTooltip() - setValuesInfo() - statusInfo.SetTitle(getStatusItemTitle(d)) - - // session count changed - case <-sessionCountChangeChannel: - setSessionsInfo() - - // quit - case <-quit.ClickedCh: - logger.Info("Quit menu item clicked, stopping") - - d.signalStop() - - // edit config - case <-editConfig.ClickedCh: - logger.Info("Edit config menu item clicked, opening config for editing") - - if err := util.OpenExternal(logger, d.config.configPath); err != nil { - logger.Warnw("Failed to open config file for editing", "error", err) - } - - case <-autostart.ClickedCh: - util.SetAutostartState(!util.GetAutostartState()) - if util.GetAutostartState() { - autostart.Check() - } else { - autostart.Uncheck() - } - - } - } - }() - - // actually start the main runtime - go onDone() - } - - onExit := func() { - logger.Debug("Tray exited") - } - - // start the tray icon - logger.Debug("Running in tray") - systray.Run(onReady, onExit) -} - -func (d *Deej) stopTray() { - d.logger.Debug("Quitting tray") - systray.Quit() -} diff --git a/pkg/deej/util/util.go b/pkg/deej/util/util.go deleted file mode 100644 index 595cfb74..00000000 --- a/pkg/deej/util/util.go +++ /dev/null @@ -1,124 +0,0 @@ -package util - -import ( - "fmt" - "math" - "os" - "os/signal" - "runtime" - "syscall" - - "go.uber.org/zap" -) - -// OpenExternal opens a file using the default associated program -func OpenExternal(logger *zap.SugaredLogger, filename string) error { - command := getOpenExternalCommand(filename) - - if err := command.Run(); err != nil { - logger.Warnw("Failed to open file", - "filename", filename, - "error", err) - return fmt.Errorf("open file proc: %w", err) - } - - return nil -} - -// EnsureDirExists creates the given directory path if it doesn't already exist -func EnsureDirExists(path string) error { - if err := os.MkdirAll(path, os.ModePerm); err != nil { - return fmt.Errorf("ensure directory exists (%s): %w", path, err) - } - - return nil -} - -// FileExists checks if a file exists and is not a directory before we -// try using it to prevent further errors. -func FileExists(filename string) bool { - info, err := os.Stat(filename) - if os.IsNotExist(err) { - return false - } - return !info.IsDir() -} - -// Linux returns true if we're running on Linux -func Linux() bool { - return runtime.GOOS == "linux" -} - -// SetupCloseHandler creates a 'listener' on a new goroutine which will notify the -// program if it receives an interrupt from the OS -func SetupCloseHandler() chan os.Signal { - c := make(chan os.Signal, 1) - signal.Notify(c, os.Interrupt, syscall.SIGTERM) - - return c -} - -// GetCurrentWindowProcessNames returns the process names (including extension, if applicable) -// of the current foreground window. This includes child processes belonging to the window. -// This is currently only implemented for Windows -func GetCurrentWindowProcessNames(checkFullscreen bool) ([]string, error) { - return getCurrentWindowProcessNames(checkFullscreen) -} - -func GetAutostartState() bool { - return getAutostartState() -} - -func SetAutostartState(state bool) error { - return setAutostartState(state) -} - -// NormalizeScalar "trims" the given float32 to 2 points of precision (e.g. 0.15442 -> 0.15) -// This is used both for windows core audio volume levels and for cleaning up slider level values from serial -func NormalizeScalar(v float32) float32 { - return float32(math.Round(float64(v)*100) / 100.0) -} - -// SignificantlyDifferent returns true if there's a significant enough volume difference between two given values -func SignificantlyDifferent(oldValue int, newValue int, noiseReductionLevel string) bool { - const ( - noiseReductionHigh = "high" - noiseReductionLow = "low" - noiseReductionNone = "none" - ) - - // this threshold is solely responsible for dealing with hardware interference when - // sliders are producing noisy values. - var significantDifferenceThreshold int - - // choose our noise reduction level based on the config-provided value - switch noiseReductionLevel { - case noiseReductionHigh: - significantDifferenceThreshold = 20 - case noiseReductionLow: - significantDifferenceThreshold = 5 - case noiseReductionNone: - significantDifferenceThreshold = 1 - default: - significantDifferenceThreshold = 10 - } - - // lower the threshold for edges to snap to 1.0 and 0.0 - if (newValue < 10 || newValue > 1013) && significantDifferenceThreshold > 5 { - significantDifferenceThreshold = 5 - } - - if AbsInt(oldValue-newValue) >= significantDifferenceThreshold { - return true - } - - // values are close enough to not warrant any action - return false -} - -func AbsInt(x int) int { - if x < 0 { - return -x - } - return x -} diff --git a/scripts/README.md b/scripts/README.md index 77cf9c80..320fee46 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,22 +1,46 @@ -## Developer scripts +## Build automation -This document lists the various scripts in the project and their purposes. +Builds are defined in `Taskfile.yml` and `build/tasks/{windows,linux}.yml` and can be run through either +the Wails CLI (`wails3 task `) or the standalone Task CLI (`task `). +Run `wails3 task --list` from the repository root to see all available tasks. +Build configuration stays in `build/`; binaries/installers go to `bin/`. +The entry point is in `cmd/deej/`; Windows manifest/version-info sources live in +`build/windows/`, and embedded icons live in `internal/icon/assets/`. -> Note: All scripts are meant to be run from the root of the repository, i.e. from the _root_ `deej` directory: `.\pkg\deej\scripts\...\whatever.bat`. They're not guaranteed to work correctly if run from another directory. +The files remaining under `scripts/` are inputs to build tasks rather than +executable build wrappers: -### Windows +- [`windows/installer.iss`](./windows/installer.iss) defines the Inno Setup package. +- [`misc/release-notes.txt`](./misc/release-notes.txt) is a release-note template. -- [`build-dev.bat`](./windows/build-dev.bat): Builds deej with a console window, for development purposes -- [`build-release.bat`](./windows/build-release.bat): Builds deej as a standalone tray application without a console window, for releases -- [`build-all.bat`](./windows/build-all.bat): Helper script to build all variants -- [`make-icon.bat`](./windows/make-icon.bat): Converts a .ico file to an icon byte array in a Go file. Used by our systray library. You shouldn't need to run this unless you change the deej logo -- [`make-rsrc.bat`](./windows/make-rsrc.bat): Generates `rsrc_windows_.syso` resource files inside `cmd` alongside `main.go` (via [go-winres](https://github.com/tc-hib/go-winres), configured in `cmd/winres/winres.json`) - These indicate to the Go linker to use the deej application manifest and icon when building. -- [`prepare-release.bat`](./windows/prepare-release.bat): Tags, builds and renames the release binaries in preparation for a GitHub release. Usage: `prepare-release.bat vX.Y.Z` (binaries will be under `releases\vX.Y.Z\`) +Common commands: -### Linux +```sh +wails3 task build:all # release + development binaries for this OS +wails3 task windows:package # Windows release binary + single-arch installer +wails3 task windows:package:universal # installer from prebuilt amd64 + arm64 binaries +wails3 task generate:resources # generate Windows icon/manifest/version resources for GOARCH +``` -- [`build-dev.sh`](./linux/build-dev.sh): Builds deej for development 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 +### Build requirements -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. +All GUI build tasks build the settings frontend first, which requires [Node.js](https://nodejs.org) (see [`frontend/`](../frontend)). + +Windows builds also require the Wails CLI version specified in `go.mod` (CI installs +that exact version). Every Windows build runs `wails3 generate syso` with the +existing `logo.ico`, `build/windows/wails.exe.manifest` and `build/windows/info.json`. +It selects the target with `go env GOARCH` and writes the git-ignored +`cmd/deej/wails_windows_.syso`, which Go embeds into the executable. +`generate:resources` runs the same generation task on its own. + +The manifest and version-info files were generated once using Wails' +`generate build-assets` templates and are committed source files. Edit them to +change Windows metadata; runtime Git version information is still supplied through +the existing linker flags. To refresh the templates, generate build assets into a +temporary directory and copy the relevant Windows files into `build/windows/`. + +Linux GUI builds use the experimental [Wails purego backend](https://github.com/wailsapp/wails/blob/v3.1.0/v3/pkg/application/PUREGO_LINUX.md) with `CGO_ENABLED=0`. No C compiler, GTK/WebKit development headers or Docker are needed to build. The target machine still needs GTK4/WebKitGTK runtime libraries, e.g. on Ubuntu 24.04+: `sudo apt-get install libgtk-4-1 libwebkitgtk-6.0-4`. For older distros that lack `webkitgtk-6.0`, use `wails3 task linux:build EXTRA_TAGS=gtk3` and install `libgtk-3-0 libwebkit2gtk-4.1-0` on the target instead. + +Windows and Linux binaries are CGO-free and cross-compilable. Select the architecture with `GOARCH`, e.g. `GOARCH=arm64 wails3 task linux:build:all`. All Linux tasks set `GOOS=linux`; Windows tasks need `GOOS=windows` when invoked on another OS. CI builds Linux amd64 and arm64 GUI binaries on one runner. The Linux headless build (`linux:build:headless`) omits Wails entirely and needs no GTK/WebKit runtime libraries. + +`wails3 task test` disables CGO and selects `purego` on Linux. To run Linux tests directly, use `CGO_ENABLED=0 go test -tags purego ./...`. diff --git a/scripts/linux/build-all.sh b/scripts/linux/build-all.sh deleted file mode 100755 index b262ddba..00000000 --- a/scripts/linux/build-all.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/sh - -echo 'Building deej (all)...' - -./scripts/linux/build-dev.sh -./scripts/linux/build-release.sh diff --git a/scripts/linux/build-dev.sh b/scripts/linux/build-dev.sh deleted file mode 100755 index 7a6d07e5..00000000 --- a/scripts/linux/build-dev.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh - -./scripts/linux/build.sh dev \ No newline at end of file diff --git a/scripts/linux/build-release.sh b/scripts/linux/build-release.sh deleted file mode 100755 index 5b39d227..00000000 --- a/scripts/linux/build-release.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh - -./scripts/linux/build.sh release \ No newline at end of file diff --git a/scripts/linux/build.sh b/scripts/linux/build.sh deleted file mode 100755 index 06689dac..00000000 --- a/scripts/linux/build.sh +++ /dev/null @@ -1,53 +0,0 @@ -#!/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)..." - -# 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" - -# 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 - -# Check if build succeeded -if [ $? -eq 0 ]; then - echo "Done." -else - echo 'Error: "go build" exited with a non-zero code. Are you running this script from the root deej directory?' - exit 1 -fi diff --git a/scripts/windows/build-all.bat b/scripts/windows/build-all.bat deleted file mode 100644 index 9795feea..00000000 --- a/scripts/windows/build-all.bat +++ /dev/null @@ -1,9 +0,0 @@ -@ECHO OFF - -ECHO Building deej (all)... - -REM set windows scripts dir root in relation to script path to avoid cwd dependency -SET "WIN_SCRIPTS_ROOT=%~dp0" - -CALL "%WIN_SCRIPTS_ROOT%build-dev.bat" -CALL "%WIN_SCRIPTS_ROOT%build-release.bat" diff --git a/scripts/windows/build-dev.bat b/scripts/windows/build-dev.bat deleted file mode 100644 index 9c291997..00000000 --- a/scripts/windows/build-dev.bat +++ /dev/null @@ -1,6 +0,0 @@ -@ECHO OFF - -REM set windows scripts dir root in relation to script path to avoid cwd dependency -SET "WIN_SCRIPTS_ROOT=%~dp0" - -CALL "%WIN_SCRIPTS_ROOT%build.bat" dev \ No newline at end of file diff --git a/scripts/windows/build-release.bat b/scripts/windows/build-release.bat deleted file mode 100644 index a0299b85..00000000 --- a/scripts/windows/build-release.bat +++ /dev/null @@ -1,6 +0,0 @@ -@ECHO OFF - -REM set windows scripts dir root in relation to script path to avoid cwd dependency -SET "WIN_SCRIPTS_ROOT=%~dp0" - -CALL "%WIN_SCRIPTS_ROOT%build.bat" release \ No newline at end of file diff --git a/scripts/windows/build.bat b/scripts/windows/build.bat deleted file mode 100644 index 451adeb7..00000000 --- a/scripts/windows/build.bat +++ /dev/null @@ -1,56 +0,0 @@ -@ECHO OFF - -REM check if mode parameter is provided -IF "%1"=="" ( - 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..\.." - -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" -) 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" -) - -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 diff --git a/scripts/windows/installer.iss b/scripts/windows/installer.iss index 4df60a1e..034dab10 100644 --- a/scripts/windows/installer.iss +++ b/scripts/windows/installer.iss @@ -9,7 +9,7 @@ ; deej binaries to package; CI overrides Amd64Exe and defines Arm64Exe ; to produce a universal installer bundling both architectures #ifndef Amd64Exe - #define Amd64Exe "../../build/deej-release.exe" + #define Amd64Exe "../../bin/deej-release.exe" #endif [Setup] @@ -24,7 +24,7 @@ SolidCompression=yes PrivilegesRequired=lowest DisableProgramGroupPage=yes UninstallDisplayIcon={app}\{#AppExeName} -SetupIconFile="..\..\pkg\icon\assets\logo.ico" +SetupIconFile="..\..\internal\icon\assets\logo.ico" ; "ArchitecturesAllowed=x64compatible" specifies that Setup cannot run ; on anything but x64 and Windows 11 on Arm. ArchitecturesAllowed=x64compatible @@ -36,7 +36,7 @@ ArchitecturesInstallIn64BitMode=x64compatible CloseApplications=yes WizardStyle=modern WizardSizePercent=100 -WizardSmallImageFile="..\..\pkg\icon\assets\logo.bmp" +WizardSmallImageFile="..\..\internal\icon\assets\logo.bmp" [Languages] Name: "english"; MessagesFile: "compiler:Default.isl" @@ -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 deleted file mode 100644 index afc48097..00000000 --- a/scripts/windows/make-installer.bat +++ /dev/null @@ -1,20 +0,0 @@ -@ECHO OFF - -SET "DEEJ_ROOT=%~dp0..\.." - -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 -) - -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 diff --git a/scripts/windows/make-rsrc.bat b/scripts/windows/make-rsrc.bat deleted file mode 100644 index fd0b57f9..00000000 --- a/scripts/windows/make-rsrc.bat +++ /dev/null @@ -1,5 +0,0 @@ -@ECHO OFF - -ECHO Creating resource file... -go generate ./... -ECHO Done diff --git a/scripts/windows/prepare-release.bat b/scripts/windows/prepare-release.bat deleted file mode 100644 index 10ff3560..00000000 --- a/scripts/windows/prepare-release.bat +++ /dev/null @@ -1,44 +0,0 @@ -@ECHO OFF - -IF "%1"=="" GOTO NOTAG - -ECHO Preparing release (%1)... -ECHO. - -git tag --delete %1 >NUL 2>&1 -git tag %1 - -REM set windows scripts dir root in relation to script path to avoid cwd dependency -SET "WIN_SCRIPTS_ROOT=%~dp0" - -CALL "%WIN_SCRIPTS_ROOT%build-all.bat" - -REM make this next part nicer by setting the repo root -SET "DEEJ_ROOT=%WIN_SCRIPTS_ROOT%..\.." -PUSHD "%DEEJ_ROOT%" -SET "DEEJ_ROOT=%CD%" -POPD - -MKDIR "%DEEJ_ROOT%\releases\%1" 2> NUL -COPY /Y "%DEEJ_ROOT%\build\deej-release.exe" "%DEEJ_ROOT%\releases\%1\deej.exe" >NUL 2>&1 -COPY /Y "%DEEJ_ROOT%\build\deej-dev.exe" "%DEEJ_ROOT%\releases\%1\deej-debug.exe" >NUL 2>&1 -COPY /Y "%DEEJ_ROOT%\config_examples\config.example.yaml" "%DEEJ_ROOT%\releases\%1\config.yaml" >NUL 2>&1 -COPY /Y "%DEEJ_ROOT%\scripts\misc\release-notes.txt" "%DEEJ_ROOT%\releases\%1\notes.txt" >NUL 2>&1 - -ISCC /O"%DEEJ_ROOT%\releases\%1" /F"deej-setup-%1" "/DAppVersion=%1" /Qp "%DEEJ_ROOT%\scripts\windows\installer.iss" - -ECHO. -ECHO Release binaries created in %DEEJ_ROOT%\releases\%1 -ECHO Opening release directory and notes for editing. -ECHO When you're done, run "git push origin %1" and draft the release on GitHub. - -START explorer.exe "%DEEJ_ROOT%\releases\%1" -START notepad.exe "%DEEJ_ROOT%\releases\%1\notes.txt" - -GOTO DONE - -:NOTAG -ECHO usage: %0 ^ (use semver i.e. v0.9.3) -GOTO DONE - -:DONE