diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644
index 0000000..59c4aed
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -0,0 +1,46 @@
+name: Bug report
+description: Something in the app is broken or behaves wrong
+labels: [bug]
+body:
+ - type: textarea
+ id: what
+ attributes:
+ label: What happens
+ description: What you did, what you expected, and what you got instead.
+ placeholder: |
+ 1. Opened a playlist
+ 2. Clicked the third video
+ 3. Player stays black, audio plays
+ validations:
+ required: true
+
+ - type: input
+ id: version
+ attributes:
+ label: Zuno version
+ description: Settings → About, or the installer filename.
+ placeholder: "1.2.1"
+ validations:
+ required: true
+
+ - type: dropdown
+ id: os
+ attributes:
+ label: OS
+ options:
+ - Windows
+ - macOS
+ - Linux
+ validations:
+ required: true
+
+ - type: textarea
+ id: logs
+ attributes:
+ label: Log
+ description: |
+ `current.log` from the log directory — it usually says what failed.
+ Windows: `%LOCALAPPDATA%\com.zuno.desktop\logs`
+ macOS: `~/Library/Logs/com.zuno.desktop`
+ Linux: `~/.local/share/com.zuno.desktop/logs`
+ render: text
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..e42d371
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,11 @@
+# Blank issues stay on: not every report fits a form, and a bad-fit form is how a real
+# bug turns into no report at all.
+blank_issues_enabled: true
+contact_links:
+ - name: Security vulnerability
+ url: https://github.com/noFAYZ/zuno/security/advisories/new
+ about: Report privately, not as a public issue. See SECURITY.md for scope.
+
+ - name: Question or help using Zuno
+ url: https://github.com/noFAYZ/zuno/discussions/new?category=q-a
+ about: Not sure whether it is a bug? Ask here — it can be moved to an issue.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
new file mode 100644
index 0000000..eb93bfa
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -0,0 +1,17 @@
+name: Feature request
+description: Suggest something Zuno should do
+labels: [enhancement]
+body:
+ - type: textarea
+ id: problem
+ attributes:
+ label: The problem
+ description: What are you trying to do that Zuno makes hard or impossible today?
+ validations:
+ required: true
+
+ - type: textarea
+ id: idea
+ attributes:
+ label: What you have in mind
+ description: Optional. A rough idea is fine — the problem above matters more.
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..0d829a2
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,33 @@
+# Dependency updates. Monthly, grouped into one PR per ecosystem — zuno has a single
+# maintainer, and a weekly drip of five separate PRs is how update PRs get ignored.
+version: 2
+updates:
+ - package-ecosystem: npm
+ directory: /
+ schedule:
+ interval: monthly
+ groups:
+ npm:
+ patterns: ["*"]
+
+ - package-ecosystem: npm
+ directory: /landing
+ schedule:
+ interval: monthly
+ groups:
+ landing:
+ patterns: ["*"]
+
+ - package-ecosystem: cargo
+ directory: /src-tauri
+ schedule:
+ interval: monthly
+ groups:
+ cargo:
+ patterns: ["*"]
+
+ # Ungrouped: an action bump is one line and worth reading on its own.
+ - package-ecosystem: github-actions
+ directory: /
+ schedule:
+ interval: monthly
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..9c968f7
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,18 @@
+## What and why
+
+
+
+Closes #
+
+## How it was checked
+
+
+
+- [ ] `npm run verify` passes
+- [ ] `cargo test` passes (if `src-tauri/` changed)
+- [ ] Ran the app and used the affected screen
+- [ ] Screenshot or clip below (UI changes)
+
+## Notes for the reviewer
+
+
diff --git a/.github/rulesets/README.md b/.github/rulesets/README.md
new file mode 100644
index 0000000..aeca327
--- /dev/null
+++ b/.github/rulesets/README.md
@@ -0,0 +1,27 @@
+# Rulesets
+
+Rulesets are repository *settings*, not configuration GitHub reads from the tree — a file here
+does nothing until it is imported. It lives in the repo so the protection on `main` is reviewable
+in a diff instead of being a thing someone once clicked.
+
+Apply or update:
+
+```sh
+gh api --method PUT repos/noFAYZ/zuno/rulesets/20021321 --input .github/rulesets/main.json
+```
+
+`main.json` is applied — id `20021321`, created 2026-07-30. Edit the file, run the command, and the
+two stay in step; skip the command and the file is fiction. `gh api repos/noFAYZ/zuno/rulesets`
+lists what is actually live. The UI equivalent is Settings → Rules → Rulesets.
+
+## What `main.json` does
+
+- No deletion, no force-push.
+- Changes arrive by pull request. **Zero** approvals required — this is a solo-maintained repo, and
+ a review count of one would lock the maintainer out of their own default branch, since nobody can
+ approve their own PR.
+- `verify` and `rust` — both CI jobs — must pass first. Not strict: a branch does not have to be
+ rebased onto the newest `main` before merging, which on a repo this size is churn without a
+ matching risk.
+- No bypass actors. An admin can still edit the ruleset itself, so the escape hatch exists without
+ a standing exemption that quietly makes the rules advisory.
diff --git a/.github/rulesets/main.json b/.github/rulesets/main.json
new file mode 100644
index 0000000..b3ab48f
--- /dev/null
+++ b/.github/rulesets/main.json
@@ -0,0 +1,35 @@
+{
+ "name": "main",
+ "target": "branch",
+ "enforcement": "active",
+ "conditions": {
+ "ref_name": {
+ "include": ["~DEFAULT_BRANCH"],
+ "exclude": []
+ }
+ },
+ "bypass_actors": [],
+ "rules": [
+ { "type": "deletion" },
+ { "type": "non_fast_forward" },
+ {
+ "type": "pull_request",
+ "parameters": {
+ "required_approving_review_count": 0,
+ "dismiss_stale_reviews_on_push": false,
+ "require_code_owner_review": false,
+ "require_last_push_approval": false,
+ "required_review_thread_resolution": false,
+ "allowed_merge_methods": ["merge", "squash", "rebase"]
+ }
+ },
+ {
+ "type": "required_status_checks",
+ "parameters": {
+ "strict_required_status_checks_policy": false,
+ "do_not_enforce_on_create": false,
+ "required_status_checks": [{ "context": "verify" }, { "context": "rust" }]
+ }
+ }
+ ]
+}
diff --git a/.github/workflows/aur.yml b/.github/workflows/aur.yml
new file mode 100644
index 0000000..58f0248
--- /dev/null
+++ b/.github/workflows/aur.yml
@@ -0,0 +1,108 @@
+# Publishes zuno to the AUR whenever a release is published.
+#
+# Runs from CI because the AUR needs an Arch environment and an SSH key, and the maintainer
+# develops on Windows — doing this by hand meant either a VM or the package going stale.
+#
+# Requires one repository secret:
+# AUR_SSH_PRIVATE_KEY — the private half of the SSH key registered on the AUR account
+name: AUR
+
+on:
+ release:
+ types: [published]
+ # Lets a botched package be re-pushed without cutting another release.
+ workflow_dispatch:
+ inputs:
+ version:
+ description: "Version to publish, without the leading v (e.g. 1.2.1)"
+ required: true
+
+# Publishing happens over SSH to the AUR, not through the GitHub token.
+permissions:
+ contents: read
+
+jobs:
+ publish:
+ runs-on: ubuntu-latest
+ container: archlinux:base-devel
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Resolve version
+ id: version
+ run: |
+ if [ -n "${{ github.event.inputs.version }}" ]; then
+ version="${{ github.event.inputs.version }}"
+ else
+ version="${GITHUB_REF_NAME#v}"
+ fi
+ echo "version=$version" >> "$GITHUB_OUTPUT"
+
+ - name: Install packaging tools
+ run: |
+ pacman -Syu --noconfirm --needed git openssh pacman-contrib sudo
+ # makepkg and everything built on it — updpkgsums, --printsrcinfo — refuse to run
+ # as root, and the container is root. One unprivileged user serves all of them.
+ useradd -m builder
+ # `makepkg -s` installs the declared dependencies, which needs pacman.
+ echo 'builder ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/builder
+
+ - name: Update version and checksum
+ working-directory: packaging/aur/zuno
+ run: |
+ version="${{ steps.version.outputs.version }}"
+ sed -i "s/^pkgver=.*/pkgver=${version}/" PKGBUILD
+ sed -i "s/^pkgrel=.*/pkgrel=1/" PKGBUILD
+ chown -R builder .
+ # updpkgsums downloads the released .deb and rewrites sha256sums from what actually
+ # shipped, rather than trusting a hash edited by hand alongside the version.
+ #
+ # .SRCINFO is what the AUR reads — not the PKGBUILD — so a stale one silently serves
+ # the old version even after a successful push.
+ su builder -c "updpkgsums && makepkg --printsrcinfo > .SRCINFO"
+
+ - name: Build it before anyone else has to
+ working-directory: packaging/aur/zuno
+ run: su builder -c "makepkg -sf --noconfirm"
+
+ - name: Publish
+ env:
+ AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
+ run: |
+ version="${{ steps.version.outputs.version }}"
+
+ mkdir -p ~/.ssh
+ echo "$AUR_SSH_PRIVATE_KEY" > ~/.ssh/aur
+ chmod 600 ~/.ssh/aur
+ # `accept-new` rather than a keyscan: a keyscan that returns nothing fails silently
+ # and leaves an empty known_hosts, which is what broke the first run. This trusts
+ # the host on first contact and pins it for the rest of the job, and unlike
+ # StrictHostKeyChecking=no it still rejects a key that changes mid-run.
+ export GIT_SSH_COMMAND="ssh -i ~/.ssh/aur -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=$HOME/.ssh/known_hosts"
+
+ git config --global user.name "${{ github.actor }}"
+ git config --global user.email "${{ github.actor }}@users.noreply.github.com"
+
+ # A package that does not exist yet clones as an empty repo rather than failing, so
+ # the first run creates it and every run after updates it. That matters because the
+ # maintainer is on Windows: without this, publishing would need a Linux box once,
+ # purely to make the initial commit.
+ git clone ssh://aur@aur.archlinux.org/zuno.git /tmp/aur || {
+ echo "No AUR repo yet — initialising one."
+ mkdir -p /tmp/aur
+ git -C /tmp/aur init -b master
+ git -C /tmp/aur remote add origin ssh://aur@aur.archlinux.org/zuno.git
+ }
+
+ # Only these two files belong in an AUR repo — no sources, no built packages.
+ cp packaging/aur/zuno/PKGBUILD packaging/aur/zuno/.SRCINFO /tmp/aur/
+
+ cd /tmp/aur
+ # `--quiet` alone reports nothing on a fresh repo where the files are untracked.
+ git add PKGBUILD .SRCINFO
+ if git diff --cached --quiet; then
+ echo "AUR already at ${version}, nothing to push."
+ exit 0
+ fi
+ git commit -m "zuno ${version}"
+ git push -u origin master
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..bd11176
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,99 @@
+# Typecheck and self-checks on every push and pull request.
+#
+# release.yml was the only workflow, and it runs `tsc` solely as part of `npm run build` at
+# tag time — so a type error could sit on main until someone cut a version. This is the gate
+# that was missing, kept deliberately cheap: no Rust toolchain, no bundling, no artifacts.
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+# Nothing here writes to the repo. A compromised dependency in `npm ci` gets a token that
+# can only read.
+permissions:
+ contents: read
+
+# A second push to the same branch makes the first run's answer irrelevant.
+concurrency:
+ group: ci-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ verify:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: npm
+
+ - run: npm ci
+
+ - name: Typecheck
+ run: npm run typecheck
+
+ - name: Self-checks
+ run: npm run check
+
+ # Windows rather than Ubuntu: the crate pulls in Tauri, and building it on Linux needs
+ # webkit2gtk and friends installed first. Windows needs nothing, and it is the platform
+ # these tests — cookie jar merging, range headers, URL sanitising — actually ship on.
+ rust:
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: dtolnay/rust-toolchain@stable
+
+ - uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: src-tauri
+
+ - name: Rust tests
+ working-directory: src-tauri
+ run: cargo test
+
+ # Compile-only, and only because of `#[cfg(target_os = "linux")]`.
+ #
+ # The job above runs on Windows, so every Linux-gated line — `linux_media.rs`, the MPRIS
+ # session, anything else added behind that cfg — was invisible to CI. A pull request touching
+ # it could go green without the code ever having been fed to a compiler.
+ #
+ # `cargo check` rather than `cargo test`: the tests are platform-independent and the Windows
+ # job already runs them. What is missing here is the compiler, not the assertions.
+ rust-linux:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: dtolnay/rust-toolchain@stable
+
+ - uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: src-tauri
+ # A different target dir to the Windows job's — sharing one would thrash the cache.
+ key: linux
+
+ # Mirrors the list in release.yml's Linux build. If one changes, change both.
+ - name: System dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y \
+ libwebkit2gtk-4.1-dev \
+ libgtk-3-dev \
+ libayatana-appindicator3-dev \
+ libjavascriptcoregtk-4.1-dev \
+ libsoup-3.0-dev \
+ librsvg2-dev \
+ libssl-dev \
+ build-essential
+
+ # `--locked` because a Cargo.toml change that forgets its Cargo.lock is the other way this
+ # goes wrong quietly.
+ - name: Typecheck
+ working-directory: src-tauri
+ run: cargo check --locked
diff --git a/.github/workflows/flatpak.yml b/.github/workflows/flatpak.yml
new file mode 100644
index 0000000..5d5a15c
--- /dev/null
+++ b/.github/workflows/flatpak.yml
@@ -0,0 +1,95 @@
+# Builds the Flatpak and generates the offline source manifests.
+#
+# Flatpak builds have no network, so every crate and npm package must be listed in
+# cargo-sources.json / node-sources.json. Those are generated, not written — this job
+# regenerates them, builds against them, and uploads both plus the .flatpak so a submission
+# can be assembled without a Linux machine.
+#
+# Not wired to releases: Flathub builds from its own repo once the app is accepted. This is
+# for validating the manifest before and after submission.
+name: Flatpak
+
+on:
+ workflow_dispatch:
+ pull_request:
+ paths:
+ - "packaging/flatpak/**"
+ - ".github/workflows/flatpak.yml"
+
+# Artifacts upload without repo write access.
+permissions:
+ contents: read
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ container:
+ image: bilelmoussaoui/flatpak-github-actions:gnome-46
+ options: --privileged
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install generators
+ run: |
+ # appstream and desktop-file-utils supply the two validators below; neither ships
+ # in the flatpak-builder image.
+ dnf install -y python3-pip git nodejs npm appstream desktop-file-utils
+ # tomlkit and aiohttp are what the cargo generator imports — python3-toml is a
+ # different library and does not satisfy it.
+ pip3 install --break-system-packages tomlkit aiohttp
+ curl -sSLo /usr/local/bin/flatpak-cargo-generator.py \
+ https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/master/cargo/flatpak-cargo-generator.py
+ chmod +x /usr/local/bin/flatpak-cargo-generator.py
+ # The node generator is a Python package inside flatpak-builder-tools, not an npm
+ # module — `npm i -g flatpak-node-generator` installs something unrelated.
+ git clone --depth 1 https://github.com/flatpak/flatpak-builder-tools /tmp/fbt
+ pip3 install --break-system-packages /tmp/fbt/node
+
+ - name: Generate cargo sources
+ run: |
+ python3 /usr/local/bin/flatpak-cargo-generator.py \
+ src-tauri/Cargo.lock -o packaging/flatpak/cargo-sources.json
+ echo "cargo sources: $(wc -l < packaging/flatpak/cargo-sources.json) lines"
+
+ - name: Generate node sources
+ run: |
+ python3 -m flatpak_node_generator npm package-lock.json \
+ -o packaging/flatpak/node-sources.json
+ echo "node sources: $(wc -l < packaging/flatpak/node-sources.json) lines"
+
+ - name: Add flathub and install the runtime
+ run: |
+ flatpak remote-add --user --if-not-exists flathub \
+ https://dl.flathub.org/repo/flathub.flatpakrepo
+ # The SDK extensions track the freedesktop base, not the GNOME version — GNOME 47
+ # sits on 24.08, so rust-stable//47 does not exist and the build would fail
+ # resolving it.
+ flatpak install --user -y --noninteractive flathub \
+ org.gnome.Platform//47 \
+ org.gnome.Sdk//47 \
+ org.freedesktop.Sdk.Extension.rust-stable//24.08 \
+ org.freedesktop.Sdk.Extension.node20//24.08
+
+ - name: Validate metadata
+ run: |
+ # Both are hard gates at Flathub review, and both are cheap to check here.
+ appstreamcli validate --no-net packaging/flatpak/io.github.nofayz.Zuno.metainfo.xml
+ desktop-file-validate packaging/flatpak/io.github.nofayz.Zuno.desktop
+
+ - name: Build
+ run: |
+ flatpak-builder --user --disable-rofiles-fuse --force-clean \
+ --install-deps-from=flathub \
+ --repo=repo build-dir packaging/flatpak/io.github.nofayz.Zuno.yml
+ flatpak build-bundle repo zuno.flatpak io.github.nofayz.Zuno
+
+ - uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: flatpak
+ path: |
+ zuno.flatpak
+ packaging/flatpak/cargo-sources.json
+ packaging/flatpak/node-sources.json
+ if-no-files-found: warn
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index a02508d..d717d6f 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,242 +1,364 @@
-name: Release
-
-on:
- workflow_dispatch:
- inputs:
- release_tag:
- description: Existing GitHub Release tag, for example v1.2.4
- required: true
- type: string
- release:
- types: [published]
-permissions:
- contents: write
-
-env:
- RELEASE_TAG: ${{ inputs.release_tag || github.event.release.tag_name }}
-
-jobs:
- release:
- name: Build ${{ matrix.platform.name }}
- strategy:
- fail-fast: false
- matrix:
- platform:
- - name: Windows
- os: windows-latest
- rust_target: ""
- build_args: --bundles msi,nsis
- apple_signing_identity: ""
- - name: macOS Apple Silicon
- os: macos-latest
- rust_target: aarch64-apple-darwin
- build_args: --target aarch64-apple-darwin --bundles dmg
- apple_signing_identity: "-"
- - name: macOS Intel
- os: macos-15-intel
- rust_target: x86_64-apple-darwin
- build_args: --target x86_64-apple-darwin --bundles dmg
- apple_signing_identity: "-"
- - name: Linux
- os: ubuntu-latest
- rust_target: ""
- build_args: --bundles deb,appimage
- apple_signing_identity: ""
- runs-on: ${{ matrix.platform.os }}
-
- steps:
- - name: Verify release exists
- shell: bash
- run: gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY"
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Read release type
- id: release_metadata
- shell: bash
- run: |
- set -euo pipefail
- is_prerelease="$(gh release view "$RELEASE_TAG" \
- --repo "$GITHUB_REPOSITORY" \
- --json isPrerelease \
- --jq .isPrerelease)"
-
- if test "$is_prerelease" = "true"; then
- echo "publish_update=false" >> "$GITHUB_OUTPUT"
- else
- echo "publish_update=true" >> "$GITHUB_OUTPUT"
- fi
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Check out repository
- uses: actions/checkout@v4
- with:
- ref: ${{ env.RELEASE_TAG }}
- fetch-depth: 0
-
- - name: Verify checked out tag
- shell: bash
- run: |
- set -euo pipefail
- test "$(git describe --tags --exact-match)" = "$RELEASE_TAG"
-
- - name: Set up Node.js
- uses: actions/setup-node@v4
- with:
- node-version: 22
- cache: npm
-
- - name: Set up Rust
- uses: dtolnay/rust-toolchain@stable
-
- - name: Add Rust target
- if: matrix.platform.rust_target != ''
- run: rustup target add ${{ matrix.platform.rust_target }}
-
- - name: Install Linux system dependencies
- if: matrix.platform.name == 'Linux'
- shell: bash
- run: |
- sudo apt-get update
- sudo apt-get install -y \
- libwebkit2gtk-4.1-dev \
- build-essential \
- curl \
- wget \
- file \
- libssl-dev \
- libayatana-appindicator3-dev \
- librsvg2-dev \
- libgtk-3-dev \
- libsoup-3.0-dev \
- libjavascriptcoregtk-4.1-dev \
- patchelf
-
- - name: Cache Rust build
- uses: swatinem/rust-cache@v2
- with:
- workspaces: "./src-tauri -> target"
-
- - name: Install frontend dependencies
- run: npm ci
-
- - name: Build and upload installers
- uses: tauri-apps/tauri-action@v0
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- APPLE_SIGNING_IDENTITY: ${{ matrix.platform.apple_signing_identity }}
- TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
- TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
- with:
- tagName: ${{ env.RELEASE_TAG }}
- releaseDraft: false
- uploadUpdaterJson: ${{ steps.release_metadata.outputs.publish_update }}
- uploadUpdaterSignatures: false
- updaterJsonPreferNsis: true
- args: ${{ matrix.platform.build_args }}
-
- - name: Upload Windows portable executable
- if: matrix.platform.name == 'Windows'
- shell: bash
- run: |
- set -euo pipefail
- gh release upload "$RELEASE_TAG" \
- "src-tauri/target/release/just-another-music-client.exe#Just-Another-Music-Client-Windows-portable.exe" \
- --clobber
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Verify macOS DMG and app signature
- if: startsWith(matrix.platform.name, 'macOS')
- shell: bash
- run: |
- set -euo pipefail
-
- bundle_dir="src-tauri/target/${{ matrix.platform.rust_target }}/release/bundle/dmg"
- dmg_path="$(find "$bundle_dir" -maxdepth 1 -name '*.dmg' -print -quit)"
- test -n "$dmg_path"
- hdiutil verify "$dmg_path"
-
- mount_dir="$(mktemp -d)"
- cleanup() {
- hdiutil detach "$mount_dir" -quiet || true
- rmdir "$mount_dir" || true
- }
- trap cleanup EXIT
-
- hdiutil attach "$dmg_path" -readonly -nobrowse -mountpoint "$mount_dir"
- app_path="$(find "$mount_dir" -maxdepth 1 -name '*.app' -print -quit)"
- test -n "$app_path"
- codesign --verify --deep --strict --verbose=2 "$app_path"
-
- publish-updater-channel:
- name: Publish gated updater metadata
- needs: release
- runs-on: ubuntu-latest
-
- steps:
- - name: Check out repository
- uses: actions/checkout@v4
- with:
- ref: ${{ env.RELEASE_TAG }}
- fetch-depth: 0
-
- - name: Read release type
- id: release_metadata
- shell: bash
- run: |
- set -euo pipefail
- is_prerelease="$(gh release view "$RELEASE_TAG" \
- --repo "$GITHUB_REPOSITORY" \
- --json isPrerelease \
- --jq .isPrerelease)"
-
- if test "$is_prerelease" = "true"; then
- echo "publish_update=false" >> "$GITHUB_OUTPUT"
- else
- echo "publish_update=true" >> "$GITHUB_OUTPUT"
- fi
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Download generated updater metadata
- if: steps.release_metadata.outputs.publish_update == 'true'
- shell: bash
- run: |
- set -euo pipefail
- gh release download "$RELEASE_TAG" \
- --repo "$GITHUB_REPOSITORY" \
- --pattern latest.json \
- --dir "$RUNNER_TEMP/updater-channel"
- test -s "$RUNNER_TEMP/updater-channel/latest.json"
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Publish updater channel
- if: steps.release_metadata.outputs.publish_update == 'true'
- shell: bash
- run: |
- set -euo pipefail
-
- git config user.name "github-actions[bot]"
- git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
-
- if git ls-remote --exit-code --heads origin updater-channel >/dev/null 2>&1; then
- git fetch origin updater-channel
- git switch --force-create updater-channel origin/updater-channel
- else
- git switch --orphan updater-channel
- fi
-
- git rm -rf --ignore-unmatch .
- cp "$RUNNER_TEMP/updater-channel/latest.json" latest.json
- git add latest.json
-
- if git diff --cached --quiet; then
- echo "Updater channel already points to $RELEASE_TAG."
- exit 0
- fi
-
- git commit -m "Offer $RELEASE_TAG through the updater"
- git push origin updater-channel
+name: Release
+
+on:
+ # Three ways in, all landing on the same `prepare` job:
+ # - a commit on main that changes the version in package.json (the normal path),
+ # - pushing a `v*` tag,
+ # - a manual dispatch, to rebuild a release that already exists.
+ push:
+ branches: [main]
+ tags: ['v*']
+ workflow_dispatch:
+ inputs:
+ release_tag:
+ description: Tag to build, for example v1.2.4. Defaults to the version in package.json.
+ required: false
+ type: string
+ release:
+ types: [published]
+permissions:
+ contents: write
+
+jobs:
+ prepare:
+ name: Resolve release
+ runs-on: ubuntu-latest
+ outputs:
+ tag: ${{ steps.resolve.outputs.tag }}
+ proceed: ${{ steps.resolve.outputs.proceed }}
+
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Resolve tag and create the release
+ id: resolve
+ shell: bash
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ EVENT_NAME: ${{ github.event_name }}
+ REF_NAME: ${{ github.ref_name }}
+ REF_TYPE: ${{ github.ref_type }}
+ INPUT_TAG: ${{ inputs.release_tag }}
+ EVENT_TAG: ${{ github.event.release.tag_name }}
+ run: |
+ set -euo pipefail
+
+ # The tag under discussion, whichever way this run started.
+ case "$EVENT_NAME" in
+ workflow_dispatch) tag="$INPUT_TAG" ;;
+ release) tag="$EVENT_TAG" ;;
+ push) test "$REF_TYPE" = tag && tag="$REF_NAME" || tag="" ;;
+ *) tag="" ;;
+ esac
+ if test -z "$tag"; then
+ tag="v$(node -p "require('./package.json').version")"
+ fi
+ echo "tag=$tag" >> "$GITHUB_OUTPUT"
+ echo "Release tag: $tag"
+
+ # An ordinary commit on main is not a release. Only the one that lands a version
+ # nobody has released yet is, which is what makes "bump, commit, push" enough.
+ if gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
+ if test "$EVENT_NAME" = push && test "$REF_TYPE" = branch; then
+ echo "$tag is already released; nothing to do."
+ echo "proceed=false" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ echo "proceed=true" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ if ! git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git tag -a "$tag" -m "Release ${tag#v}"
+ git push origin "$tag"
+ fi
+
+ # Notes come from the annotated tag, else the release commit's own message — the
+ # place the change is already described — and only then from GitHub's generator.
+ notes="$RUNNER_TEMP/release-notes.md"
+ git tag -l --format='%(contents:body)' "$tag" > "$notes"
+ if ! test -s "$notes"; then
+ git log -1 --format='%b' "$tag^{commit}" | grep -v '^Co-Authored-By:' > "$notes" || true
+ fi
+
+ if test -s "$notes"; then
+ gh release create "$tag" --repo "$GITHUB_REPOSITORY" \
+ --title "Zuno ${tag#v}" --notes-file "$notes"
+ else
+ gh release create "$tag" --repo "$GITHUB_REPOSITORY" \
+ --title "Zuno ${tag#v}" --generate-notes
+ fi
+ echo "proceed=true" >> "$GITHUB_OUTPUT"
+
+ release:
+ name: Build ${{ matrix.platform.name }}
+ needs: prepare
+ if: needs.prepare.outputs.proceed == 'true'
+ env:
+ RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
+ strategy:
+ fail-fast: false
+ matrix:
+ platform:
+ - name: Windows
+ os: windows-latest
+ rust_target: ""
+ build_args: --bundles msi,nsis
+ apple_signing_identity: ""
+ - name: macOS Apple Silicon
+ os: macos-latest
+ rust_target: aarch64-apple-darwin
+ build_args: --target aarch64-apple-darwin --bundles app,dmg
+ apple_signing_identity: "-"
+ - name: macOS Intel
+ os: macos-15-intel
+ rust_target: x86_64-apple-darwin
+ build_args: --target x86_64-apple-darwin --bundles app,dmg
+ apple_signing_identity: "-"
+ - name: Linux
+ os: ubuntu-latest
+ rust_target: ""
+ build_args: --bundles deb,rpm,appimage
+ apple_signing_identity: ""
+ runs-on: ${{ matrix.platform.os }}
+
+ steps:
+ - name: Verify release exists
+ shell: bash
+ run: gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY"
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Read release type
+ id: release_metadata
+ shell: bash
+ run: |
+ set -euo pipefail
+ is_prerelease="$(gh release view "$RELEASE_TAG" \
+ --repo "$GITHUB_REPOSITORY" \
+ --json isPrerelease \
+ --jq .isPrerelease)"
+
+ if test "$is_prerelease" = "true"; then
+ echo "publish_update=false" >> "$GITHUB_OUTPUT"
+ else
+ echo "publish_update=true" >> "$GITHUB_OUTPUT"
+ fi
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Check out repository
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ env.RELEASE_TAG }}
+ fetch-depth: 0
+
+ - name: Verify checked out tag
+ shell: bash
+ run: |
+ set -euo pipefail
+ test "$(git describe --tags --exact-match)" = "$RELEASE_TAG"
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+
+ - name: Set up Rust
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: Add Rust target
+ if: matrix.platform.rust_target != ''
+ run: rustup target add ${{ matrix.platform.rust_target }}
+
+ - name: Install Linux system dependencies
+ if: matrix.platform.name == 'Linux'
+ shell: bash
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y \
+ libwebkit2gtk-4.1-dev \
+ build-essential \
+ curl \
+ wget \
+ file \
+ libssl-dev \
+ libayatana-appindicator3-dev \
+ librsvg2-dev \
+ libgtk-3-dev \
+ libsoup-3.0-dev \
+ libjavascriptcoregtk-4.1-dev \
+ patchelf
+
+ - name: Cache Rust build
+ uses: swatinem/rust-cache@v2
+ with:
+ workspaces: "./src-tauri -> target"
+
+ - name: Install frontend dependencies
+ run: npm ci
+
+ - name: Build and upload installers
+ uses: tauri-apps/tauri-action@v0
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ APPLE_SIGNING_IDENTITY: ${{ matrix.platform.apple_signing_identity }}
+ TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
+ TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
+ with:
+ tagName: ${{ env.RELEASE_TAG }}
+ releaseDraft: false
+ uploadUpdaterJson: ${{ steps.release_metadata.outputs.publish_update }}
+ uploadUpdaterSignatures: false
+ updaterJsonPreferNsis: true
+ args: ${{ matrix.platform.build_args }}
+
+ - name: Upload Windows portable executable
+ if: matrix.platform.name == 'Windows'
+ shell: bash
+ run: |
+ set -euo pipefail
+ # Located rather than hardcoded: the executable is named from the Cargo package
+ # (`zuno`), which Tauri may rename to `productName` during bundling. Globbing the
+ # one top-level exe survives either, and fails loudly if that assumption breaks.
+ portable_exe=$(find src-tauri/target/release -maxdepth 1 -type f -name '*.exe' | head -n 1)
+ if [ -z "$portable_exe" ]; then
+ echo "No portable executable found in src-tauri/target/release" >&2
+ exit 1
+ fi
+ echo "Uploading $portable_exe"
+ gh release upload "$RELEASE_TAG" \
+ "${portable_exe}#Zuno-Windows-portable.exe" \
+ --clobber
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Verify macOS DMG and app signature
+ if: startsWith(matrix.platform.name, 'macOS')
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ bundle_dir="src-tauri/target/${{ matrix.platform.rust_target }}/release/bundle/dmg"
+ dmg_path="$(find "$bundle_dir" -maxdepth 1 -name '*.dmg' -print -quit)"
+ test -n "$dmg_path"
+ hdiutil verify "$dmg_path"
+
+ mount_dir="$(mktemp -d)"
+ cleanup() {
+ hdiutil detach "$mount_dir" -quiet || true
+ rmdir "$mount_dir" || true
+ }
+ trap cleanup EXIT
+
+ hdiutil attach "$dmg_path" -readonly -nobrowse -mountpoint "$mount_dir"
+ app_path="$(find "$mount_dir" -maxdepth 1 -name '*.app' -print -quit)"
+ test -n "$app_path"
+ codesign --verify --deep --strict --verbose=2 "$app_path"
+
+ publish-updater-channel:
+ name: Publish gated updater metadata
+ needs: [prepare, release]
+ runs-on: ubuntu-latest
+ env:
+ RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
+
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ env.RELEASE_TAG }}
+ fetch-depth: 0
+
+ - name: Read release type
+ id: release_metadata
+ shell: bash
+ run: |
+ set -euo pipefail
+ is_prerelease="$(gh release view "$RELEASE_TAG" \
+ --repo "$GITHUB_REPOSITORY" \
+ --json isPrerelease \
+ --jq .isPrerelease)"
+
+ if test "$is_prerelease" = "true"; then
+ echo "publish_update=false" >> "$GITHUB_OUTPUT"
+ else
+ echo "publish_update=true" >> "$GITHUB_OUTPUT"
+ fi
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Download generated updater metadata
+ if: steps.release_metadata.outputs.publish_update == 'true'
+ shell: bash
+ run: |
+ set -euo pipefail
+ gh release download "$RELEASE_TAG" \
+ --repo "$GITHUB_REPOSITORY" \
+ --pattern latest.json \
+ --dir "$RUNNER_TEMP/updater-channel"
+ test -s "$RUNNER_TEMP/updater-channel/latest.json"
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Publish updater channel
+ if: steps.release_metadata.outputs.publish_update == 'true'
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+
+ if git ls-remote --exit-code --heads origin updater-channel >/dev/null 2>&1; then
+ git fetch origin updater-channel
+ git switch --force-create updater-channel origin/updater-channel
+ else
+ git switch --orphan updater-channel
+ fi
+
+ git rm -rf --ignore-unmatch .
+ cp "$RUNNER_TEMP/updater-channel/latest.json" latest.json
+ git add latest.json
+
+ if git diff --cached --quiet; then
+ echo "Updater channel already points to $RELEASE_TAG."
+ exit 0
+ fi
+
+ git commit -m "Offer $RELEASE_TAG through the updater"
+ git push origin updater-channel
+
+ publish-winget:
+ name: Publish to winget
+ needs: [prepare, release]
+ runs-on: ubuntu-latest
+ env:
+ RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
+
+ steps:
+ - name: Read release type
+ id: release_metadata
+ shell: bash
+ run: |
+ set -euo pipefail
+ gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \
+ --json isPrerelease --jq '"prerelease=" + (.isPrerelease | tostring)' \
+ >> "$GITHUB_OUTPUT"
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ # Version defaults to the release tag with the leading `v` stripped.
+ - uses: vedantmgoyal9/winget-releaser@main
+ if: steps.release_metadata.outputs.prerelease == 'false'
+ with:
+ identifier: noFAYZ.Zuno
+ release-tag: ${{ env.RELEASE_TAG }}
+ installers-regex: '_x64-setup\.exe$'
+ token: ${{ secrets.WINGET_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 77a9ba9..4e636ee 100644
--- a/.gitignore
+++ b/.gitignore
@@ -29,12 +29,38 @@ dist-ssr
*.VC.db
*.VC.VC.opendb
+.docs
+
# Tooling caches
.eslintcache
# Rust application lockfiles should be committed for reproducible builds.
-.vscode/*
-
outputs/*
.codex/*
+
+# Crash droppings from the Git-for-Windows shell.
+*.stackdump
+
+# Updater signing keys. `tauri signer generate -w ~/...` does not expand `~` on
+# Windows and drops a literal `~/` folder in the repo root — this makes sure a
+# private key landing here can never be committed.
+~/
+*.key
+!**/*.key.pub
+
+# Secrets. `*.local` above already covers `.env.local`, but not a plain `.env` —
+# which is the one people actually create in a hurry.
+.env
+.env.*
+!.env.example
+*.pem
+*.p12
+*.pfx
+
+# Reference dumps and working images kept in docs/ for local use only. The committed
+# documentation (architecture.md, backend.md, frontend.md, solarIcons.md) and the README's
+# screenshots stay tracked — this covers the scratch material that accumulates beside them.
+docs/*.txt
+docs/*.webp
+docs/zuno.PNG
diff --git a/CLA.md b/CLA.md
index aedb36d..05c8f1b 100644
--- a/CLA.md
+++ b/CLA.md
@@ -1,14 +1,14 @@
-# Contributor License Agreement
-
-By submitting a contribution to JustAnotherMusicClient, you confirm that:
-
-1. You have the right to submit the contribution.
-2. You retain copyright ownership of your contribution.
-3. You grant the project owner a perpetual, worldwide, irrevocable,
- royalty-free, transferable, and sublicensable license to use, reproduce,
- modify, distribute, commercialize, and relicense your contribution as part
- of this project or related products.
-4. Your contribution is provided without warranties or guarantees.
-
-Submitting a pull request or other contribution indicates your acceptance of
-this agreement.
+# Contributor License Agreement
+
+By submitting a contribution to Zuno, you confirm that:
+
+1. You have the right to submit the contribution.
+2. You retain copyright ownership of your contribution.
+3. You grant the project owner a perpetual, worldwide, irrevocable,
+ royalty-free, transferable, and sublicensable license to use, reproduce,
+ modify, distribute, commercialize, and relicense your contribution as part
+ of this project or related products.
+4. Your contribution is provided without warranties or guarantees.
+
+Submitting a pull request or other contribution indicates your acceptance of
+this agreement.
diff --git a/README.md b/README.md
index 2499ee8..69fcca0 100644
--- a/README.md
+++ b/README.md
@@ -1,199 +1,292 @@
+
+
+
+
+Zuno
+
+
+ A fast, native-feeling desktop client for YouTube Music.
+ Built with Tauri, React and TypeScript for Windows, macOS and Linux .
+
+
+
+
+
+
+
+
+
-
-
+
+
+
-
-
-
-
-
-
-
-
-
-
-A desktop YouTube Music client built with Tauri, React, and TypeScript for **Windows, MacOs & Linux**.
-
+> [!IMPORTANT]
+> Zuno is an independent, unofficial project. It is not affiliated with, authorized by,
+> sponsored by, or endorsed by YouTube or Google.
+
+
+
+## About
+
+Zuno brings YouTube Music to the desktop as a focused application rather than a browser tab.
+There is no official YouTube Music desktop client, so the goal here is a polished one that
+feels native on each platform and stays fast with large libraries.
+
+If you find it useful, **starring the repo** genuinely helps.
+
+
+
+## Screenshots
+
+
+
+ Home — dark
+ Home — light
+
+
+
+
+
+
+ The "Made for you" carousel, quick destinations and recently played. The carousel can be hidden from Settings.
+
+
+
+
+
+ Lyrics — dark
+ Lyrics — light
+
+
+
+
+
+
+ Lines fade with distance from the one playing. Click any line to seek, nudge the timing if a match is a little off, and see which source the words came from.
+
+
+
+
+
+ Artist — dark
+ Artist — light
+
+
+
+
+
+
+ Popular tracks and every release, filtered by albums, singles or EPs — with subscribe and shuffle in reach.
+
+
+
+
+
+ Queue — dark
+ Library — light
+
+
+
+
+
+
+ The queue separates what you added by hand from what came next on its own. The library marks what is liked, downloaded and explicit at a glance.
+
+
-> **IMPORTANT** This is an independent, unofficial project and is not affiliated with, authorized by, sponsored by, or endorsed by YouTube or Google. The only reason I am making this, is becuase there is no official YouTube music desktop client.
+
+
+
+ Search is one keystroke away — Ctrl+Space from anywhere, across artists, songs, playlists and albums.
+
-
-
-
-
-
+
+
+ Settings — quality, lyrics source, translation and text size, downloads and the disk cap.
+
-
+
-## About ℹ️
+## Features
-JustAnotherMusicClient brings YouTube Music to the desktop in a focused, native-feeling application. YouTube does not provide an official desktop client, so this project aims to provide a polished alternative that integrates with YouTube Music while keeping the experience fast and familiar.
+| Feature | Description |
+|---|---|
+| **Multiple tabs** | Each tab keeps its own queue, volume and player state — start an album in one, browse in another, and the first keeps playing |
+| **Offline downloads** | Save a song, a selection, or a whole album or playlist, with its own quality setting and a size cap you control |
+| **Mini player** | A morphing capsule that appears when you tab away; drag it anywhere, hover to expand |
+| **Synced lyrics** | Line-by-line lyrics that follow the song — not available on the official web client. Click a line to jump to it, nudge the timing when a match is slightly off, and pick which source is tried first |
+| **Lyrics translation** | Show a translation under each line, in any of twenty languages, with an adjustable text size |
+| **Like & dislike** | Rate from the row, the player or the right-click menu; ratings sync to your YouTube Music account |
+| **Batch actions** | Shift/ctrl-select rows, then queue, download, add to a playlist or remove them together |
+| **Queue control** | Collapses to an artwork rail — end the queue here, build a queue from here, shuffle, clear |
+| **Recommendations** | Personalised suggestions plus a "surprise me" shuffle — hide the carousel if you would rather open on your library |
+| **Browse** | Explore, charts, moods and genres, and podcasts, with mood chips you can drill into |
+| **Local files** | Folders from your own machine sit alongside your library, with a tag editor for fixing metadata |
+| **Discord & Last.fm** | Rich Presence and scrobbling, each toggleable straight from the toolbar |
+| **Account support** | Sign in with Google for your library and playlists, and switch between channels on the same account |
+| **Playlist import/export** | Save a playlist to a file and bring it back, on this machine or another |
+| **Desktop integration** | Media keys, minimise to tray, launch at login, remembered window position, rebindable shortcuts |
+| **Light & dark themes** | Follows the OS by default, or pin either one — plus a reduced-motion mode |
+| **Caching** | Playlists, lyrics and artwork are cached, so revisits are instant |
+| **Auto-updates** | Signed updates install themselves; no manual re-download |
+
+
+
+## Download
+
+Grab the newest installer from the **[latest release](https://github.com/noFAYZ/zuno/releases/latest)**
+for Windows, macOS or Linux.
+
+On Arch and derivatives, install from the AUR instead:
-If you like this project, **starring it on Github** would help A LOT!
+```bash
+yay -S zuno # or: paru -S zuno
+```
-**Contact for legal purposes:** team@2late.org
+
-[**Buy me a cofee**](https://ko-fi.com/totally2late): This supports development and helps me update this project and pay for hosting! If you want a feature to be finished a bit faster, mabey add it to a message there (;
-
-
-
-
-
-
-
-
-
-
-
-
-
+## Platform support
-## Features ✨
-| Feature | Description |
-|---|---|
-| Multiple Tabs | Create multiple music tabs, each with its own playback queue, volume, and player state |
-| Caching | Playlists, lyrics, and more are cached for significantly faster performance |
-| Recommendations | Home Tab with personalized song sugesstions and a "random" shuffle wheel, just like on the mobile client |
-| Discord RPC | The songs you are listening to are displayed on Discord |
-| Last FM | Last FM integration to track listening |
-| Mini player | Small Mini Music player when you tab out of the app that can be moved with either right mouse or dragging |
-| Synced Lyrics | Real-time synced lyrics, not even available on the official client |
-| YouTube Music Integration | Browse, search, like songs, and play music via an integrated search bar |
-| Account Support | Sign in to access your library, playlists, recommendations, and other account features |
-| Song Management | Add songs to playlists or queue, view your queue, quickly via Ctrl+S or right-click |
-## Download ⏬
-
-Download the **newest available installer** from the [latest release](https://github.com/2latemc/JustAnotherMusicClient/releases/latest) for either Windows, MacOs or Linux.
-
-
-
-
-
-
-
-## Roadmap 📌
-- Website with easy download buttons!
-- **Better lyrics consistency** (some songs still don't have them)
-- Raycast support with app specific URLs
-- Fix Windows media player controls sometimes not working
-- **Music Video** support
-- ~~Last.fm integration~~
-- ~~App Auto-Update so that you don't manually have to check for changes~~
-- ~~Maximize Button + An option for Windows-style window controls~~
-- ~~Start App on boot option~~
-- ~~Liked songs playlist support~~
-- ~~Discord RCP is coming~~
-- ~~Full Linux and MacOS support~~
-- ~~Space to pause sometimes selects elements instead of pausing / resuming~~
-- ~~Cap Too long Song names and make them smoothly move vertically~~
-- ~~Artist Pages with monthly listeners, playlists and popular songs alongside streams amount feature when clicking on the artist name, also integrate into the search results so that one can find artists~~
-
-## Platform Support 💻
-
-- **Windows** is the primary supported platform.
-- **macOS** supported. Sometimes some experimental releases don't work at first, so try some older ones if that is the case.
-- **Linux** supported. Sometimes some experimental releases don't work at first, so try some older ones if that is the case
-
-### Linux Notes
-On some Wayland desktops, the AppImage can open a blank grey window with an EGL error. If that happens, try launching it with:
+- **Windows** — primary supported platform.
+- **macOS** — supported.
+- **Linux** — supported.
+
+### Linux notes
+
+Install the `.deb` or `.rpm`, or `zuno` from the AUR on Arch. These use your system's
+WebKitGTK and GStreamer, which is what playback runs through.
+
+**No sound, or "YouTube player error 5".** Playback decodes through GStreamer, and most
+distros do not install the codecs YouTube needs by default:
```bash
-LD_PRELOAD=/usr/lib/libwayland-client.so ./Just.Another.Music.Client_1.2.71_amd64.AppImage
+# Debian, Ubuntu, Mint
+sudo apt install gstreamer1.0-libav gstreamer1.0-plugins-base gstreamer1.0-plugins-good
+
+# Fedora (gstreamer1-libav needs RPM Fusion enabled)
+sudo dnf install gstreamer1-libav gstreamer1-plugins-base gstreamer1-plugins-good
+
+# Arch — installed automatically with the AUR package
+sudo pacman -S gst-libav gst-plugins-base gst-plugins-good
```
-If playback or window controls still fail, open the app log from Settings and include it with the issue report. The exact desktop environment, display server, and distro help a lot for Linux bugs.
+Confirm they registered:
-### MacOs Issues
-**MacOs may prompt you with a Keychain popup asking for permission.** The app stores one encryption key in its own Keychain entry. Your YouTube Music session is encrypted with that key before it is saved in the app data directory.
+```bash
+gst-inspect-1.0 | grep -E 'avdec_aac|avdec_h264'
+```
-If you do not need signing into YouTube Music you dont need to grant Keychain permissions. If you do it is recommended to click "always allow" in the popup to prevent MacOs from being annoying
+**A blank grey window.** A WebKitGTK rendering problem under Wayland, most often on Nvidia.
+Launch with one of:
-## Legal
-**This project DOES NOT offer any downloading functionality. This is a client providing Theme and Decorative additions for audio listening only.**
+```bash
+WEBKIT_DISABLE_DMABUF_RENDERER=1 zuno
+WEBKIT_DISABLE_COMPOSITING_MODE=1 zuno
+GDK_BACKEND=x11 zuno
+```
-**If anyone from Google reads this:** There was no official client, I just wanted a good desktop client. Thats why I made this, please don't sue me!
-**Contact me here:** team@2late.org
+Open the app log from **Settings → Library → Application log** for anything else, and attach
+it to an issue. The desktop environment, display server and distro all help a lot for Linux
+bugs. The log also lives at `~/.local/share/com.zuno.desktop/logs/current.log`.
-The application interacts with YouTube and YouTube Music. Access to those services remains governed by their respective terms, policies, availability, and regional restrictions.
+### macOS notes
-JustAnotherMusicClient does not host or claim ownership of music, videos, artwork, metadata, or other content supplied by third parties. Rights in that content remain with their respective owners.
+**"Apple is not able to verify that it is free from malware."** The macOS builds are not
+signed with an Apple Developer ID, so Gatekeeper blocks them on first launch. This is not a
+malware finding — it means the binary is unsigned. Drag Zuno to Applications, then either:
-The project is not intended to circumvent access controls, geographic restrictions, advertising, paid service requirements, or content licensing. It is also not intended to enable unauthorized downloading, copying, redistribution, or public performance of third-party content.
+- Open **System Settings → Privacy & Security**, scroll to the message about Zuno, and click
+ **Open Anyway**, or
+- clear the quarantine flag yourself:
-YouTube and YouTube Music are trademarks of Google LLC. All other trademarks are the property of their respective owners. References to third-party products are used only to describe compatibility and integration.
+```bash
+xattr -dr com.apple.quarantine /Applications/Zuno.app
+```
-- [YouTube Terms of Service](https://www.youtube.com/static?template=terms)
-- [YouTube API Services Terms of Service](https://developers.google.com/youtube/terms/api-services-terms-of-service)
-- [YouTube API Services Developer Policies](https://developers.google.com/youtube/terms/developer-policies)
+Build from source if you would rather not trust a prebuilt binary.
+macOS may also show a Keychain prompt. Zuno stores one encryption key in its own Keychain entry,
+and your YouTube Music session is encrypted with that key before being written to the app
+data directory. Choosing **Always Allow** avoids repeated prompts.
+If you do not intend to sign in to YouTube Music, you can decline it.
-## For Developers 🛠️
+
-### Prerequisites
+## For developers
-Install these before running the app:
+### Prerequisites
- Node.js LTS and npm
- [Rust and Cargo](https://rustup.rs/)
-- Windows C++ build tools
-- Microsoft Edge WebView2 Runtime
+- C++ build tools (MSVC on Windows)
+- Microsoft Edge WebView2 Runtime (Windows)
-The Tauri CLI is included in the project's development dependencies. A global Tauri installation is not required.
+The Tauri CLI ships in the project's dev dependencies — no global install needed.
+### Install, run, build
-### Install
-
-```powershell
+```bash
npm install
-```
-
-### Run
-
-```powershell
npm run tauri dev
+npm run tauri build
```
-### Build
+### Architecture
-```powershell
-npm run tauri build
-```
+The `docs/` folder documents the codebase:
+
+- [`docs/architecture.md`](docs/architecture.md) — system overview and module map
+- [`docs/frontend.md`](docs/frontend.md) — React structure, styling tokens, icon conventions
+- [`docs/backend.md`](docs/backend.md) — Rust commands and the IPC surface
### Contributing
-Contributions are welcome. Fork the repository, create a branch for your change, test it locally, and open a pull request with a clear description of what you changed and why.
+Contributions are welcome. Fork the repo, branch, test locally, and open a pull request
+describing what changed and why. For larger changes, open an issue first so the approach can
+be discussed.
+
+By contributing you agree to the [Contributor License Agreement](CLA.md).
+
+
+
+## Credits
-By submitting a contribution, you agree to the [Contributor License Agreement](CLA.md). You retain copyright in your contribution while granting the project owner the rights needed to use, modify, distribute, commercialize, and relicense it.
+Zuno is a fork of **[JustAnotherMusicClient](https://github.com/2latemc/JustAnotherMusicClient)**
+by [2latemc](https://github.com/2latemc), used under the Apache 2.0 licence. The original
+project did the hard groundwork of getting YouTube Music working on the desktop.
-For larger changes, consider opening an issue first so the approach can be discussed before implementation.
+If you want to support the original author, they accept donations
+[on Ko-fi](https://ko-fi.com/totally2late).
-### Common Issues
+
-#### Rust is not installed
+## Legal
+
+**Zuno provides no downloading functionality.** It is a client for audio listening, with
+theming and interface additions.
+
+Zuno interacts with YouTube and YouTube Music. Access to those services remains governed by
+their own terms, policies, availability and regional restrictions.
+
+Zuno does not host or claim ownership of music, videos, artwork, metadata, or any other
+content supplied by third parties. Rights in that content remain with their respective
+owners.
-Install Rust and Cargo from [rustup.rs](https://rustup.rs/), restart your terminal, and run the command again.
+The project is not intended to circumvent access controls, geographic restrictions,
+advertising, paid service requirements, or content licensing, nor to enable unauthorised
+downloading, copying, redistribution or public performance of third-party content.
-#### WebView2 is missing
+YouTube and YouTube Music are trademarks of Google LLC. All other trademarks are the property
+of their respective owners. References to third-party products describe compatibility and
+integration only.
-Install the [Microsoft Edge WebView2 Runtime](https://developer.microsoft.com/en-us/microsoft-edge/webview2/), then run the app again.
+- [YouTube Terms of Service](https://www.youtube.com/static?template=terms)
+- [YouTube API Services Terms of Service](https://developers.google.com/youtube/terms/api-services-terms-of-service)
+- [YouTube API Services Developer Policies](https://developers.google.com/youtube/terms/developer-policies)
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..679bc02
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,31 @@
+# Security Policy
+
+## Supported versions
+
+Only the latest release gets fixes. Zuno auto-updates; older versions are not patched.
+
+## Reporting a vulnerability
+
+Use GitHub's [private vulnerability reporting](https://github.com/noFAYZ/zuno/security/advisories/new)
+— it is private until a fix ships. If that is unavailable, email m.faizanasad97@gmail.com
+with `zuno security` in the subject.
+
+Please do not open a public issue for a vulnerability.
+
+Include what you have: affected version, OS, steps to reproduce, and what an attacker gains.
+Expect a first reply within a week. This is a one-maintainer hobby project — there is no
+bounty, and a fix lands in the next release rather than on a fixed SLA.
+
+## Scope
+
+Zuno is a desktop app that plays YouTube content locally. Things worth reporting:
+
+- Remote content (video metadata, captions, thumbnails, URLs) escaping into command
+ execution, file writes outside the app's own data directory, or the webview's privileged
+ context.
+- Anything that leaks the updater signing key, cookies, or the local cookie jar.
+- A malicious or spoofed update passing signature verification.
+
+Out of scope: YouTube's own terms of service, rate limits or bot checks, missing hardening
+that is not exploitable, and vulnerabilities in a dependency that Zuno does not actually
+reach.
diff --git a/app-icon.png b/app-icon.png
new file mode 100644
index 0000000..c5b19e1
Binary files /dev/null and b/app-icon.png differ
diff --git a/assets/img/2.png b/assets/img/2.png
new file mode 100644
index 0000000..5cc00b7
Binary files /dev/null and b/assets/img/2.png differ
diff --git a/assets/img/2.svg b/assets/img/2.svg
new file mode 100644
index 0000000..fffef34
--- /dev/null
+++ b/assets/img/2.svg
@@ -0,0 +1 @@
+AABCHWp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAAEH3anVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46YzJwYTo3YTlmYjNhNi03ODgwLTQ5MTMtOTQ0YS0yZWE5NGVhNDhkNGYAAAAB5Gp1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAAO5qdW1iAAAAQWp1bWRjYm9yABEAEIAAAKoAOJtxE2MycGEuYWN0aW9ucy52MgAAAAAYYzJzaPF4V/K+2O1MklL5ycvc5EMAAAClY2JvcqFnYWN0aW9uc4GjZmFjdGlvbmxjMnBhLmNyZWF0ZWRtc29mdHdhcmVBZ2VudGhDYW52YSBBSXFkaWdpdGFsU291cmNlVHlwZXhTaHR0cDovL2N2LmlwdGMub3JnL25ld3Njb2Rlcy9kaWdpdGFsc291cmNldHlwZS9jb21wb3NpdGVXaXRoVHJhaW5lZEFsZ29yaXRobWljTWVkaWEAAADFanVtYgAAAEBqdW1kY2JvcgARABCAAACqADibcRNjMnBhLmhhc2guZGF0YQAAAAAYYzJzaE87g9R8u4Z3jVG/HfBY0SoAAAB9Y2JvcqVqZXhjbHVzaW9uc4GiZXN0YXJ0GQETZmxlbmd0aBlYKGRuYW1lbmp1bWJmIG1hbmlmZXN0Y2FsZ2ZzaGEyNTZkaGFzaFgg+TufoEAIKslRhgU9wwiO50WETMswRG07z90RRCUYAp5jcGFkSQAAAAAAAAAAAAAAAgtqdW1iAAAAJ2p1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0udjIAAAAB3GNib3Knamluc3RhbmNlSUR4LHhtcDppaWQ6MWY0YWZmNDktM2U4OS00ZGEwLWJkMzUtNjFhNTFjNjc4YWM0dGNsYWltX2dlbmVyYXRvcl9pbmZvo2RuYW1lZ2MycGEtcnNndmVyc2lvbmUwLjAuMHdvcmcuY29udGVudGF1dGguYzJwYV9yc2UwLjAuMGlzaWduYXR1cmV4TXNlbGYjanVtYmY9L2MycGEvdXJuOmMycGE6N2E5ZmIzYTYtNzg4MC00OTEzLTk0NGEtMmVhOTRlYTQ4ZDRmL2MycGEuc2lnbmF0dXJlcmNyZWF0ZWRfYXNzZXJ0aW9uc4GiY3VybHgpc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5oYXNoLmRhdGFkaGFzaFgghKf4hR06Z8B7rPKnBtfh3DUNcTGzBrm6DPz83axwuHxzZ2F0aGVyZWRfYXNzZXJ0aW9uc4GiY3VybHgqc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5hY3Rpb25zLnYyZGhhc2hYIOIb2/V61YNMVaTNOshIL9b8gwbIrD5Cx0Tg+ge2GMXKaGRjOnRpdGxlZTIuc3ZnY2FsZ2ZzaGEyNTYAAD25anVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAAD2JY2JvctKEWRKBogE4Jhghg1kFPzCCBTswggMjoAMCAQICEQDJQRGNRLa9AyWvkldSDz2TMA0GCSqGSIb3DQEBDQUAMCIxIDAeBgNVBAMTF1NpZ25pbmcgSW50ZXJtZWRpYXRlIENBMB4XDTI2MDcyMzIzNTEzOFoXDTI2MDczMTIzNTEzOFowODEOMAwGA1UEChMFQ2FudmExDjAMBgNVBAsTBUNhbnZhMRYwFAYDVQQDEw1DYW52YSBTaWduaW5nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAsVk+WBj27+/EnKwbQvNArG26NTQzDTOsFM77ilhG+qwcC8g+gAy5pDIzpA1dkweAW2yTjr+7x9ITxnaiKyiJR/8BaqfEtbLGeIPI+sOLgh43UgrJBHKAtGorenATFFAz40/Ya23CfoUwdY3ctUWyN4KyiJ7dU+1EQ+946HJZkfI665G8HZYwBGfL3rZslCPWysadXXPVAyjK5py0kDBLYPRFXifftUq9tLt8EMTPVCeHYJ7Q05IwgLJVg5dQkQi3GyQ+VTITvll8AgiVDWpzrf6e7weQ+6e+u+nLzJztEzUBl3kvAjWYR6coiB/5FfuepEnQgPRFeJ4sS4av8G9b+XpqVAvUoYOjD7BtvgnHDQ5dLb4/Tv+YWy4vv6gpVe2pu7dDh+eDOCw0tnBWa1azv4KlUXpJrRkLMaqgkTftjrAVPPFLSseT8LPZFIjdl3avXscH6fbsAmp+qhtCZgJ5u8K6iCTSvRObxqCMHKMn3av8eKArbnA0O3w+NyMfL+RmcWfU9kU31TcaNo6ZCY/uKq0o7P4XWwchSWzGSmzMUWBRiB6ATvPRMGfoh6V5HIvXPemk9WlTbpJ+8DSjFrAJ2jWf0qcT//wbPFmSc3Ae/HqhGLuDKkkVDlE2Dh14bp+YSwEcPoHktF25PdLnVk5eDz9dPTdV6ouipkxBTpr1nrcCAwEAAaNWMFQwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsGAQUFBwMEMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUQ0RkWQaZ5GIbHo46Xf8EvwfZxXgwDQYJKoZIhvcNAQENBQADggIBABgU0Ok0hbEEwJD66/i5+fyR12kVEVO/31pGnQVt3vT5Jthswf5A5ifJmqU0B8I3sXYa1cz6hNSODfebq8X6tUQ8NcdVFGV8PW6ISDo1yapv3bfwYRjEYWnYrhHStkIt3rA55KFyuO1F28qKC58MX9Troz24x3mDrZvQ8b3WPyLuwSw+lTKFLG3mbqhgjeXFKoNAIwNSpePTBYCxCGmkiTaP/Go8E3uaY64PuuO4hYzaGeQIseizQoT9q+n/AyY7wRCKUFcD3oS7pglId2yCbJtq+vhfNX2vGeB/EfRwP1wFWWhSPLsKQ1WGIh+khJWaTO3mOMbKLsBwdq8WOCZAL2oSP0z1aK9ygYorWOyAt147cVdOZwQBBjdFrsAGko16KZtJzOwCFQ14LPVGarM+4JTvYWflnAdLgtvUvk1bSfyI6DmtfBcvVgtftLvmTwYxCAFG016mLnXE4Mn6xvvXypYehytGtEebatfxm60sWTMjnI8ELY/7oMNMfT+EX3snzED1mY3W0PYT5oD2xeiycdcJgMKgILFDt5ZomzM/6bG1CABHYQTscpbP9q52yx62tbV+Kr8oZZCdeNpilWzFxpzaThvZCCpke8TYHrrQiehTxeaqxj6Qed3L/p4tCSmH05iliSCQXGKBTqCQbxw5eumKVE7BjLUOwdiwk2H5qAanWQboMIIG5DCCBMygAwIBAgIUJYvTycarg5Zv7zyTq6NTD2vKDq8wDQYJKoZIhvcNAQELBQAwbTELMAkGA1UEBhMCQVUxDDAKBgNVBAgTA05TVzEPMA0GA1UEBxMGU3lkbmV5MRMwEQYDVQQKEwpDYW52YSBQcm9kMRAwDgYDVQQLEwdTaWduaW5nMRgwFgYDVQQDEw9TaWduaW5nIENBIFByb2QwHhcNMjYwNzE2MDUzNzQxWhcNMjYwODE1MDUzODExWjAiMSAwHgYDVQQDExdTaWduaW5nIEludGVybWVkaWF0ZSBDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANUc8Oy2+0jHoTqO5Hb3S7NuKFsrG9bqzcjuBIAMRcDpWLMAoYs787YMzzrlNPLtSy+N8JjUDlwVI/93qRPV2fiCaDxq/CF/h/0BaV4PLdthBPZvmkW8fSMdlKc5gXqZptndkUC41+K1u71xa3sZZrs1fVnm5xhepQUzvi47NTwRn9YwNy1+wbpPkn52fOSQ7KoWboGQxgfYNU821Io/WFaB/Kxhu2xJDsZRtBRHXvNfiGWrN9PuTXtd/HroMQ1dj0cQaHkH8BvgwYEZmWnGSNBcvJy1xnSk2JCHD3uia0OeyKeT+Y1CyJpXc/PltP2I+0/qSUHepRRVo5mBLIQgHsJ6QE/Bw7vB7yT1Tb6a4aAAv9WOBx5wHo/PB8wVm8v1VwS7Bha6PvdVpjIXixrAtv8xusDrCN93Yj9DEFKIl1C9F/2npgGjsyHPipmW3iaU8iiO651Uf5Tz89r53yFqWR+ZHiBAtGbBtjIawG7gP5Oqk4kjUP4AacCFbc65FiidNGNFdpiXhjZmifv4+gNoNCEISycbgYXBkFDnYXnvYTNT7o8gNAHcjW9d1ip6ed6DYGntXns+ULV8D3XkY1jKxp/uxH04Gc2X8UqOdwxeWRiEECRBzuGKEosl/zdQhJK3XNCJNEFB/VO5vXoqww/ULmcH295Hk5iJfvlp0vTxZozPAgMBAAGjggHFMIIBwTAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUQ0RkWQaZ5GIbHo46Xf8EvwfZxXgwHwYDVR0jBBgwFoAUSlxt/ndQaL1jKWFk7YbiIcsE4x0wgdcGCCsGAQUFBwEBBIHKMIHHMEwGCCsGAQUFBzABhkBodHRwczovL3BraS5jYW52YS1pbnRlcm5hbC5jb20vdjEvcGtpL2NhbnZhLXByb2QvbDEvc2lnbmluZy9vY3NwMHcGCCsGAQUFBzAChmtodHRwczovL3BraS5jYW52YS1pbnRlcm5hbC5jb20vdjEvcGtpL2NhbnZhLXByb2QvbDEvc2lnbmluZy9pc3N1ZXIvMWRjNDVjODQtNGY5My1lMTBlLTRjMzYtZTdkZWFhMjU2MzkyL2RlcjCBgAYDVR0fBHkwdzB1oHOgcYZvaHR0cHM6Ly9wa2kuY2FudmEtaW50ZXJuYWwuY29tL3YxL3BraS9jYW52YS1wcm9kL2wxL3NpZ25pbmcvaXNzdWVyLzFkYzQ1Yzg0LTRmOTMtZTEwZS00YzM2LWU3ZGVhYTI1NjM5Mi9jcmwvZGVyMA0GCSqGSIb3DQEBCwUAA4ICAQB4Y9NtmDHlRgo1HMSAsOKb3IjGXxk1ivWeA4Qk2bcehCcqmEqEAVxGbGYxzuFircH2GsXS8vAPU/FaXeAdFos631Y3tOeQNLeFKsMvvslVmfzncKFypt+Jve0k+nxGYGL1dBECdPLBHGzvHtAlQCONOtj1Nj9gvHUmGBRypu80+DojmVbED+k9wqukpm1TZQh79iRDSihoP8GT6FgfW2dEALn+wKbzXQZi1pu9+FPnWFy3jMLCO9mv9ldRuobX3sDlaAdsZqUC4qiQnNOyU+jQVj4sY032HAn19F1H67vX2KDywYFNvpTssOxcG0OHXB4x0BLTF/Vo8QXKNYGJt+6gYa6H84VOAL44NwHbAsnTgGLTuXUpsb4E15AxIk8YcIVJFOcwZJSXEWRl1U/keWnqKyJC5Tv5e5SNA29YeXgxWHJWnD8EpRKWYoFJfnrE7JX6amp3LNws1/mE7fqWGqM9+hYdQVOqHX/CrLvzD9qSPoLeMJzW/uEfaqXLAkZZfJbI1R7UzBiNzVNZTfx3zRGrAlLnVKFy3ddctN+ECZ5nAtC2CgNGQ59533fVbTQiU9xUhEOyNYtc3Og2EA7e0Nm/WznD7vndrQP+wPzZRgSW0uCQa72yPXJNBNhXBlP6oK0qB+2AhtqfhisLy1N2GTJlABaqAxwQCdtqXnE/+SSjQVkGSjCCBkYwggQuoAMCAQICEQDJtWzGUz0MB/MhdTvrUGHhMA0GCSqGSIb3DQEBDQUAMIGBMQswCQYDVQQGEwJBVTETMBEGA1UECgwKQ2FudmEgUHJvZDEXMBUGA1UECwwOQ2xvdWQgUGxhdGZvcm0xDDAKBgNVBAgMA05TVzElMCMGA1UEAwwcQ2FudmEgUHJvZCBSb290IENBIEdsb2JhbCBHMzEPMA0GA1UEBwwGU3lkbmV5MB4XDTI2MDUxNTAzMTI1NVoXDTI2MTExMTAzMTI1NVowbTELMAkGA1UEBhMCQVUxDDAKBgNVBAgTA05TVzEPMA0GA1UEBxMGU3lkbmV5MRMwEQYDVQQKEwpDYW52YSBQcm9kMRAwDgYDVQQLEwdTaWduaW5nMRgwFgYDVQQDEw9TaWduaW5nIENBIFByb2QwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQD0MHZe4JEmHf4WkG5+tA+VwWcyYzsYw19aFaMnh/QZp8Awkci8GjqGdN09zzOrYEp6k9nAXTT0Y7H3tIUhpDIWy0jqysBROIYvKdGkz5Lp4MH3hB6nXnIiLwXpHfq/O95Cs8Q7wrmMoB1DPMLXBNfA/qRFqQeGVgL2UlxPchxkM1l1jEhSftxgZ09KpYFqcLeDEyPB2+nrfsX9qGuYLnmJOEZs1EtmLa5qjqbdGp7mlO8QawHQUmZ2QsD7YJDoSb7wU3XOHn/6h7ps5rVhTK516ryakEKf9P109m376yyUoyfGxqQD5E4KtlaTVsH0AWon+L3Mo0cpn/QxLikF+2iR2RGxyVsMUaQLwW0UXWlOw471623wcrsvkGZ+Ys40G+QOBrIHa/bq0cA6HZvxSNQt6I7+M0GDbGz2kdD3CobNaXXNGFt4y+qXjoMbx/NLfMhC5+frDobUF8RUSkCQ+Snzi95PibxJFHaInji8LrjueMnunf851089syyrXNqBaFQbyY/1tkESEeQmFudPrDl7qwh6sHSzWLOzrmdKZzJd49+ABzlAcOGU+b6l33JyP7TDzxGsZMRyQaEeQaYELwPIeENs1cIfFRLVlfQimCg0J6db37RK7GIG4z/yrvMbz9vnkk9ckzBF+3CzTtT8z/mtinltewY4QDpPdCfbPa/3fQIDAQABo4HLMIHIMBIGA1UdEwEB/wQIMAYBAf8CAQEwHwYDVR0jBBgwFoAUuzhkeWmWJGuw/pESspQ08s+cY4swHQYDVR0OBBYEFEpcbf53UGi9YylhZO2G4iHLBOMdMA4GA1UdDwEB/wQEAwIBhjBiBgNVHR8EWzBZMFegVaBThlFodHRwOi8vZDF1YWRuMHBqc2loOG0uY2xvdWRmcm9udC5uZXQvY3JsLzVlNDliYWRkLTBhNjYtNGYzYS1iM2NmLTFhNGZkMGY2MzVkMS5jcmwwDQYJKoZIhvcNAQENBQADggIBAKqOtwanq6lfW6XuFzlw/EJHJ2wVi9XY9dTjNZSPlIr3fnJ2qz2JRZ3f5VCa/+TmA9p2wv5aBNKSJyN4/uX1/qPxFunqykKFx3mNDD09JmxMEA1kTsjEePbhf5eBBlw2yg4lC3X+E/PTnx8VXTjwbG9jcaNGbJrD1/SZtYgjbti8YKnZMOeMh93wtv6NttshgFRcxUmS6sbvvhcAKi3U/YsMnbtSBJJH1dirr6rQuGSaghdLbxawcLd1OfMnw8aKe+G+4TOuKjQ10dwvMKwlBRTcnNJuun+vBvPujqfNmt5mc+0Hu2pDwtHB+LbmpC+hx2ksSmgTCLMjC+oiJUeAFnq1EVJlB++nqKzC4uD3XayTG1jvZ8JlJeZwdCIbo16DWmDP5eUrLiDJ8t/cR46hWzlmk549H1f9UZqiTMCyODjrnO0xbwm9yynPJCMvcN98sqWjOGSWQQPUXAqsMdNMzm1hWdjNzxntt5q9uYRa9rvYcmeu8M9Ek8Lz1sWoEaVcpkdI/XOZBY1nJC/xItlK22e39DS/JQibDVZgKsfkOkZ+tXn3rlD7zxpuJMvUtBmRnhA3iqZxa50+XEk1P2fxX2Ke4A025hkk4oVJY2+JZjWgD1qYvXZ5WRHZSU8SMiwx1iiw2PXcF8H/yEeEisZlIGM21Lc4gzhUmRLxrrTsDbbvoWNwYWRZKO8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2WQIAaarnRTgafFmqupaSlGCAozEPO3Weqwe1oTPkls+XrC3k42KEgi+pOSjCOtGBrqTsEA+d3LKb3UtorA07fQO97sKPyUVxkoKFI5ER4rDvLX9rd7+wKQ5fGGFBKctNfDekMs3uUBaL2nAXtnZNYf9wlc8cUMiJOA4wkwwbGBwDKJmVF7erbcfPqfuHFMLKfybn9Yv6wdLZn+9luUIGW1llrISo86ogZJ1t+461Mewwmqmhi77nDDe/4V/5mh2ts9aqUBaGop/jAlDS38V9SkW41CAN+v5A745BuLkSu02s/7sKtzT6rpvQTpu8CtQGZPUWdnV24HzyKO9golwfwoP0YtBGIotXTHINioPUMwr7JNUF/HgmjZ7/4xv8YiREPsn6aiVWOTnDA/EnpZmeIBS2QyP9H1PecApT2SNs4vaGj+neHrqVqAGDLgfjR8f0LqfwtA8euswwYGh3sbcPPTWf+asDOhcsLTws38mDcp5/bkKb7T/j5TIWLnwS0AozhE/biNCBYHhPDMgM7QGkfs1OpvG8ivYz2VB0vNeXjRsPUzAiNxTDsF/4CuyDSK/2+YSIRB7Oi2ZloKOrbGxjHycycp8gSvq6ptbiLefwyMamjhsvyZ/H9RB4WOSFZiNumpQxD1HS+xPOg42sXhm/8tWSk6KobFZ6Is+P8KBlXqQ+PEM= Yes
\ No newline at end of file
diff --git a/assets/img/6.svg b/assets/img/6.svg
new file mode 100644
index 0000000..d98c331
--- /dev/null
+++ b/assets/img/6.svg
@@ -0,0 +1 @@
+AABCHWp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAAEH3anVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46YzJwYTpjM2MyOGU2OC0yNzBlLTQ3ZDUtODk1ZC03ODZiNmM0YWNhODEAAAAB5Gp1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAAO5qdW1iAAAAQWp1bWRjYm9yABEAEIAAAKoAOJtxE2MycGEuYWN0aW9ucy52MgAAAAAYYzJzaLVYdjsHeLcgqvvt0Ekn6uUAAAClY2JvcqFnYWN0aW9uc4GjZmFjdGlvbmxjMnBhLmNyZWF0ZWRtc29mdHdhcmVBZ2VudGhDYW52YSBBSXFkaWdpdGFsU291cmNlVHlwZXhTaHR0cDovL2N2LmlwdGMub3JnL25ld3Njb2Rlcy9kaWdpdGFsc291cmNldHlwZS9jb21wb3NpdGVXaXRoVHJhaW5lZEFsZ29yaXRobWljTWVkaWEAAADFanVtYgAAAEBqdW1kY2JvcgARABCAAACqADibcRNjMnBhLmhhc2guZGF0YQAAAAAYYzJzaPDZWnL3f8zWzt795YTxsQcAAAB9Y2JvcqVqZXhjbHVzaW9uc4GiZXN0YXJ0GQETZmxlbmd0aBlYKGRuYW1lbmp1bWJmIG1hbmlmZXN0Y2FsZ2ZzaGEyNTZkaGFzaFggtJSRSi9BF230a6XIkGW0pi/3lMFrYE85kHGmJH5VRZ9jcGFkSQAAAAAAAAAAAAAAAgtqdW1iAAAAJ2p1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0udjIAAAAB3GNib3Knamluc3RhbmNlSUR4LHhtcDppaWQ6N2ZjYjQ0ZjYtZGQ1MC00ZTAxLWJlZGYtNGYyNDg2NmQ5YjhidGNsYWltX2dlbmVyYXRvcl9pbmZvo2RuYW1lZ2MycGEtcnNndmVyc2lvbmUwLjAuMHdvcmcuY29udGVudGF1dGguYzJwYV9yc2UwLjAuMGlzaWduYXR1cmV4TXNlbGYjanVtYmY9L2MycGEvdXJuOmMycGE6YzNjMjhlNjgtMjcwZS00N2Q1LTg5NWQtNzg2YjZjNGFjYTgxL2MycGEuc2lnbmF0dXJlcmNyZWF0ZWRfYXNzZXJ0aW9uc4GiY3VybHgpc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5oYXNoLmRhdGFkaGFzaFggJOhSSTAZiGiO5p9vrlzGR0/GeuIfXzgw9qvpGzmAj59zZ2F0aGVyZWRfYXNzZXJ0aW9uc4GiY3VybHgqc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5hY3Rpb25zLnYyZGhhc2hYIEzYQR3rpcYBjsRivxZ9VVtPW+bxtz+GKsEIfFI37n6haGRjOnRpdGxlZTYuc3ZnY2FsZ2ZzaGEyNTYAAD25anVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAAD2JY2JvctKEWRKBogE4Jhghg1kFPzCCBTswggMjoAMCAQICEQDJQRGNRLa9AyWvkldSDz2TMA0GCSqGSIb3DQEBDQUAMCIxIDAeBgNVBAMTF1NpZ25pbmcgSW50ZXJtZWRpYXRlIENBMB4XDTI2MDcyMzIzNTEzOFoXDTI2MDczMTIzNTEzOFowODEOMAwGA1UEChMFQ2FudmExDjAMBgNVBAsTBUNhbnZhMRYwFAYDVQQDEw1DYW52YSBTaWduaW5nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAsVk+WBj27+/EnKwbQvNArG26NTQzDTOsFM77ilhG+qwcC8g+gAy5pDIzpA1dkweAW2yTjr+7x9ITxnaiKyiJR/8BaqfEtbLGeIPI+sOLgh43UgrJBHKAtGorenATFFAz40/Ya23CfoUwdY3ctUWyN4KyiJ7dU+1EQ+946HJZkfI665G8HZYwBGfL3rZslCPWysadXXPVAyjK5py0kDBLYPRFXifftUq9tLt8EMTPVCeHYJ7Q05IwgLJVg5dQkQi3GyQ+VTITvll8AgiVDWpzrf6e7weQ+6e+u+nLzJztEzUBl3kvAjWYR6coiB/5FfuepEnQgPRFeJ4sS4av8G9b+XpqVAvUoYOjD7BtvgnHDQ5dLb4/Tv+YWy4vv6gpVe2pu7dDh+eDOCw0tnBWa1azv4KlUXpJrRkLMaqgkTftjrAVPPFLSseT8LPZFIjdl3avXscH6fbsAmp+qhtCZgJ5u8K6iCTSvRObxqCMHKMn3av8eKArbnA0O3w+NyMfL+RmcWfU9kU31TcaNo6ZCY/uKq0o7P4XWwchSWzGSmzMUWBRiB6ATvPRMGfoh6V5HIvXPemk9WlTbpJ+8DSjFrAJ2jWf0qcT//wbPFmSc3Ae/HqhGLuDKkkVDlE2Dh14bp+YSwEcPoHktF25PdLnVk5eDz9dPTdV6ouipkxBTpr1nrcCAwEAAaNWMFQwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsGAQUFBwMEMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUQ0RkWQaZ5GIbHo46Xf8EvwfZxXgwDQYJKoZIhvcNAQENBQADggIBABgU0Ok0hbEEwJD66/i5+fyR12kVEVO/31pGnQVt3vT5Jthswf5A5ifJmqU0B8I3sXYa1cz6hNSODfebq8X6tUQ8NcdVFGV8PW6ISDo1yapv3bfwYRjEYWnYrhHStkIt3rA55KFyuO1F28qKC58MX9Troz24x3mDrZvQ8b3WPyLuwSw+lTKFLG3mbqhgjeXFKoNAIwNSpePTBYCxCGmkiTaP/Go8E3uaY64PuuO4hYzaGeQIseizQoT9q+n/AyY7wRCKUFcD3oS7pglId2yCbJtq+vhfNX2vGeB/EfRwP1wFWWhSPLsKQ1WGIh+khJWaTO3mOMbKLsBwdq8WOCZAL2oSP0z1aK9ygYorWOyAt147cVdOZwQBBjdFrsAGko16KZtJzOwCFQ14LPVGarM+4JTvYWflnAdLgtvUvk1bSfyI6DmtfBcvVgtftLvmTwYxCAFG016mLnXE4Mn6xvvXypYehytGtEebatfxm60sWTMjnI8ELY/7oMNMfT+EX3snzED1mY3W0PYT5oD2xeiycdcJgMKgILFDt5ZomzM/6bG1CABHYQTscpbP9q52yx62tbV+Kr8oZZCdeNpilWzFxpzaThvZCCpke8TYHrrQiehTxeaqxj6Qed3L/p4tCSmH05iliSCQXGKBTqCQbxw5eumKVE7BjLUOwdiwk2H5qAanWQboMIIG5DCCBMygAwIBAgIUJYvTycarg5Zv7zyTq6NTD2vKDq8wDQYJKoZIhvcNAQELBQAwbTELMAkGA1UEBhMCQVUxDDAKBgNVBAgTA05TVzEPMA0GA1UEBxMGU3lkbmV5MRMwEQYDVQQKEwpDYW52YSBQcm9kMRAwDgYDVQQLEwdTaWduaW5nMRgwFgYDVQQDEw9TaWduaW5nIENBIFByb2QwHhcNMjYwNzE2MDUzNzQxWhcNMjYwODE1MDUzODExWjAiMSAwHgYDVQQDExdTaWduaW5nIEludGVybWVkaWF0ZSBDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANUc8Oy2+0jHoTqO5Hb3S7NuKFsrG9bqzcjuBIAMRcDpWLMAoYs787YMzzrlNPLtSy+N8JjUDlwVI/93qRPV2fiCaDxq/CF/h/0BaV4PLdthBPZvmkW8fSMdlKc5gXqZptndkUC41+K1u71xa3sZZrs1fVnm5xhepQUzvi47NTwRn9YwNy1+wbpPkn52fOSQ7KoWboGQxgfYNU821Io/WFaB/Kxhu2xJDsZRtBRHXvNfiGWrN9PuTXtd/HroMQ1dj0cQaHkH8BvgwYEZmWnGSNBcvJy1xnSk2JCHD3uia0OeyKeT+Y1CyJpXc/PltP2I+0/qSUHepRRVo5mBLIQgHsJ6QE/Bw7vB7yT1Tb6a4aAAv9WOBx5wHo/PB8wVm8v1VwS7Bha6PvdVpjIXixrAtv8xusDrCN93Yj9DEFKIl1C9F/2npgGjsyHPipmW3iaU8iiO651Uf5Tz89r53yFqWR+ZHiBAtGbBtjIawG7gP5Oqk4kjUP4AacCFbc65FiidNGNFdpiXhjZmifv4+gNoNCEISycbgYXBkFDnYXnvYTNT7o8gNAHcjW9d1ip6ed6DYGntXns+ULV8D3XkY1jKxp/uxH04Gc2X8UqOdwxeWRiEECRBzuGKEosl/zdQhJK3XNCJNEFB/VO5vXoqww/ULmcH295Hk5iJfvlp0vTxZozPAgMBAAGjggHFMIIBwTAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUQ0RkWQaZ5GIbHo46Xf8EvwfZxXgwHwYDVR0jBBgwFoAUSlxt/ndQaL1jKWFk7YbiIcsE4x0wgdcGCCsGAQUFBwEBBIHKMIHHMEwGCCsGAQUFBzABhkBodHRwczovL3BraS5jYW52YS1pbnRlcm5hbC5jb20vdjEvcGtpL2NhbnZhLXByb2QvbDEvc2lnbmluZy9vY3NwMHcGCCsGAQUFBzAChmtodHRwczovL3BraS5jYW52YS1pbnRlcm5hbC5jb20vdjEvcGtpL2NhbnZhLXByb2QvbDEvc2lnbmluZy9pc3N1ZXIvMWRjNDVjODQtNGY5My1lMTBlLTRjMzYtZTdkZWFhMjU2MzkyL2RlcjCBgAYDVR0fBHkwdzB1oHOgcYZvaHR0cHM6Ly9wa2kuY2FudmEtaW50ZXJuYWwuY29tL3YxL3BraS9jYW52YS1wcm9kL2wxL3NpZ25pbmcvaXNzdWVyLzFkYzQ1Yzg0LTRmOTMtZTEwZS00YzM2LWU3ZGVhYTI1NjM5Mi9jcmwvZGVyMA0GCSqGSIb3DQEBCwUAA4ICAQB4Y9NtmDHlRgo1HMSAsOKb3IjGXxk1ivWeA4Qk2bcehCcqmEqEAVxGbGYxzuFircH2GsXS8vAPU/FaXeAdFos631Y3tOeQNLeFKsMvvslVmfzncKFypt+Jve0k+nxGYGL1dBECdPLBHGzvHtAlQCONOtj1Nj9gvHUmGBRypu80+DojmVbED+k9wqukpm1TZQh79iRDSihoP8GT6FgfW2dEALn+wKbzXQZi1pu9+FPnWFy3jMLCO9mv9ldRuobX3sDlaAdsZqUC4qiQnNOyU+jQVj4sY032HAn19F1H67vX2KDywYFNvpTssOxcG0OHXB4x0BLTF/Vo8QXKNYGJt+6gYa6H84VOAL44NwHbAsnTgGLTuXUpsb4E15AxIk8YcIVJFOcwZJSXEWRl1U/keWnqKyJC5Tv5e5SNA29YeXgxWHJWnD8EpRKWYoFJfnrE7JX6amp3LNws1/mE7fqWGqM9+hYdQVOqHX/CrLvzD9qSPoLeMJzW/uEfaqXLAkZZfJbI1R7UzBiNzVNZTfx3zRGrAlLnVKFy3ddctN+ECZ5nAtC2CgNGQ59533fVbTQiU9xUhEOyNYtc3Og2EA7e0Nm/WznD7vndrQP+wPzZRgSW0uCQa72yPXJNBNhXBlP6oK0qB+2AhtqfhisLy1N2GTJlABaqAxwQCdtqXnE/+SSjQVkGSjCCBkYwggQuoAMCAQICEQDJtWzGUz0MB/MhdTvrUGHhMA0GCSqGSIb3DQEBDQUAMIGBMQswCQYDVQQGEwJBVTETMBEGA1UECgwKQ2FudmEgUHJvZDEXMBUGA1UECwwOQ2xvdWQgUGxhdGZvcm0xDDAKBgNVBAgMA05TVzElMCMGA1UEAwwcQ2FudmEgUHJvZCBSb290IENBIEdsb2JhbCBHMzEPMA0GA1UEBwwGU3lkbmV5MB4XDTI2MDUxNTAzMTI1NVoXDTI2MTExMTAzMTI1NVowbTELMAkGA1UEBhMCQVUxDDAKBgNVBAgTA05TVzEPMA0GA1UEBxMGU3lkbmV5MRMwEQYDVQQKEwpDYW52YSBQcm9kMRAwDgYDVQQLEwdTaWduaW5nMRgwFgYDVQQDEw9TaWduaW5nIENBIFByb2QwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQD0MHZe4JEmHf4WkG5+tA+VwWcyYzsYw19aFaMnh/QZp8Awkci8GjqGdN09zzOrYEp6k9nAXTT0Y7H3tIUhpDIWy0jqysBROIYvKdGkz5Lp4MH3hB6nXnIiLwXpHfq/O95Cs8Q7wrmMoB1DPMLXBNfA/qRFqQeGVgL2UlxPchxkM1l1jEhSftxgZ09KpYFqcLeDEyPB2+nrfsX9qGuYLnmJOEZs1EtmLa5qjqbdGp7mlO8QawHQUmZ2QsD7YJDoSb7wU3XOHn/6h7ps5rVhTK516ryakEKf9P109m376yyUoyfGxqQD5E4KtlaTVsH0AWon+L3Mo0cpn/QxLikF+2iR2RGxyVsMUaQLwW0UXWlOw471623wcrsvkGZ+Ys40G+QOBrIHa/bq0cA6HZvxSNQt6I7+M0GDbGz2kdD3CobNaXXNGFt4y+qXjoMbx/NLfMhC5+frDobUF8RUSkCQ+Snzi95PibxJFHaInji8LrjueMnunf851089syyrXNqBaFQbyY/1tkESEeQmFudPrDl7qwh6sHSzWLOzrmdKZzJd49+ABzlAcOGU+b6l33JyP7TDzxGsZMRyQaEeQaYELwPIeENs1cIfFRLVlfQimCg0J6db37RK7GIG4z/yrvMbz9vnkk9ckzBF+3CzTtT8z/mtinltewY4QDpPdCfbPa/3fQIDAQABo4HLMIHIMBIGA1UdEwEB/wQIMAYBAf8CAQEwHwYDVR0jBBgwFoAUuzhkeWmWJGuw/pESspQ08s+cY4swHQYDVR0OBBYEFEpcbf53UGi9YylhZO2G4iHLBOMdMA4GA1UdDwEB/wQEAwIBhjBiBgNVHR8EWzBZMFegVaBThlFodHRwOi8vZDF1YWRuMHBqc2loOG0uY2xvdWRmcm9udC5uZXQvY3JsLzVlNDliYWRkLTBhNjYtNGYzYS1iM2NmLTFhNGZkMGY2MzVkMS5jcmwwDQYJKoZIhvcNAQENBQADggIBAKqOtwanq6lfW6XuFzlw/EJHJ2wVi9XY9dTjNZSPlIr3fnJ2qz2JRZ3f5VCa/+TmA9p2wv5aBNKSJyN4/uX1/qPxFunqykKFx3mNDD09JmxMEA1kTsjEePbhf5eBBlw2yg4lC3X+E/PTnx8VXTjwbG9jcaNGbJrD1/SZtYgjbti8YKnZMOeMh93wtv6NttshgFRcxUmS6sbvvhcAKi3U/YsMnbtSBJJH1dirr6rQuGSaghdLbxawcLd1OfMnw8aKe+G+4TOuKjQ10dwvMKwlBRTcnNJuun+vBvPujqfNmt5mc+0Hu2pDwtHB+LbmpC+hx2ksSmgTCLMjC+oiJUeAFnq1EVJlB++nqKzC4uD3XayTG1jvZ8JlJeZwdCIbo16DWmDP5eUrLiDJ8t/cR46hWzlmk549H1f9UZqiTMCyODjrnO0xbwm9yynPJCMvcN98sqWjOGSWQQPUXAqsMdNMzm1hWdjNzxntt5q9uYRa9rvYcmeu8M9Ek8Lz1sWoEaVcpkdI/XOZBY1nJC/xItlK22e39DS/JQibDVZgKsfkOkZ+tXn3rlD7zxpuJMvUtBmRnhA3iqZxa50+XEk1P2fxX2Ke4A025hkk4oVJY2+JZjWgD1qYvXZ5WRHZSU8SMiwx1iiw2PXcF8H/yEeEisZlIGM21Lc4gzhUmRLxrrTsDbbvoWNwYWRZKO8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2WQIAq5GGDDJIhB2OfYnlhlIyFUUgRAE7LRilCQDgQ1EN60Ebur2ImtzPoTgzK5VDYciv7ArlgN1xJvDaoANI/5lwf9ihSpxIYCa4olnZeRrewwpKtVo/ys2w/fLlKPMs3JQzkjFQItErZvMIctW/nyBI28Ixd/HjhOSPotpFaHmBgUqifvqs6bUkQlq5GKInPIQ83chyw8WSA9TVtI2++aA9igh/wwm83A+cPbLNEn+tmZz0EWkl2LleimxhGPVwxn5iRTDCOKm2wOyCnUgtRKgYhDBVmqI0hk/2eExqVg34kViDZEtUv9W6Q9DesKFuBk9hZeUl/kxixAgmvE8AKV14RBtTuC8c+tA+WjJmVVYDE5/irQ4OZGqEFtoNodsyYCqhugvyP75NGnWYXZtsmXPilteFKlRcub9fcsEq7RXzx0BlXu/9NcLDlRVFrO8br/Wym97lEWnLvDUXscpIHu7HNObgyrYz3hB/DejpMfEvljMCWRcttHktiIkMezkeiuqqRfAcY0Y9SfubfMntyMFcTUIqZS9PggZgytG7hloZpvRXYUyMhMtOkZsB2dD9nNxhghHdNU8UnuJ5OKdMN+ptbsWv+GqGubV5hxA2iLXC46vtboyo5hrowFu4nFn7Ccc3mBKzDAVt+YYaLnDG9pAM5/PHUY4/K2qAmZuk4iP/EM8= Yes
\ No newline at end of file
diff --git a/assets/img/Logo.png b/assets/img/Logo.png
index 5335d45..c5b19e1 100644
Binary files a/assets/img/Logo.png and b/assets/img/Logo.png differ
diff --git a/assets/img/liked.jpg b/assets/img/liked.jpg
new file mode 100644
index 0000000..79e3803
Binary files /dev/null and b/assets/img/liked.jpg differ
diff --git a/assets/img/zuno-d-1.2.PNG b/assets/img/zuno-d-1.2.PNG
new file mode 100644
index 0000000..ea0c792
Binary files /dev/null and b/assets/img/zuno-d-1.2.PNG differ
diff --git a/assets/img/zuno-d1-1.2.PNG b/assets/img/zuno-d1-1.2.PNG
new file mode 100644
index 0000000..495c192
Binary files /dev/null and b/assets/img/zuno-d1-1.2.PNG differ
diff --git a/assets/img/zuno-d3-1.2.PNG b/assets/img/zuno-d3-1.2.PNG
new file mode 100644
index 0000000..3a3df74
Binary files /dev/null and b/assets/img/zuno-d3-1.2.PNG differ
diff --git a/assets/img/zuno-d4-1.2.PNG b/assets/img/zuno-d4-1.2.PNG
new file mode 100644
index 0000000..d8eb52f
Binary files /dev/null and b/assets/img/zuno-d4-1.2.PNG differ
diff --git a/assets/img/zuno-l-1.2.PNG b/assets/img/zuno-l-1.2.PNG
new file mode 100644
index 0000000..921cf70
Binary files /dev/null and b/assets/img/zuno-l-1.2.PNG differ
diff --git a/assets/img/zuno-l2-1.2.PNG b/assets/img/zuno-l2-1.2.PNG
new file mode 100644
index 0000000..39ff7bf
Binary files /dev/null and b/assets/img/zuno-l2-1.2.PNG differ
diff --git a/assets/img/zuno-l3-1.2.PNG b/assets/img/zuno-l3-1.2.PNG
new file mode 100644
index 0000000..e4be312
Binary files /dev/null and b/assets/img/zuno-l3-1.2.PNG differ
diff --git a/assets/img/zuno-l4-1.2.PNG b/assets/img/zuno-l4-1.2.PNG
new file mode 100644
index 0000000..8d63808
Binary files /dev/null and b/assets/img/zuno-l4-1.2.PNG differ
diff --git a/assets/img/zuno-l5-1.2.PNG b/assets/img/zuno-l5-1.2.PNG
new file mode 100644
index 0000000..6bd611b
Binary files /dev/null and b/assets/img/zuno-l5-1.2.PNG differ
diff --git a/assets/img/zuno-set-1.2.PNG b/assets/img/zuno-set-1.2.PNG
new file mode 100644
index 0000000..81054a1
Binary files /dev/null and b/assets/img/zuno-set-1.2.PNG differ
diff --git a/assets/img/zuno.mp4 b/assets/img/zuno.mp4
new file mode 100644
index 0000000..db06114
Binary files /dev/null and b/assets/img/zuno.mp4 differ
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 0000000..d646b5c
--- /dev/null
+++ b/docs/architecture.md
@@ -0,0 +1,487 @@
+# Architecture — Zuno
+
+An unofficial desktop YouTube Music client. Tauri 2 (Rust) shell + React 19 / TypeScript
+front end, bundled by Vite 7. No router and no Redux — plain classes with `useSyncExternalStore`
+and direct Tauri IPC. The UI is built on Tailwind v4 plus animated components vendored from the
+[beUI](https://beui.dev) registry, with [Solar](https://solar-icons.vercel.app) icons.
+
+- Version: `1.2.1` (`package.json`, `src-tauri/Cargo.toml`, `src-tauri/tauri.conf.json` are kept in lockstep)
+- Bundle id: `com.zuno.desktop` · Rust crate `zuno` / lib `zuno_lib`
+- Platforms: Windows, macOS, Linux
+- Companion docs: [frontend.md](./frontend.md) (UI), [backend.md](./backend.md) (Rust/IPC)
+
+---
+
+## 1. Big picture
+
+```mermaid
+flowchart TB
+ subgraph Main["Main window (index.html → src/main.tsx)"]
+ UI["React UI src/ui/**"]
+ STATE["Controllers + stores src/player/**"]
+ DS["DataSource layer src/datasource/**"]
+ IFRAME["Two hidden IFrame decksor an <audio> element"]
+ end
+
+ subgraph Mini["mini-player window (mini.html → src/mini.tsx)"]
+ MP["MiniPlayer component"]
+ end
+
+ subgraph Rust["Tauri backend (src-tauri/src/**)"]
+ CMD["Tauri commands"]
+ CACHE["File cache (LRU)"]
+ OFF["Offline audio store"]
+ SET["settings-v1.json"]
+ KEY["OS keyring / encrypted session"]
+ HTTP["reqwest proxy + cookie jar"]
+ MEDIA["SMTC / MPNowPlayingInfoCenter"]
+ TRAY["Tray icon"]
+ RPC["Discord IPC"]
+ LFM["Last.fm API"]
+ SRV["127.0.0.1 media server"]
+ WATCH["notify folder watcher"]
+ end
+
+ UI --> STATE --> DS
+ STATE --> IFRAME
+ DS -->|invoke| CMD
+ UI -->|invoke| CMD
+ MP <-->|Tauri events| UI
+ CMD --> CACHE & OFF & SET & KEY & HTTP & MEDIA & TRAY & RPC & LFM & SRV & WATCH
+ HTTP -->|HTTPS| YT["music.youtube.com / googlevideo.com"]
+ IFRAME -->|audio| YT
+```
+
+Four rules explain most of the design:
+
+1. **All network traffic to Google goes through Rust** (`proxy_http_request`) so the WebView's
+ CORS/cookie rules never apply, cookie auth can be signed properly, and rotated `Set-Cookie`
+ values can be merged back into the stored session.
+2. **There are two playback engines and the user picks one** (`ui/settings/audioEngine.ts`).
+ `iframe` (the default) hands the video id to a hidden YouTube IFrame player, dodging the 403s
+ that signed googlevideo URLs return when replayed from a different context — at the cost of a
+ `youtube.com` subframe process, roughly 90 MB. `native` resolves and downloads the track through
+ Rust and plays it from an `` element, with no subframe at all. Local and downloaded
+ tracks always take the native path regardless of the setting.
+3. **Downloads are a separate path from playback.** `offline_audio_save` fetches the same signed URL
+ in Rust with a PO token, in 4 MiB ranges, and stores the bytes on disk; playback of a downloaded
+ track then never touches the network.
+4. **Every piece of durable state has two homes**: `localStorage` (synchronous, drives the UI)
+ and a Rust-owned JSON file (survives WebView data wipes). See §6.
+
+---
+
+## 2. Process and window model
+
+| Window | Label | Entry | Notes |
+|---|---|---|---|
+| Main | `main` | `index.html` → `src/main.tsx` | 1280×900, min 900×600, `decorations: false`, `transparent: true`, `shadow: false` (custom title bar and window edge; Linux forces decorations on at runtime) |
+| Mini player | `mini-player` | `mini.html` → `src/mini.tsx` | transparent, always-on-top, `skipTaskbar`. **Created on first show and destroyed when the main window regains focus** — a hidden webview keeps its whole renderer process (~32 MB), so only destroying returns it. Create and destroy are serialized against each other in `miniPlayer.ts`, because alt-tab fires blur and focus within milliseconds. |
+| Sign-in | `youtube-music-login` | Google sign-in URL | Created on demand by Rust during `sign_in_youtube_music` (and headlessly by `refresh_youtube_music_cookie`), destroyed after the session cookie appears |
+
+Vite is configured with two Rollup inputs (`vite.config.ts`), so main and mini ship as separate
+HTML entry points sharing the same module graph. `@` is aliased to `src/`.
+
+**Production frontend hosting is unusual.** In release builds `run()` picks a free port, serves
+`dist/` through `tauri-plugin-localhost`, and rewrites `frontend_dist` to `http://localhost:`.
+The YouTube IFrame API refuses to run under the `tauri://` / `asset://` origins, so the app needs a
+real `http://` origin. Dev builds use the normal Vite dev server on port 1420.
+
+**Rust → frontend events:**
+
+| Event | Emitted when | Consumed by |
+|---|---|---|
+| `main-window-backgrounded` | main window loses focus and mini player isn't focused (100 ms debounce) | `App.tsx` — creates and shows the mini player window |
+| `main-window-minimized` | same check, but the window turned out to be minimized | `App.tsx` — creates and shows it even during drag suppression |
+| `window-focused` | main window regains focus | `App.tsx` — destroys the mini player window, triggers connection recovery |
+| `windows-media-control` | SMTC or taskbar thumbnail button pressed | `useMediaSession` |
+| `offline-download-progress` | per-chunk progress during `offline_audio_save` | `player/offlineStore.ts` |
+| `local-audio-changed` | `notify` watcher sees a change under a watched music folder | `main.tsx` → `notifyLocalPlaylistsChanged()` |
+
+**Mini ↔ main** is a set of plain Tauri events, no shared controllers:
+main emits `player-state-sync` / `player-time-sync` / `player-volume-sync`; the mini window emits
+`mini-player:request-sync`, `:toggle-play-pause`, `:skip-next`, `:skip-previous`, `:seek`,
+`:volume`, `:position-changed`, `:restore-main`.
+
+---
+
+## 3. Layers
+
+```
+src/
+├─ main.tsx / mini.tsx bootstrap: settings hydration, error hooks, React root
+├─ internal/ cross-cutting: cache, app settings, logging, updater, equality helpers
+├─ datasource/ "where does music come from" — abstract API + YouTube Music impl
+├─ player/ playback engine, queue, tabs, library, offline, integrations
+├─ components/ vendored beUI primitives (registry paths, do not restructure)
+├─ lib/ beUI helpers: cn, ease tokens, use-hover-capable
+└─ ui/ app components, pages, settings modules, stores, icons.tsx
+```
+
+Dependency direction is strictly downward: `ui → player → datasource → internal → Tauri IPC`.
+Nothing in `datasource/` imports from `ui/`.
+
+Alongside the source, `*.check.ts` files hold executable assertions for the non-trivial pure logic
+(queue moves, lyric timing, artwork picking, translation parsing, playlist transfer, prop equality,
+…). `npm run check` bundles and runs each in its own process — see §8.
+
+### 3.1 `internal/` — cross-cutting utilities
+
+| Module | Responsibility |
+|---|---|
+| `cache.ts` | Thin wrapper over the Rust file cache (`cache_get/set/stats/clear`). JSON in, JSON out. Default budget 4 GiB. |
+| `appSettings.ts` | `app_setting_get/set/remove` + `app_settings_clear`. Never throws (except `clear`). |
+| `durableLocalSetting.ts` | The localStorage ⇄ durable-settings mirroring helpers (`read/write/hydrateLocal{Boolean,Json}Setting`). Every settings module in `ui/settings/` is built on these. |
+| `logging.ts` | `logInternalDebug/Info/Warn/Error`. Redacts cookies, tokens, authorization headers and full URLs before forwarding to Rust via `frontend_log`, then also mirrors to the console. |
+| `updateChecker.ts` | `tauri-plugin-updater` wrapper; on macOS it degrades to a GitHub Releases API check (notify-only, no in-app install). 24 h per-version snooze in localStorage. |
+| `releaseNote.ts` | The one-time "what's new" note, keyed to the installed version rather than a flag. |
+| `artworkCache.ts` | Remembers which artwork candidate URL actually loaded, per source URL, so remounts don't rewalk the fallback chain. Hydrated before React mounts. |
+| `audioQuality.ts` | Streaming and download quality caps, set independently. |
+| `lyricsSourcePreference.ts` | Which lyric provider is promoted to the front of the race. A preference, not a restriction. |
+| `base64.ts` | Byte-oriented base64 for binary payloads crossing IPC (`btoa` is Latin-1 only). |
+| `shallowEqual.ts` / `propsEqual.ts` | One-level equality for store selectors and for `memo` on components handed inline arrows. |
+
+### 3.2 `datasource/` — content abstraction
+
+`DataSource` (`datasource/DataSource.ts`) is an abstract class where *everything except
+`getTrack` and `getStreamUrl` is optional*. Controllers feature-detect (`this.dataSource.getLyrics?.(…)`),
+so a partial implementation degrades gracefully instead of crashing. The optional surface now covers
+search, link resolution, session lifecycle, multi-account (`listAccounts` / `selectAccount`),
+library, albums, artists (subscribe, notification level), playlists (create/rename/describe/delete/
+reorder/add/remove), likes and ratings, notifications, recommendations, related shelves, browse
+pages, and lyrics.
+
+Domain types live in `datasource/types.ts`: `Track`, `Album`, `Playlist`, `Artist`, `ArtistPage`,
+`SearchResults`, `TrackPage`, `Lyrics`, `BrowsePage`/`BrowseShelf`/`BrowseTarget`, `FeedNotification`,
+`AccountOption`/`AccountProfile`, `AuthPrompt`/`AuthProgress`, `LibrarySnapshot`. `Track.source` is
+`"youtube" | "local"` — the single discriminator that routes local files down a different
+playback path.
+
+| File | Role |
+|---|---|
+| `youtube/YouTubeMusicDataSource.ts` | **The only implementation wired up.** ~6.1k lines. Owns Innertube clients, library/playlist/album/artist fetching, browse surfaces, search, recommendations, likes, ratings, notifications, accounts, lyrics, and stream resolution. |
+| `youtube/tauriFetch.ts` | `fetch`-shaped adapter that funnels youtubei.js through `proxy_http_request`. Computes the `SAPISIDHASH` authorization header, sets `origin`/`referer` to `music.youtube.com`, and rewrites `www.youtube.com/youtubei/*` → `music.youtube.com` when the client id is `67` (YouTube Music). |
+| `youtube/poToken.ts` | Proof-of-Origin token minting via `bgutils-js`. Signed googlevideo URLs are only served to a session that can prove it came from a real browser; without it the second and later downloads 403. |
+| `youtube/lyricsSources.ts` | The provider table. Sources are grouped into waves: wave 1 is raced in parallel, wave 2 (the expensive YouTube calls) only runs if wave 1 came back empty. Per-source `timeoutMs` and a `LyricsSourceAttempt` trail for the UI. |
+| `youtube/links.ts` | Parses a pasted YouTube / YouTube Music URL without a network round-trip; returns null for the shapes that genuinely need the API (`@handles`, `youtu.be` redirects, vanity channels). |
+| `youtube/artwork.ts` | Walks arbitrary Innertube response objects collecting `{url,width,height}` candidates and picks the largest; plus `i.ytimg.com` fallbacks by video id. |
+| `translate.ts` | Lyric translation through Google's keyless `translate_a` endpoint. Every failure is normal: a miss returns null and the caller shows the original words. |
+| `youtube/YouTubeDataSource.ts` | **Dead code.** Older non-Music implementation, no longer imported anywhere (see §9). |
+
+Caching inside the data source is a consistent stale-while-revalidate pattern:
+
+```
+getX() → read cache → if usable, return it and kick off a background refresh
+ → else await refresh, write cache, return
+refresh promises are deduped per key in a Map so concurrent callers share one request
+```
+
+Cache keys are versioned strings (`youtube-music:library:v5`, `youtube-music:track:v1:`,
+`lyrics:synced:v2:`, …); bumping the version is how you invalidate a schema change.
+
+### 3.3 `player/` — playback and app state
+
+| Module | Responsibility |
+|---|---|
+| `AudioEngine.ts` | Owns **two** hidden YouTube IFrame decks plus an optional `HTMLAudioElement`, and routes between them on `shouldUseNativeAudio()` — a live read of the audio-engine setting, so a change takes effect on the next track rather than the next launch. The standby deck holds the *next* track already cued, which is what makes transitions gapless; with a non-zero crossfade the two decks' volumes are ramped past each other. **Neither applies in native mode**, which has no deck to preload onto. `releaseIframePlayer()` frees the decks (and their subframe process) without disposing the engine; a module-level listener calls it on every engine except the current playback owner when the setting flips to native. `playbackClaimId` + `playbackOwner` guarantee only one engine makes sound at a time. |
+| `Queue.ts` | Pure queue data structure with three regions: played/current, a **manual queue** segment (`playNext` / `addToQueue`), and automatic upcoming tracks. Shuffle only touches the automatic region and remembers the original order so it can be restored. `move()` rejects cross-region moves. |
+| `PlayerController.ts` | One per tab. Orchestrates DataSource → AudioEngine → Queue, playback order (shuffle and repeat compose independently), crossfade/gapless settings, playback rate, stop-after-track, radio/autoplay refills, history, error surfacing, session export/restore, and Discord presence updates. Warms the next track as soon as the current one starts — its metadata always, its audio too on the native engine — because a skip lands whenever the listener decides, not only in the last few seconds. |
+| `TabManager.ts` | Owns the `Map`. Distinguishes the **focused** tab (what you're looking at) from the **playback owner** (what's making sound), and suspends/resumes engines on switch. |
+| `playerStore.ts` | Composition root: constructs the data source and the three controllers, restores the persisted session, and exposes `usePlayerState` / `usePlayerSelector` / `usePlayerSession` / `useLibraryState` plus an `ActivePlayerController` facade that always targets the right tab. |
+| `LibraryController.ts` | Sign-in/out, account switching, staged auth progress (`browser → session → library`), library snapshot loading with timeouts and post-sign-in retries, silent session recovery, optimistic like/rating mutations with rollback, and local-playlist merging. |
+| `SearchController.ts` | Trims queries, falls back from `search` to `searchTracks`, forwards streaming `onUpdate` callbacks. |
+| `offlineStore.ts` | Download queue and manifest. Statuses `absent → queued → downloading → ready \| failed`, a byte ceiling (default 8 GiB), progress fed by the `offline-download-progress` event, and `useOfflineState()` for the UI. |
+| `playHistory.ts` | Local play log (500 entries, oldest trimmed) behind the History page. A replay inside a short window updates the existing entry instead of appending. |
+| `localPlaylists.ts` | Local-file playlists: folder paths, per-playlist added tracks, ordering — all in localStorage, resolved through `local_audio_scan`, kept live by the folder watcher. |
+| `playlistMembership.ts` | Remembers which playlists a track is in (1000 tracks, pruned oldest-first) so the context menu can render without a round-trip. |
+| `playlistTransfer.ts` | Versioned JSON export/import of playlists. |
+| `recentPlaylists.ts` | Last-played timestamps per playlist for sidebar ordering. |
+| `playbackSettings.ts` | Volume/mute, playback rate, crossfade seconds, gapless flag — mirrored to durable settings. |
+| `appSession.ts` | Whole-app session snapshot in localStorage: tabs + per-tab player sessions. Restores `playing` as `paused` so launching the app never autoplays. Honours the "restore tabs and queues" setting. `saveAppSession` compares the serialized payload against the last one written and skips the (synchronous, disk-backed) `setItem` when nothing moved — it is called from three places and most calls are no-ops. |
+| `DiscordRPC.ts` | Sanitizes presence payloads (128-char text limit, HTTPS-only artwork from a trusted host allowlist) before invoking Rust. |
+| `LastFm.ts` | Tracks listened seconds, sends `nowPlaying` once per track and scrobbles at `min(240 s, duration/2)`; tracks shorter than 31 s never scrobble. |
+| `useMediaSession.ts` | Bridges player state to Windows SMTC (via `update_windows_media_session` + `windows-media-control` events) or, elsewhere, the WebView `navigator.mediaSession`. |
+| `shuffleTracks.ts` | Fisher–Yates helper. |
+| `Recommender.ts` | **Dead code** — returns `[]`, nothing imports it. |
+
+**Account-facing choices** live in `ui/settings/youtubeAccount.ts`: whether playback resolves
+streams with the session attached, and whether finished plays are reported to YouTube's history.
+Both default off — each changes what leaves the machine. Enabling scrobbling enables signed-in
+resolution with it; the reverse is not true, so signed-in playback without history is reachable.
+
+### 3.4 `ui/` — React
+
+Detailed in [frontend.md](./frontend.md). Summary: `App.tsx` is the single stateful root
+(tabs, navigation history, onboarding, updates, window/mini-player wiring, global shortcuts);
+every page below it is `lazy`-loaded, and everything else is presentational or reads a store hook
+directly.
+
+### 3.5 `src-tauri/src/` — Rust
+
+Detailed in [backend.md](./backend.md).
+
+| File | Responsibility |
+|---|---|
+| `main.rs` | Sets the Windows AppUserModelID, strips the WebView2 diagnostics env var, calls `run()` |
+| `lib.rs` (~3.9k lines) | Commands, cache, settings, logging, keyring/session + cookie jar, HTTP proxy, audio fetch, offline store, local files and tags, folder watcher, media server, tray, window event wiring |
+| `discord_rpc.rs` | `DiscordIpcClient` lifecycle and activity building |
+| `lastfm.rs` | MD5-signed Last.fm API calls, session in keyring |
+| `windows_media.rs` | SMTC integration + taskbar thumbnail toolbar buttons |
+| `macos_media.rs` | `MPNowPlayingInfoCenter` integration |
+
+---
+
+## 4. Key flows
+
+### 4.1 Boot
+
+```
+main.tsx
+ hydrateArtworkCache() → before paint, so no fallback-icon flash
+ applyPlatformAttributes() → data-platform-linux on
+ applyTheme() + watchSystemTheme() → data-theme; "system" keeps following the OS
+ applyPaperPcMode() → data-paper-pc (reduced-motion / no blur theme)
+ applyNativeWindowControls() → window decorations on/off
+ hydrateMainWindowGeometry() → restoreMainWindowGeometry()
+ Promise.all([ ~18 hydrate* calls: paperPc, theme, windowControls, miniPlayer, playerControls,
+ queuePanel, tray, audioQuality, audioEngineMode, lastFm, discord, sidebar,
+ keyboardShortcuts, toolbarItems, homeSections, playbackSettings, playHistory,
+ sessionRestore ])
+ DiscordRpcService.init()
+ window error / unhandledrejection hooks
+ createRoot().render( )
+ syncLocalAudioWatcher() + listen("local-audio-changed")
+
+playerStore.ts (module side effect, imported by App)
+ new YouTubeMusicDataSource()
+ new LibraryController / SearchController / TabManager
+ loadAppSession() → tabManager.restoreSession(...) (or create tab "1")
+
+App mount
+ libraryController.initialize() → cached library first, then restoreSession() + refresh()
+ loading screen dismisses after ≥1 s, ≤4 s
+```
+
+Hydration order matters: durable settings are read from Rust *after* the synchronous
+localStorage read, so the UI paints instantly with the last known values and corrects itself
+once the file read resolves.
+
+### 4.2 Playing a track
+
+```
+UI onClick
+ → playerController.playTrackById(videoId, queue?, autoplayWhenQueueEnds?)
+ → tabManager.claimFocusedPlayer() (suspends the previous playback owner)
+ → PlayerController.playTrackById
+ queue.set(playbackQueue, startIndex) isPlaylistMode = queue>1 && !autoplay
+ dataSource.getTrack(videoId) cached, merged with the queued row
+ ensureTrackLoaded(track)
+ local / downloaded → local_audio_read | offline_audio_source →
+ remote, iframe → audioEngine.loadTrack(videoId) → deck cueVideoById, wait for CUED
+ remote, native → getStreamData() → fetch_audio_source →
+ (also releases the IFrame decks, freeing the subframe)
+ audioEngine.play() claims global playback, waits for PLAYING
+ setState({status:"playing"}) → recordPlay(track)
+ → emit() → React re-render + Discord presence + Last.fm nowPlaying
+```
+
+In `iframe` mode, ahead of the end of a track the engine cues the next one onto the **standby
+deck**. Track end (`onEnded`) then hands over: with `crossfadeSec` at 0 the swap is gapless; above 0
+the two decks' volumes ramp past each other. Native mode has no standby deck, so it neither
+preloads nor crossfades — `onEnded` on the `` element simply advances the queue.
+Repeat-one replays instead; otherwise the next queue item; otherwise
+queue-end recommendations (playlist mode); otherwise the radio queue if autoplay is on; otherwise
+pause. `refillAutomaticQueue()` tops the automatic region back up when fewer than 10 tracks remain
+and the queue isn't a fixed playlist.
+
+Every async step is guarded by a monotonically increasing request id (`playTrackRequestId`,
+`radioQueueRequestId`, `loadRequestId`), so a fast skip cancels the in-flight work rather than
+letting a stale response overwrite state.
+
+### 4.3 Stream resolution
+
+| Track kind | Path |
+|---|---|
+| YouTube, streaming, `iframe` mode | No stream URL is fetched at all — the IFrame deck handles it from the video id. |
+| YouTube, streaming, `native` mode | `getStreamData` → `resolveStreamUrl` → `fetch_audio_source` downloads it in parallel ranges and re-serves it from the local media server. The MP4 `ftyp` check only applies when MP4 was the format asked for — `high` now reaches Opus, which has no `ftyp` box. |
+| YouTube, downloaded | `offline_audio_source` serves the stored bytes from the media server, with Range support. No network. |
+| YouTube, download | `getStreamData` → Innertube `getBasicInfo` → best adaptive `audio/mp4` at the configured quality → decipher → PO token → `offline_audio_save` fetches it in 4 MiB ranges, 6 at a time, emitting progress. |
+| Local file | `local_audio_read` returns base64 bytes + a MIME type guessed from the extension; the frontend builds a `Blob` object URL. |
+
+`shouldUseNativeAudio()` was hardcoded `false` from v1.2.65 — the backend download path answered
+403 for remote streams, so every platform was reverted to the IFrame player. **PO tokens are what
+fixed it**: the download feature has used this same resolve-and-fetch path successfully ever since,
+which is what made it safe to offer again as a setting. It stays opt-in because native resolves and
+downloads the whole track before the first sample plays (so a track starts slower) and because
+gapless and crossfade ride the standby IFrame deck, so neither applies to it.
+
+**Two resolvers, not one.** `resolveStreamUrl` (playback) and `resolveDownloadUrl` (offline queue)
+are thin wrappers over a shared private `resolveStream(track, quality, clientOrder)`. Only the
+playback one reads `usesAuthenticatedStreaming()`; the download one has no conditional and no
+import of it, so downloads cannot inherit the setting through a mis-edited branch. `playerStore`
+binds `resolveDownloadUrl` into the offline queue, which puts that guarantee at the wiring site.
+
+### 4.4 Authentication
+
+Cookie-based, not OAuth:
+
+```
+signIn()
+ → Rust sign_in_youtube_music
+ opens the "youtube-music-login" window at accounts.google.com
+ clear_all_browsing_data() first
+ polls once a second, up to 300 s, for cookies on music.youtube.com
+ success = has SAPISID|__Secure-1PAPISID|__Secure-3PAPISID AND the window is on music.youtube.com
+ serializes them into one Cookie header, persists, closes the window, returns the header
+ → frontend clears the data cache (unless it's the same account), rebuilds the Innertube
+ client, refreshes the library
+```
+
+`LibraryController` reports the stages — `browser`, `session`, `library` — through `authProgress`,
+so the overlay can tell "waiting on a browser window" apart from "fetching your library".
+`cookie_account_identity()` compares the `SAPISID` across a re-sign-in: the same account keeps its
+cache, a different one drops it.
+
+**Keeping the session alive.** Google rotates session cookies continuously. Every proxied response
+from a YouTube host has its `Set-Cookie` headers merged into a Rust-side cookie jar
+(`apply_set_cookie`): a rotated value replaces the stale one, a tombstone drops it, an unchanged
+value writes nothing. Credential rotations persist immediately; the noisy `SIDCC` family may wait
+for a 5-minute interval. `refresh_youtube_music_cookie` re-mints a lapsed session in a hidden window
+without user interaction.
+
+Storage differs by OS: Windows/Linux split the header into ≤900-byte chunks across up to 16 keyring
+entries plus a `chunks:` manifest (keyrings cap entry size); macOS encrypts the header with
+AES-256-GCM into `youtube-music-session-v1.bin` under the app data dir and keeps only the 32-byte
+key in the keychain. The keyring service name is still the legacy `com.ytmusicdock.app` so existing
+sign-ins survived the rename, and `migrate_legacy_app_data` copies over data from the older
+`com.justanothermusicclient.desktop` identifier on first run.
+
+### 4.5 Search and browse
+
+`SearchOverlay` (Ctrl/⌘+K) does three things at once: fuzzy-matches your own playlists and albums
+locally, requests `getSearchSuggestions`, and on submit opens a `search` view (optionally in a new
+tab). A pasted YouTube link is recognised by `looksLikeYouTubeLink` and resolved instead of searched.
+`SearchResultsPage` renders the mixed `SearchResults` with streaming `onUpdate` callbacks painting
+partial results as they arrive. `BrowsePage` renders the non-search discovery surfaces
+(`explore` / `charts` / `moods` / `podcasts`) through `getBrowsePage`, and `RelatedPage` renders
+`getRelated` shelves for a single track.
+
+---
+
+## 5. State management
+
+No state library. Three patterns, in order of preference:
+
+1. **Class + listener set + `useSyncExternalStore`** — `TabManager`, `LibraryController`,
+ `playerUIStore`, `ambientArtworkStore`, `offlineStore`. Subscribers get a `getSnapshot` that must
+ return a stable reference; controllers cache their snapshot and invalidate it on `emit()`.
+ `usePlayerSelector` narrows that with `shallowEqual` so a component only re-renders for the
+ fields it read.
+2. **localStorage + custom `window` event + `useSyncExternalStore`** — every module under
+ `ui/settings/`. The event name (e.g. `mini-player-enabled-change`) is the change channel, and the
+ `storage` event covers the other window.
+3. **`useState` in `App.tsx`** — tabs, navigation history, onboarding, update toast. Tab state is
+ pure React; only the per-tab `PlayerController` lives outside React.
+
+### The focused-vs-owner split
+
+`TabManager` tracks `activeId` (focused tab) and `playbackOwnerId` (audible tab) separately, and
+`getEffectivePlayer()` resolves them: if the focused tab has a current track it wins, otherwise the
+playback owner does. This is what lets you browse tab 2 while tab 1 keeps playing, and it is why the
+`playerController` exported from `playerStore` is a facade — read methods hit the effective player,
+while `loadTrack`/`playTrackById` first *claim* the focused tab as the new owner.
+
+---
+
+## 6. Persistence
+
+| Store | Location | Contents |
+|---|---|---|
+| Durable app settings | `/settings-v1.json` (Rust, mutex-guarded) | Mirror of every UI setting: theme, shortcuts, window geometry, mini-player position, sidebar mode, toolbar/home-section toggles, audio quality, tray, onboarding flags |
+| localStorage | WebView profile | Same settings (fast path) + session, play history, local playlists, playlist membership, offline manifest, recent playlists, recent searches, update snoozes |
+| Data cache | `/data-cache-v1/entries/.json` | Library, playlists, albums, artists, tracks, browse pages, lyrics, search results. LRU-evicted to a configurable budget (default 4 GiB) |
+| Offline audio | `/offline-audio-v1/.bin` | Downloaded track bytes. The frontend keeps the metadata manifest in localStorage and reconciles it against `offline_audio_list`. Pruned oldest-first to a configurable ceiling (default 8 GiB) |
+| In-memory media bodies | `MediaServer.items`, Rust process | The audio currently playing and the one preloaded. Hard cap of 3 entries, coldest evicted — see [backend.md](./backend.md) §3 |
+| Secrets | OS keyring (`com.ytmusicdock.app`) — plus an AES-GCM file on macOS | YouTube cookie header, Last.fm session key |
+| Logs | `/current.log` | Truncated on every launch; older `*.log` files deleted |
+
+"Delete all app data" in Settings clears them: `clearAppSettings()`, `clearCache()`,
+`clearAppSession()`, `removeAllDownloads()`, `localStorage.clear()`, plus sign-out.
+
+---
+
+## 7. Platform integrations
+
+| Integration | Where | Notes |
+|---|---|---|
+| Discord Rich Presence | `player/DiscordRPC.ts` → `discord_rpc_update` / `_clear` → `discord_rpc.rs` | Client id `1515682467154100344`. Timestamps derived from position so Discord shows a live progress bar. Artwork is forced to `i.ytimg.com/vi//hqdefault.jpg` for YouTube tracks because Google CDN hosts block Discord's fetcher. Toggleable (`ui/settings/discord.ts`). |
+| YouTube Music history | `YouTubeMusicDataSource.beginPlayReport` / `updatePlayReport`, driven by `PlayerController` | Off by default (`ui/settings/youtubeAccount.ts`). Native engine only — the IFrame embed reports its own plays, so pinging as well would double-count. A play counts only when reported watch time grows with real elapsed time: a single ping claiming a whole track was heard is accepted with 204 and recorded as nothing. |
+| Last.fm | `player/LastFm.ts` → `lastfm_*` → `lastfm.rs` | Desktop auth-token flow opened in the system browser; session key in the keyring; MD5 `api_sig` computed in Rust. |
+| Windows SMTC | `windows_media.rs` | `MediaPlayer`/`SystemMediaTransportControls` for the OS overlay, plus taskbar thumbnail toolbar buttons. Sends `windows-media-control` events back to JS. |
+| macOS Now Playing | `macos_media.rs` | `MPNowPlayingInfoCenter` via `objc2`. Requires `macOSPrivateApi: true`. |
+| Other platforms | `useMediaSession.ts` | Falls back to `navigator.mediaSession` with metadata, position state, and action handlers. |
+| Tray icon | `lib.rs` `build_tray()` | Always built so the setting takes effect without a restart. With "minimize to tray" on, closing the main window hides it and playback continues; the tray menu restores or quits. |
+| Local folder watching | `lib.rs` `local_audio_watch` (`notify`) | Emits `local-audio-changed` with no detail — a rescan is cheap and diffing renames/temp files would be far more code for the same result. |
+| Autostart | `ui/settings/autostart.ts` | `tauri-plugin-autostart`. |
+| Updates | `internal/updateChecker.ts` | `tauri-plugin-updater`, minisign-signed, endpoints on GitHub Releases (`latest.json`) and an `updater-channel` branch. macOS is notify-only. |
+
+---
+
+## 8. Build, test, release, security
+
+**Build:** `npm run dev` (Vite only) · `npm run tauri dev` · `npm run tauri build`
+(runs `tsc && vite build` first via `beforeBuildCommand`). Bundle targets: `all`,
+with updater artifacts enabled. Linux AppImage bundles the media framework; the RPM declares
+webkit2gtk4.1 / gtk3 / libayatana-appindicator and recommends the GStreamer plugin sets.
+
+**Checks:** `npm run typecheck` (`tsc --noEmit`) and `npm run check` — the latter runs
+`scripts/run-checks.mjs`, which finds every `*.check.ts` under `src/`, bundles it with esbuild's JS
+API and runs each in its own process with a 30 s timeout. `npm run verify` is both. The Rust side
+has a `#[cfg(test)]` module in `lib.rs` — 14 tests (`cargo test`). There is no component/DOM test harness.
+
+**Repo layout beyond the app:** `landing/` is a separate Vite site for the product page,
+`packaging/` holds the AUR PKGBUILD and the Flatpak manifest, `manifests/` holds the winget
+submission. Workflows: `ci.yml`, `release.yml`, `aur.yml`, `flatpak.yml`.
+
+**Security posture:**
+
+- `app.security.csp` is `null` — no CSP is enforced. Needed because the app loads the YouTube
+ IFrame API from `youtube.com` at runtime; worth revisiting if that path ever changes.
+- `src-tauri/capabilities/default.json` allowlists core and plugin permissions per window. Note its
+ `command.allow` / `allowlist` blocks are Tauri 1 syntax and no longer gate anything (see
+ [backend.md](./backend.md) §4) — they are stale relative to `generate_handler!`.
+- Log sanitization happens twice: `internal/logging.ts` redacts before sending, and Rust's
+ `sanitize_log_message` / `sanitize_log_url` redact again before writing to disk. The URL
+ sanitizer keeps the diagnostic params (`expire`, `itag`, `c`, `cver`, `clen`) and replaces
+ signatures with a `[9ch]`-style length marker.
+- Discord and artwork URLs are validated against host allowlists before leaving the app.
+- The cookie jar only accepts `Set-Cookie` from YouTube hosts — a suffix match alone would hand the
+ session to a lookalike domain, which is what `is_youtube_cookie_host` guards.
+- Signed `googlevideo.com` URLs carry an `ip=` parameter; the Rust client binds its local address
+ to the matching IP family so the signature stays valid.
+- `local_audio_read` / `local_audio_write_tags` re-validate the path and extension rather than
+ trusting the frontend.
+
+---
+
+## 9. Known dead code and rough edges
+
+Recorded so nobody re-derives them:
+
+- `src/datasource/youtube/YouTubeDataSource.ts` (809 lines) — superseded by `YouTubeMusicDataSource`, not imported.
+- `src/player/Recommender.ts` — stub returning `[]`, not imported. Recommendations live in the data source.
+- `src/ui/components/MagicDice.tsx` — not imported; `DiceCard.tsx` is the live one.
+- `src/ui/components/player/ExpandedPlayerBar.tsx` — its render block is still commented out in
+ `App.tsx` (~line 1954); the `isExpandedPlayerBar` state and toggle are live and do nothing visible.
+- `greet` command in `lib.rs` — Tauri scaffolding leftover, still registered.
+- `capabilities/default.json` `command.allow` — Tauri 1 leftover; missing every command added since
+ (all `offline_audio_*`, `read_text_file`, `write_text_file`, `local_audio_*_tags`,
+ `local_audio_watch`/`unwatch`, `refresh_youtube_music_cookie`, `open_current_log`) and nothing
+ broke, which is the proof it is inert.
+- Gapless and crossfade silently do nothing in `native` audio-engine mode — they ride the standby
+ IFrame deck, which that mode has no equivalent of. The Settings copy says so, but nothing in the
+ code prevents the combination.
+- `App.tsx` is 2156 lines with ~30 `useEffect` blocks, some at zero indentation — still the
+ highest-value refactor target in the repo.
+- `getStreamUrl` on the YouTube Music data source is required by the abstract class but never called
+ by the controllers.
diff --git a/docs/backend.md b/docs/backend.md
new file mode 100644
index 0000000..3a974f6
--- /dev/null
+++ b/docs/backend.md
@@ -0,0 +1,364 @@
+# Backend — Rust / Tauri
+
+`src-tauri/` — Tauri 2, edition 2021, crate `zuno`, lib target `zuno_lib`.
+See [architecture.md](./architecture.md) for the system view.
+
+---
+
+## 1. Files
+
+| File | Lines | Responsibility |
+|---|---|---|
+| `main.rs` | 24 | Windows AppUserModelID, WebView2 diagnostics workaround, calls `run()` |
+| `lib.rs` | ~3940 | Everything else: commands, cache, settings, logging, session storage + cookie jar, HTTP proxy, audio fetch, offline store, local files/tags/watcher, media server, tray, window events, builder wiring |
+| `discord_rpc.rs` | 213 | Discord IPC client lifecycle and activity payloads |
+| `lastfm.rs` | 283 | Last.fm auth + scrobbling |
+| `windows_media.rs` | 630 | SMTC + taskbar thumbnail toolbar (Windows only) |
+| `macos_media.rs` | 189 | `MPNowPlayingInfoCenter` (macOS only) |
+
+### Dependencies of note
+
+`tauri` (features `macos-private-api`, `tray-icon`, `image-png`),
+`tauri-plugin-{autostart,opener,localhost,updater,process,dialog}`,
+`reqwest` (rustls-tls, gzip/brotli/deflate, **stream**), `futures-util` (parallel range downloads),
+`keyring` 3.6 with native backends, `lofty` 0.24 (audio tag read/write), `notify` 8.2 (folder
+watching), `discord-rich-presence`, `md5`, `base64`, `url`, `portpicker`.
+macOS adds `aes-gcm`, `objc2`, `objc2-foundation`, `block2`, `rand`. Windows adds the `windows`
+crate (Media, Media_Playback, Storage_Streams, Win32 Shell/Gdi/Com/UI/Controls).
+
+---
+
+## 2. `run()` — startup
+
+```rust
+manage(CacheLock) // Mutex<()> serializing all cache file ops
+manage(AppSettingsLock) // Mutex<()> serializing settings file ops
+manage(YoutubeCookieJar) // Mutex — the live session cookies
+manage(DiscordRpcManager)
+plugin(autostart, updater, process, dialog, opener)
+
+#[cfg(not(debug_assertions))] // release only
+ port = pick_unused_port()
+ config.build.frontend_dist = Url("http://localhost:{port}")
+ plugin(tauri_plugin_localhost::Builder::new(port))
+
+#[cfg(windows)] manage(WindowsMediaSession)
+#[cfg(macos)] manage(MacosMediaSession)
+manage(LocalAudioWatcher)
+
+#[cfg(linux)] force main window decorations = true // mutated on the config before build
+
+setup → migrate_legacy_app_data() // before the log opens, so a first run after the
+ → initialize_app_log() // rename logs into the restored directory
+ → build_tray() // always built; the setting only changes behaviour
+on_window_event → close / focus handling
+invoke_handler(...)
+```
+
+Serving the frontend over `http://localhost` in release builds is deliberate: the YouTube IFrame
+player API will not initialize under the `tauri://` custom protocol origin.
+
+`migrate_legacy_app_data` copies app data from the pre-rename
+`com.justanothermusicclient.desktop` identifier into `com.zuno.desktop` on first run.
+
+### Window events
+
+| Event | Behaviour |
+|---|---|
+| `CloseRequested` on `main` | `api.prevent_close()` then `close_or_hide_main_window()` — hides to the tray if `minimize-to-tray` is set, otherwise `app.exit(0)`. The titlebar button routes through the same function via `quit_app`, so the two can't disagree |
+| `Focused(false)` on `main` | Spawns a thread, waits 100 ms, re-checks that neither main nor mini is focused, then emits `main-window-minimized` (if the window is minimized) or `main-window-backgrounded` — the debounce guards against focus flicker during window drags |
+| `Focused(true)` on `main` | Emits `window-focused` |
+
+### Tray
+
+`build_tray()` installs a `main-tray` icon with a two-item menu (**Show Zuno** / **Quit Zuno**).
+Left click restores the window; the menu is right-click only. **Quit Zuno** is the one path that
+always exits regardless of the minimize-to-tray setting.
+
+---
+
+## 3. Command reference
+
+Every command is invoked from TypeScript via `@tauri-apps/api/core`'s `invoke` and registered in
+`tauri::generate_handler!`.
+
+### App settings — `/settings-v1.json`
+
+| Command | Signature | Notes |
+|---|---|---|
+| `app_setting_get` | `(key) -> Option` | Whole-file read under `AppSettingsLock` |
+| `app_setting_set` | `(key, value)` | Read-modify-write, atomic (`.tmp` + rename) |
+| `app_setting_remove` | `(key)` | |
+| `app_settings_clear` | `()` | Used by "delete all app data" |
+
+Simple flat `HashMap`. Whole-file rewrite per key — fine at this scale, and the mutex
+prevents interleaved writes. Rust reads one key of its own out of this file: `minimize-to-tray`.
+
+### Data cache — `/data-cache-v1/`
+
+| Command | Signature | Notes |
+|---|---|---|
+| `cache_get` | `(key) -> Option` | Touches `last_accessed_ms` and rewrites the entry |
+| `cache_set` | `(key, value) -> { changed }` | `changed` lets the frontend skip re-rendering identical data after a background refresh |
+| `cache_stats` | `() -> { maxBytes, usedBytes, entryCount }` | |
+| `cache_set_max_bytes` | `(maxBytes) -> CacheStats` | Applies eviction immediately |
+| `cache_clear` | `() -> CacheStats` | Removes and recreates `entries/` |
+
+Implementation details:
+
+- File per entry: `entries/.json`, holding `{ key, value, updatedAtMs, lastAccessedMs }`. The stored `key` is compared on read so a hash collision misses instead of corrupting.
+- Writes are atomic: serialize → write `.tmp` → remove target → rename.
+- `enforce_cache_limit` sorts entries by `last_accessed_ms` and deletes oldest-first until under budget. Runs on every `cache_set` — O(n) directory stat per write; acceptable at current entry counts, worth revisiting if the cache grows into tens of thousands of files.
+- Default budget: 4 GiB (`DEFAULT_CACHE_MAX_BYTES`), configurable in Settings.
+
+### Logging
+
+| Command | Signature | Notes |
+|---|---|---|
+| `frontend_log` | `(level, context, payload)` | Appends a sanitized line to `/current.log` |
+| `open_current_log` | `()` | Opens the log in the system handler |
+
+`initialize_app_log` truncates `current.log` on every launch and deletes every other `*.log` in the
+directory. A local `eprintln!` macro shadows the std one so *all* Rust logging is routed through
+`sanitize_log_message` and appended to the same file.
+
+`sanitize_log_url` is the interesting half: it keeps the parameters that explain a googlevideo 403
+(`expire`, `itag`, `mime`, `clen`, `c`, `cver`) and replaces the rest — `sig`, `lsig`, `pot`, `n`,
+`ip` — with a `[9ch]`-style length marker, so a truncated signature is distinguishable from a
+missing one without leaking either. Unparseable input becomes `[redacted-url]`.
+
+### YouTube session
+
+| Command | Signature | Notes |
+|---|---|---|
+| `sign_in_youtube_music` | `() -> String` | Opens the login window, polls for cookies, returns the Cookie header |
+| `refresh_youtube_music_cookie` | `() -> Option` | Silent re-mint in a hidden window, up to `YOUTUBE_SILENT_REFRESH_POLLS` (25) polls |
+| `load_youtube_music_cookie` | `() -> Option` | Also seeds the in-memory cookie jar |
+| `delete_youtube_music_cookie` | `()` | Also clears the login window's browsing data if present |
+
+**Sign-in flow** (`sign_in_youtube_music`): create window `youtube-music-login` at `about:blank` →
+`clear_all_browsing_data()` → navigate to the Google sign-in URL → poll once a second for up to 300
+polls. Success requires *both* an auth cookie (`SAPISID` / `__Secure-1PAPISID` / `__Secure-3PAPISID`)
+*and* the window sitting on `music.youtube.com`. On success the cookies are joined into one header,
+persisted, and the window closes. If the user closes the window, the command errors with "cancelled".
+macOS reads cookies via `window.cookies()` filtered by `cookie_domain_matches` (unit-tested); other
+platforms use `cookies_for_url`. macOS also sets a Safari user agent so Google serves the desktop flow.
+
+**Cookie jar.** Google rotates session cookies continuously, and a session that only ever stores what
+sign-in returned goes stale overnight. `proxy_http_request` feeds every `Set-Cookie` from a YouTube
+host through `apply_set_cookie`, which merges it into `YoutubeCookieJar` in original order:
+
+- a rotated value replaces the stale one and reports a change,
+- the same value again reports *no* change, so it triggers no keyring write,
+- an expiry/`max-age=0` tombstone drops the cookie.
+
+Credential rotations (`__Secure-*PSIDTS`, `SAPISID`, …) are persisted immediately.
+`is_slow_persist_cookie` marks only the noisy `SIDCC` family as allowed to wait for the
+`YOUTUBE_COOKIE_PERSIST_INTERVAL` (5 min) — getting that list backwards is what once made a session
+look lost after a `PSIDTS` rotated at 23:58 and never got written. `is_youtube_cookie_host` gates
+the whole thing: a suffix match alone would hand the session to `notyoutube.com`.
+
+`cookie_account_identity()` reduces a header to its `SAPISID` (falling back to
+`__Secure-1PAPISID` / `__Secure-3PAPISID`), so a re-signed-in session is recognised as the same
+account and keeps its cache, while a genuinely different account drops it. An empty value returns
+`None` rather than `""`, so two unknowns don't compare equal.
+
+**Storage** (service name `com.ytmusicdock.app`, kept from before the rename):
+
+- Windows / Linux — the header is split into ≤900-byte chunks across at most 16 keyring entries, plus a `chunks:` manifest entry. Keyring backends cap individual secret sizes; a cookie header is far larger.
+- macOS — the header is encrypted with AES-256-GCM (random 12-byte nonce prefixed to the ciphertext) into `/youtube-music-session-v1.bin`; only the 32-byte key lives in the keychain. This avoids repeated keychain prompts for 16 separate entries.
+
+### HTTP proxy
+
+| Command | Signature |
+|---|---|
+| `proxy_http_request` | `({ url, method, headers, body_base64, timeout_ms }) -> { status, headers, body_base64 }` |
+
+Every youtubei.js request goes through here (`src/datasource/youtube/tauriFetch.ts` is the adapter).
+Why: the WebView can't set `Cookie`/`Origin` headers or bypass CORS, and Innertube needs both.
+
+- Fixed Chrome 135 user agent.
+- Merges response `Set-Cookie` headers into the cookie jar (see above).
+- `signed_googlevideo_local_address()` — signed googlevideo URLs embed an `ip=` parameter; the client binds `local_address` to the matching IP family (`0.0.0.0` or `::`) so the signature stays valid on dual-stack machines.
+- Cookie and Authorization headers are redacted in logs.
+- `/browse` responses under 400 get their renderer types counted into the debug log — a diagnostic for when YouTube changes response shapes.
+
+### Audio — streaming and fallback
+
+| Command | Signature | Notes |
+|---|---|---|
+| `fetch_audio_bytes` | `(url, trackId) -> Vec` | Downloads with YouTube-ish headers (Range, Origin, Referer, Sec-Fetch-*), same signed-IP handling |
+| `fetch_audio_source` | `(url, trackId, mimeType, cookie?) -> { url, mimeType, byteLength }` | Downloads via `fetch_audio_ranged`, publishes the bytes under `stream-`, returns a `http://127.0.0.1:/audio/` URL. The `ftyp` check runs only when `mimeType` is MP4 — Opus-in-WebM has no such box |
+| `fetch_youtube_music_audio` | `(videoId) -> { bodyBase64, mimeType }` | Direct InnerTube player API using web-remix / web / iOS / Android / TV contexts that return undeciphered URLs |
+
+**Media server**: a lazily started `TcpListener` on `127.0.0.1:0` with a thread per connection,
+backed by `HashMap`, and `parse_media_range` implementing HTTP Range so the WebView
+can seek. It exists because signed googlevideo URLs 403 when replayed from the WebView, and base64
+blobs of a whole track are wasteful.
+
+Three things about it are load-bearing, and each fixed half of a leak that only became hot once
+`native` playback made `fetch_audio_source` the default streaming path:
+
+- **`store_media_item()` caps the map at `MEDIA_SERVER_MAX_ITEMS` (3)**, evicting the coldest by an `AtomicU64` sequence — a counter rather than a timestamp, because two inserts inside a millisecond still need an order. Two is the real working set (playing track plus preloaded); the third is slack for a fast skip. The map was previously insert-only, so every song ever played stayed resident.
+- **Keys are stable** — `stream-` and `offline-`. `fetch_audio_source` used to append a millisecond timestamp, so a replay stored a second copy of the same song *and* left the webview holding a response under a URL it would never request again.
+- **Responses carry `Cache-Control: no-store`.** Without it the webview keeps its own copy of every audio body — whole songs, in the renderer process, retained a second time by the one place that cannot be asked to give them back. The cost is that seeking backwards re-requests a range instead of reading the webview's copy, which over loopback against an in-memory `Vec` is a memcpy.
+
+Replacing a key mid-playback is safe: `handle_media_request` clones the `Arc` before it writes, so
+a request already in flight finishes against the bytes it started with.
+
+**Note:** which of these runs for ordinary streaming depends on the audio-engine setting. In
+`iframe` mode (the default) none of them do — the IFrame decks stream directly. Downloads always
+use `offline_audio_save` below.
+
+**`fetch_audio_ranged`** is the one downloader both playback and the offline store use, so the
+two cannot drift apart on speed — playback used to take the single-request path while downloads
+took the parallel one, and a skip paid the difference.
+
+- Ranges are sized by `audio_chunk_size(total)`: aim for roughly `OFFLINE_CHUNK_CONCURRENCY` (6) of them, floored at `AUDIO_MIN_CHUNK_BYTES` (512 KiB) and capped at `OFFLINE_CHUNK_BYTES` (4 MiB). A fixed 4 MiB was previously *both* the range size and the threshold, so a typical 2–4 MB song never split at all.
+- Below the floor, or with no `clen` to plan from, it falls back to one request.
+- One refused range aborts the assembly and restarts on the single-request path rather than returning a buffer with a hole in it.
+- `on_progress(received, total)` is caller-supplied; the offline store emits `offline-download-progress`, playback passes a no-op.
+
+### Audio — offline downloads
+
+| Command | Signature | Notes |
+|---|---|---|
+| `offline_audio_save` | `(url, trackId, cookie?) -> u64` | Downloads the signed URL into `/offline-audio-v1/.bin`, returns byte length |
+| `offline_audio_source` | `(trackId, mimeType) -> { url, mimeType, byteLength }` | Loads the stored bytes into the media server under key `offline-` and returns its URL |
+| `offline_audio_has` | `(trackId) -> bool` | |
+| `offline_audio_remove` | `(trackId)` | |
+| `offline_audio_list` | `() -> Vec<{ trackId, byteLength }>` | The source of truth the frontend manifest reconciles against |
+| `offline_audio_stats` | `() -> { entryCount, usedBytes }` | |
+| `offline_audio_prune` | `(maxBytes) -> OfflineStats` | Deletes oldest-first until under the ceiling |
+
+- **Ranged and parallel.** `OFFLINE_CHUNK_BYTES` is 4 MiB and `OFFLINE_CHUNK_CONCURRENCY` is 6.
+ googlevideo throttles a single sequential stream hard, which is why downloading a track used to
+ take far longer than streaming the same track takes to buffer; several ranges in flight side-step
+ it. `signed_content_length()` reads `clen` from the URL to plan the ranges, and
+ `audio_url_with_range()` appends `range=start-end` without re-encoding the query (re-encoding `==`
+ breaks the signature).
+- **Progress.** `emit_offline_progress` emits `offline-download-progress`
+ `{ trackId, receivedBytes, totalBytes, percent }`.
+- **Path safety.** `offline_entry_path` rejects ids that are empty, contain `..`, or contain
+ `/ \ : \0` — track ids come from YouTube and are filename-safe, but a hostile one must not escape
+ the directory.
+
+### Local files
+
+| Command | Signature | Notes |
+|---|---|---|
+| `local_audio_scan` | `(paths: Vec) -> Vec` | Recursive directory walk; extensions mp3, m4a, mp4, aac, flac, wav, ogg, oga, opus, webm. Sorted case-insensitively and deduped. Album name is inferred from the parent folder |
+| `local_audio_read` | `(path) -> { bodyBase64, mimeType }` | Re-validates the path is a file with an allowed extension before reading |
+| `local_audio_read_tags` | `(path) -> LocalAudioTags` | `lofty` |
+| `local_audio_write_tags` | `(path, tags)` | `lofty` |
+| `local_audio_watch` | `(paths: Vec)` | Replaces the active `notify` watcher wholesale; emits `local-audio-changed` at most once a second |
+| `local_audio_unwatch` | `()` | Drops the watcher, which unregisters every path |
+| `read_text_file` | `(path) -> String` | Playlist import. Capped at 16 MiB — the picker allows any file, and reading a multi-gigabyte one into a `String` to discover it isn't a playlist would take the app down |
+| `write_text_file` | `(path, contents)` | Playlist export; creates the parent directory |
+
+The watcher is deliberately coarse: one debounced "something changed" event, no diff. A precise
+change feed would have to model renames, temp files and write-then-replace editors, all of which
+produce the same user-visible outcome — a rescan.
+
+### Integrations
+
+| Command | Module | Notes |
+|---|---|---|
+| `discord_rpc_update` | `discord_rpc.rs` | Lazily connects on first use. Sends a raw `SET_ACTIVITY` payload of type 2 (Listening): title as `details`, artist (or `artist (paused)`) as `state`, artwork as the large image, `details_url`/`state_url`/`assets.large_url` pointing at the song/artist/album on YouTube Music, one button linking to the GitHub repo, and `start`/`end` timestamps derived from the play position so Discord renders a live progress bar. Client id `1515682467154100344` |
+| `discord_rpc_clear` | | Clears presence on idle/error |
+| `lastfm_auth_token` | `lastfm.rs` | `auth.getToken` → returns token + browser auth URL |
+| `lastfm_complete_auth` | | `auth.getSession` → stores the session key in the keyring |
+| `lastfm_get_session` / `lastfm_disconnect` | | |
+| `lastfm_update_now_playing` / `lastfm_scrobble` | | MD5 `api_sig` over sorted params + shared secret |
+| `update_windows_media_session` | `windows_media.rs` | Windows only (`#[cfg]` inside `generate_handler!`) |
+| `update_macos_media_session` | `macos_media.rs` | macOS only |
+| `quit_app` | `lib.rs` | Routes to `close_or_hide_main_window` |
+| `greet` | `lib.rs` | Tauri scaffolding leftover; unused |
+
+**Windows media** (`windows_media.rs`): a `MediaPlayer` + `SystemMediaTransportControls` pair driving
+the OS media overlay — metadata, thumbnail (downloaded and wrapped in a `RandomAccessStream`),
+playback status, and timeline position. Button presses are emitted back to JS as
+`windows-media-control` payloads (`"play" | "pause" | "playPause" | "next" | "previous"` or
+`{ action: "seekTo", positionSec }`). It also installs a taskbar thumbnail toolbar
+(`ITaskbarList3`) with previous/play-pause/next buttons via a Win32 subclass.
+
+**macOS media** (`macos_media.rs`): populates `MPNowPlayingInfoCenter` through `objc2`. Requires
+`macOSPrivateApi: true` in `tauri.conf.json`.
+
+**Last.fm** (`lastfm.rs`): API key and shared secret are compiled in — standard for a desktop client,
+since the secret only signs this app's requests and the user's session key is what actually
+authorizes writes. Session key lives in the keyring under `lastfm-session-v1`.
+
+---
+
+## 4. Capabilities and security
+
+`src-tauri/capabilities/default.json` scopes both windows (`main`, `mini-player`):
+
+- Core window permissions: drag, minimize/maximize/unmaximize, fullscreen, decorations, show/hide/focus/destroy, position/size get+set, current monitor, cursor position and icon, ignore-cursor-events (the mini player's click-through behaviour), and webview-window creation.
+- Plugin permissions: `autostart:{enable,disable,is-enabled}`, `opener:default`, `updater:default`, `process:allow-restart`, `dialog:{allow-open,allow-message}`.
+- `remote.urls` permits `https://**` and localhost/127.0.0.1.
+
+**The `command.allow` and `allowlist.shell` blocks in that file are Tauri 1 syntax and gate nothing
+in Tauri 2** — capabilities scope *core and plugin* permissions; your own `#[tauri::command]`
+functions are callable from the app's own windows once they're in `generate_handler!`. The proof is
+that the list is missing every command added since it was written (all `offline_audio_*`,
+`read_text_file`, `write_text_file`, `local_audio_*_tags`, `local_audio_watch`/`unwatch`,
+`refresh_youtube_music_cookie`, `open_current_log`) and all of them work. Either delete the two
+blocks or stop treating them as a checklist; do not add to them expecting an effect.
+
+`app.security.csp` is `null` — no CSP. Required because the YouTube IFrame API script is loaded from
+`youtube.com` at runtime. If native playback ever replaces the IFrame path, this should be tightened.
+
+Defense in depth that *is* in place:
+
+- Two-stage log redaction (TS `sanitizeForLog` → Rust `sanitize_log_message` / `sanitize_log_url`).
+- Cookie jar writes are gated on `is_youtube_cookie_host`, an exact-or-parent-domain check.
+- Discord artwork restricted to `i.ytimg.com` / `lh3.googleusercontent.com` / `yt3.ggpht.com`, presence links to YouTube hosts, HTTPS only.
+- `local_audio_read` / `local_audio_write_tags` re-validate path and extension rather than trusting the frontend; `read_text_file` is size-capped.
+- `offline_entry_path` rejects traversal in track ids.
+- Secrets never touch localStorage — keyring or an encrypted file only.
+
+---
+
+## 5. Updater
+
+```json
+"plugins": { "updater": {
+ "pubkey": "",
+ "endpoints": [
+ "https://github.com/noFAYZ/zuno/releases/latest/download/latest.json",
+ "https://raw.githubusercontent.com/noFAYZ/zuno/updater-channel/latest.json"
+ ]}}
+```
+
+`createUpdaterArtifacts: true` in the bundle config produces signed artifacts. Two endpoints give a
+fallback if the Releases redirect is unavailable. macOS skips the plugin entirely
+(`internal/updateChecker.ts` uses the GitHub Releases API and only links to the download page).
+
+Linux packaging lives outside `src-tauri/`: `packaging/aur/` (PKGBUILD + `.SRCINFO`),
+`packaging/flatpak/` (manifest, desktop entry, metainfo), and `manifests/` for winget — each with a
+release workflow under `.github/workflows/`.
+
+---
+
+## 6. Tests
+
+`cargo test` runs the `#[cfg(test)]` module at the bottom of `lib.rs`:
+
+| Test | Covers |
+|---|---|
+| `set_cookie_merges_rotations_into_the_stored_session` | `apply_set_cookie` — replace, add, no-op, tombstone, order preservation |
+| `only_the_noisy_cookies_may_be_persisted_late` | `is_slow_persist_cookie` — `SIDCC` may wait, credentials may not |
+| `account_identity_survives_a_renewal_and_changes_with_the_account` | `cookie_account_identity` fallbacks and the empty-value `None` |
+| `only_youtube_hosts_touch_the_cookie_jar` | `is_youtube_cookie_host` rejects `notyoutube.com` and googlevideo |
+| `audio_url_with_range_appends_without_disturbing_the_signature` | query present/absent, percent-encoding preserved |
+| `signed_content_length_reads_clen` | `clen` parsing |
+| `audio_chunk_size_splits_a_song_and_bounds_the_extremes` | a 2.5 MB song splits into several ranges; tiny files stay at the floor, huge ones at the cap |
+| `sanitize_log_url_*` | secrets withheld by value, diagnostics kept, unparseable input |
+| `cookie_domain_matches_*` | exact, parent-domain, and rejection cases |
+| `media_items_stay_capped_and_evict_the_coldest_first` | `store_media_item` holds the cap and drops the oldest, not an arbitrary entry |
+| `restoring_the_same_track_replaces_rather_than_accumulates` | a stable key means a replay reuses its slot |
+
+The frontend's equivalent is `npm run check` (`scripts/run-checks.mjs` over `src/**/*.check.ts`).
+There is no component/DOM test harness on either side.
diff --git a/docs/frontend.md b/docs/frontend.md
new file mode 100644
index 0000000..7327da1
--- /dev/null
+++ b/docs/frontend.md
@@ -0,0 +1,358 @@
+# Frontend — components, pages, modules
+
+React 19 + TypeScript, Tailwind v4, `motion` for animation, Solar icons. No router, no state
+library, no CSS Modules. See [architecture.md](./architecture.md) for the system view and
+[backend.md](./backend.md) for the IPC surface.
+
+---
+
+## 1. Entry points
+
+### `src/main.tsx` — main window
+
+Runs before React mounts, in this order:
+
+1. `hydrateArtworkCache()` — a resolution restored after first paint is one that already flashed the fallback icon.
+2. `applyPlatformAttributes()` — sets `data-platform-linux` on ``.
+3. `applyTheme()` + `watchSystemTheme()` — sets `data-theme`; `system` keeps following the OS rather than resolving once.
+4. `applyPaperPcMode()` / `applyNativeWindowControls()` — synchronous theme + decoration flags from localStorage.
+5. `hydrateMainWindowGeometry()` → `restoreMainWindowGeometry()`.
+6. `Promise.all` of ~18 `hydrate*` functions (paperPc, theme, windowControls, miniPlayer, playerControls, queuePanel, tray, audioQuality, audioEngineMode, lastFm, discord, sidebar, keyboardShortcuts, toolbarItems, homeSections, playbackSettings, playHistory, sessionRestore) — these reconcile localStorage against Rust-owned durable settings.
+7. `DiscordRpcService.init()`.
+8. `window.error` + `window.unhandledrejection` → `logInternalError`.
+9. `createRoot().render( )` — the outermost boundary exists because a desktop shell has no address bar to reload a blank window from.
+10. `syncLocalAudioWatcher()` and `listen("local-audio-changed")` → `notifyLocalPlaylistsChanged()`.
+
+### `src/mini.tsx` — mini-player window
+
+Minimal: platform attributes, `hydrateMiniPlayerSettings()`, render ` `. It holds no
+controllers — all state arrives over Tauri events from the main window.
+
+---
+
+## 2. `App.tsx` — the root
+
+2150 lines, the only component with meaningful state. It owns:
+
+| Concern | State / refs |
+|---|---|
+| Tabs | `tabs: Tab[]`, `activeTabId`, `nextTabId` — restored from `loadAppSession()` |
+| Navigation | Per-tab `navigationHistory: { back: TabViewState[], forward: TabViewState[] }` |
+| Layout | `sidebarWidth`, queue-panel open/collapsed state |
+| Onboarding | `onboardingStep`, keychain notice, completion toast |
+| Updates | `availableUpdate`, snooze handling, release note dialog |
+| Loading screen | `loadingScreenState`, min 1000 ms / max 4000 ms, 80 ms fade |
+| Mini player | positioning, focus-driven create/destroy, suppression windows during drags |
+| Recovery | sleep detection (15 s timer, 60 s drift threshold) → reload; connection recovery on window focus |
+| Session persistence | an effect on `[tabs, activeTabId, nextTabId, playerSession]`, `beforeunload`, and a `SESSION_HEARTBEAT_MS` (5 s) heartbeat |
+
+**On that heartbeat:** the effect and `beforeunload` are the real persistence path; the timer only
+keeps `positionSec` fresh for a restore after a crash or a kill. It ran every second, rebuilding
+every tab's full queue and history into a multi-megabyte object graph, stringifying it and writing
+it synchronously — for a field that only has to be roughly right. At five seconds a hard kill costs
+at most five seconds of position, and `saveAppSession` skips the write outright when the payload is
+unchanged.
+
+Every page below it is `lazy`-loaded behind `Suspense`. Only Home is reachable at startup; the
+chunks come off local disk in a Tauri app, so the win is startup parse/compile time rather than
+transfer size. Each page is a named export, hence the `.then(m => ({ default: m.X }))` shim.
+
+### The tab model (`ui/types/tab.ts`)
+
+```ts
+type TabView =
+ | "home" | "album" | "artist" | "playlist" | "related"
+ | "search" | "history" | "browse" | "library" | "settings";
+type NavigableTabView = Exclude;
+
+interface Tab {
+ id: string;
+ view: TabView;
+ title?: string;
+ browseTab?: string; // which Browse surface; only meaningful when view is "browse"
+ album?: Album; artist?: Artist; playlist?: Playlist;
+ relatedTrack?: Track; // the track a "related" view is about
+ searchQuery?: string; searchResults?: Track[]; mixedSearchResults?: SearchResults;
+ searchLoading?: boolean;
+ isQueueOpen?: boolean;
+ navigationHistory?: TabNavigationHistory;
+}
+```
+
+A tab is *both* a browser-style navigation context **and** an independent player. `settings` is
+excluded from navigation history (`NavigableTabView`), so opening Settings never pollutes back/forward.
+`getNavigationKey()` deduplicates consecutive identical entries; `stripNavigationHistory()` keeps
+history out of the persisted session.
+
+### Global keyboard handling
+
+Shortcuts come from `ui/settings/keyboardShortcuts.ts` (user-remappable, persisted) — 19 actions:
+`playPause`, `mute`, `previousTrack`, `nextTrack`, `closeTab`, `newTab`, `search`,
+`navigateBack`/`Forward`, and `tab1`–`tab9`. `App.tsx` matches them with `eventMatchesShortcut`;
+mouse buttons 3/4 map to back/forward. `ui/pages/pageSearchKeyboard.ts` decides when a bare
+printable keypress should start in-page search — it refuses when focus is in a text field or when
+the key matches any bound shortcut.
+
+### Render tree
+
+```
+ driven by Paper-PC mode; stops beUI's JS motion
+ artist-link click → navigate current tab
+ right-click track menu, like/queue/playlist actions
+ right-click playlist/album menu
+ drag region, MusicTabs, toolbar items, window controls
+ Sidebar, SearchBar, StarField, custom scrollbar
+ └ page (lazy): HomePage | AlbumView | ArtistView | PlaylistView | RelatedPage
+ | SearchResultsPage | LibraryPage | BrowsePage | HistoryPage | SettingsPage
+ └ rightPanel: full or collapsed to a 62px artwork rail
+ └ overlay:
+ TrackInfo, PlaybackControls, SeekBar, VolumeControl,
+ DownloadButton, PlaybackOptions, LyricsButton
+ leaf subscriber; keeps volume drags off the root
+ multi-select actions, docked above the player bar
+ Ctrl/⌘+K
+
+
+```
+
+---
+
+## 3. Components (`src/ui/components/`)
+
+Tailwind classes inline; there are no `*.module.css` files.
+
+### Chrome and layout
+
+| Component | Purpose |
+|---|---|
+| `TitleBar.tsx` | Custom frameless title bar: drag region, home button, embedded `MusicTabs`, optional toolbar items (downloads, notifications, account), and minimize/maximize/close. Honours the "Windows-style controls" and "native controls" settings; hidden entirely when native decorations are on. Carries the Discord / Last.fm / YouTube Music / GitHub indicators, each dimmed when its feature is off and each clickable to toggle it; all four are hideable via `toolbarItems.ts`. |
+| `MusicTabs.tsx` | Tab strip with pointer-based drag reorder, close buttons, a volume icon marking the tab currently producing sound, and a title ellipsis. |
+| `Layout.tsx` | Three-column shell: resizable sidebar, page content, optional right panel. Implements a custom transient scrollbar (browser scrollbars are hidden globally in `global.css`) with hover/drag persistence and auto-hide. Renders `StarField` unless Paper-PC mode is on, and subscribes to `ambientArtworkStore` for the accent wash behind the chrome. |
+| `Sidebar.tsx` | Library rail: liked songs, playlists, albums, local playlists. Three width modes (`collapsed` 62px / `expanded` 240px / `hover`). Ordering, filtering and drag-reorder logic lives in `sidebarLibrary.ts` (with a `.check.ts`) precisely because dropping a row while the list is filtered or alphabetised would write an order derived from a list the user wasn't looking at. |
+| `SearchBar.tsx` | Inline search entry in the layout header; opens the overlay. |
+| `StarField.tsx` | Decorative animated background; skipped in Paper-PC mode. |
+| `FloatingPanel.tsx` | Shared anchored-popover shell for the title-bar panels: side selection, 10px gap, 12px viewport margin. |
+| `ErrorBoundary.tsx` | Wraps the app root and each lazy page. React unmounts the whole tree on an unhandled render error — in a desktop shell that's a permanently blank window. |
+
+### Content and interaction
+
+| Component | Purpose |
+|---|---|
+| `SearchOverlay.tsx` | ⌘/Ctrl+K palette. Fuzzy-scores your playlists/albums locally (`searchMatchScore`: exact 4 → prefix 3 → contains 2 → reverse-contains 1, with NFKD-normalized fallbacks), fetches remote suggestions, keeps recent searches, and recognises a pasted YouTube link (`looksLikeYouTubeLink`) to resolve rather than search. Shift-Enter opens results in a new tab. |
+| `TrackRow.tsx` | The one row used by every track list. `memo`'d with `propsEqual`, which ignores handler identity — comparing inline arrows with `Object.is` meant the memo never returned true and did nothing at all, rebuilding 500 rows to repaint two. Handlers are invoked through a ref refreshed each render, which is what makes that safe. Also carries `content-visibility: auto` with `contain-intrinsic-size: auto 52px`, so off-screen rows skip layout, paint and compositing — see §8. |
+| `TrackContextMenu.tsx` | React context provider + menu: like/dislike, play next, add to queue, add to playlist (with submenu and pending state), remove from playlist, download/remove download, edit tags (local files), copy link, search artist, go to related. Viewport-edge flipping via `useLayoutEffect`. |
+| `PlaylistContextMenu.tsx` | Same pattern for playlists/albums: play, save/unsave, rename/describe/delete, local-playlist management, import/export, download all, remove. |
+| `SelectionBar.tsx` | Bulk actions for a multi-selection, floating above the player bar so it doesn't push the list around as it appears. |
+| `MediaHeader.tsx` | Shared album/playlist/artist header. Its "24 songs · 1 hr 32 min" line drops the duration unless every counted track reported one *and* the list is fully loaded — YouTube omits `durationSec` on most playlist entries, and summing what happens to be present read "98 songs · 3 min". |
+| `BrowseShelves.tsx` | Horizontal scrollable shelves for browse/related surfaces, clipped at the edge to hint at more. |
+| `HomeDestinations.tsx` | Library / History / Downloads / Browse entry points on the home page, with live counts — they moved off the collapsed rail because unlabelled glyphs there competed with playlist artwork for attention. |
+| `AccountSwitcher.tsx` | Channel/brand-account picker plus the shared `AccountAvatar` (a new URL gets a fresh attempt, so one broken image doesn't poison the slot). |
+| `GoogleSignInButton.tsx` | The single sign-in button used everywhere. Previously three different-looking buttons for the same consequential action. |
+| `AuthOverlay.tsx` | Progress for sign-in and account-switch, driven by `LibraryState.authProgress`. The stages are listed per flow rather than filtered from one array — switching channel has no browser step, and rendering it as "skipped" would claim something that never happened. |
+| `NotificationsPanel.tsx` | Artist release feed with an unseen badge, refreshed on a slow interval — new releases arrive on the order of days. |
+| `DownloadsPanel.tsx` | Recent downloads (4) with progress and byte totals; the full list is the `downloads` browse surface. |
+| `TagEditor.tsx` | Local-file tag editing via `local_audio_read_tags` / `local_audio_write_tags`. |
+| `ArtistLinks.tsx` | Renders `Track.artists[]` as individually clickable links through `ArtistNavigationProvider`; falls back to the plain artist string. |
+| `TrackArtwork.tsx` | Artwork with loading/fallback states, backed by `internal/artworkCache.ts` so remounts don't rewalk the candidate URLs. |
+| `AlbumCard.tsx` / `PickCard.tsx` | Grid card and carousel card (the latter distinguishes a tap from a carousel drag with a 5px slop). |
+| `DiceCard.tsx` | The "surprise me" shuffle entry point on the home page. |
+| `ExternalLinkButton.tsx` | Open-or-copy with inline outcome feedback. |
+| `Onboarding.tsx` | `OnboardingWelcome`, stepped `Onboarding` (`onboardingSteps.ts`), `OnboardingCompleteToast`, and `KeychainNotice` (macOS keychain-prompt explainer). |
+| `AppLoadingScreen.tsx` | Startup splash with a leaving/fade state. |
+| `UpdateToast.tsx` / `ReleaseNoteDialog.tsx` | Update available → download progress → restart (or "open release page" on macOS); and the one-time post-update note. |
+
+### Player (`components/player/`)
+
+| Component | Purpose |
+|---|---|
+| `PlayerBar.tsx` | Bottom bar composing the pieces below; also surfaces connection-restored recovery. Compact and expanded layouts (`playerControls.ts`). |
+| `TrackInfo.tsx` | Artwork + title + `ArtistLinks`, click-through to album/artist. |
+| `PlaybackControls.tsx` | Previous / play-pause / next, shuffle and repeat as independent toggles. |
+| `SeekBar.tsx` | Position scrubber; writes `isSeeking` to `playerUIStore` so position polling doesn't fight the drag. |
+| `VolumeControl.tsx` | Slider + mute; writes `isDraggingVolume` to the UI store. |
+| `VolumeSyncBridge.tsx` | Mirrors volume/mute to the mini window. A leaf component rather than an effect in `App` on purpose: volume commits on every pointer move, and subscribing at the root re-rendered the whole tree per drag. |
+| `DownloadButton.tsx` | Download / cancel / remove for the **now-playing** track specifically — an indicator that lit up for any queued download reported on a song the listener hadn't thought about in ten minutes. |
+| `PlaybackOptions.tsx` | Playback speed (0.75–2×) and a sleep timer (15–90 min, plus "end of track"). |
+| `LyricsButton.tsx` | Toggles the lyrics overlay. |
+| `QueuePanel.tsx` | Right-panel queue with drag reorder, remove, jump-to-index, and stop-after-track. Collapses to a 62px artwork rail. Reordering is restricted to within a queue region (manual↔manual, automatic↔automatic) — `Queue.move()` rejects cross-region moves (`Queue.check.ts`). |
+| `ExpandedPlayerBar.tsx` | **Not rendered** — its JSX in `App.tsx` is commented out. |
+
+### Mini player (`components/mini-player/MiniPlayer.tsx`)
+
+A transparent always-on-top pill in its own window (~1.1k lines).
+
+- Created on `main-window-backgrounded` / `main-window-minimized`, **destroyed** on `window-focused`. Its own close button calls `win.destroy()` for the same reason. `handleRestore` raises the main window without awaiting its own `hide()`: the main window answers `mini-player:restore-main` by destroying this one, so an awaited self-call can reject after the window is gone and abort the restore.
+- Expands on hover into a two-pill layout with transport controls.
+- Right-mouse or empty-area drag moves the window; the position is debounced into settings and echoed as `mini-player:position-changed`.
+- Hover behaviour over the progress area is configurable: `seek` or `volume` (`mini-player-hover-action`).
+- Communicates purely over Tauri events. It asks for `mini-player:request-sync` only *after* its `player-state-sync` listener is live, so the reply can't arrive before anything is listening.
+
+---
+
+## 4. Pages (`src/ui/pages/`)
+
+| Page | Notes |
+|---|---|
+| `HomePage.tsx` | Recently played, `HomeDestinations`, a `CylinderCarousel` of picks, and generated suggestions memoized in a module-level `Map` (20 entries, LRU) keyed by tab *and* a signature of the recently-played list. The key is computed above the `useState` calls so the initial render can read it — seeding from `tabId` alone meant the memo never hit and Home opened on a spinner every time. Falls back to canned queries when signed out. Sections are toggleable (`homeSections.ts`). |
+| `LibraryPage.tsx` | The whole saved library in one view — playlists, albums, artists, songs. |
+| `BrowsePage.tsx` | The non-search surfaces through `getBrowsePage` — `explore` / `charts` / `moods` / `podcasts`, plus the local `downloads` list. Which one opens is carried on `Tab.browseTab`. |
+| `HistoryPage.tsx` | Local play log from `player/playHistory.ts`, with per-entry removal and clear-all. |
+| `AlbumView.tsx` | Album header + track list, save/unsave, play-all, download-all, per-track context menu. |
+| `ArtistView.tsx` | Artist header, subscribe toggle and notification level, popular songs, all songs, releases, playlists. Subscription state has a 60 s optimistic override because the remote value lags. |
+| `PlaylistView.tsx` | Paginated track list (`getPlaylistTrackPage`, page-key session cached), in-page search filter, multi-select, drag reorder, rename/describe/delete, import/export, remove-from-playlist. |
+| `RelatedPage.tsx` | `getRelated` shelves for a single track. |
+| `SearchResultsPage.tsx` | Mixed results grouped by artists / tracks / albums / playlists, painted incrementally from streaming `onUpdate` callbacks, with per-category drill-down. |
+| `LyricsView.tsx` | Overlay. Synced lyrics scroll to the active line; per-track timing offset, font scale, translation, and a source-attempt trail. Whether a karaoke highlight runs is decided by `lyricsTiming.ts` from the lines themselves, not from the provider's `timing` field — one line without a start time makes the highlight jump backwards. |
+| `SettingsPage.tsx` | Six tabs: **Account** (sign-in, accounts, Last.fm, Discord, updates, delete all data), **Appearance** (theme light/dark/system, Paper-PC mode, made-for-you sections), **Playback** (audio engine, signed-in stream resolution, YouTube history reporting, streaming/download quality, gapless, crossfade, transitions, lyrics translation/size/source), **Library** (cache size and clearing, local music folders via `dialog:open` → `local_audio_scan`, downloads ceiling), **Window** (decorations, control style, mini player, sidebar mode, tray, geometry, session restore, autostart), **Shortcuts** (rebinding). |
+| `collectTrackPages.ts` | Pages a track list to the end. Extracted from the views because the loop's *termination* is what fails quietly — a cursor that stops advancing spins forever (`collectTrackPages.check.ts`). |
+| `pageSearchKeyboard.ts` | `shouldStartPageSearch()` — see §2. |
+
+---
+
+## 5. Settings modules (`src/ui/settings/`)
+
+All follow the same shape: a storage key, a change-event name, `read*` / `set*` / `hydrate*`
+functions, and a `use*` hook built on `useSyncExternalStore` that listens to both the custom event
+and `storage` (for cross-window sync). Writes go to localStorage **and** durable Rust settings via
+`internal/durableLocalSetting.ts`.
+
+| Module | Setting(s) |
+|---|---|
+| `theme.ts` | `system` / `light` / `dark`, written as `data-theme` before React mounts. `system` keeps following the OS via a `matchMedia` listener rather than resolving once at startup. |
+| `keyboardShortcuts.ts` | Remappable map for 19 actions, with `eventMatchesShortcut` and platform-aware modifier labels (⌘ vs Ctrl). |
+| `miniPlayer.ts` | Enabled flag, saved position (debounced), hover action, `resetMiniPlayerPosition()`. Owns the window's whole lifecycle: `ensureMiniPlayerWindow()` / `destroyMiniPlayerWindow()` run through one serialized promise chain so a blur/focus pair can't interleave into an orphaned or prematurely-destroyed window. |
+| `audioEngine.ts` | `iframe` (default) vs `native` playback. The value is cached in-module because `AudioEngine` reads it on every load, play, pause, seek and volume change — a raw localStorage hit would sit in the playback path. Exports `AUDIO_ENGINE_MODE_CHANGE_EVENT` so the engine can free its IFrame decks the moment the mode stops being `iframe`. |
+| `youtubeAccount.ts` | Two account choices, both default off: resolve streams with the session attached, and report plays to YouTube Music history. `setYouTubeScrobbling` carries the streaming flag with it in both directions; `setAuthenticatedStreaming` leaves scrobbling alone, so signed-in playback without history is reachable. Both cached in-module — one is read on every track load, the other on every progress tick. |
+| `mainWindowGeometry.ts` | Size + position persistence with a min-size guard (900×600) and monitor-bounds validation; toggleable, and clears the stored value when disabled. |
+| `windowControls.ts` | `native-window-controls` (default on for Linux) and `windows-style-window-controls`; applies `setDecorations()` and toggles `data-native-window-controls`. |
+| `paperPcMode.ts` | Low-end mode: kills animations, transitions, shadows, and backdrop filters via `data-paper-pc`. Reloads on Linux where blur can't be toggled live. |
+| `sidebarMode.ts` | `collapsed` (62px) / `expanded` (240px) / `hover` — default `hover`. |
+| `queuePanel.ts` | Queue collapsed to an artwork rail vs full list. Defaults to collapsed. |
+| `toolbarItems.ts` | Which optional buttons the title bar carries. |
+| `homeSections.ts` | Which optional sections the home page carries. |
+| `playerControls.ts` | Compact vs expanded player bar; whether extra controls are always visible. |
+| `playbackTransitions.ts` | Gapless flag and crossfade seconds, backed by `player/playbackSettings.ts`. |
+| `sessionRestore.ts` | Whether tabs and queues come back after a restart. Read synchronously at boot, so the hydrate only backfills a cleared localStorage — it takes effect from the next launch. |
+| `tray.ts` | `minimize-to-tray`; the one setting Rust reads out of `settings-v1.json` itself. |
+| `lyricsTranslation.ts` | Target language or `off`. Off by default and off is a real value: translation sends the words of whatever is playing to a third-party endpoint, so it happens because someone asked. |
+| `lyricsFontScale.ts` | A multiplier, not a size set — the underlying scale is a container-width `clamp()`, and fixed sizes would throw that away. |
+| `lyricsOffset.ts` | Per-track timing nudge. LRCLIB matches on title/artist/±2 s duration, which is loose enough to return a different master whose words land a second off. |
+| `lastfm.ts` / `discord.ts` | Scrobbling and rich-presence enable flags + connection state. |
+| `autostart.ts` | `tauri-plugin-autostart` enable/disable/isEnabled. |
+
+Non-`ui/settings` siblings that follow the same contract: `internal/audioQuality.ts` (streaming and
+download caps set independently — the reason to cap one is not the reason to cap the other) and
+`internal/lyricsSourcePreference.ts`.
+
+---
+
+## 6. Stores and hooks
+
+| Export | Source | Purpose |
+|---|---|---|
+| `usePlayerState()` | `player/playerStore.ts` | Effective player's `PlayerState` (status, current track, history, order mode, shuffle, volume, muted) |
+| `usePlayerSelector(select)` | same | Narrowed slice compared with `shallowEqual` — the preferred hook; the whole state object re-renders far more than any one component needs |
+| `usePlayerSession()` | same | Full session snapshot including the queue |
+| `useLibraryState()` | same | `LibraryController` state (status, snapshot, auth prompt, auth progress, pending likes, `sessionConfirmedAt`) |
+| `usePlayerUIState()` | `ui/stores/playerUIStore.ts` | Transient UI: `isSeeking`, `isDraggingVolume`, `showAlbumArt`, `isLyricsOpen`, `isQueueOpen` |
+| `useAmbientArtwork()` | `ui/stores/ambientArtworkStore.ts` | Artwork the current page wants the chrome tinted by. The page publishes, `Layout` subscribes — the wash starts above the search bar but the page that knows the artwork renders below it, inside a clipping scroll container |
+| `useOfflineState()` | `player/offlineStore.ts` | Download manifest, statuses and progress |
+| `usePlayHistory()` | `player/playHistory.ts` | Local play log |
+| `useNowPlaying()` | `ui/hooks/` | Just the primitives a collection page needs to mark rows, so `TrackRow`'s memo stays cheap |
+| `useTrackSelection()` | `ui/hooks/` | Multi-select modelled on file managers: ctrl/cmd toggles, shift extends from the last row touched, a plain click does nothing to the selection |
+| `useDisableContextMenu()` | `ui/hooks/` | Suppresses the native WebView context menu app-wide |
+| `isMacOS` / `isLinux` / `isWindows`, `primaryModifierLabel` | `ui/platform.ts` | UA-based platform detection |
+
+`playerController` (from `playerStore`) is the facade any component should call — it always resolves
+to the correct tab's `PlayerController` and claims playback ownership when starting new audio.
+
+---
+
+## 7. Styling
+
+**Tailwind CSS v4** via `@tailwindcss/vite`, with one global sheet at `ui/styles/global.css`.
+Inter is self-hosted through `@fontsource-variable/inter` — this is an offline desktop app, and a
+CDN webfont that fails leaves the UI on a fallback face. `docs/` is excluded from Tailwind's source
+scan (the reference dumps in there are full of class names the app never uses).
+
+- **Design tokens live in a Tailwind `@theme` block** and follow shadcn naming, because that is
+ what the vendored beUI components are written against: `--color-background`, `--color-foreground`,
+ `--color-card`, `--color-popover`, `--color-muted` / `--color-muted-foreground`, `--color-border`,
+ `--color-input`, `--color-ring`, `--color-destructive`, `--color-secondary`, `--color-primary`.
+- The palette is **neutral shadcn surfaces + the app's `#ff0033` as `--color-primary`**. Red is an
+ accent only — `bg-primary` / `text-primary` for play state, active tabs and primary actions,
+ never for large surfaces. It stays `#ff0033` in both themes: it's the brand accent, not a surface.
+- **Never put `backdrop-blur` on an opaque background.** `--color-background`, `--color-card` and
+ `--color-popover` have no alpha in either theme, so a backdrop filter behind them costs a
+ composited layer and a blur pass to render something nothing can see through. Seven always-on
+ surfaces carried one — the content pane, title bar, sidebar, player bar, search field, right
+ panel and queue header — and removing them changed nothing visually. Blur belongs only on a
+ translucent fill (`bg-card/60`, `bg-background/70`).
+- **Blur radius sizes the compositor's intermediate textures**, so the two always-on filters are
+ kept modest: the ambient wash at `blur-[32px]` (its source is a 120px image stretched to full
+ width — a ~20x upscale is already most of the softness) and StarField's blobs at `blur-2xl`
+ (they are `rounded-full` gradients fading to transparent, so the fill does the work).
+- The UI is **borderless** — surfaces are separated by background contrast, not outlines.
+ `--color-border` / `--color-input` exist only because beUI internals reference them.
+- Non-colour `:root` constants: `--ambient-bloom` (strength of the accent wash behind the window,
+ a token because the same wash reads as a glow over near-black and a stain over near-white),
+ `--window-edge` / `--window-edge-highlight` (the window is transparent with its OS shadow
+ disabled, so a hairline is the only thing separating the app from what's behind it),
+ `--titlebar-height`, `--sidebar-width`.
+- Global resets beyond Tailwind preflight: `body { overflow: hidden }`, scrollbars zeroed out
+ (`Layout.tsx` draws its own transient scrollbar), text selection disabled except in inputs,
+ focus rings only on `:focus-visible`.
+- **Theme/behaviour switches are HTML attributes**, not classes:
+ - `html[data-theme="light" | "dark"]` — the `@theme` block is the dark default; the light block
+ flips the same token names, so every component follows without knowing a theme exists.
+ - `html[data-paper-pc]` — overrides the colours with fully opaque equivalents and kills
+ animation, box-shadow and backdrop-filter. That only stops *CSS* animation; beUI's JS-driven
+ motion is stopped by the `` bridge in `App.tsx`.
+ - `html[data-platform-linux]` — disables backdrop filters (WebKitGTK performance).
+ - `html[data-native-window-controls]` — hides the custom window buttons.
+
+### Component vocabulary
+
+Animated primitives are vendored from the [beUI](https://beui.dev) registry into
+`src/components/motion/`, with shared helpers in `src/lib/` (`cn`, `ease`, `use-hover-capable`).
+They are kept at the registry's own paths so a future re-pull drops in without rewriting imports.
+App-owned components stay in `src/ui/`. `@` is aliased to `src/`, so beUI's `@/components/...`
+imports resolve unchanged.
+
+Vendored: `action-swap`, `bounce-sidebar`, `bouncy-accordion`, `button` (base/stateful/magnetic),
+`center-morph-modal`, `command-palette`, `cylinder-carousel`, `drawer`, `input`, `loader`,
+`magnetic`, `marquee`, `popover`, `range-slider`, `select`, `switch`, `tabs`, `tilt-card`, `tooltip`.
+
+**When re-pulling from the registry**, run the de-lucide pass afterwards — beUI ships
+`lucide-react` imports for its default icons, and this project uses Solar exclusively.
+
+### Icons
+
+All icons come from `@solar-icons/react` (pinned to `2.0.0-beta.0` — the v2 API does not exist on
+the `latest` 1.x line) and are re-exported from **`src/ui/icons.tsx`** (~86 exports). Components
+import only from there, never from `@solar-icons/react` directly, so a renamed upstream icon breaks
+one file. Single-icon import paths are used deliberately: the style barrel makes the dev server
+resolve ~1.2k modules per style.
+
+Convention (see [solarIcons.md](./solarIcons.md)): the default export is Solar **Linear**
+(resting/secondary state); a `*ActiveIcon` alias is Solar **Bold** (active/primary — playing, liked,
+saved, selected). The weight change *is* the state signal. Solar ships no brand icons, so
+`LastFmIcon` stays a hand-rolled inline SVG. Solar icons take `strokeWidth`, not the raw SVG
+`stroke` attribute.
+
+---
+
+## 8. Conventions worth keeping
+
+- **Feature-detect optional data-source methods** (`this.dataSource.getLyrics?.(...)`) instead of assuming.
+- **Guard async work with request ids** — the codebase uses this everywhere for cancellation; a bare `await` that then calls `setState` is a bug waiting for a fast click.
+- **Two-tier settings**: never write only to localStorage; use the `durableLocalSetting` helpers so the value survives a WebView data reset.
+- **Prefer `usePlayerSelector` over `usePlayerState`**, and subscribe at the leaf that needs the value — `VolumeSyncBridge` exists solely because subscribing to volume at the root re-rendered the tree on every pointer move.
+- **`memo` with `propsEqual`, not the default**, for row components handed inline arrows — and route the handlers through a ref so it stays correct.
+- **The track lists are not windowed, deliberately.** `PlaylistView`, `AlbumView`, `SearchResultsPage`, `HistoryPage` and `QueuePanel` all map the full array; the `IntersectionObserver` in `PlaylistView` is infinite-scroll paging, not virtualization. Windowing fights two features that need the whole index space — drag-reorder (the dragged row must survive scrolling out of range) and `useTrackSelection`'s shift-range. `content-visibility: auto` on `TrackRow` buys most of the rendering win for one line instead: the nodes stay, the work does not. Use `contain-intrinsic-size` with the `auto` keyword so the browser prefers the height it last measured and the literal only covers rows never yet on screen.
+- **Policy belongs in a named method, not a flag on a shared one.** `resolveStreamUrl` and `resolveDownloadUrl` wrap one private core; only the playback wrapper reads the authenticated-streaming setting, so downloads cannot inherit it through a mis-edited condition. A `purpose` parameter would have left that guarantee resting on a boolean.
+- **Bound every module-level cache.** `artworkCache` (16 MB of blobs, 500 entries), `HomePage`'s `suggestionCache` (20 entries), `playHistory` (500), `playlistMembership` (1000) all evict oldest-first. A `Map` keyed by anything that varies with use — a tab id, a search key, a recently-played signature — grows for as long as the app is open.
+- **Log through `internal/logging.ts`**, never raw `console.*` — it redacts secrets and mirrors to the on-disk log.
+- **Non-trivial pure logic gets a `*.check.ts`** next to it, run by `npm run check`. That is the whole frontend test story; there is no component harness.
+- **Optimistic mutations roll back**: `LibraryController` snapshots the previous library, applies the change, and restores on error. Match that pattern for new mutations.
+- **New Tauri command?** Register it in `generate_handler!` — that is sufficient. The `command.allow` list in `capabilities/default.json` is Tauri 1 leftover and gates nothing (see [backend.md](./backend.md) §4).
diff --git a/docs/solarIcons.md b/docs/solarIcons.md
new file mode 100644
index 0000000..0943d90
--- /dev/null
+++ b/docs/solarIcons.md
@@ -0,0 +1,203 @@
+# React (/docs/v2/packages/react)
+
+
+
+
+
+
+ npm
+
+
+
+ pnpm
+
+
+
+ yarn
+
+
+
+ bun
+
+
+
+
+ ```bash
+ npm install @solar-icons/react@2.0.0-beta.0
+ ```
+
+
+
+ ```bash
+ pnpm add @solar-icons/react@2.0.0-beta.0
+ ```
+
+
+
+ ```bash
+ yarn add @solar-icons/react@2.0.0-beta.0
+ ```
+
+
+
+ ```bash
+ bun add @solar-icons/react@2.0.0-beta.0
+ ```
+
+
+
+## Import patterns [#import-patterns]
+
+```jsx
+// Per-style (no style suffix in name - style is in the path)
+import { HeartIcon } from '@solar-icons/react/bold'
+
+// Single icon (lighter on the dev server — avoids resolving ~8k modules)
+import { HeartIcon } from '@solar-icons/react/bold/heart'
+
+// Top-level (style suffix in name - path doesn't specify style)
+import { HeartBoldIcon } from '@solar-icons/react'
+
+// Dynamic (runtime style switching)
+import { HeartIcon } from '@solar-icons/react/dynamic'
+import { HeartIcon } from '@solar-icons/react/dynamic/heart'
+```
+
+## Basic usage [#basic-usage]
+
+```jsx
+import { HeartIcon } from '@solar-icons/react/bold'
+
+function App() {
+ return
+}
+```
+
+## Duotone [#duotone]
+
+BoldDuotone and LineDuotone styles render a secondary accent path. Control it with `secondaryColor` and `secondaryOpacity`.
+
+```jsx
+import { HeartIcon } from '@solar-icons/react/bold-duotone'
+
+function App() {
+ return
+}
+```
+
+## Stroke width [#stroke-width]
+
+Linear, Broken, and LineDuotone icons accept `strokeWidth`. Default is `1.5`.
+
+```jsx
+import { SettingsIcon } from '@solar-icons/react/linear'
+
+function App() {
+ return
+}
+```
+
+## SolarProvider [#solarprovider]
+
+Wrap your app in `SolarProvider` to set defaults. Icons inherit through the CSS cascade without re-rendering.
+
+```jsx
+import { SolarProvider } from '@solar-icons/react'
+import { HeartIcon } from '@solar-icons/react/bold'
+
+function App() {
+ return (
+
+
+
+ )
+}
+```
+
+The provider sets CSS custom properties on a `` with `display: contents`. Changing a value updates the wrapper only. Icons do not re-render.
+
+### Provider props [#provider-props]
+
+| Prop | CSS Variable | Fallback |
+| ------------------ | --------------------------- | -------------- |
+| `color` | `--solar-color` | `currentColor` |
+| `size` | `--solar-size` | `24px` |
+| `strokeWidth` | `--solar-stroke-width` | `1.5` |
+| `secondaryColor` | `--solar-secondary-color` | `currentColor` |
+| `secondaryOpacity` | `--solar-secondary-opacity` | `0.5` |
+
+The provider does not set defaults. Icons fall back to these values through CSS when no provider or prop is specified.
+
+## useSolar [#usesolar]
+
+Access and change provider values from any descendant component. Must be called inside a `
`.
+
+```jsx
+import { useSolar } from '@solar-icons/react'
+
+function ColorToggle() {
+ const { color, setColor } = useSolar()
+
+ return setColor('#ef4444')}>Current: {color}
+}
+```
+
+## Icon props [#icon-props]
+
+Every icon accepts these props plus standard SVG attributes (`ref`, `className`, `style`, etc.).
+
+| Prop | Type | Description |
+| ------------------ | ------------------ | ----------------------------------------------------- |
+| `color` | `string` | Icon color. Overrides provider. |
+| `size` | `string \| number` | Width and height. Numbers become `{n}px`. |
+| `strokeWidth` | `string \| number` | Stroke width for Linear/Broken/LineDuotone. |
+| `secondaryColor` | `string` | Duotone accent color. |
+| `secondaryOpacity` | `number` | Duotone accent opacity (0–1). |
+| `isolated` | `boolean` | Ignores provider. Uses `24px`, `currentColor`, `1.5`. |
+| `alt` | `string` | Accessibility label. Renders `` in SVG. |
+
+## Dynamic icons [#dynamic-icons]
+
+```jsx
+import { HeartIcon } from '@solar-icons/react/dynamic'
+
+function App() {
+ return
+}
+```
+
+The `weight` prop accepts `'Bold' | 'Linear' | 'Outline' | 'BoldDuotone' | 'LineDuotone' | 'Broken'`.
+
+## CSS variables [#css-variables]
+
+Icons resolve styling through CSS custom properties.
+
+| Variable | Role |
+| --------------------------- | ------------------------------------------ |
+| `--solar-color` | Fill and stroke color |
+| `--solar-size` | Width and height |
+| `--solar-stroke-width` | Stroke width for Linear/Broken/LineDuotone |
+| `--solar-secondary-color` | Secondary color for duotone styles |
+| `--solar-secondary-opacity` | Secondary opacity for duotone styles |
+
+## CSS classes [#css-classes]
+
+Every icon renders with two classes:
+
+| Class | Description |
+| ---------------------- | ---------------------------------------------------------- |
+| `solar` | Present on every icon. Use for global styling. |
+| `solar-{name}-{style}` | Specific to the icon and style (e.g., `solar-heart-bold`). |
+
+```css
+.solar {
+ vertical-align: middle;
+}
+.solar-heart-bold {
+ color: #ef4444;
+}
+```
+
+## ESM only [#esm-only]
+
+`@solar-icons/react` is ESM-only. `require()` does not work. Use `import` syntax or `await import()`. All V2 packages follow this convention.
diff --git a/docs/zuno-d.PNG b/docs/zuno-d.PNG
new file mode 100644
index 0000000..dd2632e
Binary files /dev/null and b/docs/zuno-d.PNG differ
diff --git a/docs/zuno-l.PNG b/docs/zuno-l.PNG
new file mode 100644
index 0000000..6c2c8d8
Binary files /dev/null and b/docs/zuno-l.PNG differ
diff --git a/docs/zuno.JPG b/docs/zuno.JPG
new file mode 100644
index 0000000..f92a265
Binary files /dev/null and b/docs/zuno.JPG differ
diff --git a/index.html b/index.html
index 7aa9756..c432693 100644
--- a/index.html
+++ b/index.html
@@ -1,14 +1,14 @@
-
-
-
-
-
-
- Just Another Music Client
-
-
-
-
-
-
-
+
+
+
+
+
+
+ Zuno
+
+
+
+
+
+
+
diff --git a/landing/README.md b/landing/README.md
new file mode 100644
index 0000000..bffda76
--- /dev/null
+++ b/landing/README.md
@@ -0,0 +1,62 @@
+# Zuno — marketing site
+
+The public landing page. A separate Vite project that shares Zuno's look but none of its build.
+
+```bash
+cd landing
+npm install
+npm run dev # http://localhost:5173
+npm run build # -> landing/dist
+```
+
+## Why it is its own project
+
+The desktop app's Vite config has fixed Rollup inputs (`index.html`, `mini.html`), a strict dev
+port Tauri depends on, and a `src-tauri` watcher. Adding a third entry point would tie a public
+website's release cadence to the app's, and a stray import could pull `@tauri-apps/*` into a
+bundle that has no Tauri runtime under it.
+
+Nothing here is reachable from the app: its `tsconfig` is `include: ["src"]` and its Rollup
+inputs are the two HTML files above. Running `npm run build` in either directory leaves the
+other untouched.
+
+## What is shared, and how
+
+- **Colour tokens** — `src/styles.css` carries a byte-identical copy of the app's `@theme`
+ block and its `html[data-theme="light"]` overrides. Copied rather than imported, because
+ `src/ui/styles/global.css` also contains window rounding, titlebar heights and Paper-PC rules
+ that a website has no use for. If the brand shifts, change both.
+- **Component shapes** — `src/components/ui.tsx` reproduces the app's primary/secondary pill
+ buttons and its `rounded-2xl bg-card/50` section card, so the page and the product read as one
+ thing.
+- **Icons** — OS marks come from Iconify's `logos` set via `@iconify/react`. Iconify normally
+ fetches at runtime and the full set is 7.4 MB, so the four marks actually used are extracted
+ into `src/components/brandIcons.tsx` and registered up front — no request, no pop-in.
+ `@iconify-json/logos` stays a devDependency as the source of record; regenerate that file from
+ it if a mark changes. The handful of UI glyphs are hand-rolled in `src/components/icons.tsx`.
+- **Screenshots** — copied into `public/` from `docs/`. Re-copy them when the app's shots are
+ refreshed:
+
+ ```bash
+ cp ../assets/img/Logo.png public/logo.png
+ cp ../docs/zuno-d.PNG public/screenshot-dark.png
+ cp ../docs/zuno-l.PNG public/screenshot-light.png
+ ```
+
+## Downloads
+
+`src/releases.ts` reads the newest release from the GitHub API at page load and points each
+platform button at the matching installer. Asset names carry the version
+(`Zuno_1.1.1_x64-setup.exe`), so there is no stable per-file URL to hardcode.
+
+Every button falls back to the releases page — while the request is in flight, if it fails, and
+if a release is missing an asset for that platform. A rate-limited or offline visitor still gets
+somewhere useful.
+
+The visitor's platform is detected only to emphasise one card. Detection is unreliable and
+downloading for another machine is ordinary, so the other three stay fully available.
+
+## Deploying
+
+`base` is `"./"`, so the build works from a subpath — including GitHub Pages project sites,
+which serve from `//`. Publish `landing/dist` as-is.
diff --git a/landing/index.html b/landing/index.html
new file mode 100644
index 0000000..40ac945
--- /dev/null
+++ b/landing/index.html
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+ Zuno — a desktop client for YouTube Music
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/landing/package-lock.json b/landing/package-lock.json
new file mode 100644
index 0000000..7aaa09d
--- /dev/null
+++ b/landing/package-lock.json
@@ -0,0 +1,2525 @@
+{
+ "name": "zuno-landing",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "zuno-landing",
+ "version": "0.1.0",
+ "dependencies": {
+ "@iconify-json/logos": "^1.2.11",
+ "@iconify/react": "^6.0.2",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ },
+ "devDependencies": {
+ "@tailwindcss/vite": "^4.0.0",
+ "@types/react": "^19.0.0",
+ "@types/react-dom": "^19.0.0",
+ "@vitejs/plugin-react": "^4.3.0",
+ "tailwindcss": "^4.0.0",
+ "typescript": "~5.6.0",
+ "vite": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
+ "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
+ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-compilation-targets": "^7.29.7",
+ "@babel/helper-module-transforms": "^7.29.7",
+ "@babel/helpers": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
+ "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
+ "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.29.7",
+ "@babel/helper-validator-option": "^7.29.7",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
+ "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
+ "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
+ "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
+ "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
+ "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
+ "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.7"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
+ "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
+ "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
+ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
+ "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-globals": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
+ "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
+ "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
+ "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
+ "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
+ "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
+ "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
+ "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
+ "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
+ "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
+ "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
+ "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
+ "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
+ "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
+ "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
+ "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
+ "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
+ "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
+ "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
+ "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
+ "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
+ "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@iconify-json/logos": {
+ "version": "1.2.11",
+ "resolved": "https://registry.npmjs.org/@iconify-json/logos/-/logos-1.2.11.tgz",
+ "integrity": "sha512-fOo4pGEatuyuCFNL+cwquYMa2Im0oJHRHV7lt/Qqs5Ode/lPImHCQcfTtPzZj7qYMPb/h8YHN3TG54uEowrjNQ==",
+ "license": "CC0-1.0",
+ "dependencies": {
+ "@iconify/types": "*"
+ }
+ },
+ "node_modules/@iconify/react": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/@iconify/react/-/react-6.0.2.tgz",
+ "integrity": "sha512-SMmC2sactfpJD427WJEDN6PMyznTFMhByK9yLW0gOTtnjzzbsi/Ke/XqsumsavFPwNiXs8jSiYeZTmLCLwO+Fg==",
+ "license": "MIT",
+ "dependencies": {
+ "@iconify/types": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/cyberalien"
+ },
+ "peerDependencies": {
+ "react": ">=16"
+ }
+ },
+ "node_modules/@iconify/types": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
+ "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-beta.27",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
+ "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.3.tgz",
+ "integrity": "sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.3.tgz",
+ "integrity": "sha512-3YjElDdWN+qXAFbJ/CzPV+0wspLqh54k/I6GfdYtEJRqg7buSgc1yPM3B+93j1M4neobtkATHZTmxK2AMVGfnA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.3.tgz",
+ "integrity": "sha512-Pch2pFNOxxz1hTjypIdPyRTR6riiwRl84+VcN9djS680fw+Co1nAJINrdpqp7KV0NvyuU8ilZXZCjd7ykJl1GQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.3.tgz",
+ "integrity": "sha512-LEuncFUHFiF8t4yZVZvvZA1wk0pjAscRnsrn1EfTEmN4HXotBi2YtcnLRyaK6UbuczW7xZS5ES+81Rdz8Z0T6g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.3.tgz",
+ "integrity": "sha512-zvBUvsQUpOWALdDsk6qbS8bXf2VxmPisuudNDrY7x0p0jBdsoZl8HsHczIOgkQiZldmcacMKtBzpoGVNeIe2bQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.3.tgz",
+ "integrity": "sha512-C2KmNrcSem/AMg984H/dev+si0lieQGdXdR/lYGJnuumXnFb9Y7QdiI62obFdLlxRYLBv4P0eUVIDbD4c1vVvw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.3.tgz",
+ "integrity": "sha512-ggXnsTAEzNQx74XpunRsiZ9aBZDsI7XIa0hm2nzR9f4WzH5/f/d73ZSDaC5ejJ8YLY4NW+V3wr0tjOaeCq8hqA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.3.tgz",
+ "integrity": "sha512-2vng+FlzNUhKZxtej3IUqJgbZoQk2M/dwQM20+ULV0R/E/8tr9/P6uEf2iiGIk4HL0zMKh5Jry7mUHdUOvyGgA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.3.tgz",
+ "integrity": "sha512-LLLFZKt4/Nraf9rxDkhiU8QVgLF4WmCkfr0L4fj0fPfIZFBib0DeiFk1hhaYKd03LFAFJcxHslhDFlNJLylf5Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.3.tgz",
+ "integrity": "sha512-WJkdQCvS9sWNOUBJZfQRKpZGFBztRzcowI+nndmflKgU4XY+3a420FgTOSKTsVqJbnzSxeT4vaJalpOaPo2YCQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.3.tgz",
+ "integrity": "sha512-PwHXCCS2n64/1Ot6rP1YEYA02MGYBcQlr8CSZZyrUG2O7NH6NklYmvr9v3Jy+5e/eDeNchc/ukmKJi9LuflMIQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.3.tgz",
+ "integrity": "sha512-vUjxINQu3RC8NZS3ykk1gN65gIz8pAopOq2HXuZhiIxHdx7TFvDG+jgrdSgInu1Eza4/Rfi2VzZgyIgEH4WOaw==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.3.tgz",
+ "integrity": "sha512-wzko4aJ13+0G3kGnviCg5gnXFKd40izKsrf2uOw12US4XqprkDrmwOpeW14aSNa37V8bfPcz5Fkob6LZ3BAPmA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.3.tgz",
+ "integrity": "sha512-8120ue0JUMSwy11stlwnfdX3pPd+WZYGCDBwEHWtIHi6pOpZmsEF5QKB7a/UN+XFdqvobxz98kv8RTqikyCEBw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.3.tgz",
+ "integrity": "sha512-XLFHnR3tXMjbOCh2vtVJHmxt+995uJsTERQyseFDRA0xxMxyTZPLa3OIUlyFaO4mF/Lu0FjmWHCuPXJT1n/IOg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.3.tgz",
+ "integrity": "sha512-se6yXvNGMIl0f+RQzyh7XAmia8/9kplQx424wnG2w0C1oi6XgO6Y8otKhdXFHbHs88Ihavzmvh1NWjuovE76BQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.3.tgz",
+ "integrity": "sha512-gNoxRefktVIiGflpONuxWWXZAzIQG++z9qHO3xKwk4WdDMuQja3JHGfE1u0i3PfPDyvhypdk+WrgIJqLhGG7sg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.3.tgz",
+ "integrity": "sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.3.tgz",
+ "integrity": "sha512-LBx9LYXvj2CBkMkjLdNAWLwH0MLMin7do2VcVo9kVPibGLkY0BQQut2fv7NVqkXqZ/CrAu9LqDHVV1xHCMpCPw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.3.tgz",
+ "integrity": "sha512-ABVf3Q0RCu7NcyCCOZQI0pJ3GuSdfSl8EXcy88QtdceIMIoCUdfhsJChZ64L9zVM2aJHjde1Bhn5uqSRcX9ySA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.3.tgz",
+ "integrity": "sha512-+2Cy/ldweGBLlPIKsQLF8U5N44a0KDdbrk1rAjHOM9M2K+kGdIVjHLmmrZIcx+9Ny3ke/1JomCsDI1ocb11+sg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.3.tgz",
+ "integrity": "sha512-dtZvzc8BedpSaFNy75x6uiWwAGTH+aZHDtdrqP6qk+WcLJrfti6sGje1ZJ9UxyzDLF23d/mV+PaMwuC0hL7UVA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.3.tgz",
+ "integrity": "sha512-Rj8Ra4noo+aYy7sKBggCx0407mws34kAb1ySyWuq5DAtFBQdkSwnsjCgPrhPe9cvgBKZIukpE+CVHvORCS93kQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.3.tgz",
+ "integrity": "sha512-vp7N084ew/odXn2gi/mzm9mUkQu9l6AiN6dt4IeUM2Uvm9o+cVmP+YkqbMOteLbiGgqBBlJZjIMYVCfOOIVbVQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.3.tgz",
+ "integrity": "sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "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,
+ "libc": [
+ "glibc"
+ ],
+ "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,
+ "libc": [
+ "musl"
+ ],
+ "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,
+ "libc": [
+ "glibc"
+ ],
+ "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,
+ "libc": [
+ "musl"
+ ],
+ "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/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "19.2.17",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
+ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.3",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
+ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
+ "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.28.0",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-beta.27",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.17.0"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.11.4",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.4.tgz",
+ "integrity": "sha512-s4+sLr9mZ/CyqeRritFeYV/Zx73OAtmaHn6kkBS1XRoJn1hrg3xIDUcpicAEX68tkcIN0iBCgti31C8zxtkhsQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.7",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz",
+ "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.10.44",
+ "caniuse-lite": "^1.0.30001806",
+ "electron-to-chromium": "^1.5.393",
+ "node-releases": "^2.0.51",
+ "update-browserslist-db": "^1.2.3"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001806",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
+ "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.396",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.396.tgz",
+ "integrity": "sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/enhanced-resolve": {
+ "version": "5.24.3",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.3.tgz",
+ "integrity": "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.3.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
+ "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.1",
+ "@esbuild/android-arm": "0.28.1",
+ "@esbuild/android-arm64": "0.28.1",
+ "@esbuild/android-x64": "0.28.1",
+ "@esbuild/darwin-arm64": "0.28.1",
+ "@esbuild/darwin-x64": "0.28.1",
+ "@esbuild/freebsd-arm64": "0.28.1",
+ "@esbuild/freebsd-x64": "0.28.1",
+ "@esbuild/linux-arm": "0.28.1",
+ "@esbuild/linux-arm64": "0.28.1",
+ "@esbuild/linux-ia32": "0.28.1",
+ "@esbuild/linux-loong64": "0.28.1",
+ "@esbuild/linux-mips64el": "0.28.1",
+ "@esbuild/linux-ppc64": "0.28.1",
+ "@esbuild/linux-riscv64": "0.28.1",
+ "@esbuild/linux-s390x": "0.28.1",
+ "@esbuild/linux-x64": "0.28.1",
+ "@esbuild/netbsd-arm64": "0.28.1",
+ "@esbuild/netbsd-x64": "0.28.1",
+ "@esbuild/openbsd-arm64": "0.28.1",
+ "@esbuild/openbsd-x64": "0.28.1",
+ "@esbuild/openharmony-arm64": "0.28.1",
+ "@esbuild/sunos-x64": "0.28.1",
+ "@esbuild/win32-arm64": "0.28.1",
+ "@esbuild/win32-ia32": "0.28.1",
+ "@esbuild/win32-x64": "0.28.1"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.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/jiti": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
+ "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "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,
+ "libc": [
+ "musl"
+ ],
+ "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,
+ "libc": [
+ "glibc"
+ ],
+ "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,
+ "libc": [
+ "musl"
+ ],
+ "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/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.16",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
+ "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.51",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
+ "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.23",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
+ "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.16",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.2.8",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
+ "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.8",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
+ "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.8"
+ }
+ },
+ "node_modules/react-refresh": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.62.3",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.3.tgz",
+ "integrity": "sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.62.3",
+ "@rollup/rollup-android-arm64": "4.62.3",
+ "@rollup/rollup-darwin-arm64": "4.62.3",
+ "@rollup/rollup-darwin-x64": "4.62.3",
+ "@rollup/rollup-freebsd-arm64": "4.62.3",
+ "@rollup/rollup-freebsd-x64": "4.62.3",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.62.3",
+ "@rollup/rollup-linux-arm-musleabihf": "4.62.3",
+ "@rollup/rollup-linux-arm64-gnu": "4.62.3",
+ "@rollup/rollup-linux-arm64-musl": "4.62.3",
+ "@rollup/rollup-linux-loong64-gnu": "4.62.3",
+ "@rollup/rollup-linux-loong64-musl": "4.62.3",
+ "@rollup/rollup-linux-ppc64-gnu": "4.62.3",
+ "@rollup/rollup-linux-ppc64-musl": "4.62.3",
+ "@rollup/rollup-linux-riscv64-gnu": "4.62.3",
+ "@rollup/rollup-linux-riscv64-musl": "4.62.3",
+ "@rollup/rollup-linux-s390x-gnu": "4.62.3",
+ "@rollup/rollup-linux-x64-gnu": "4.62.3",
+ "@rollup/rollup-linux-x64-musl": "4.62.3",
+ "@rollup/rollup-openbsd-x64": "4.62.3",
+ "@rollup/rollup-openharmony-arm64": "4.62.3",
+ "@rollup/rollup-win32-arm64-msvc": "4.62.3",
+ "@rollup/rollup-win32-ia32-msvc": "4.62.3",
+ "@rollup/rollup-win32-x64-gnu": "4.62.3",
+ "@rollup/rollup-win32-x64-msvc": "4.62.3",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/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==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.6.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
+ "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "7.3.6",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
+ "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
+ },
+ "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",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.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
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "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/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ }
+ }
+}
diff --git a/landing/package.json b/landing/package.json
new file mode 100644
index 0000000..7e62f3a
--- /dev/null
+++ b/landing/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "zuno-landing",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@iconify/react": "^6.0.2",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ },
+ "devDependencies": {
+ "@tailwindcss/vite": "^4.0.0",
+ "@types/react": "^19.0.0",
+ "@types/react-dom": "^19.0.0",
+ "@vitejs/plugin-react": "^4.3.0",
+ "tailwindcss": "^4.0.0",
+ "typescript": "~5.6.0",
+ "vite": "^7.0.0",
+ "@iconify-json/logos": "^1.2.11"
+ }
+}
diff --git a/landing/public/HOZM7mpaEAECNOW.png b/landing/public/HOZM7mpaEAECNOW.png
new file mode 100644
index 0000000..68cb914
Binary files /dev/null and b/landing/public/HOZM7mpaEAECNOW.png differ
diff --git a/landing/public/Tom_Rhodes_Hustle_Standard_-_Low_Tide.mp3 b/landing/public/Tom_Rhodes_Hustle_Standard_-_Low_Tide.mp3
new file mode 100644
index 0000000..89840e3
Binary files /dev/null and b/landing/public/Tom_Rhodes_Hustle_Standard_-_Low_Tide.mp3 differ
diff --git a/landing/public/bgvid.mp4 b/landing/public/bgvid.mp4
new file mode 100644
index 0000000..6ce1aab
Binary files /dev/null and b/landing/public/bgvid.mp4 differ
diff --git a/landing/public/bgvids.mp4 b/landing/public/bgvids.mp4
new file mode 100644
index 0000000..73037c9
Binary files /dev/null and b/landing/public/bgvids.mp4 differ
diff --git a/landing/public/bottom faded image.PNG b/landing/public/bottom faded image.PNG
new file mode 100644
index 0000000..f1c83ec
Binary files /dev/null and b/landing/public/bottom faded image.PNG differ
diff --git a/landing/public/images.png b/landing/public/images.png
new file mode 100644
index 0000000..e278356
Binary files /dev/null and b/landing/public/images.png differ
diff --git a/landing/public/logo.png b/landing/public/logo.png
new file mode 100644
index 0000000..c5b19e1
Binary files /dev/null and b/landing/public/logo.png differ
diff --git a/landing/public/low-tide.mp3 b/landing/public/low-tide.mp3
new file mode 100644
index 0000000..89840e3
Binary files /dev/null and b/landing/public/low-tide.mp3 differ
diff --git a/landing/public/screenshot-dark.png b/landing/public/screenshot-dark.png
new file mode 100644
index 0000000..dd2632e
Binary files /dev/null and b/landing/public/screenshot-dark.png differ
diff --git a/landing/public/screenshot-light.png b/landing/public/screenshot-light.png
new file mode 100644
index 0000000..6c2c8d8
Binary files /dev/null and b/landing/public/screenshot-light.png differ
diff --git a/landing/public/youtube music.PNG b/landing/public/youtube music.PNG
new file mode 100644
index 0000000..4f99f92
Binary files /dev/null and b/landing/public/youtube music.PNG differ
diff --git a/landing/public/ytmusic.svg b/landing/public/ytmusic.svg
new file mode 100644
index 0000000..8e341c8
--- /dev/null
+++ b/landing/public/ytmusic.svg
@@ -0,0 +1,5120 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/landing/public/zuno v1.1.2.PNG b/landing/public/zuno v1.1.2.PNG
new file mode 100644
index 0000000..ae63481
Binary files /dev/null and b/landing/public/zuno v1.1.2.PNG differ
diff --git a/landing/public/zuno-character.mp4 b/landing/public/zuno-character.mp4
new file mode 100644
index 0000000..db06114
Binary files /dev/null and b/landing/public/zuno-character.mp4 differ
diff --git a/landing/public/zuno-d-1.2.PNG b/landing/public/zuno-d-1.2.PNG
new file mode 100644
index 0000000..ea0c792
Binary files /dev/null and b/landing/public/zuno-d-1.2.PNG differ
diff --git a/landing/public/zuno-d-settings.PNG b/landing/public/zuno-d-settings.PNG
new file mode 100644
index 0000000..ff6f1c7
Binary files /dev/null and b/landing/public/zuno-d-settings.PNG differ
diff --git a/landing/public/zuno-d1-1.2.PNG b/landing/public/zuno-d1-1.2.PNG
new file mode 100644
index 0000000..495c192
Binary files /dev/null and b/landing/public/zuno-d1-1.2.PNG differ
diff --git a/landing/public/zuno-d2.PNG b/landing/public/zuno-d2.PNG
new file mode 100644
index 0000000..9249bb6
Binary files /dev/null and b/landing/public/zuno-d2.PNG differ
diff --git a/landing/public/zuno-d3.PNG b/landing/public/zuno-d3.PNG
new file mode 100644
index 0000000..03d6416
Binary files /dev/null and b/landing/public/zuno-d3.PNG differ
diff --git a/landing/public/zuno-da.PNG b/landing/public/zuno-da.PNG
new file mode 100644
index 0000000..f7cc479
Binary files /dev/null and b/landing/public/zuno-da.PNG differ
diff --git a/landing/public/zuno-dd.PNG b/landing/public/zuno-dd.PNG
new file mode 100644
index 0000000..4e09aa5
Binary files /dev/null and b/landing/public/zuno-dd.PNG differ
diff --git a/landing/public/zuno-l.PNG b/landing/public/zuno-l.PNG
new file mode 100644
index 0000000..4d41d1a
Binary files /dev/null and b/landing/public/zuno-l.PNG differ
diff --git a/landing/public/zuno-l2.PNG b/landing/public/zuno-l2.PNG
new file mode 100644
index 0000000..ab3fc07
Binary files /dev/null and b/landing/public/zuno-l2.PNG differ
diff --git a/landing/public/zuno-l4-1.2.PNG b/landing/public/zuno-l4-1.2.PNG
new file mode 100644
index 0000000..8d63808
Binary files /dev/null and b/landing/public/zuno-l4-1.2.PNG differ
diff --git a/landing/public/zuno-l5-1.2.PNG b/landing/public/zuno-l5-1.2.PNG
new file mode 100644
index 0000000..6bd611b
Binary files /dev/null and b/landing/public/zuno-l5-1.2.PNG differ
diff --git a/landing/src/App.tsx b/landing/src/App.tsx
new file mode 100644
index 0000000..992225a
--- /dev/null
+++ b/landing/src/App.tsx
@@ -0,0 +1,90 @@
+import { useLatestRelease } from "@/useLatestRelease";
+import { Downloads } from "@/components/Downloads";
+import { Hero } from "@/components/Hero";
+import { MiniPlayer } from "@/components/MiniPlayer";
+import { Mono, Section } from "./components/ui";
+import { GITHUB_REPO, RELEASES_URL } from "./releases";
+
+/**
+ * The Zuno character, looping.
+ *
+ * The one place with room for personality — everything else on the page is doing a job. Muted,
+ * looping and `playsInline` so it behaves like a graphic rather than media: no controls, nothing
+ * to pause, and it never asks for sound.
+ */
+function Character() {
+ return (
+
+ );
+}
+
+function Footer() {
+ return (
+
+
+
+
+
+
+
+ zuno_
+
+
+ an independent, unofficial project. not affiliated with, authorised by or endorsed
+ by youtube or google.
+
+
+
+
+
+ {[
+ ["github", GITHUB_REPO],
+ ["releases", RELEASES_URL],
+ ["issues", `${GITHUB_REPO}/issues`],
+ ["licence", `${GITHUB_REPO}/blob/main/LICENSE`],
+ ].map(([label, href]) => (
+
+ {label}
+
+ ))}
+
+
+
+ );
+}
+
+export function App() {
+ const latest = useLatestRelease();
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/landing/src/components/Downloads.tsx b/landing/src/components/Downloads.tsx
new file mode 100644
index 0000000..0bb93e5
--- /dev/null
+++ b/landing/src/components/Downloads.tsx
@@ -0,0 +1,239 @@
+import { useState } from "react";
+import { BrandIcon, OS_ICON } from "./brandIcons";
+import { CheckIcon, DownloadIcon, ShieldIcon } from "./icons";
+import { LinkButton, Mono, cn } from "./ui";
+import {
+ RELEASES_URL,
+ formatSize,
+ type LatestRelease,
+ type PlatformId,
+} from "../releases";
+
+/**
+ * One tile per operating system, each with its own format choice.
+ *
+ * A flat list of every asset makes the reader do the matching: four `.dmg`-ish names, two of
+ * which are wrong for their machine. Grouping by OS and putting the variants behind a small
+ * segmented control means there is exactly one decision per tile, and it is one the reader can
+ * actually answer — "Apple Silicon or Intel" is a question about their laptop, not about a
+ * filename.
+ */
+interface Variant {
+ label: string;
+ /** Which resolved platform build this maps to. */
+ platform: PlatformId;
+ /** Narrows further within a platform's assets — Linux ships two formats under one id. */
+ match?: RegExp;
+ note?: string;
+}
+
+interface OsTile {
+ id: "windows" | "macos" | "linux";
+ name: string;
+ icon: string;
+ /** Which detected platforms should light this tile up. */
+ detects: readonly PlatformId[];
+ requirement: string;
+ variants: readonly Variant[];
+}
+
+const TILES: readonly OsTile[] = [
+ {
+ id: "windows",
+ name: "Windows",
+ icon: OS_ICON.windows,
+ detects: ["windows"],
+ requirement: "Windows 10 and 11 · 64-bit",
+ variants: [
+ { label: "Installer", platform: "windows", match: /-setup\.exe$/i },
+ { label: "MSI", platform: "windows", match: /\.msi$/i },
+ ],
+ },
+ {
+ id: "macos",
+ name: "macOS",
+ icon: OS_ICON.macos,
+ detects: ["macos-arm", "macos-intel"],
+ requirement: "macOS 12 and later",
+ variants: [
+ {
+ label: "Apple Silicon",
+ platform: "macos-arm",
+ note: "unsigned — right-click → Open on first launch",
+ },
+ {
+ label: "Intel",
+ platform: "macos-intel",
+ note: "unsigned — right-click → Open on first launch",
+ },
+ ],
+ },
+ {
+ id: "linux",
+ name: "Linux",
+ icon: OS_ICON.linux,
+ detects: ["linux"],
+ requirement: "x86_64 · glibc 2.31+",
+ variants: [
+ { label: "AppImage", platform: "linux", match: /\.AppImage$/i, note: "needs chmod +x" },
+ { label: ".deb", platform: "linux", match: /\.deb$/i, note: "apt-based distributions" },
+ { label: ".rpm", platform: "linux", match: /\.rpm$/i, note: "fedora and opensuse" },
+ ],
+ },
+];
+
+function Tile({
+ tile,
+ release,
+ detected,
+}: {
+ tile: OsTile;
+ release: LatestRelease | null;
+ detected: PlatformId | null;
+}) {
+ const isYours = detected !== null && tile.detects.includes(detected);
+ /* Open on the variant that matches the reader's machine, not always the first. */
+ const [variantIndex, setVariantIndex] = useState(() => {
+ const index = tile.variants.findIndex((variant) => variant.platform === detected);
+ return index >= 0 ? index : 0;
+ });
+
+ const variant = tile.variants[variantIndex];
+ const build = release?.downloads[variant.platform];
+ /*
+ * Linux ships two formats behind one platform id, so the resolved asset may be the AppImage
+ * when the reader asked for the .deb. Rather than link the wrong file, the variant falls back
+ * to the releases page — a correct extra click beats a confident wrong download.
+ */
+ const asset = build && (!variant.match || variant.match.test(build.name)) ? build : undefined;
+
+ return (
+
+
+
+ {isYours ? (
+
+ your system
+
+ ) : null}
+
+
+
+
{tile.name}
+ {tile.requirement}
+
+
+ {tile.variants.length > 1 ? (
+
+ {tile.variants.map((option, index) => (
+ setVariantIndex(index)}
+ className={cn(
+ "flex-1 rounded-full px-3 py-2 font-mono text-[13px] transition-colors",
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
+ index === variantIndex
+ ? "bg-card text-foreground"
+ : "text-muted-foreground hover:text-foreground",
+ )}
+ >
+ {option.label}
+
+ ))}
+
+ ) : null}
+
+
+
+
+ Download
+
+
+
+
+ {asset ? `${asset.name} · ${formatSize(asset.size)}` : "see all releases"}
+
+ {variant.note ? (
+
+ {variant.note}
+
+ ) : null}
+
+
+
+ );
+}
+
+const ASSURANCES: readonly string[] = [
+ "No telemetry — no analytics, crash reporting or usage pings",
+ "Your own Google sign-in — Zuno has no account to create",
+ "Apache 2.0, built from the public tree by CI",
+];
+
+export function Downloads({
+ release,
+ platform,
+}: {
+ release: LatestRelease | null;
+ platform: PlatformId | null;
+}) {
+ return (
+
+
+
+
+ {release ? `v${release.version}` : "Latest release"}
+
+ · updates install themselves after the first run
+
+
+
+ signed
+
+
+
+
+ {TILES.map((tile) => (
+
+ ))}
+
+
+
+ {ASSURANCES.map((line) => (
+
+
+
+
+ {line}
+
+ ))}
+
+
+ no subscription · no upsell · no bundled software · no ad blocking or DRM
+ circumvention — it plays what your account can already play
+
+
+
+
+ );
+}
diff --git a/landing/src/components/Features.tsx b/landing/src/components/Features.tsx
new file mode 100644
index 0000000..62080c6
--- /dev/null
+++ b/landing/src/components/Features.tsx
@@ -0,0 +1,312 @@
+import { useState, type ReactNode } from "react";
+import { TabsDemo } from "./TabsDemo";
+import { Mono, cn } from "./ui";
+
+/** Download rows mid-flight: one done, one in progress, one queued. */
+function OfflineVisual() {
+ const rows = [
+ { name: "Am I Dreaming", state: "done" as const, pct: 100 },
+ { name: "Creepin'", state: "active" as const, pct: 62 },
+ { name: "Superhero", state: "queued" as const, pct: 0 },
+ { name: "Around Me", state: "queued" as const, pct: 0 },
+ ];
+
+ return (
+
+ {rows.map((row) => (
+
+
+ {row.state === "done" ? "✓" : row.state === "active" ? "↓" : "·"}
+
+
+ {row.name}
+
+
+
+
+
+ ))}
+
1 of 4 · 128 kbps AAC · 8 GB cap
+
+ );
+}
+
+/** Three lyric lines with the current one lit. */
+function LyricsVisual() {
+ const lines = [
+ "I've been so lost since you've been gone",
+ "Am I dreaming?",
+ "Or is this real now",
+ "Tell me what you want",
+ ];
+ return (
+
+ {lines.map((line, index) => (
+
+ {line}
+
+ ))}
+ synced · not available on the web client
+
+ );
+}
+
+/** The mini player pill, at rest and expanded. */
+function MiniPlayerVisual() {
+ return (
+
+
+
+
+ Am I Dreaming
+ Metro Boomin
+
+
+
+
+
+
appears when you tab away · drag it anywhere
+
+ );
+}
+
+/** Local paths sitting in the same library. */
+function LocalVisual() {
+ return (
+
+ {["~/Music/FLAC", "D:\\Rips\\2024", "~/Downloads/live-sets"].map((path) => (
+
+
+ ♪
+
+ {path}
+
+ ))}
+
same rows, same queue, same player · tag editor included
+
+ );
+}
+
+/** The two integration toggles, as they appear in the toolbar. */
+function IntegrationsVisual() {
+ return (
+
+ {[
+ { label: "Discord Rich Presence", on: true, detail: "shows the artist, not the app" },
+ { label: "Last.fm scrobbling", on: false, detail: "one click to stop broadcasting" },
+ ].map((item) => (
+
+
+ {item.label}
+ {item.detail}
+
+
+
+
+
+ ))}
+
+ );
+}
+
+interface Feature {
+ id: string;
+ title: string;
+ body: string;
+ visual: ReactNode;
+}
+
+const FEATURES: readonly Feature[] = [
+ {
+ id: "tabs",
+ title: "Tabs that each keep playing",
+ body:
+ "Open a second tab and the first carries on. Each holds its own queue, volume and position, and the player bar follows whichever is playing — not whichever you are looking at.",
+ visual: ,
+ },
+ {
+ id: "offline",
+ title: "Offline downloads",
+ body:
+ "A song, a selection, or a whole album or playlist. Its own quality setting, its own size cap, and a queue you can watch.",
+ visual: ,
+ },
+ {
+ id: "lyrics",
+ title: "Synced lyrics",
+ body: "Line by line and in time with the track, which the official web client does not offer.",
+ visual: ,
+ },
+ {
+ id: "mini",
+ title: "A mini player",
+ body:
+ "Click away from the window and a small pill appears. Hover to expand it, drag it anywhere, click through to come back.",
+ visual: ,
+ },
+ {
+ id: "local",
+ title: "Your local files, in the same list",
+ body:
+ "Point Zuno at folders on your machine and they sit alongside your library — nothing uploaded, and a tag editor for fixing metadata.",
+ visual: ,
+ },
+ {
+ id: "integrations",
+ title: "Last.fm and Discord",
+ body:
+ "Scrobbling and Rich Presence, each one click away in the toolbar for when you would rather not broadcast what you are playing.",
+ visual: ,
+ },
+];
+
+/**
+ * Features as an explorer, not an inventory.
+ *
+ * A grid of six cards gives every feature identical weight and asks the reader to evaluate all
+ * of them at once. This shows one at a time, at full size, with the list acting as a table of
+ * contents — the reader picks what they care about and gets a real look at it rather than a
+ * thumbnail of everything.
+ *
+ * It is a proper tablist: arrow keys move between features, and the panel is labelled by the tab
+ * that opened it. On narrow screens the same data renders as a stack, because a two-column
+ * explorer on a phone is a column of buttons above a column of panels.
+ */
+export function Features() {
+ const [activeId, setActiveId] = useState(FEATURES[0].id);
+ const active = FEATURES.find((feature) => feature.id === activeId) ?? FEATURES[0];
+
+ const move = (delta: number) => {
+ const index = FEATURES.findIndex((feature) => feature.id === activeId);
+ const next = FEATURES[(index + delta + FEATURES.length) % FEATURES.length];
+ setActiveId(next.id);
+ document.getElementById(`feature-tab-${next.id}`)?.focus();
+ };
+
+ return (
+
+
{
+ if (event.key === "ArrowDown") {
+ event.preventDefault();
+ move(1);
+ }
+ if (event.key === "ArrowUp") {
+ event.preventDefault();
+ move(-1);
+ }
+ }}
+ >
+ {FEATURES.map((feature, index) => {
+ const isActive = feature.id === activeId;
+ return (
+ setActiveId(feature.id)}
+ className={cn(
+ "group relative flex items-start gap-4 rounded-xl px-4 py-4 text-left transition-colors",
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
+ isActive ? "bg-card/60" : "hover:bg-card/30",
+ )}
+ >
+
+
+ {String(index + 1).padStart(2, "0")}
+
+
+
+ {feature.title}
+
+ {isActive ? (
+
+ {feature.body}
+
+ ) : null}
+
+
+ );
+ })}
+
+
+
+ {active.visual}
+
+
+ {/* Narrow screens get the same content as a stack; an explorer needs two columns. */}
+
+ {FEATURES.map((feature, index) => (
+
+
+
{String(index + 1).padStart(2, "0")}
+
+
{feature.title}
+
+ {feature.body}
+
+
+
+
{feature.visual}
+
+ ))}
+
+
+ );
+}
diff --git a/landing/src/components/Header.tsx b/landing/src/components/Header.tsx
new file mode 100644
index 0000000..2e9f0c1
--- /dev/null
+++ b/landing/src/components/Header.tsx
@@ -0,0 +1,61 @@
+import { useEffect, useState } from "react";
+import { LinkButton, cn } from "./ui";
+
+/**
+ * One header for the whole page.
+ *
+ * Its own file rather than living in App: Hero renders it, and App renders Hero, so importing
+ * it from App made the two modules import each other. That resolves today only because of
+ * hoisting, and stops resolving the moment either file grows a top-level constant the other
+ * needs.
+ *
+ * A floating pill rather than a full-width bar. The hero is a single continuous surface, and a
+ * bar spanning it cuts the composition in two before the eye reaches the product.
+ */
+export function Header() {
+ const [scrolled, setScrolled] = useState(false);
+
+ useEffect(() => {
+ const onScroll = () => setScrolled(window.scrollY > 40);
+ onScroll();
+ window.addEventListener("scroll", onScroll, { passive: true });
+ return () => window.removeEventListener("scroll", onScroll);
+ }, []);
+
+ return (
+
+ );
+}
diff --git a/landing/src/components/Hero.tsx b/landing/src/components/Hero.tsx
new file mode 100644
index 0000000..edf93ef
--- /dev/null
+++ b/landing/src/components/Hero.tsx
@@ -0,0 +1,284 @@
+import type { CSSProperties } from "react";
+import { BrandIcon, OS_ICON, SERVICE_ICON } from "./brandIcons";
+import { ArrowDownIcon, ArrowRightIcon } from "./icons";
+import { LinkButton, Mono } from "./ui";
+import { GITHUB_REPO, type LatestRelease, type PlatformId } from "../releases";
+
+const PLATFORM_LABEL: Record = {
+ windows: "Windows",
+ "macos-arm": "macOS",
+ "macos-intel": "macOS",
+ linux: "Linux",
+};
+
+const DEMO_VIDEO = "https://pub-493a5d4ea10b45dcaa83917aa3856a32.r2.dev/zunodem.mp4";
+const DEMO_POSTER = "./zuno-d1-1.2.PNG";
+/** Read off the file's own header. Reserves the box before a byte arrives, and sets the frame's
+ * aspect — the stage is cut to the footage rather than the footage cropped to the stage. */
+const DEMO_W = 1234;
+const DEMO_H = 922;
+
+/** Six features, one clean row. The hero itself stays pure type, so they sit under the film. */
+const FEATURES = [
+ "tabs, one queue each",
+ "line-synced lyrics",
+ "offline downloads",
+ "your local files",
+ "discord presence",
+ "last.fm scrobbling",
+];
+
+/**
+ * Entrance cascade.
+ *
+ * Every line of the hero arrives on the same curve, a beat apart, top to bottom — which is what
+ * makes a stack of centred text read as composed rather than as a page that finished loading.
+ * The delay is the only thing that differs, so it is the only thing passed.
+ *
+ * Under reduced motion the global rule in styles.css collapses the duration, and because
+ * `reveal-in` ends at the resting state that leaves everything simply visible.
+ */
+const step = (i: number): CSSProperties => ({ animationDelay: `${i * 0.09}s` });
+
+/**
+ * A floating service badge.
+ *
+ * Decorative, hence `aria-hidden`: both of these already appear in the feature index below, and
+ * announcing them twice buys a screen reader nothing. Hidden outright below `lg`, where the
+ * centred column takes the full width and there are no margins left to float in.
+ *
+ * Two elements rather than one, because the two motions have nothing to do with each other: the
+ * outer box carries the position and the scroll-driven fade, the inner pill carries the bob. A
+ * single element would need one `animation` list with mismatched timelines to do both.
+ *
+ * The negative delay starts the bob already in progress, so the pair does not rise and fall in
+ * lockstep for the first cycle.
+ */
+function ServiceBadge({
+ className,
+ tilt,
+ delay,
+ duration,
+ children,
+}: {
+ className: string;
+ tilt: string;
+ delay: string;
+ duration: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+/**
+ * The hero.
+ *
+ * Type first, product second — the two are not fighting over the same viewport. The fold is the
+ * wordmark and the claim, centred, with nothing behind it; then the demo takes over completely
+ * as a frame that pins to the viewport and grows to fill it while you scroll past.
+ *
+ * That split is the whole idea. A hero that runs copy beside a screenshot gives each half of the
+ * frame to a different job and does neither at full size. Sequencing them means the words get a
+ * clean page and the footage gets the entire screen.
+ */
+export function Hero({
+ release,
+ platform,
+}: {
+ release: LatestRelease | null;
+ platform: PlatformId | null;
+}) {
+ const label = platform ? PLATFORM_LABEL[platform] : "your system";
+
+ return (
+ <>
+
+
+ {/*
+ Sized to the viewport minus the header, so the film frame below starts exactly at the
+ fold rather than a header's height past it.
+ */}
+
+ {/*
+ Wider than the viewport and lifted until only the foot of the limb is in frame, so what
+ reads is an arc across the top of the page rather than a circle behind the words.
+ */}
+
+
+ {/*
+ Flanking the column diagonally, offset from the centre line rather than from the
+ viewport edge — the text is centred, so anything measured from the edge drifts away from
+ it as the window widens. This keeps the same gap at every width.
+ */}
+
+
+ rich presence
+
+
+
+
+ scrobbling
+
+
+ {/*
+ The wordmark is the headline.
+
+ It can be, because the line under it says what the thing is inside twelve words. Set as
+ a mark rather than as a sentence: heavier, tighter, and ending in the underscore it is
+ already named after — which the blinking block simply admits is a prompt.
+ */}
+
+
+
+ zuno_
+ {release ? (
+
+ v{release.version}
+
+ ) : null}
+
+
+ {/* Two deliberate lines: what it is, then the one thing no browser tab does. */}
+
+ A desktop client for the YouTube Music.
+ Downloads, Lyrics & No Ads.
+
+
+
+ Free and open source. No telemetry, and no account to create.
+
+
+
+ {/* Down to the tiles rather than straight at the asset: the format choice (msi vs exe,
+ deb vs rpm vs AppImage) lives there, and a hero button cannot make it for you. */}
+
+ Download for {label}
+
+
+
+
+ Read the source
+
+
+
+
+ {/* The same three marks the download tiles use, so the promise here and the choice there
+ read as one row. Apple's glyph ships unfilled, so it takes the text colour. */}
+
+ {(["windows", "macos", "linux"] as const).map((os) => (
+
+ {/* Apple and Tux are silhouettes on currentColor; the Windows mark carries its
+ own blue and ignores this. */}
+
+ {PLATFORM_LABEL[os === "macos" ? "macos-arm" : os]}
+
+ ))}
+
+
+
+ {/*
+ The film stage.
+
+ Taller than the viewport, with the frame pinned inside it: the demo holds still and grows
+ to fill the screen while the page scrolls behind it, then releases. The video is the only
+ thing in it — no chrome, no transport, no play button, because it is already playing.
+ */}
+
+
+
+
+ {/* The footage is lit brighter than the page. A flat tint settles it into the
+ surrounding dark without touching the video's own colour. */}
+
+
+
+
+
+ {/* The features, as a quiet index under the film rather than as a claim inside the hero. */}
+
+ {FEATURES.map((feature, i) => (
+
+
+ {String(i + 1).padStart(2, "0")}
+
+ {feature}
+
+ ))}
+
+ >
+ );
+}
diff --git a/landing/src/components/MiniPlayer.tsx b/landing/src/components/MiniPlayer.tsx
new file mode 100644
index 0000000..7698ace
--- /dev/null
+++ b/landing/src/components/MiniPlayer.tsx
@@ -0,0 +1,277 @@
+import { useEffect, useRef, useState, type CSSProperties, type PointerEvent } from "react";
+import { PauseIcon, PlayIcon, SkipIcon } from "./icons";
+import { cn } from "./ui";
+
+/**
+ * The app's mini player, on the page.
+ *
+ * A copy of `src/ui/components/mini-player/MiniPlayer.tsx` in shape and materials — same capsule,
+ * same collapsed/expanded geometry, same progress ring around a spinning record, same glass. The
+ * app's version exists to control a window: it drags, it hit-tests the cursor against the OS, it
+ * resizes its own Tauri window to match the capsule. None of that has a meaning in a browser, so
+ * what is left is the part a visitor can see — hover expands it, and the buttons drive an
+ * `` element instead of an IPC bus.
+ *
+ * Expansion is CSS rather than state: the capsule is the hover target and every animated property
+ * is a class on it, so nothing here re-renders on mouse-over.
+ */
+
+/* The demo track. One object, so swapping the song is one edit. */
+const TRACK = {
+ src: "./low-tide.mp3",
+ title: "Low Tide",
+ artist: "Tom Rhodes",
+ /** The Zuno character loop stands in for cover art; the still tints the glass behind it. */
+ avatar: "./zuno-character.mp4",
+ artwork: "./logo.png",
+};
+
+/** Matching the app's capsule: 44px collapsed, 96px expanded. */
+const COLLAPSED_HEIGHT = 44;
+const PROGRESS_RING_WIDTH = 2;
+
+/** Where it parks before anyone moves it: clear of the header, in from the left edge. */
+const HOME = { x: 24, y: 88 };
+
+const clamp = (value: number, min: number, max: number) =>
+ Math.min(Math.max(value, min), Math.max(min, max));
+
+const MINI_BUTTON =
+ "flex size-7 shrink-0 items-center justify-center rounded-full text-neutral-300 transition-all " +
+ "hover:bg-white/10 hover:text-white active:scale-90 disabled:opacity-40 " +
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/50";
+
+export function MiniPlayer() {
+ const audioRef = useRef(null);
+ const [playing, setPlaying] = useState(false);
+ const [time, setTime] = useState(0);
+ const [duration, setDuration] = useState(0);
+ const [position, setPosition] = useState(HOME);
+ const [dragging, setDragging] = useState(false);
+
+ const toggle = () => {
+ const audio = audioRef.current;
+ if (!audio) return;
+ if (audio.paused) void audio.play();
+ else audio.pause();
+ };
+
+ const seek = (seconds: number) => {
+ const audio = audioRef.current;
+ if (audio) audio.currentTime = seconds;
+ };
+
+ /* The element is the source of truth for `playing`: pausing can come from the OS media keys or
+ from another tab taking the audio focus, neither of which goes through the button. */
+ useEffect(() => {
+ const audio = audioRef.current;
+ if (!audio) return;
+
+ const sync = () => setPlaying(!audio.paused);
+ audio.addEventListener("play", sync);
+ audio.addEventListener("pause", sync);
+
+ /*
+ * Autoplay, with the fallback the platform forces.
+ *
+ * Every engine refuses `play()` with sound until the visitor has interacted with the page,
+ * and the refusal is a rejected promise rather than an error — so the first gesture anywhere
+ * on the document starts it instead. `once` means the listener costs nothing after that, and
+ * a browser that does allow the first attempt never registers it at all.
+ */
+ let start: (() => void) | null = null;
+ void audio.play().catch(() => {
+ start = () => void audio.play();
+ document.addEventListener("pointerdown", start, { once: true });
+ document.addEventListener("keydown", start, { once: true });
+ });
+
+ return () => {
+ audio.removeEventListener("play", sync);
+ audio.removeEventListener("pause", sync);
+ if (start) {
+ document.removeEventListener("pointerdown", start);
+ document.removeEventListener("keydown", start);
+ }
+ };
+ }, []);
+
+ /*
+ * Drag, the browser's version.
+ *
+ * The app moves an OS window; here the capsule moves itself. Pointer capture is what makes the
+ * grab survive the cursor outrunning the element, which it will — the capsule is small and the
+ * pointer is not obliged to stay inside it.
+ */
+ const grabRef = useRef<{ x: number; y: number } | null>(null);
+
+ const startDrag = (event: PointerEvent) => {
+ // Buttons and the slider own their own presses; only the bare capsule is a handle.
+ if (event.target instanceof Element && event.target.closest("button, input")) return;
+
+ const box = event.currentTarget.getBoundingClientRect();
+ grabRef.current = { x: event.clientX - box.left, y: event.clientY - box.top };
+ event.currentTarget.setPointerCapture(event.pointerId);
+ setDragging(true);
+ };
+
+ const onDrag = (event: PointerEvent) => {
+ const grab = grabRef.current;
+ if (!grab) return;
+
+ // Clamped so it can never be parked off-screen, where nothing would bring it back.
+ const box = event.currentTarget.getBoundingClientRect();
+ setPosition({
+ x: clamp(event.clientX - grab.x, 8, window.innerWidth - box.width - 8),
+ y: clamp(event.clientY - grab.y, 8, window.innerHeight - box.height - 8),
+ });
+ };
+
+ const endDrag = () => {
+ grabRef.current = null;
+ setDragging(false);
+ };
+
+ const progress = duration > 0 ? Math.min(100, (time / duration) * 100) : 0;
+
+ return (
+
+
setTime(event.currentTarget.currentTime)}
+ onLoadedMetadata={(event) => setDuration(event.currentTarget.duration)}
+ />
+
+ {/*
+ One capsule that morphs, rather than two stacked pills. Width and height are the animated
+ properties, so the transport row is revealed by the container growing around it rather
+ than by a second element appearing.
+ */}
+
+ {/* Album-reactive backdrop: the artwork blown up and blurred past recognition, tinting
+ the glass with the record's own palette. No extra request, no colour extraction. */}
+
+
+ {/* Specular top edge — the highlight a real glass material catches. */}
+
+
+ {/* ── Identity row: always visible ─────────────────────────────── */}
+
+
+ {/* Position, drawn as a ring rather than a bar — the collapsed pill has no width to
+ spend on one. The conic gradient fills the disc; a radial mask cuts the centre
+ out, so thickness is one constant instead of a gap between two elements. */}
+
+ {/* The character loop rather than a sleeve. It carries its own motion, so the app's
+ record spin is left off — two rotations at once reads as a glitch. */}
+
+
+
+ {/* Takes whatever the chrome leaves. `min-w-0` is what lets it actually shrink. */}
+
+
+ {TRACK.title}
+
+
{TRACK.artist}
+
+
+
+ {/* ── Control row ───────────────────────────────────────────────
+ Collapsed it is clamped to zero width so it cannot hold the capsule open past the
+ collapsed cap; expanding releases it and the capsule grows to fit. */}
+
+
+ {/* One track, so "previous" is a restart and "next" has nowhere to go. */}
+
seek(0)}
+ aria-label="Restart"
+ >
+
+
+
+ {playing ? : }
+
+
+
+
+
+
+
seek(Number(event.currentTarget.value))}
+ aria-label="Song position"
+ className="h-1 min-w-24 flex-1 cursor-pointer appearance-none rounded-full bg-transparent [&::-webkit-slider-runnable-track]:h-1 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-[linear-gradient(to_right,#fff_var(--slider-progress),rgba(255,255,255,0.18)_var(--slider-progress))] [&::-webkit-slider-thumb]:-mt-[3px] [&::-webkit-slider-thumb]:size-2.5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:transition-transform hover:[&::-webkit-slider-thumb]:scale-125"
+ style={{ "--slider-progress": `${progress}%` } as CSSProperties}
+ />
+
+
+
+ );
+}
diff --git a/landing/src/components/TabsDemo.tsx b/landing/src/components/TabsDemo.tsx
new file mode 100644
index 0000000..cc802d8
--- /dev/null
+++ b/landing/src/components/TabsDemo.tsx
@@ -0,0 +1,143 @@
+import { useState } from "react";
+import { Mono, cn } from "./ui";
+
+interface DemoTab {
+ id: string;
+ label: string;
+ heading: string;
+ rows: string[];
+}
+
+const TABS: readonly DemoTab[] = [
+ {
+ id: "a",
+ label: "Am I Dreaming",
+ heading: "Metro Boomin · Heroes & Villains",
+ rows: ["Am I Dreaming", "Creepin'", "Superhero", "Around Me", "Trance"],
+ },
+ {
+ id: "b",
+ label: "New tab",
+ heading: "A$AP Rocky · Popular",
+ rows: ["Praise The Lord", "F**kin' Problems", "Fashion Killa", "L$D", "Sundress"],
+ },
+ {
+ id: "c",
+ label: "Browse",
+ heading: "Made for you",
+ rows: ["Late night drive", "Focus, no lyrics", "2019 chill mix", "Discover weekly"],
+ },
+];
+
+/** Three animated bars. The app uses the same treatment to mark the row that is playing. */
+function PlayingBars({ className }: { className?: string }) {
+ return (
+
+ {[0, 1, 2].map((bar) => (
+
+ ))}
+
+ );
+}
+
+/**
+ * A working miniature of the tab model.
+ *
+ * The feature is genuinely hard to describe and trivial to show: switch tabs and the playing
+ * indicator stays pinned to the first one while the content underneath changes completely.
+ * A paragraph claiming "each tab keeps its own playback" asks to be believed; this just does it,
+ * and the player bar at the bottom never changes song no matter which tab you are looking at.
+ *
+ * Deliberately not a video or a GIF — it is a handful of divs, so it is sharp at any size, it
+ * costs nothing to load, and it cannot drift out of date the way a recording does.
+ */
+export function TabsDemo() {
+ const [activeId, setActiveId] = useState(TABS[0].id);
+ /** The tab that owns playback. Fixed to the first, which is the entire point. */
+ const playingId = TABS[0].id;
+ const active = TABS.find((tab) => tab.id === activeId) ?? TABS[0];
+
+ return (
+
+ {/* Window chrome, so it reads as an app rather than a web widget. */}
+
+
+ {["bg-[#ff5f57]", "bg-[#febc2e]", "bg-[#28c840]"].map((colour) => (
+
+ ))}
+
+
+
+ {TABS.map((tab) => {
+ const isActive = tab.id === activeId;
+ const isPlaying = tab.id === playingId;
+
+ return (
+
setActiveId(tab.id)}
+ className={cn(
+ "flex min-w-0 items-center gap-1.5 rounded-lg px-3 py-2 text-sm transition-colors",
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
+ isActive
+ ? "bg-background text-foreground"
+ : "text-muted-foreground hover:text-foreground",
+ )}
+ >
+ {/* Present whether or not the tab is focused — that is the whole demonstration. */}
+ {isPlaying ? : null}
+ {tab.label}
+
+ );
+ })}
+
+
+
+ {/* Content changes with the tab. */}
+
+
{active.heading}
+
+ {active.rows.map((row, index) => {
+ const isNowPlaying = active.id === playingId && index === 0;
+ return (
+
+
+ {isNowPlaying ? : {index + 1} }
+
+ {row}
+
+ );
+ })}
+
+
+
+ {/* The player bar. Bound to the playing tab, never to the visible one. */}
+
+
+
+ Am I Dreaming
+
+ Metro Boomin, A$AP Rocky, Roisee
+
+
+
+
+ still playing from “{TABS[0].label}”
+
+
+
+ );
+}
diff --git a/landing/src/components/brandIcons.tsx b/landing/src/components/brandIcons.tsx
new file mode 100644
index 0000000..0a17407
--- /dev/null
+++ b/landing/src/components/brandIcons.tsx
@@ -0,0 +1,52 @@
+import { Icon, addIcon } from "@iconify/react";
+import type { ComponentProps } from "react";
+
+/*
+ * Six Iconify glyphs, inlined.
+ *
+ * Iconify normally fetches icons from its API at runtime, which means a network round trip and
+ * a visible pop-in for the first paint of a download section — the one place on this page where
+ * the icons *are* the wayfinding. The full `@iconify-json/logos` set is 7.4 MB, so bundling it
+ * is not an option either.
+ *
+ * These six are extracted from that package and registered up front, so they render on the
+ * first frame with no request. Regenerate by re-running the extraction if a mark changes; the
+ * package stays a devDependency purely as the source of record.
+ */
+const ICONS: Record = {
+ "microsoft-windows-icon": {"body":" ","width":256,"height":256},
+ // Apple's mark is a solid silhouette with no fill in the source, which SVG resolves to black —
+ // invisible on a dark page. currentColor lets the call site set it.
+ "apple": {"body":" ","width":256,"height":315},
+ "linux-tux": {"body":" ","width":256,"height":295},
+ "github-icon": {"body":" ","width":256,"height":250},
+ "discord-icon": {"body": " ", "width": 256, "height": 199},
+ "lastfm": {"body": " ", "width": 512, "height": 131},
+};
+
+for (const [name, data] of Object.entries(ICONS)) {
+ addIcon(`logos:${name}`, data);
+}
+
+/** Brand marks keep their own colours; `color: unset` stops a parent text colour flattening them. */
+export function BrandIcon(props: ComponentProps) {
+ return ;
+}
+
+export const OS_ICON = {
+ windows: "logos:microsoft-windows-icon",
+ macos: "logos:apple",
+ linux: "logos:linux-tux",
+ github: "logos:github-icon",
+} as const;
+
+/**
+ * The two services Zuno talks to, for the hero's floating badges.
+ *
+ * Aspect ratios differ wildly and both are wrong to guess at: the Discord mark is 256×199 and
+ * the Last.fm one is the wordmark at 512×131, so each call site sizes its own.
+ */
+export const SERVICE_ICON = {
+ discord: "logos:discord-icon",
+ lastfm: "logos:lastfm",
+} as const;
diff --git a/landing/src/components/icons.tsx b/landing/src/components/icons.tsx
new file mode 100644
index 0000000..c60947f
--- /dev/null
+++ b/landing/src/components/icons.tsx
@@ -0,0 +1,137 @@
+import type { SVGProps } from "react";
+
+/**
+ * Icons, hand-rolled rather than pulled from Solar.
+ *
+ * The app routes every icon through `@/ui/icons` and pays for ~1.2k modules of Solar to do it.
+ * This page needs six glyphs; a dependency and a barrel import for that would be the tail
+ * wagging the dog. Sized and stroked to match Solar Linear (24px box, 1.5 stroke) so they sit
+ * correctly beside the app's own screenshots.
+ */
+type IconProps = SVGProps & { size?: number };
+
+function Icon({ size = 20, children, ...props }: IconProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function DownloadIcon(props: IconProps) {
+ return (
+
+
+
+
+
+ );
+}
+
+/*
+ * Bare direction arrows, for the two hero actions.
+ *
+ * `DownloadIcon` has a tray under it, which promises a file. Both hero buttons only move you —
+ * one down the page to the tiles, one out to the repository — so both get an arrow and nothing
+ * else, and the direction is the whole message.
+ */
+export function ArrowDownIcon(props: IconProps) {
+ return (
+
+
+
+
+ );
+}
+
+export function ArrowRightIcon(props: IconProps) {
+ return (
+
+
+
+
+ );
+}
+
+/* Transport glyphs are solid — a hairline triangle reads as an arrow, not a play button. */
+export function PlayIcon(props: IconProps) {
+ return (
+
+
+
+ );
+}
+
+export function PauseIcon(props: IconProps) {
+ return (
+
+
+
+
+ );
+}
+
+/** One glyph, two states: the waves are dropped when muted rather than drawing a second icon. */
+export function SpeakerIcon({ muted, ...props }: IconProps & { muted?: boolean }) {
+ return (
+
+
+ {muted ? (
+ <>
+
+
+ >
+ ) : (
+ <>
+
+
+ >
+ )}
+
+ );
+}
+
+/** One glyph mirrored by the caller — a skip is the same shape pointed the other way. */
+export function SkipIcon({ back, ...props }: IconProps & { back?: boolean }) {
+ return (
+
+
+
+
+
+
+ );
+}
+
+export function CheckIcon(props: IconProps) {
+ return (
+
+
+
+ );
+}
+
+export function ShieldIcon(props: IconProps) {
+ return (
+
+
+
+
+ );
+}
diff --git a/landing/src/components/ui.tsx b/landing/src/components/ui.tsx
new file mode 100644
index 0000000..84b7a00
--- /dev/null
+++ b/landing/src/components/ui.tsx
@@ -0,0 +1,148 @@
+import type { AnchorHTMLAttributes, ReactNode } from "react";
+
+/**
+ * Class merger.
+ *
+ * The app uses clsx + tailwind-merge, which exist there because components take a `className`
+ * override and have to resolve conflicts. Nothing on this page does, so a join is the whole
+ * requirement — two dependencies for a filter would not earn their place.
+ */
+export function cn(...classes: Array): string {
+ return classes.filter(Boolean).join(" ");
+}
+
+/**
+ * Geometry, type and behaviour shared by every weight — only colour differs, so only colour
+ * lives in the variants.
+ *
+ * Nothing glows and nothing floats. A tinted shadow spilling out from under a saturated pill,
+ * a `brightness` filter on hover, an inset top highlight faking a lit edge — those are the
+ * default settings of a button that nobody decided anything about. The press is the whole
+ * interaction: colour shifts on hover, and the pill moves a single pixel *down* when it is
+ * actually clicked, because that is the direction a pressed thing goes.
+ *
+ * `group` is in the base because the buttons carry icons that react to the button's own hover,
+ * and a variant that forgot it would silently kill the nudge.
+ */
+const BASE =
+ "group inline-flex shrink-0 select-none items-center justify-center gap-2 rounded-full font-semibold tracking-[-0.01em] " +
+ "transition-[background-color,border-color,color,transform] duration-150 ease-out " +
+ "active:translate-y-px " +
+ /* Offset colour set once here: Tailwind defaults it to white, which on this page draws a white
+ gap around every focused button. `background` is right for the hero and the sections both. */
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-background";
+
+/**
+ * Size is a prop, not a `className` override.
+ *
+ * `cn` is a plain join, so a caller passing `px-7` did not replace the variant's `px-5` — both
+ * landed on the element and Tailwind's stylesheet order picked the winner. That resolves the way
+ * you want today only because `px-7` happens to be emitted after `px-5`. Naming the three sizes
+ * that actually exist means padding is set once, by whoever knows which one they want.
+ */
+const SIZES = {
+ sm: "px-4 py-2 text-sm",
+ md: "px-5 py-2.5 text-base",
+ /* Wide rather than tall: the label is the shape, so the horizontal padding carries the weight
+ and the type stays at reading size instead of growing with the button. */
+ lg: "px-8 py-4 text-base",
+} as const;
+
+export type ButtonSize = keyof typeof SIZES;
+
+/*
+ * The primary action, matching the app's primary button — same accent fill, same hover step.
+ *
+ * The download buttons are the one place the page and the product are the same object, so they
+ * wear the product's colour rather than the page's neutral.
+ */
+const SOLID = "bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-primary";
+
+/*
+ * The secondary weight, for actions sitting on top of the video.
+ *
+ * Nothing opaque works there — a card fill punches a hole in the footage — and nothing fully
+ * transparent works either, because the label would compete with whatever frame happens to be
+ * underneath. A hairline over blur is the least that stays legible over moving colour.
+ */
+const OUTLINE =
+ "border border-white/15 bg-white/[0.06] text-white backdrop-blur-xl " +
+ "hover:border-white/25 hover:bg-white/[0.12] focus-visible:ring-white";
+
+/* Same weight, on an opaque surface — the blurred variant needs something behind it to blur. */
+const MUTED =
+ "border border-border text-foreground hover:border-white/20 hover:bg-white/[0.05] " +
+ "focus-visible:ring-ring";
+
+/*
+ * Borders, not rings, on the two bordered weights.
+ *
+ * `ring-1 ring-border` plus `focus-visible:ring-2 focus-visible:ring-ring` is two rules fighting
+ * over one property, settled by whichever variant Tailwind emitted last. Using `border` for the
+ * resting outline leaves the ring to mean exactly one thing: focus.
+ */
+const VARIANTS = { solid: SOLID, outline: OUTLINE, muted: MUTED } as const;
+export type ButtonVariant = keyof typeof VARIANTS;
+
+export function LinkButton({
+ variant = "solid",
+ size = "md",
+ className,
+ ...props
+}: AnchorHTMLAttributes & {
+ variant?: ButtonVariant;
+ size?: ButtonSize;
+}) {
+ return ;
+}
+
+/** Small monospace label. Used for versions, sizes, counts — anything machine-ish. */
+export function Mono({ className, children }: { className?: string; children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+/**
+ * Section shell.
+ *
+ * Everything is on one measure and left-aligned. Centred columns read as a template; a single
+ * consistent left edge running the length of the page is what makes it read as designed.
+ */
+export function Section({
+ id,
+ index,
+ title,
+ lede,
+ children,
+}: {
+ id?: string;
+ /** Two-digit marker in the margin — the page reads as a numbered document. */
+ index?: string;
+ title?: string;
+ lede?: ReactNode;
+ children?: ReactNode;
+}) {
+ return (
+
+
+
+
{index}
+
+
+ {title}
+
+ {lede ? (
+
+ {lede}
+
+ ) : null}
+ {children ?
{children}
: null}
+
+
+
+
+ );
+}
diff --git a/landing/src/main.tsx b/landing/src/main.tsx
new file mode 100644
index 0000000..60735e4
--- /dev/null
+++ b/landing/src/main.tsx
@@ -0,0 +1,19 @@
+import React from "react";
+import ReactDOM from "react-dom/client";
+import { App } from "./App";
+import "./styles.css";
+
+/*
+ * Dark, always.
+ *
+ * The site ships one theme, so this is set once here rather than read from a preference. The
+ * token layer still keys off `data-theme`, which keeps the light values one attribute away if
+ * that ever changes.
+ */
+document.documentElement.dataset.theme = "dark";
+
+ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
+
+
+ ,
+);
diff --git a/landing/src/releases.ts b/landing/src/releases.ts
new file mode 100644
index 0000000..fb6c044
--- /dev/null
+++ b/landing/src/releases.ts
@@ -0,0 +1,148 @@
+export const GITHUB_REPO = "https://github.com/noFAYZ/zuno";
+export const RELEASES_URL = `${GITHUB_REPO}/releases/latest`;
+const LATEST_RELEASE_API = "https://api.github.com/repos/noFAYZ/zuno/releases/latest";
+
+export type PlatformId = "windows" | "macos-arm" | "macos-intel" | "linux";
+
+export interface PlatformBuild {
+ id: PlatformId;
+ label: string;
+ /** Shown under the label — "Apple Silicon", ".deb · AppImage". */
+ hint: string;
+ /** Matched against asset filenames, first match wins. */
+ patterns: RegExp[];
+}
+
+/**
+ * One entry per download button.
+ *
+ * Order matters: the first pattern that matches an asset name is the one linked, so the
+ * preferred installer for each platform is listed first — NSIS ahead of MSI on Windows because
+ * it is the one the updater ships, `.deb` ahead of AppImage on Linux because it integrates.
+ */
+export const PLATFORM_BUILDS: readonly PlatformBuild[] = [
+ {
+ id: "windows",
+ label: "Windows",
+ hint: "Installer · 10 and 11",
+ patterns: [/-setup\.exe$/i, /\.msi$/i],
+ },
+ {
+ id: "macos-arm",
+ label: "macOS",
+ hint: "Apple Silicon",
+ patterns: [/aarch64\.dmg$/i, /arm64\.dmg$/i],
+ },
+ {
+ id: "macos-intel",
+ label: "macOS",
+ hint: "Intel",
+ patterns: [/x64\.dmg$/i, /x86_64\.dmg$/i],
+ },
+ {
+ id: "linux",
+ label: "Linux",
+ hint: "AppImage · .deb · .rpm",
+ patterns: [/\.AppImage$/i, /\.deb$/i, /\.rpm$/i],
+ },
+];
+
+export interface ReleaseAsset {
+ name: string;
+ browser_download_url: string;
+ size: number;
+}
+
+export interface LatestRelease {
+ version: string;
+ publishedAt: string | null;
+ /** Direct download URL per platform. Absent when the release has no matching asset. */
+ downloads: Partial>;
+}
+
+function pickAsset(assets: ReleaseAsset[], patterns: RegExp[]) {
+ for (const pattern of patterns) {
+ const match = assets.find((asset) => pattern.test(asset.name));
+ if (match) {
+ return { url: match.browser_download_url, name: match.name, size: match.size };
+ }
+ }
+ return undefined;
+}
+
+/**
+ * Reads the newest release straight from GitHub.
+ *
+ * Asset names carry the version (`Zuno_1.1.1_x64-setup.exe`), so there is no stable per-file
+ * URL to hardcode — the alternative to this request is sending everyone to the releases page
+ * to work out which file they need. Every button falls back to exactly that page if this
+ * fails, so a rate limit or an offline visitor still gets a working download.
+ */
+export async function fetchLatestRelease(signal?: AbortSignal): Promise {
+ const response = await fetch(LATEST_RELEASE_API, {
+ headers: { Accept: "application/vnd.github+json" },
+ signal,
+ });
+ if (!response.ok) {
+ throw new Error(`GitHub returned HTTP ${response.status}`);
+ }
+
+ const payload = (await response.json()) as {
+ tag_name?: string;
+ name?: string;
+ published_at?: string;
+ assets?: ReleaseAsset[];
+ };
+
+ const assets = payload.assets ?? [];
+ const downloads: LatestRelease["downloads"] = {};
+ for (const build of PLATFORM_BUILDS) {
+ const asset = pickAsset(assets, build.patterns);
+ if (asset) downloads[build.id] = asset;
+ }
+
+ return {
+ version: (payload.tag_name ?? payload.name ?? "").replace(/^v/, ""),
+ publishedAt: payload.published_at ?? null,
+ downloads,
+ };
+}
+
+/**
+ * Best guess at the visitor's platform, used only to emphasise one button.
+ *
+ * Never used to hide the others: detection is unreliable, and someone downloading for a
+ * different machine is a completely ordinary thing to do.
+ */
+export function detectPlatform(): PlatformId | null {
+ if (typeof navigator === "undefined") return null;
+
+ const platform = `${navigator.platform ?? ""} ${navigator.userAgent ?? ""}`.toLowerCase();
+ if (platform.includes("win")) return "windows";
+ if (platform.includes("linux") && !platform.includes("android")) return "linux";
+ if (platform.includes("mac")) {
+ /*
+ * Apple Silicon is not in the user agent — Safari reports Intel on every Mac. This probes
+ * for a WebGL renderer string that only the Apple GPU exposes, and falls back to Apple
+ * Silicon because it is the overwhelmingly more common Mac to be reading this on now.
+ */
+ try {
+ const canvas = document.createElement("canvas");
+ const gl = canvas.getContext("webgl");
+ const info = gl?.getExtension("WEBGL_debug_renderer_info");
+ const renderer = info
+ ? String(gl?.getParameter(info.UNMASKED_RENDERER_WEBGL) ?? "")
+ : "";
+ if (renderer.toLowerCase().includes("intel")) return "macos-intel";
+ } catch {
+ // Blocked WebGL is not worth failing detection over.
+ }
+ return "macos-arm";
+ }
+ return null;
+}
+
+export function formatSize(bytes: number): string {
+ const megabytes = bytes / 1024 / 1024;
+ return `${megabytes.toFixed(1)} MB`;
+}
diff --git a/landing/src/styles.css b/landing/src/styles.css
new file mode 100644
index 0000000..f9f6865
--- /dev/null
+++ b/landing/src/styles.css
@@ -0,0 +1,369 @@
+@import "tailwindcss";
+
+/*
+ * The app's token layer, copied rather than imported.
+ *
+ * `src/ui/styles/global.css` is full of things a website has no use for — window rounding,
+ * titlebar heights, Paper-PC overrides, scrollbar hiding — and importing across project
+ * boundaries would make the site's build depend on the desktop app's stylesheet staying
+ * website-safe. These are the colour tokens only, byte-identical to the app's, so the two
+ * genuinely match. If the brand shifts, both change together by hand.
+ */
+@theme {
+ --color-background: oklch(0.145 0 0);
+ --color-foreground: oklch(0.985 0 0);
+
+ --color-card: oklch(0.285 0 0);
+ --color-card-foreground: oklch(0.985 0 0);
+ --color-popover: oklch(0.205 0 0);
+ --color-popover-foreground: oklch(0.985 0 0);
+
+ --color-muted: oklch(0.269 0 0);
+ --color-muted-foreground: oklch(0.708 0 0);
+
+ /* Brand accent — the one colour carried over from the old identity. */
+ --color-primary: #ff0033;
+ --color-primary-foreground: oklch(0.985 0 0);
+
+ --color-secondary: oklch(0.269 0 0);
+ --color-secondary-foreground: oklch(0.985 0 0);
+
+ --color-destructive: oklch(0.704 0.191 22.216);
+ --color-destructive-foreground: oklch(0.985 0 0);
+
+ --color-border: oklch(1 0 0 / 10%);
+ --color-input: oklch(1 0 0 / 15%);
+ --color-ring: #ff0033;
+
+ --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono",
+ monospace;
+}
+
+html[data-theme="light"] {
+ --color-background: oklch(0.954 0 0);
+ --color-foreground: oklch(0.205 0 0);
+
+ --color-card: oklch(1 0 0);
+ --color-card-foreground: oklch(0.205 0 0);
+ --color-popover: oklch(1 0 0);
+ --color-popover-foreground: oklch(0.205 0 0);
+
+ --color-muted: oklch(0.915 0 0);
+ --color-muted-foreground: oklch(0.45 0 0);
+
+ --color-secondary: oklch(0.915 0 0);
+ --color-secondary-foreground: oklch(0.205 0 0);
+
+ --color-border: oklch(0 0 0 / 10%);
+ --color-input: oklch(0 0 0 / 14%);
+
+ color-scheme: light;
+}
+
+html[data-theme="dark"] {
+ color-scheme: dark;
+}
+
+html,
+body,
+#root {
+ min-height: 100%;
+ background: var(--color-background);
+ color: var(--color-foreground);
+ font-family: "Inter Variable", ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto,
+ Arial, "Apple Color Emoji", "Segoe UI Emoji";
+ /* Same numeral treatment as the app, so version strings and sizes do not jitter. */
+ font-feature-settings: "cv11", "tnum";
+}
+
+/* The accent earns its keep in the small places too. */
+::selection {
+ background: color-mix(in oklch, var(--color-primary) 28%, transparent);
+ color: var(--color-foreground);
+}
+
+/* Anchors land below the sticky header rather than under it. */
+html {
+ scroll-behavior: smooth;
+ scroll-padding-top: 6rem;
+}
+
+/*
+ * Sections reveal as they arrive.
+ *
+ * `animation-timeline: view()` does this with no observer, no state and no JS — the browser
+ * drives it off scroll position. Unsupported engines simply see the finished state, because
+ * the rule only applies where the timeline resolves.
+ */
+@supports (animation-timeline: view()) {
+ @media (prefers-reduced-motion: no-preference) {
+ .reveal {
+ animation: reveal-in linear both;
+ animation-timeline: view();
+ animation-range: entry 0% cover 22%;
+ }
+ }
+}
+
+/*
+ * Film grain over the hero.
+ *
+ * A single tiling `feTurbulence` as a data URI — no image request, no canvas, no library. It is
+ * what stops a large dark surface from banding, and what stops the whole thing from reading as
+ * a stock gradient. Kept faint enough to be felt rather than seen.
+ */
+.grain::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ opacity: 0.055;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)'/%3E%3C/svg%3E");
+}
+
+/*
+ * The hero's entrance cascade.
+ *
+ * Shares `reveal-in` with the scroll-revealed sections, so the page has one arrival motion rather
+ * than two. Time-based here rather than scroll-driven, because the hero is already on screen when
+ * the page loads and has no entry to animate.
+ */
+.hero-in {
+ animation: reveal-in 0.8s cubic-bezier(0.16, 1, 0.3, 1) both;
+}
+
+/*
+ * The moon, as an arc across the top.
+ *
+ * A circle far wider than the viewport, pushed up until only the bottom of its limb is in frame.
+ * Nothing sits behind the type — the light arrives from above it and falls away, which is the
+ * whole difference between a light source and a blurred blob parked behind a headline.
+ *
+ * The border is what draws the arc: on a `border-radius: 50%` box it follows the curve for free,
+ * and because the top of the circle is out of frame only the bottom of the ring ever shows. The
+ * outer shadow is the spill below it. The mask then fades both towards the ends, so the arc has
+ * no visible termination — it simply stops being bright.
+ */
+.moon-arc {
+ border: 1px solid oklch(1 0 0 / 0.14);
+ box-shadow:
+ 0 0 90px 6px color-mix(in oklch, var(--color-primary) 20%, transparent),
+ 0 0 36px 0 oklch(1 0 0 / 0.05);
+ /* Prefixed as well as standard: Safari shipped `-webkit-mask-image`, and a dropped mask here
+ would leave a hard-edged 1px circle drawn across the top of the page. */
+ -webkit-mask-image: radial-gradient(38% 34% at 50% 100%, #5e0606 0%, transparent 74%);
+ mask-image: radial-gradient(38% 34% at 50% 100%, #000 0%, transparent 74%);
+}
+
+/*
+ * Decorations that get out of the way once you start reading.
+ *
+ * `scroll(root block)` is the document's own scroll position rather than the element's view
+ * progress — which is what "fade as the page moves" actually means, and it does not care where
+ * in the hero the element happens to sit. Lengths rather than percentages in the range, because
+ * the interesting distance is the first screen of scrolling, not a fraction of the page.
+ */
+@supports (animation-timeline: scroll()) {
+ @media (prefers-reduced-motion: no-preference) {
+ .scroll-fade {
+ animation: fade-away linear both;
+ animation-timeline: scroll(root block);
+ animation-range: 0 55vh;
+ }
+ }
+}
+
+@keyframes fade-away {
+ from {
+ opacity: 1;
+ }
+ to {
+ opacity: 0;
+ }
+}
+
+/*
+ * The floating badges.
+ *
+ * Rotation is carried in `--tilt` rather than in a class, because a `transform` inside keyframes
+ * replaces the whole property — a `rotate-3` utility beside this would simply be overwritten the
+ * moment the animation started. Each badge sets its own tilt, duration and a negative delay, so
+ * they start out of phase instead of bobbing in unison.
+ */
+.float {
+ animation: float-y 7s ease-in-out infinite alternate;
+}
+
+@keyframes float-y {
+ from {
+ transform: translateY(-0.4rem) rotate(var(--tilt, 0deg));
+ }
+ to {
+ transform: translateY(0.4rem) rotate(var(--tilt, 0deg));
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .float {
+ animation: none;
+ transform: rotate(var(--tilt, 0deg));
+ }
+}
+
+/*
+ * The film stage.
+ *
+ * Taller than the viewport, with a `position: sticky` child that pins for a screen's worth of
+ * scrolling. Inside it the frame grows from a rounded card to a full-bleed one — so the demo
+ * takes the whole screen without the page ever handing it a separate route or a lightbox.
+ *
+ * `animation-timeline: view()` drives the growth off the stage's own position, which means no
+ * scroll listener, no rAF loop and no state — the same trick the sections already use to reveal.
+ * Where it is unsupported the frame renders at its finished size and simply sits there, which is
+ * also exactly what the narrow layout does on purpose.
+ */
+.film-stage {
+ position: relative;
+ height: 185svh;
+ /*
+ * The timeline is named on the stage and consumed by the frame inside it, rather than the frame
+ * reading its own `view()`. A sticky element stops moving relative to the scrollport the moment
+ * it pins, so its own view progress freezes exactly when the growth is supposed to happen. The
+ * stage never pins, so its progress is the honest one.
+ */
+ view-timeline-name: --film;
+}
+
+.film-sticky {
+ position: sticky;
+ top: 0;
+ display: flex;
+ height: 100svh;
+ align-items: center;
+ justify-content: center;
+}
+
+/*
+ * Cut to the footage, not the other way round.
+ *
+ * The capture is 4:3, so the frame is too — a 16:9 stage would crop the app's own titlebar and
+ * player bar off the top and bottom, which are the parts that make it read as a desktop window.
+ * At full size that makes the height the limiting dimension, hence the width ceiling.
+ */
+.film-frame {
+ position: relative;
+ width: min(70%, calc(100svh * 4 / 3));
+ aspect-ratio: 4 / 3;
+ overflow: hidden;
+ border-radius: 20px;
+ background: #000;
+ box-shadow: 0 8px 40px rgb(0 0 0 / 0.45);
+}
+
+@supports (animation-timeline: view()) {
+ @media (prefers-reduced-motion: no-preference) {
+ .film-frame {
+ animation: film-grow linear both;
+ animation-timeline: --film;
+ /*
+ * `contain` is the phase where the stage completely covers the viewport — which, because the
+ * stage is taller than the viewport, is precisely the span during which the frame is pinned.
+ * So the frame stays small while it slides up into place and grows only once it is holding
+ * still, over the first half of the pin.
+ */
+ animation-range: contain 0% contain 45%;
+ }
+ }
+}
+
+@keyframes film-grow {
+ from {
+ transform: scale(0.68);
+ border-radius: 24px;
+ }
+ to {
+ transform: scale(1);
+ border-radius: 0;
+ }
+}
+
+/*
+ * The wordmark's cursor.
+ *
+ * `zuno_` ends in an underscore, so it is already a prompt — this just admits it. The one
+ * piece of motion in the hero, and it belongs to the logo rather than being applied to it.
+ * Steady rather than absent under reduced motion, since the block is part of the mark.
+ */
+.caret {
+ animation: caret-blink 1.2s steps(1) infinite;
+}
+
+@keyframes caret-blink {
+ 0%,
+ 50% {
+ opacity: 1;
+ }
+ 50.01%,
+ 100% {
+ opacity: 0;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .caret {
+ animation: none;
+ opacity: 1;
+ }
+}
+
+/*
+ * The playing indicator, shared by the tab demo and the bento cells.
+ *
+ * Three bars on staggered delays. Paused rather than removed under reduced motion, so the mark
+ * still reads as "this is the one playing" without the movement.
+ */
+.equaliser-bar {
+ height: 100%;
+ animation: equalise 1.1s ease-in-out infinite alternate;
+ transform-origin: bottom;
+}
+
+@keyframes equalise {
+ from {
+ transform: scaleY(0.35);
+ }
+ to {
+ transform: scaleY(1);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .equaliser-bar {
+ animation: none;
+ transform: scaleY(0.7);
+ }
+}
+
+@keyframes reveal-in {
+ from {
+ opacity: 0;
+ transform: translateY(1.25rem);
+ }
+ to {
+ opacity: 1;
+ transform: none;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ html {
+ scroll-behavior: auto;
+ }
+
+ *,
+ *::before,
+ *::after {
+ animation-duration: 0.001ms !important;
+ transition-duration: 0.001ms !important;
+ }
+}
diff --git a/landing/src/useLatestRelease.ts b/landing/src/useLatestRelease.ts
new file mode 100644
index 0000000..d210464
--- /dev/null
+++ b/landing/src/useLatestRelease.ts
@@ -0,0 +1,31 @@
+import { useEffect, useState } from "react";
+import { detectPlatform, fetchLatestRelease, type LatestRelease, type PlatformId } from "./releases";
+
+/**
+ * Everything the release page knows, in one hook.
+ *
+ * Shared by the hero's button and the download tiles, so the two can never disagree about which
+ * version they are offering. Lives beside `releases.ts` rather than under `components/`, because
+ * it renders nothing — it was previously called `Download.tsx`, one letter from `Downloads.tsx`,
+ * which is exactly the kind of pair someone edits the wrong half of.
+ */
+export function useLatestRelease() {
+ const [release, setRelease] = useState(null);
+ const [failed, setFailed] = useState(false);
+ const [platform, setPlatform] = useState(null);
+
+ useEffect(() => {
+ setPlatform(detectPlatform());
+
+ const controller = new AbortController();
+ fetchLatestRelease(controller.signal)
+ .then(setRelease)
+ .catch((error: unknown) => {
+ if (error instanceof DOMException && error.name === "AbortError") return;
+ setFailed(true);
+ });
+ return () => controller.abort();
+ }, []);
+
+ return { release, failed, platform };
+}
diff --git a/landing/src/useReducedMotion.ts b/landing/src/useReducedMotion.ts
new file mode 100644
index 0000000..f18408d
--- /dev/null
+++ b/landing/src/useReducedMotion.ts
@@ -0,0 +1,29 @@
+import { useEffect, useState } from "react";
+
+/**
+ * The OS "reduce motion" preference, live.
+ *
+ * CSS can shorten an animation to nothing, which is what styles.css already does, but it
+ * cannot stop a `` — that keeps playing regardless, and a looping video
+ * is exactly the kind of continuous motion the preference exists to suppress. This is the only
+ * way to honour it for the demo footage.
+ *
+ * Subscribed rather than read once: the preference can be toggled while the page is open, and
+ * a value captured at mount would keep playing for someone who just asked it to stop.
+ */
+export function useReducedMotion(): boolean {
+ const [reduced, setReduced] = useState(() =>
+ typeof window !== "undefined"
+ ? window.matchMedia("(prefers-reduced-motion: reduce)").matches
+ : false,
+ );
+
+ useEffect(() => {
+ const query = window.matchMedia("(prefers-reduced-motion: reduce)");
+ const onChange = () => setReduced(query.matches);
+ query.addEventListener("change", onChange);
+ return () => query.removeEventListener("change", onChange);
+ }, []);
+
+ return reduced;
+}
diff --git a/landing/tsconfig.json b/landing/tsconfig.json
new file mode 100644
index 0000000..dbddb13
--- /dev/null
+++ b/landing/tsconfig.json
@@ -0,0 +1,31 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "lib": [
+ "ES2020",
+ "DOM",
+ "DOM.Iterable"
+ ],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "baseUrl": ".",
+ "paths": {
+ "@/*": [
+ "src/*"
+ ]
+ }
+ },
+ "include": [
+ "src"
+ ]
+}
diff --git a/landing/vite.config.ts b/landing/vite.config.ts
new file mode 100644
index 0000000..cf76c6a
--- /dev/null
+++ b/landing/vite.config.ts
@@ -0,0 +1,26 @@
+import { fileURLToPath } from "node:url";
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+import tailwindcss from "@tailwindcss/vite";
+
+/**
+ * The marketing site, deliberately its own Vite project.
+ *
+ * It lives beside the app rather than inside it: the app's build has fixed entry points
+ * (index.html, mini.html) and a Tauri-tuned dev server on a strict port, and adding a third
+ * entry would couple a public website's release cadence to the desktop app's. Nothing here is
+ * reachable from the app's tsconfig (`include: ["src"]`) or its Rollup inputs.
+ *
+ * `base` is relative so the built site works from a subpath — GitHub Pages serves project
+ * sites from //, and absolute asset paths 404 there.
+ */
+export default defineConfig({
+ base: "./",
+ resolve: {
+ alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
+ },
+ plugins: [react(), tailwindcss()],
+ build: {
+ outDir: "dist",
+ },
+});
diff --git a/manifests/n/noFAYZ/Zuno/1.1.2/noFAYZ.Zuno.installer.yaml b/manifests/n/noFAYZ/Zuno/1.1.2/noFAYZ.Zuno.installer.yaml
new file mode 100644
index 0000000..64e719b
--- /dev/null
+++ b/manifests/n/noFAYZ/Zuno/1.1.2/noFAYZ.Zuno.installer.yaml
@@ -0,0 +1,12 @@
+# Created using wingetcreate 1.12.8.0
+# yaml-language-server: $schema=https://aka.ms/winget-manifest.installer.1.12.0.schema.json
+
+PackageIdentifier: noFAYZ.Zuno
+PackageVersion: 1.1.2
+InstallerType: nullsoft
+Installers:
+- Architecture: x64
+ InstallerUrl: https://github.com/noFAYZ/zuno/releases/download/v1.1.2/Zuno_1.1.2_x64-setup.exe
+ InstallerSha256: 62424AE146E90E1174BDF2BD78C4EFACE9AC4C8C3297DE70FFCC3F1BD8D3BD92
+ManifestType: installer
+ManifestVersion: 1.12.0
diff --git a/manifests/n/noFAYZ/Zuno/1.1.2/noFAYZ.Zuno.locale.en-US.yaml b/manifests/n/noFAYZ/Zuno/1.1.2/noFAYZ.Zuno.locale.en-US.yaml
new file mode 100644
index 0000000..dda0da0
--- /dev/null
+++ b/manifests/n/noFAYZ/Zuno/1.1.2/noFAYZ.Zuno.locale.en-US.yaml
@@ -0,0 +1,12 @@
+# Created using wingetcreate 1.12.8.0
+# yaml-language-server: $schema=https://aka.ms/winget-manifest.defaultLocale.1.12.0.schema.json
+
+PackageIdentifier: noFAYZ.Zuno
+PackageVersion: 1.1.2
+PackageLocale: en-US
+Publisher: noFFAYZ
+PackageName: Zuno
+License: Apache
+ShortDescription: A Youtube Music Desktop client
+ManifestType: defaultLocale
+ManifestVersion: 1.12.0
diff --git a/manifests/n/noFAYZ/Zuno/1.1.2/noFAYZ.Zuno.yaml b/manifests/n/noFAYZ/Zuno/1.1.2/noFAYZ.Zuno.yaml
new file mode 100644
index 0000000..5f2e482
--- /dev/null
+++ b/manifests/n/noFAYZ/Zuno/1.1.2/noFAYZ.Zuno.yaml
@@ -0,0 +1,8 @@
+# Created using wingetcreate 1.12.8.0
+# yaml-language-server: $schema=https://aka.ms/winget-manifest.version.1.12.0.schema.json
+
+PackageIdentifier: noFAYZ.Zuno
+PackageVersion: 1.1.2
+DefaultLocale: en-US
+ManifestType: version
+ManifestVersion: 1.12.0
diff --git a/mini.html b/mini.html
index fa4e983..4d50344 100644
--- a/mini.html
+++ b/mini.html
@@ -1,21 +1,21 @@
-
-
-
-
-
- Mini Player
-
-
-
-
-
-
+
+
+
+
+
+ Zuno Mini Player
+
+
+
+
+
+
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 1dce510..3ee8e55 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,31 +1,37 @@
{
- "name": "just-another-music-client",
- "version": "1.2.81",
+ "name": "zuno",
+ "version": "1.2.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "just-another-music-client",
- "version": "1.2.81",
+ "name": "zuno",
+ "version": "1.2.1",
"dependencies": {
- "@tabler/icons-react": "^3.44.0",
+ "@fontsource-variable/inter": "^5.3.0",
+ "@solar-icons/react": "^2.0.0-beta.0",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-autostart": "^2.5.1",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
- "discord-rpc": "^4.0.1",
+ "bgutils-js": "^4.0.2",
+ "clsx": "^2.1.1",
+ "googlevideo": "^4.1.1",
+ "motion": "^12.42.2",
"react": "^19.1.0",
"react-dom": "^19.1.0",
+ "tailwind-merge": "^3.6.0",
"youtubei.js": "^17.0.1"
},
"devDependencies": {
+ "@tailwindcss/vite": "^4.3.3",
"@tauri-apps/cli": "^2",
- "@types/discord-rpc": "^4.0.10",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
+ "tailwindcss": "^4.3.3",
"typescript": "~5.8.3",
"vite": "^7.0.4"
}
@@ -319,9 +325,9 @@
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
"node_modules/@esbuild/aix-ppc64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
- "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
+ "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
@@ -336,9 +342,9 @@
}
},
"node_modules/@esbuild/android-arm": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
- "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
+ "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
@@ -353,9 +359,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
- "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
+ "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
@@ -370,9 +376,9 @@
}
},
"node_modules/@esbuild/android-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
- "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
+ "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
@@ -387,9 +393,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
- "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
+ "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
@@ -404,9 +410,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
- "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
+ "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
@@ -421,9 +427,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
- "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
@@ -438,9 +444,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
- "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
@@ -455,9 +461,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
- "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
+ "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
@@ -472,9 +478,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
- "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
+ "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
@@ -489,9 +495,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
- "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
+ "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
@@ -506,9 +512,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
- "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
+ "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
@@ -523,9 +529,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
- "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
+ "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
@@ -540,9 +546,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
- "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
+ "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
@@ -557,9 +563,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
- "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
+ "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
@@ -574,9 +580,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
- "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
+ "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
@@ -591,9 +597,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
- "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
+ "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
@@ -608,9 +614,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
- "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
@@ -625,9 +631,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
- "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
@@ -642,9 +648,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
- "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
@@ -659,9 +665,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
- "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
@@ -676,9 +682,9 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
- "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
+ "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
@@ -693,9 +699,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
- "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
+ "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
@@ -710,9 +716,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
- "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
+ "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
@@ -727,9 +733,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
- "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
+ "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
@@ -744,9 +750,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
- "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
+ "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
@@ -760,6 +766,15 @@
"node": ">=18"
}
},
+ "node_modules/@fontsource-variable/inter": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz",
+ "integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==",
+ "license": "OFL-1.1",
+ "funding": {
+ "url": "https://github.com/sponsors/ayuhito"
+ }
+ },
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1167,30 +1182,367 @@
"win32"
]
},
- "node_modules/@tabler/icons": {
- "version": "3.44.0",
- "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.44.0.tgz",
- "integrity": "sha512-Wn0AOZG9sg0L+bjfMqq4eNhC6pQjIrk94LvvWYNYkY8KH8wC3YILRzQlrnVJc4FUeMxH/AK97QsYCX35H3LndA==",
+ "node_modules/@solar-icons/react": {
+ "version": "2.0.0-beta.0",
+ "resolved": "https://registry.npmjs.org/@solar-icons/react/-/react-2.0.0-beta.0.tgz",
+ "integrity": "sha512-fJ9thdy4g2WRNu/SgsE7vVq1nVj9EnyzGgdI1F+WyM/gDGpdR+6bBWEmDCoK6aZ+wIZPDL5ibCIgFvptBv/6uQ==",
"license": "MIT",
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/codecalm"
+ "engines": {
+ "node": ">=16"
+ },
+ "peerDependencies": {
+ "react": ">= 16.8",
+ "react-dom": ">= 16.8"
+ }
+ },
+ "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,
+ "libc": [
+ "glibc"
+ ],
+ "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,
+ "libc": [
+ "musl"
+ ],
+ "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,
+ "libc": [
+ "glibc"
+ ],
+ "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,
+ "libc": [
+ "musl"
+ ],
+ "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-wasm32-wasi/node_modules/@emnapi/core": {
+ "version": "1.11.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.2",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
+ "version": "1.11.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.2",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
}
},
- "node_modules/@tabler/icons-react": {
- "version": "3.44.0",
- "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.44.0.tgz",
- "integrity": "sha512-8+rvzBbVm/1Z3sG3x7GUNAaxIKxwgz8xaMhRs23nrCnMTKRFAhEC+82zAIFeAA0seXdrAGX5HFCkaLpGK2rVHg==",
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.4",
+ "dev": true,
+ "inBundle": true,
"license": "MIT",
+ "optional": true,
"dependencies": {
- "@tabler/icons": "3.44.0"
+ "@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
- "url": "https://github.com/sponsors/codecalm"
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
+ "version": "0.10.2",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
+ "version": "2.8.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "0BSD",
+ "optional": true
+ },
+ "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": {
- "react": ">= 16"
+ "vite": "^5.2.0 || ^6 || ^7 || ^8"
}
},
"node_modules/@tauri-apps/api": {
@@ -1510,16 +1862,6 @@
"@babel/types": "^7.28.2"
}
},
- "node_modules/@types/discord-rpc": {
- "version": "4.0.11",
- "resolved": "https://registry.npmjs.org/@types/discord-rpc/-/discord-rpc-4.0.11.tgz",
- "integrity": "sha512-w2WgzgtyDNHMbmIeogN4f1uy1Mz2Woe+27XcvyjPRcCI9QdWyJifHHIJopwXm8f04fHia/vkhId/eyqxmbLoHw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/events": "*"
- }
- },
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -1527,13 +1869,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/@types/events": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.3.tgz",
- "integrity": "sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/@types/react": {
"version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
@@ -1588,15 +1923,14 @@
"node": ">=6.0.0"
}
},
- "node_modules/bindings": {
- "version": "1.5.0",
- "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
- "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "file-uri-to-path": "1.0.0"
- }
+ "node_modules/bgutils-js": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/bgutils-js/-/bgutils-js-4.0.2.tgz",
+ "integrity": "sha512-PyZRxSkwC2MyVtIidD0DejgeNQfb9p3N/wY/d9sa3zAFeMTg6nDMv99wPYoUMU8JhzgG22ZVTnCzAcbfzKPVfQ==",
+ "funding": [
+ "https://github.com/sponsors/LuanRT"
+ ],
+ "license": "MIT"
},
"node_modules/browserslist": {
"version": "4.28.2",
@@ -1653,6 +1987,15 @@
],
"license": "CC-BY-4.0"
},
+ "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/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1685,17 +2028,14 @@
}
}
},
- "node_modules/discord-rpc": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/discord-rpc/-/discord-rpc-4.0.1.tgz",
- "integrity": "sha512-HOvHpbq5STRZJjQIBzwoKnQ0jHplbEWFWlPDwXXKm/bILh4nzjcg7mNqll0UY7RsjFoaXA7e/oYb/4lvpda2zA==",
- "license": "MIT",
- "dependencies": {
- "node-fetch": "^2.6.1",
- "ws": "^7.3.1"
- },
- "optionalDependencies": {
- "register-scheme": "github:devsnek/node-register-scheme"
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
}
},
"node_modules/electron-to-chromium": {
@@ -1705,10 +2045,24 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/enhanced-resolve": {
+ "version": "5.24.3",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.3.tgz",
+ "integrity": "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.3.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
"node_modules/esbuild": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
- "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
+ "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -1719,32 +2073,32 @@
"node": ">=18"
},
"optionalDependencies": {
- "@esbuild/aix-ppc64": "0.27.7",
- "@esbuild/android-arm": "0.27.7",
- "@esbuild/android-arm64": "0.27.7",
- "@esbuild/android-x64": "0.27.7",
- "@esbuild/darwin-arm64": "0.27.7",
- "@esbuild/darwin-x64": "0.27.7",
- "@esbuild/freebsd-arm64": "0.27.7",
- "@esbuild/freebsd-x64": "0.27.7",
- "@esbuild/linux-arm": "0.27.7",
- "@esbuild/linux-arm64": "0.27.7",
- "@esbuild/linux-ia32": "0.27.7",
- "@esbuild/linux-loong64": "0.27.7",
- "@esbuild/linux-mips64el": "0.27.7",
- "@esbuild/linux-ppc64": "0.27.7",
- "@esbuild/linux-riscv64": "0.27.7",
- "@esbuild/linux-s390x": "0.27.7",
- "@esbuild/linux-x64": "0.27.7",
- "@esbuild/netbsd-arm64": "0.27.7",
- "@esbuild/netbsd-x64": "0.27.7",
- "@esbuild/openbsd-arm64": "0.27.7",
- "@esbuild/openbsd-x64": "0.27.7",
- "@esbuild/openharmony-arm64": "0.27.7",
- "@esbuild/sunos-x64": "0.27.7",
- "@esbuild/win32-arm64": "0.27.7",
- "@esbuild/win32-ia32": "0.27.7",
- "@esbuild/win32-x64": "0.27.7"
+ "@esbuild/aix-ppc64": "0.28.1",
+ "@esbuild/android-arm": "0.28.1",
+ "@esbuild/android-arm64": "0.28.1",
+ "@esbuild/android-x64": "0.28.1",
+ "@esbuild/darwin-arm64": "0.28.1",
+ "@esbuild/darwin-x64": "0.28.1",
+ "@esbuild/freebsd-arm64": "0.28.1",
+ "@esbuild/freebsd-x64": "0.28.1",
+ "@esbuild/linux-arm": "0.28.1",
+ "@esbuild/linux-arm64": "0.28.1",
+ "@esbuild/linux-ia32": "0.28.1",
+ "@esbuild/linux-loong64": "0.28.1",
+ "@esbuild/linux-mips64el": "0.28.1",
+ "@esbuild/linux-ppc64": "0.28.1",
+ "@esbuild/linux-riscv64": "0.28.1",
+ "@esbuild/linux-s390x": "0.28.1",
+ "@esbuild/linux-x64": "0.28.1",
+ "@esbuild/netbsd-arm64": "0.28.1",
+ "@esbuild/netbsd-x64": "0.28.1",
+ "@esbuild/openbsd-arm64": "0.28.1",
+ "@esbuild/openbsd-x64": "0.28.1",
+ "@esbuild/openharmony-arm64": "0.28.1",
+ "@esbuild/sunos-x64": "0.28.1",
+ "@esbuild/win32-arm64": "0.28.1",
+ "@esbuild/win32-ia32": "0.28.1",
+ "@esbuild/win32-x64": "0.28.1"
}
},
"node_modules/escalade": {
@@ -1775,12 +2129,32 @@
}
}
},
- "node_modules/file-uri-to-path": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
- "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
+ "node_modules/framer-motion": {
+ "version": "12.42.2",
+ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.42.2.tgz",
+ "integrity": "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==",
"license": "MIT",
- "optional": true
+ "dependencies": {
+ "motion-dom": "^12.42.2",
+ "motion-utils": "^12.39.0",
+ "tslib": "^2.4.0"
+ },
+ "peerDependencies": {
+ "@emotion/is-prop-valid": "*",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@emotion/is-prop-valid": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
},
"node_modules/fsevents": {
"version": "2.3.3",
@@ -1807,11 +2181,40 @@
"node": ">=6.9.0"
}
},
- "node_modules/js-tokens": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
- "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
- "dev": true,
+ "node_modules/googlevideo": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/googlevideo/-/googlevideo-4.1.1.tgz",
+ "integrity": "sha512-14o64O+CXXlQ1stYZK/+ypCEwZb25lzEuzhbFYYEzf26R+HhBDOR+Dkdk7MKkZ3qXeIjvZfeC0AJlVKjwQCFjw==",
+ "funding": [
+ "https://github.com/sponsors/LuanRT"
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@bufbuild/protobuf": "^2.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/jiti": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
+ "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
"license": "MIT"
},
"node_modules/jsesc": {
@@ -1840,6 +2243,279 @@
"node": ">=6"
}
},
+ "node_modules/lightningcss": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "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,
+ "libc": [
+ "musl"
+ ],
+ "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,
+ "libc": [
+ "glibc"
+ ],
+ "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,
+ "libc": [
+ "musl"
+ ],
+ "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/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -1850,6 +2526,16 @@
"yallist": "^3.0.2"
}
},
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
"node_modules/meriyah": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/meriyah/-/meriyah-6.1.4.tgz",
@@ -1859,6 +2545,47 @@
"node": ">=18.0.0"
}
},
+ "node_modules/motion": {
+ "version": "12.42.2",
+ "resolved": "https://registry.npmjs.org/motion/-/motion-12.42.2.tgz",
+ "integrity": "sha512-Atvv11yUKIid41cVrRBDVX5m8tF8kNpExRSlbpt6APClhDjtwQssgFHhQzejxw7/7YYbjHSPKBVbHo05BuJT5Q==",
+ "license": "MIT",
+ "dependencies": {
+ "framer-motion": "^12.42.2",
+ "tslib": "^2.4.0"
+ },
+ "peerDependencies": {
+ "@emotion/is-prop-valid": "*",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@emotion/is-prop-valid": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/motion-dom": {
+ "version": "12.42.2",
+ "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.42.2.tgz",
+ "integrity": "sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-utils": "^12.39.0"
+ }
+ },
+ "node_modules/motion-utils": {
+ "version": "12.39.0",
+ "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz",
+ "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==",
+ "license": "MIT"
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -1867,9 +2594,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
- "version": "3.3.13",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.13.tgz",
- "integrity": "sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==",
+ "version": "3.3.16",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
+ "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"dev": true,
"funding": [
{
@@ -1885,33 +2612,6 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
- "node_modules/node-addon-api": {
- "version": "1.7.2",
- "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz",
- "integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==",
- "license": "MIT",
- "optional": true
- },
- "node_modules/node-fetch": {
- "version": "2.7.0",
- "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
- "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
- "license": "MIT",
- "dependencies": {
- "whatwg-url": "^5.0.0"
- },
- "engines": {
- "node": "4.x || >=6.0.0"
- },
- "peerDependencies": {
- "encoding": "^0.1.0"
- },
- "peerDependenciesMeta": {
- "encoding": {
- "optional": true
- }
- }
- },
"node_modules/node-releases": {
"version": "2.0.48",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz",
@@ -1943,9 +2643,9 @@
}
},
"node_modules/postcss": {
- "version": "8.5.15",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
- "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
+ "version": "8.5.25",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
+ "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
"dev": true,
"funding": [
{
@@ -1963,7 +2663,7 @@
],
"license": "MIT",
"dependencies": {
- "nanoid": "^3.3.12",
+ "nanoid": "^3.3.16",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -2002,17 +2702,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/register-scheme": {
- "version": "0.0.2",
- "resolved": "git+ssh://git@github.com/devsnek/node-register-scheme.git#e7cc9a63a1f512565da44cb57316d9fb10750e17",
- "hasInstallScript": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "bindings": "^1.3.0",
- "node-addon-api": "^1.3.0"
- }
- },
"node_modules/rollup": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
@@ -2084,6 +2773,37 @@
"node": ">=0.10.0"
}
},
+ "node_modules/tailwind-merge": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz",
+ "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/dcastil"
+ }
+ },
+ "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",
@@ -2101,11 +2821,11 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
- "node_modules/tr46": {
- "version": "0.0.3",
- "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
- "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
- "license": "MIT"
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
},
"node_modules/typescript": {
"version": "5.8.3",
@@ -2153,13 +2873,13 @@
}
},
"node_modules/vite": {
- "version": "7.3.5",
- "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz",
- "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==",
+ "version": "7.3.6",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
+ "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "esbuild": "^0.27.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
"fdir": "^6.5.0",
"picomatch": "^4.0.3",
"postcss": "^8.5.6",
@@ -2227,43 +2947,6 @@
}
}
},
- "node_modules/webidl-conversions": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
- "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
- "license": "BSD-2-Clause"
- },
- "node_modules/whatwg-url": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
- "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
- "license": "MIT",
- "dependencies": {
- "tr46": "~0.0.3",
- "webidl-conversions": "^3.0.0"
- }
- },
- "node_modules/ws": {
- "version": "7.5.11",
- "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz",
- "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==",
- "license": "MIT",
- "engines": {
- "node": ">=8.3.0"
- },
- "peerDependencies": {
- "bufferutil": "^4.0.1",
- "utf-8-validate": "^5.0.2"
- },
- "peerDependenciesMeta": {
- "bufferutil": {
- "optional": true
- },
- "utf-8-validate": {
- "optional": true
- }
- }
- },
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
diff --git a/package.json b/package.json
index 7c555a7..b8ea679 100644
--- a/package.json
+++ b/package.json
@@ -1,33 +1,42 @@
{
- "name": "just-another-music-client",
+ "name": "zuno",
"private": true,
- "version": "1.2.81",
+ "version": "1.2.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
+ "typecheck": "tsc --noEmit",
+ "check": "node scripts/run-checks.mjs",
+ "verify": "npm run typecheck && npm run check",
"tauri": "tauri"
},
"dependencies": {
- "@tabler/icons-react": "^3.44.0",
+ "@fontsource-variable/inter": "^5.3.0",
+ "@solar-icons/react": "^2.0.0-beta.0",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-autostart": "^2.5.1",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
- "discord-rpc": "^4.0.1",
+ "bgutils-js": "^4.0.2",
+ "clsx": "^2.1.1",
+ "googlevideo": "^4.1.1",
+ "motion": "^12.42.2",
"react": "^19.1.0",
"react-dom": "^19.1.0",
+ "tailwind-merge": "^3.6.0",
"youtubei.js": "^17.0.1"
},
"devDependencies": {
+ "@tailwindcss/vite": "^4.3.3",
"@tauri-apps/cli": "^2",
- "@types/discord-rpc": "^4.0.10",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
+ "tailwindcss": "^4.3.3",
"typescript": "~5.8.3",
"vite": "^7.0.4"
}
diff --git a/packaging/aur/README.md b/packaging/aur/README.md
new file mode 100644
index 0000000..467f95b
--- /dev/null
+++ b/packaging/aur/README.md
@@ -0,0 +1,35 @@
+# AUR packaging
+
+`zuno` repackages the published `.deb`. Edit it here, not in the AUR repo — this is what the
+workflow pushes.
+
+## Publishing
+
+`.github/workflows/aur.yml` runs on every published release: bumps `pkgver`, refreshes the
+checksum from the shipped artifact, regenerates `.SRCINFO`, builds the package, then pushes.
+
+Requires the repository secret **`AUR_SSH_PRIVATE_KEY`**.
+
+To publish without cutting a release:
+
+```
+Actions → AUR → Run workflow → version: 1.2.1
+```
+
+## Editing by hand
+
+Only `PKGBUILD` and `.SRCINFO` go to the AUR. The AUR reads `.SRCINFO`, so regenerate it after
+any PKGBUILD change or the old version keeps being served:
+
+```bash
+makepkg --printsrcinfo > .SRCINFO
+```
+
+Bump `pkgrel` instead of `pkgver` when only the packaging changed. The workflow always resets
+`pkgrel` to 1, so a packaging-only fix has to go through a manual run.
+
+## Notes
+
+- The GStreamer deps are load-bearing: without `gst-libav` playback fails with
+ `GStreamer element appsink not found`.
+- `conflicts=('zuno-bin')` — the community `zuno-bin` package installs the same files.
diff --git a/packaging/aur/zuno/.SRCINFO b/packaging/aur/zuno/.SRCINFO
new file mode 100644
index 0000000..2bfaf2f
--- /dev/null
+++ b/packaging/aur/zuno/.SRCINFO
@@ -0,0 +1,24 @@
+pkgbase = zuno
+ pkgdesc = YouTube Music as a focused desktop app — tabs, offline downloads, synced lyrics
+ pkgver = 1.2.1
+ pkgrel = 1
+ url = https://github.com/noFAYZ/zuno
+ arch = x86_64
+ license = Apache-2.0
+ depends = webkit2gtk-4.1
+ depends = gtk3
+ depends = libayatana-appindicator
+ depends = gst-plugins-base
+ depends = gst-plugins-good
+ depends = gst-libav
+ optdepends = gst-plugins-bad: extra container and codec support
+ optdepends = gst-plugins-ugly: extra codec support
+ optdepends = xdg-utils: opening links and the log file from inside the app
+ optdepends = libnotify: desktop notifications
+ conflicts = zuno-bin
+ options = !strip
+ options = !emptydirs
+ source = zuno-1.2.1.deb::https://github.com/noFAYZ/zuno/releases/download/v1.2.1/Zuno_1.2.1_amd64.deb
+ sha256sums = 3abc57c1accaaf3ef68a0aa37d3b9e6e81ad9ae1a5a8216a739efeb8578c05da
+
+pkgname = zuno
diff --git a/packaging/aur/zuno/PKGBUILD b/packaging/aur/zuno/PKGBUILD
new file mode 100644
index 0000000..e0d4fa6
--- /dev/null
+++ b/packaging/aur/zuno/PKGBUILD
@@ -0,0 +1,54 @@
+# Maintainer: Faizan Asad
+
+pkgname=zuno
+pkgver=1.2.1
+pkgrel=1
+pkgdesc="YouTube Music as a focused desktop app — tabs, offline downloads, synced lyrics"
+arch=('x86_64')
+url="https://github.com/noFAYZ/zuno"
+license=('Apache-2.0')
+
+# Mapped from the .deb's own Depends: libwebkit2gtk-4.1-0, libgtk-3-0,
+# libayatana-appindicator3-1.
+depends=(
+ 'webkit2gtk-4.1'
+ 'gtk3'
+ 'libayatana-appindicator'
+)
+
+# Playback runs through WebKitGTK, which decodes via GStreamer. Unlike the AppImage — which
+# ships its own WebKitGTK and needs its plugins bundled with it — this package uses the
+# system's, so the plugins are ordinary dependencies and there is no bundle to get wrong.
+# gst-libav supplies the AAC and H.264 decoding YouTube streams need; without it playback
+# fails with "GStreamer element appsink not found" and player error 5.
+depends+=(
+ 'gst-plugins-base'
+ 'gst-plugins-good'
+ 'gst-libav'
+)
+
+optdepends=(
+ 'gst-plugins-bad: extra container and codec support'
+ 'gst-plugins-ugly: extra codec support'
+ 'xdg-utils: opening links and the log file from inside the app'
+ 'libnotify: desktop notifications'
+)
+
+# Installs the same files as the community-maintained binary package, so the two cannot
+# be installed together.
+conflicts=('zuno-bin')
+
+# The binary is already stripped and relocation-sensitive; leave it alone.
+options=('!strip' '!emptydirs')
+
+source=("${pkgname}-${pkgver}.deb::${url}/releases/download/v${pkgver}/Zuno_${pkgver}_amd64.deb")
+sha256sums=('3abc57c1accaaf3ef68a0aa37d3b9e6e81ad9ae1a5a8216a739efeb8578c05da')
+
+package() {
+ # bsdtar reads the ar archive and the inner tarball without needing dpkg installed.
+ bsdtar -O -xf "${pkgname}-${pkgver}.deb" data.tar.gz | bsdtar -C "${pkgdir}" -xf -
+
+ install -Dm644 "${pkgdir}/usr/share/applications/Zuno.desktop" \
+ "${pkgdir}/usr/share/applications/${pkgname}.desktop"
+ rm -f "${pkgdir}/usr/share/applications/Zuno.desktop"
+}
diff --git a/packaging/flatpak/README.md b/packaging/flatpak/README.md
new file mode 100644
index 0000000..b3aaefd
--- /dev/null
+++ b/packaging/flatpak/README.md
@@ -0,0 +1,74 @@
+# Flatpak packaging
+
+Built from source. Flathub reserves `extra-data` repackaging for software that cannot be
+built, so a repackaged `.deb` would be sent back at review.
+
+## The app ID is not the Tauri identifier
+
+Flathub requires an ID under a domain the submitter controls. `zuno.com` is not one, so the
+Flatpak is **`io.github.nofayz.Zuno`** while the app keeps `com.zuno.desktop` internally.
+Changing the Tauri identifier would move every existing install's data directory, so they
+stay different on purpose.
+
+## Generated files
+
+`cargo-sources.json` and `node-sources.json` list every crate and npm package, because Flatpak
+builds run with no network. They are generated from the lockfiles, never edited:
+
+```bash
+flatpak-cargo-generator.py src-tauri/Cargo.lock -o packaging/flatpak/cargo-sources.json
+flatpak-node-generator npm package-lock.json -o packaging/flatpak/node-sources.json
+```
+
+`.github/workflows/flatpak.yml` does both and builds against them. Run it from **Actions →
+Flatpak → Run workflow** and take the generated files from the artifact — that is how to
+regenerate them without a Linux machine.
+
+They must be committed to the Flathub repo. They are large and they change with every
+dependency bump.
+
+## Submitting to Flathub
+
+Checked against
+[docs.flathub.org](https://docs.flathub.org/docs/for-app-authors/requirements).
+
+1. Get a green run of the Flatpak workflow, and take the generated source files from the
+ artifact.
+2. Fork [flathub/flathub](https://github.com/flathub/flathub), then branch **from `new-pr`**:
+
+ ```bash
+ git checkout -b zuno-submission new-pr
+ ```
+
+3. Copy these to the **repository root** — the manifest "must be at the top level", and
+ `flathub.json` must sit next to it:
+
+ ```
+ io.github.nofayz.Zuno.yml manifest, filename must equal the app ID
+ io.github.nofayz.Zuno.metainfo.xml mandatory for the Flathub listing
+ io.github.nofayz.Zuno.desktop
+ flathub.json architecture limit
+ cargo-sources.json generated; dependency manifests must be included
+ node-sources.json generated
+ ```
+
+4. Open the PR against the **`new-pr`** base branch, titled `Add io.github.nofayz.Zuno`.
+
+Never close the PR to address review comments or to change the app ID — the same PR is
+amended throughout.
+
+Expect comments on `finish-args`: every permission has to be justified, and the usual request
+is to narrow or drop the broadest. `--filesystem=xdg-run/app/com.discordapp.Discord` is the
+likeliest to be questioned.
+
+### Why the ID is what it is
+
+Flathub requires GitHub-hosted projects to use `io.github.`, with at least four components and
+the domain portion lowercased. `io.github.nofayz.Zuno` satisfies all three. It also cannot end
+in `.desktop`, `.app` or `.linux` — which the app's own `com.zuno.desktop` identifier does,
+and is a second reason the two differ.
+
+## Pinning
+
+The manifest builds a tagged commit, not a branch. Update `tag` and `commit` together for a
+new release; a tag alone is not reproducible if it is ever moved.
diff --git a/packaging/flatpak/flathub.json b/packaging/flatpak/flathub.json
new file mode 100644
index 0000000..53a6d78
--- /dev/null
+++ b/packaging/flatpak/flathub.json
@@ -0,0 +1,4 @@
+{
+ "//": "Flathub builds every architecture it supports unless told otherwise. Zuno has only ever been built and tested on x86_64 — the releases, the .deb and the AUR package are all x86_64 — so aarch64 would be shipping something nobody has run. Remove this once an arm64 build is verified.",
+ "only-arches": ["x86_64"]
+}
diff --git a/packaging/flatpak/io.github.nofayz.Zuno.desktop b/packaging/flatpak/io.github.nofayz.Zuno.desktop
new file mode 100644
index 0000000..25ff1ca
--- /dev/null
+++ b/packaging/flatpak/io.github.nofayz.Zuno.desktop
@@ -0,0 +1,16 @@
+[Desktop Entry]
+Type=Application
+Name=Zuno
+Comment=Desktop client for YouTube Music
+# `Categories=` ships empty in the .deb, which desktop-file-validate rejects and which leaves
+# the app unfiled in every menu. AudioVideo is the required top-level category; Audio and
+# Player are what put it under Music rather than loose in Multimedia.
+Categories=AudioVideo;Audio;Player;
+Keywords=music;youtube;player;streaming;audio;
+Exec=zuno %U
+Icon=io.github.nofayz.Zuno
+Terminal=false
+# Must match the WM_CLASS the window actually sets, or the shell shows a second, iconless
+# entry in the dock while the app is running.
+StartupWMClass=zuno
+StartupNotify=true
diff --git a/packaging/flatpak/io.github.nofayz.Zuno.metainfo.xml b/packaging/flatpak/io.github.nofayz.Zuno.metainfo.xml
new file mode 100644
index 0000000..112cf59
--- /dev/null
+++ b/packaging/flatpak/io.github.nofayz.Zuno.metainfo.xml
@@ -0,0 +1,103 @@
+
+
+
+ io.github.nofayz.Zuno
+
+ Zuno
+ Desktop client for YouTube Music
+
+ CC0-1.0
+ Apache-2.0
+
+
+ Faizan Asad
+
+
+
+
+ Zuno brings YouTube Music to the desktop as a focused application rather than a browser
+ tab. It signs in to your own YouTube Music account and stays fast with large libraries.
+
+ Features:
+
+ Tabs that each keep their own queue, volume and player state
+ Offline downloads, with their own quality setting and a disk cap
+ Line-by-line synced lyrics, with click-to-seek and a timing offset
+ Lyrics translation under each line
+ A mini player that appears when you switch away
+ Local audio files alongside your library, with a tag editor
+ Discord Rich Presence and Last.fm scrobbling
+ Light and dark themes, following the system by default
+
+
+ Zuno is an independent, unofficial project. It is not affiliated with, authorized by,
+ sponsored by, or endorsed by YouTube or Google.
+
+
+
+ io.github.nofayz.Zuno.desktop
+
+ https://github.com/noFAYZ/zuno
+ https://github.com/noFAYZ/zuno/issues
+ https://github.com/noFAYZ/zuno
+
+
+
+ https://raw.githubusercontent.com/noFAYZ/zuno/main/assets/img/zuno-d1-1.2.PNG
+ The home view, with recommendations and recently played
+
+
+ https://raw.githubusercontent.com/noFAYZ/zuno/main/assets/img/zuno-d-1.2.PNG
+ Synced lyrics following the current line
+
+
+ https://raw.githubusercontent.com/noFAYZ/zuno/main/assets/img/zuno-l5-1.2.PNG
+ The library, in light theme
+
+
+ https://raw.githubusercontent.com/noFAYZ/zuno/main/assets/img/zuno-d4-1.2.PNG
+ An artist page, with popular songs and releases
+
+
+
+
+
+ intense
+ mild
+
+
+
+ #ffd7de
+ #4a0713
+
+
+
+
+ https://github.com/noFAYZ/zuno/releases/tag/v1.2.1
+
+ Fixes sign-in failing on updated installs, and shows release notes after an update.
+
+
+
+ https://github.com/noFAYZ/zuno/releases/tag/v1.2.0
+
+
+ Reworked lyrics with click-to-seek, a timing offset, source selection and
+ translation. Adds explicit badges, a download control in the player bar, and
+ settings for the home carousel and lyrics text size.
+
+
+
+
+
diff --git a/packaging/flatpak/io.github.nofayz.Zuno.yml b/packaging/flatpak/io.github.nofayz.Zuno.yml
new file mode 100644
index 0000000..295b142
--- /dev/null
+++ b/packaging/flatpak/io.github.nofayz.Zuno.yml
@@ -0,0 +1,109 @@
+# Flatpak manifest for Zuno.
+#
+# Built from source, which is what Flathub expects of an open-source app — `extra-data`
+# repackaging of the .deb is reserved for things that cannot be built, and would be sent back
+# at review.
+#
+# Flatpak builds run with no network, so every crate and npm package has to be listed up
+# front. `cargo-sources.json` and `node-sources.json` are generated (see the README here) and
+# must be committed alongside this file; they are not written by hand.
+app-id: io.github.nofayz.Zuno
+
+# 47, not 46, because of Rust rather than WebKit: the SDK extensions track the freedesktop
+# base, 46 sits on 23.08, and that carries Cargo 1.81 — too old for the `edition2024` several
+# transitive crates now require. 47 sits on 24.08. The 47 runtime still provides
+# webkit2gtk-4.1 alongside webkitgtk-6.0, so Tauri links as before.
+runtime: org.gnome.Platform
+runtime-version: "47"
+sdk: org.gnome.Sdk
+
+sdk-extensions:
+ - org.freedesktop.Sdk.Extension.rust-stable
+ - org.freedesktop.Sdk.Extension.node20
+
+command: zuno
+
+finish-args:
+ # Windowing. --socket=fallback-x11 rather than --socket=x11 so the app prefers Wayland and
+ # only reaches for X11 where there is no Wayland socket.
+ - --socket=wayland
+ - --socket=fallback-x11
+ - --share=ipc
+ - --device=dri
+
+ # Audio, and the network the whole app exists to reach.
+ - --socket=pulseaudio
+ - --share=network
+
+ # Media keys, MPRIS and the notifications the player publishes.
+ - --talk-name=org.freedesktop.Notifications
+ - --own-name=org.mpris.MediaPlayer2.zuno
+
+ # The tray icon.
+ - --talk-name=org.kde.StatusNotifierWatcher
+
+ # Discord Rich Presence talks over a socket in the runtime dir; Discord itself is sandboxed
+ # separately, so the path has to be shared explicitly for presence to connect at all.
+ - --filesystem=xdg-run/app/com.discordapp.Discord:create
+
+ # Where downloads and local audio come from. Read-only: Zuno reads local files and writes
+ # its own downloads inside its data directory.
+ - --filesystem=xdg-music:ro
+ - --filesystem=xdg-download:ro
+
+modules:
+ - name: zuno
+ buildsystem: simple
+
+ build-options:
+ append-path: /usr/lib/sdk/rust-stable/bin:/usr/lib/sdk/node20/bin
+ env:
+ # Matches where flatpak-cargo-generator writes its config; the sandbox has no
+ # writable $HOME for cargo's default location.
+ CARGO_HOME: /run/build/zuno/cargo
+ # Vendored sources only — a network fetch here fails the build rather than silently
+ # pulling something that is not in the manifest.
+ CARGO_NET_OFFLINE: "true"
+
+ sources:
+ - type: git
+ url: https://github.com/noFAYZ/zuno.git
+ tag: v1.2.1
+ # Pinned so the build is reproducible even if the tag were ever moved.
+ commit: 168eae956555a07e7be6e185cb82f1079a861c7d
+
+ # Generated. See packaging/flatpak/README.md.
+ - cargo-sources.json
+ - node-sources.json
+
+ - type: file
+ path: io.github.nofayz.Zuno.desktop
+ - type: file
+ path: io.github.nofayz.Zuno.metainfo.xml
+
+ build-commands:
+ # The cache path is not optional: flatpak-node-generator unpacks every tarball into
+ # flatpak-node/npm-cache, and npm looks in its own default unless told otherwise —
+ # which fails as ENOTCACHED with the packages sitting right there.
+ - npm ci --offline --no-audit --no-fund --cache=flatpak-node/npm-cache
+ - npm run build
+ - cargo build --release --offline --manifest-path src-tauri/Cargo.toml
+
+ - install -Dm755 src-tauri/target/release/zuno /app/bin/zuno
+ - install -Dm644 io.github.nofayz.Zuno.desktop
+ /app/share/applications/io.github.nofayz.Zuno.desktop
+ - install -Dm644 io.github.nofayz.Zuno.metainfo.xml
+ /app/share/metainfo/io.github.nofayz.Zuno.metainfo.xml
+
+ # The icon has to be installed under the app id, not the binary name, or the desktop
+ # entry's Icon= finds nothing inside the sandbox.
+ - install -Dm644 src-tauri/icons/128x128.png
+ /app/share/icons/hicolor/128x128/apps/io.github.nofayz.Zuno.png
+ - install -Dm644 src-tauri/icons/32x32.png
+ /app/share/icons/hicolor/32x32/apps/io.github.nofayz.Zuno.png
+ - install -Dm644 "src-tauri/icons/128x128@2x.png"
+ /app/share/icons/hicolor/256x256/apps/io.github.nofayz.Zuno.png
+ # 256 is Flathub's stated minimum; 512 is what makes it look right in the store listing
+ # and on HiDPI. There is no SVG in the project, so this is the best available.
+ - install -Dm644 src-tauri/icons/icon.png
+ /app/share/icons/hicolor/512x512/apps/io.github.nofayz.Zuno.png
diff --git a/scripts/run-checks.mjs b/scripts/run-checks.mjs
new file mode 100644
index 0000000..c710196
--- /dev/null
+++ b/scripts/run-checks.mjs
@@ -0,0 +1,91 @@
+/**
+ * Runs every `*.check.ts` self-check in the repo.
+ *
+ * These files carried their own esbuild incantation in a header comment, which meant they
+ * only ever ran when someone remembered to paste it — fifteen files of real assertions that
+ * nothing executed. This is the thing that executes them.
+ *
+ * npm run check
+ *
+ * Bundling goes through esbuild's JS API rather than the `esbuild` binary: on Windows that
+ * binary is reached through a `.cmd` shim, and Node refuses to spawn one without a shell
+ * (CVE-2024-27980), while spawning *with* a shell concatenates arguments instead of passing
+ * them. The API sidesteps both and skips a process per check.
+ */
+import { build } from "esbuild";
+import { spawnSync } from "node:child_process";
+import { mkdtempSync, readdirSync, rmSync, statSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join, relative, resolve } from "node:path";
+
+const ROOT = resolve(import.meta.dirname, "..");
+const SRC = join(ROOT, "src");
+/** A check that has not finished by now is stuck, not slow: they are pure and in-memory. */
+const TIMEOUT_MS = 30_000;
+
+function findChecks(dir) {
+ const found = [];
+ for (const entry of readdirSync(dir)) {
+ const path = join(dir, entry);
+ if (statSync(path).isDirectory()) found.push(...findChecks(path));
+ else if (entry.endsWith(".check.ts")) found.push(path);
+ }
+ return found.sort();
+}
+
+const checks = findChecks(SRC);
+if (checks.length === 0) {
+ console.error("No *.check.ts files found — did the source layout move?");
+ process.exit(1);
+}
+
+const outDir = mkdtempSync(join(tmpdir(), "zuno-checks-"));
+const failures = [];
+
+try {
+ for (const check of checks) {
+ const name = relative(ROOT, check).replace(/[\\/]/g, "_");
+ const bundle = join(outDir, `${name}.mjs`);
+
+ try {
+ await build({
+ entryPoints: [check],
+ bundle: true,
+ platform: "node",
+ format: "esm",
+ outfile: bundle,
+ logLevel: "silent",
+ });
+ } catch (error) {
+ failures.push({ check, stage: "bundle", output: String(error?.message ?? error) });
+ continue;
+ }
+
+ /* Each check runs in its own process: one that throws or hangs must not take the rest of
+ the suite with it, and the failure has to name the file that caused it. */
+ const ran = spawnSync(process.execPath, [bundle], {
+ encoding: "utf8",
+ timeout: TIMEOUT_MS,
+ });
+ if (ran.status !== 0) {
+ const output = ran.stderr || ran.stdout || ran.error?.message || "timed out";
+ failures.push({ check, stage: "run", output });
+ continue;
+ }
+
+ console.log(` ok ${relative(ROOT, check)}`);
+ }
+} finally {
+ rmSync(outDir, { recursive: true, force: true });
+}
+
+if (failures.length > 0) {
+ for (const failure of failures) {
+ console.error(`\nFAILED (${failure.stage}) ${relative(ROOT, failure.check)}`);
+ console.error(String(failure.output).trim());
+ }
+ console.error(`\n${failures.length} of ${checks.length} checks failed.`);
+ process.exit(1);
+}
+
+console.log(`\n${checks.length} checks passed.`);
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index 005dc7c..1508b00 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -26,7 +26,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
- "cpufeatures",
+ "cpufeatures 0.2.17",
]
[[package]]
@@ -380,6 +380,15 @@ dependencies = [
"alloc-stdlib",
]
+[[package]]
+name = "bs58"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
+dependencies = [
+ "tinyvec",
+]
+
[[package]]
name = "bumpalo"
version = "3.20.2"
@@ -398,6 +407,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+[[package]]
+name = "byteorder-lite"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
+
[[package]]
name = "bytes"
version = "1.11.1"
@@ -519,9 +534,20 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
-version = "0.2.1"
+version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
+
+[[package]]
+name = "chacha20"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.3.0",
+ "rand_core 0.10.1",
+]
[[package]]
name = "chrono"
@@ -657,6 +683,15 @@ dependencies = [
"libc",
]
+[[package]]
+name = "cpufeatures"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "crc32fast"
version = "1.5.0"
@@ -774,6 +809,12 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "data-encoding"
+version = "2.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
+
[[package]]
name = "dbus"
version = "0.9.11"
@@ -1213,6 +1254,15 @@ dependencies = [
"percent-encoding",
]
+[[package]]
+name = "fsevent-sys"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "futures-channel"
version = "0.3.32"
@@ -1224,9 +1274,9 @@ dependencies = [
[[package]]
name = "futures-core"
-version = "0.3.32"
+version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
[[package]]
name = "futures-executor"
@@ -1241,9 +1291,9 @@ dependencies = [
[[package]]
name = "futures-io"
-version = "0.3.32"
+version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
+checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
[[package]]
name = "futures-lite"
@@ -1260,9 +1310,9 @@ dependencies = [
[[package]]
name = "futures-macro"
-version = "0.3.32"
+version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
+checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
dependencies = [
"proc-macro2",
"quote",
@@ -1271,21 +1321,21 @@ dependencies = [
[[package]]
name = "futures-sink"
-version = "0.3.32"
+version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
+checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
[[package]]
name = "futures-task"
-version = "0.3.32"
+version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
[[package]]
name = "futures-util"
-version = "0.3.32"
+version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
+checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
dependencies = [
"futures-core",
"futures-io",
@@ -1426,11 +1476,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
- "js-sys",
"libc",
"r-efi 5.3.0",
"wasip2",
- "wasm-bindgen",
]
[[package]]
@@ -1440,10 +1488,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
+ "js-sys",
"libc",
"r-efi 6.0.0",
+ "rand_core 0.10.1",
"wasip2",
"wasip3",
+ "wasm-bindgen",
]
[[package]]
@@ -1775,7 +1826,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
- "windows-core 0.62.2",
+ "windows-core",
]
[[package]]
@@ -1912,6 +1963,19 @@ dependencies = [
"icu_properties",
]
+[[package]]
+name = "image"
+version = "0.25.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
+dependencies = [
+ "bytemuck",
+ "byteorder-lite",
+ "moxcms",
+ "num-traits",
+ "png 0.18.1",
+]
+
[[package]]
name = "indexmap"
version = "1.9.3"
@@ -1944,6 +2008,26 @@ dependencies = [
"cfb",
]
+[[package]]
+name = "inotify"
+version = "0.11.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8"
+dependencies = [
+ "bitflags 2.11.1",
+ "inotify-sys",
+ "libc",
+]
+
+[[package]]
+name = "inotify-sys"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "inout"
version = "0.1.4"
@@ -2115,35 +2199,6 @@ dependencies = [
"serde_json",
]
-[[package]]
-name = "just-another-music-client"
-version = "1.2.81"
-dependencies = [
- "aes-gcm",
- "base64 0.22.1",
- "block2",
- "discord-rich-presence",
- "keyring",
- "md5",
- "objc2",
- "objc2-foundation",
- "portpicker",
- "rand 0.8.6",
- "reqwest 0.12.28",
- "serde",
- "serde_json",
- "tauri",
- "tauri-build",
- "tauri-plugin-autostart",
- "tauri-plugin-dialog",
- "tauri-plugin-localhost",
- "tauri-plugin-opener",
- "tauri-plugin-process",
- "tauri-plugin-updater",
- "url",
- "windows",
-]
-
[[package]]
name = "keyboard-types"
version = "0.7.0"
@@ -2170,6 +2225,26 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "kqueue"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5"
+dependencies = [
+ "kqueue-sys",
+ "libc",
+]
+
+[[package]]
+name = "kqueue-sys"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"
+dependencies = [
+ "bitflags 2.11.1",
+ "libc",
+]
+
[[package]]
name = "leb128fmt"
version = "0.1.0"
@@ -2255,6 +2330,32 @@ dependencies = [
"scopeguard",
]
+[[package]]
+name = "lofty"
+version = "0.24.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dec4feeff6c7d75093278133a06e827d7af6d2bfe20b0f331f9d10338a5ec7ca"
+dependencies = [
+ "byteorder",
+ "data-encoding",
+ "flate2",
+ "lofty_attr",
+ "log",
+ "ogg_pager",
+ "paste",
+]
+
+[[package]]
+name = "lofty_attr"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "458ace39169e4b83c4f77ae3d42d5d1d11c422feef590219a97c973d3b524557"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "log"
version = "0.4.29"
@@ -2328,10 +2429,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
dependencies = [
"libc",
+ "log",
"wasi",
"windows-sys 0.61.2",
]
+[[package]]
+name = "moxcms"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
+dependencies = [
+ "num-traits",
+ "pxfm",
+]
+
[[package]]
name = "muda"
version = "0.19.1"
@@ -2383,6 +2495,33 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
+[[package]]
+name = "notify"
+version = "8.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
+dependencies = [
+ "bitflags 2.11.1",
+ "fsevent-sys",
+ "inotify",
+ "kqueue",
+ "libc",
+ "log",
+ "mio",
+ "notify-types",
+ "walkdir",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "notify-types"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a"
+dependencies = [
+ "bitflags 2.11.1",
+]
+
[[package]]
name = "num-conv"
version = "0.2.1"
@@ -2628,6 +2767,15 @@ dependencies = [
"objc2-foundation",
]
+[[package]]
+name = "ogg_pager"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d36b1d6964c3ac92b7aea701057e02b6b91143d70d83b20abf75a231a3c0216"
+dependencies = [
+ "byteorder",
+]
+
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -2742,6 +2890,12 @@ dependencies = [
"windows-link 0.2.1",
]
+[[package]]
+name = "paste"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
+
[[package]]
name = "pathdiff"
version = "0.2.3"
@@ -2890,7 +3044,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
"cfg-if",
- "cpufeatures",
+ "cpufeatures 0.2.17",
"opaque-debug",
"universal-hash",
]
@@ -3006,6 +3160,12 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "pxfm"
+version = "0.1.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
+
[[package]]
name = "quick-xml"
version = "0.39.3"
@@ -3017,9 +3177,9 @@ dependencies = [
[[package]]
name = "quinn"
-version = "0.11.9"
+version = "0.11.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
+checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
dependencies = [
"bytes",
"cfg_aliases",
@@ -3037,14 +3197,15 @@ dependencies = [
[[package]]
name = "quinn-proto"
-version = "0.11.14"
+version = "0.11.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
+checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
dependencies = [
"bytes",
- "getrandom 0.3.4",
+ "getrandom 0.4.2",
"lru-slab",
- "rand 0.9.4",
+ "rand 0.10.2",
+ "rand_pcg",
"ring",
"rustc-hash",
"rustls",
@@ -3058,16 +3219,16 @@ dependencies = [
[[package]]
name = "quinn-udp"
-version = "0.5.14"
+version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
+checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
- "windows-sys 0.60.2",
+ "windows-sys 0.52.0",
]
[[package]]
@@ -3098,18 +3259,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"libc",
- "rand_chacha 0.3.1",
+ "rand_chacha",
"rand_core 0.6.4",
]
[[package]]
name = "rand"
-version = "0.9.4"
+version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
+checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [
- "rand_chacha 0.9.0",
- "rand_core 0.9.5",
+ "chacha20",
+ "getrandom 0.4.2",
+ "rand_core 0.10.1",
]
[[package]]
@@ -3122,16 +3284,6 @@ dependencies = [
"rand_core 0.6.4",
]
-[[package]]
-name = "rand_chacha"
-version = "0.9.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
-dependencies = [
- "ppv-lite86",
- "rand_core 0.9.5",
-]
-
[[package]]
name = "rand_core"
version = "0.6.4"
@@ -3143,11 +3295,17 @@ dependencies = [
[[package]]
name = "rand_core"
-version = "0.9.5"
+version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
+
+[[package]]
+name = "rand_pcg"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
- "getrandom 0.3.4",
+ "rand_core 0.10.1",
]
[[package]]
@@ -3245,6 +3403,7 @@ dependencies = [
"base64 0.22.1",
"bytes",
"futures-core",
+ "futures-util",
"http",
"http-body",
"http-body-util",
@@ -3264,12 +3423,14 @@ dependencies = [
"sync_wrapper",
"tokio",
"tokio-rustls",
+ "tokio-util",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
+ "wasm-streams 0.4.2",
"web-sys",
"webpki-roots",
]
@@ -3309,7 +3470,7 @@ dependencies = [
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
- "wasm-streams",
+ "wasm-streams 0.5.0",
"web-sys",
]
@@ -3714,11 +3875,12 @@ dependencies = [
[[package]]
name = "serde_with"
-version = "3.19.0"
+version = "3.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f05839ce67618e14a09b286535c0d9c94e85ef25469b0e13cb4f844e5593eb19"
+checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c"
dependencies = [
"base64 0.22.1",
+ "bs58",
"chrono",
"hex",
"indexmap 1.9.3",
@@ -3733,9 +3895,9 @@ dependencies = [
[[package]]
name = "serde_with_macros"
-version = "3.19.0"
+version = "3.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cf2ebbe86054f9b45bc3881e865683ccfaccce97b9b4cb53f3039d67f355a334"
+checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660"
dependencies = [
"darling",
"proc-macro2",
@@ -3781,7 +3943,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
- "cpufeatures",
+ "cpufeatures 0.2.17",
"digest",
]
@@ -4041,7 +4203,7 @@ dependencies = [
"unicode-segmentation",
"url",
"windows",
- "windows-core 0.61.2",
+ "windows-core",
"windows-version",
"x11-dl",
]
@@ -4091,6 +4253,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
+ "image",
"jni 0.21.1",
"libc",
"log",
@@ -4557,9 +4720,9 @@ dependencies = [
[[package]]
name = "tinyvec"
-version = "1.11.0"
+version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
+checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
dependencies = [
"tinyvec_macros",
]
@@ -5144,6 +5307,19 @@ dependencies = [
"wasmparser",
]
+[[package]]
+name = "wasm-streams"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
+dependencies = [
+ "futures-util",
+ "js-sys",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
[[package]]
name = "wasm-streams"
version = "0.5.0"
@@ -5256,9 +5432,9 @@ dependencies = [
[[package]]
name = "webpki-roots"
-version = "1.0.7"
+version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"
+checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
dependencies = [
"rustls-pki-types",
]
@@ -5272,7 +5448,7 @@ dependencies = [
"webview2-com-macros",
"webview2-com-sys",
"windows",
- "windows-core 0.61.2",
+ "windows-core",
"windows-implement",
"windows-interface",
]
@@ -5296,7 +5472,7 @@ checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
dependencies = [
"thiserror 2.0.18",
"windows",
- "windows-core 0.61.2",
+ "windows-core",
]
[[package]]
@@ -5352,7 +5528,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
dependencies = [
"windows-collections",
- "windows-core 0.61.2",
+ "windows-core",
"windows-future",
"windows-link 0.1.3",
"windows-numerics",
@@ -5364,7 +5540,7 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8"
dependencies = [
- "windows-core 0.61.2",
+ "windows-core",
]
[[package]]
@@ -5376,21 +5552,8 @@ dependencies = [
"windows-implement",
"windows-interface",
"windows-link 0.1.3",
- "windows-result 0.3.4",
- "windows-strings 0.4.2",
-]
-
-[[package]]
-name = "windows-core"
-version = "0.62.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
-dependencies = [
- "windows-implement",
- "windows-interface",
- "windows-link 0.2.1",
- "windows-result 0.4.1",
- "windows-strings 0.5.1",
+ "windows-result",
+ "windows-strings",
]
[[package]]
@@ -5399,7 +5562,7 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
dependencies = [
- "windows-core 0.61.2",
+ "windows-core",
"windows-link 0.1.3",
"windows-threading",
]
@@ -5444,7 +5607,7 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
dependencies = [
- "windows-core 0.61.2",
+ "windows-core",
"windows-link 0.1.3",
]
@@ -5457,15 +5620,6 @@ dependencies = [
"windows-link 0.1.3",
]
-[[package]]
-name = "windows-result"
-version = "0.4.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
-dependencies = [
- "windows-link 0.2.1",
-]
-
[[package]]
name = "windows-strings"
version = "0.4.2"
@@ -5475,15 +5629,6 @@ dependencies = [
"windows-link 0.1.3",
]
-[[package]]
-name = "windows-strings"
-version = "0.5.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
-dependencies = [
- "windows-link 0.2.1",
-]
-
[[package]]
name = "windows-sys"
version = "0.45.0"
@@ -5915,7 +6060,7 @@ dependencies = [
"webkit2gtk-sys",
"webview2-com",
"windows",
- "windows-core 0.61.2",
+ "windows-core",
"windows-version",
"x11-dl",
]
@@ -6147,6 +6292,39 @@ version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+[[package]]
+name = "zuno"
+version = "1.2.1"
+dependencies = [
+ "aes-gcm",
+ "base64 0.22.1",
+ "block2",
+ "discord-rich-presence",
+ "futures-util",
+ "keyring",
+ "lofty",
+ "md5",
+ "notify",
+ "objc2",
+ "objc2-foundation",
+ "portpicker",
+ "rand 0.8.6",
+ "reqwest 0.12.28",
+ "serde",
+ "serde_json",
+ "tauri",
+ "tauri-build",
+ "tauri-plugin-autostart",
+ "tauri-plugin-dialog",
+ "tauri-plugin-localhost",
+ "tauri-plugin-opener",
+ "tauri-plugin-process",
+ "tauri-plugin-updater",
+ "tokio",
+ "url",
+ "windows",
+]
+
[[package]]
name = "zvariant"
version = "5.11.0"
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 36bf7cc..95b76c5 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -1,57 +1,62 @@
-[package]
-name = "just-another-music-client"
-version = "1.2.81"
-description = "A desktop music client"
-authors = ["you"]
-edition = "2021"
-
-# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
-
-[lib]
-# The `_lib` suffix may seem redundant but it is necessary
-# to make the lib name unique and wouldn't conflict with the bin name.
-# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
-name = "just_another_music_client_lib"
-crate-type = ["staticlib", "cdylib", "rlib"]
-
-[build-dependencies]
-tauri-build = { version = "2", features = [] }
-
-[dependencies]
-tauri = { version = "2", features = ["macos-private-api"] }
-tauri-plugin-autostart = "2"
-tauri-plugin-opener = "2"
-tauri-plugin-localhost = "2"
-portpicker = "0.1"
-serde = { version = "1", features = ["derive"] }
-serde_json = "1"
-reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "gzip", "brotli", "deflate"] }
-base64 = "0.22"
-url = "2.5"
-keyring = { version = "3.6", features = ["apple-native", "windows-native", "sync-secret-service"] }
-discord-rich-presence = "0.2"
-tauri-plugin-updater = "2"
-tauri-plugin-process = "2"
-tauri-plugin-dialog = "2"
-md5 = "0.8.0"
-
-[target.'cfg(target_os = "macos")'.dependencies]
-aes-gcm = "0.10"
-block2 = "0.6.2"
-objc2 = "0.6.4"
-objc2-foundation = { version = "0.3.2", features = ["NSDictionary", "NSObject", "NSString", "NSValue"] }
-rand = "0.8"
-
-[target.'cfg(target_os = "windows")'.dependencies]
-windows = { version = "0.61.3", features = [
- "Foundation",
- "Media",
- "Media_Playback",
- "Storage_Streams",
- "Win32_Foundation",
- "Win32_Graphics_Gdi",
- "Win32_System_Com",
- "Win32_UI_Controls",
- "Win32_UI_Shell",
- "Win32_UI_WindowsAndMessaging",
-] }
+[package]
+name = "zuno"
+version = "1.2.1"
+description = "Zuno - a desktop music client"
+authors = ["you"]
+edition = "2021"
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[lib]
+# The `_lib` suffix may seem redundant but it is necessary
+# to make the lib name unique and wouldn't conflict with the bin name.
+# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
+name = "zuno_lib"
+crate-type = ["staticlib", "cdylib", "rlib"]
+
+[build-dependencies]
+tauri-build = { version = "2", features = [] }
+
+[dependencies]
+tauri = { version = "2", features = ["macos-private-api", "tray-icon", "image-png"] }
+tauri-plugin-autostart = "2"
+tauri-plugin-opener = "2"
+tauri-plugin-localhost = "2"
+portpicker = "0.1"
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "gzip", "brotli", "deflate", "stream"] }
+base64 = "0.22"
+url = "2.5"
+keyring = { version = "3.6", features = ["apple-native", "windows-native", "sync-secret-service"] }
+discord-rich-presence = "0.2"
+tauri-plugin-updater = "2"
+tauri-plugin-process = "2"
+tauri-plugin-dialog = "2"
+md5 = "0.8.0"
+lofty = "0.24.0"
+notify = "8.2.0"
+futures-util = "0.3.33"
+# Already in the tree via tauri; declared directly only for `sleep` in the range-retry backoff.
+tokio = { version = "1", features = ["time"] }
+
+[target.'cfg(target_os = "macos")'.dependencies]
+aes-gcm = "0.10"
+block2 = "0.6.2"
+objc2 = "0.6.4"
+objc2-foundation = { version = "0.3.2", features = ["NSDictionary", "NSObject", "NSString", "NSValue"] }
+rand = "0.8"
+
+[target.'cfg(target_os = "windows")'.dependencies]
+windows = { version = "0.61.3", features = [
+ "Foundation",
+ "Media",
+ "Media_Playback",
+ "Storage_Streams",
+ "Win32_Foundation",
+ "Win32_Graphics_Gdi",
+ "Win32_System_Com",
+ "Win32_UI_Controls",
+ "Win32_UI_Shell",
+ "Win32_UI_WindowsAndMessaging",
+] }
diff --git a/src-tauri/build.rs b/src-tauri/build.rs
index d860e1e..22e98cb 100644
--- a/src-tauri/build.rs
+++ b/src-tauri/build.rs
@@ -1,3 +1,8 @@
fn main() {
+ // Cargo does not track the icon, so regenerating it (`tauri icon`) leaves the old one
+ // embedded in the exe until something else forces a relink. Declaring it here means a
+ // new icon is picked up on the next build instead of needing `cargo clean -p`.
+ println!("cargo:rerun-if-changed=icons/icon.ico");
+
tauri_build::build()
}
diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json
index 24c8037..be9cab0 100644
--- a/src-tauri/capabilities/default.json
+++ b/src-tauri/capabilities/default.json
@@ -24,6 +24,8 @@
"dialog:allow-message",
"core:window:allow-hide",
"core:window:allow-show",
+ "core:webview:allow-create-webview-window",
+ "core:window:allow-destroy",
"core:window:allow-set-focus",
"core:window:allow-unminimize",
"core:window:allow-outer-position",
diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png
index b848b0a..f4f8fb7 100644
Binary files a/src-tauri/icons/128x128.png and b/src-tauri/icons/128x128.png differ
diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png
index 3697e35..b58bdc9 100644
Binary files a/src-tauri/icons/128x128@2x.png and b/src-tauri/icons/128x128@2x.png differ
diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png
index 217b0d2..fc277f6 100644
Binary files a/src-tauri/icons/32x32.png and b/src-tauri/icons/32x32.png differ
diff --git a/src-tauri/icons/64x64.png b/src-tauri/icons/64x64.png
index 500cd70..ba64687 100644
Binary files a/src-tauri/icons/64x64.png and b/src-tauri/icons/64x64.png differ
diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png
index 8ba2f4a..d9226c9 100644
Binary files a/src-tauri/icons/Square107x107Logo.png and b/src-tauri/icons/Square107x107Logo.png differ
diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png
index 6aa189d..38a0b2a 100644
Binary files a/src-tauri/icons/Square142x142Logo.png and b/src-tauri/icons/Square142x142Logo.png differ
diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png
index 0406bed..cbdcf49 100644
Binary files a/src-tauri/icons/Square150x150Logo.png and b/src-tauri/icons/Square150x150Logo.png differ
diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png
index 856d6f0..5e4304b 100644
Binary files a/src-tauri/icons/Square284x284Logo.png and b/src-tauri/icons/Square284x284Logo.png differ
diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png
index 62b4b50..06ea49e 100644
Binary files a/src-tauri/icons/Square30x30Logo.png and b/src-tauri/icons/Square30x30Logo.png differ
diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png
index 6968917..55bf9f2 100644
Binary files a/src-tauri/icons/Square310x310Logo.png and b/src-tauri/icons/Square310x310Logo.png differ
diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png
index 49c3f81..98dfa73 100644
Binary files a/src-tauri/icons/Square44x44Logo.png and b/src-tauri/icons/Square44x44Logo.png differ
diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png
index f3c735b..be8ead9 100644
Binary files a/src-tauri/icons/Square71x71Logo.png and b/src-tauri/icons/Square71x71Logo.png differ
diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png
index edc6ee3..85ab481 100644
Binary files a/src-tauri/icons/Square89x89Logo.png and b/src-tauri/icons/Square89x89Logo.png differ
diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png
index 4dd1993..7d2888a 100644
Binary files a/src-tauri/icons/StoreLogo.png and b/src-tauri/icons/StoreLogo.png differ
diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png
index 318118e..a9b7fc4 100644
Binary files a/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png differ
diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png
index 3ba7985..f77c66a 100644
Binary files a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png differ
diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png
index d04afa3..35772e3 100644
Binary files a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png
index a8da7b1..b27d196 100644
Binary files a/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png differ
diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png
index 27b6f80..b3c4905 100644
Binary files a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png differ
diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png
index 2a36ed0..ae0ab28 100644
Binary files a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png
index 4e81b5f..e19c55a 100644
Binary files a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png
index dad88d5..3542edd 100644
Binary files a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png differ
diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png
index ea2cfa9..e15801e 100644
Binary files a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png
index 221f471..c327d9b 100644
Binary files a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png
index b155da9..4d6d7f4 100644
Binary files a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png differ
diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png
index 346af0e..5a27c1f 100644
Binary files a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png
index bfab8b5..a5581e5 100644
Binary files a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png
index c0e25e9..744c144 100644
Binary files a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png
index 5608368..a78bf41 100644
Binary files a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns
index b3521b2..89d2da5 100644
Binary files a/src-tauri/icons/icon.icns and b/src-tauri/icons/icon.icns differ
diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico
index 779d95f..6a14a43 100644
Binary files a/src-tauri/icons/icon.ico and b/src-tauri/icons/icon.ico differ
diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png
index a350f34..4469bae 100644
Binary files a/src-tauri/icons/icon.png and b/src-tauri/icons/icon.png differ
diff --git a/src-tauri/icons/ios/AppIcon-20x20@1x.png b/src-tauri/icons/ios/AppIcon-20x20@1x.png
index a122f70..16f8a67 100644
Binary files a/src-tauri/icons/ios/AppIcon-20x20@1x.png and b/src-tauri/icons/ios/AppIcon-20x20@1x.png differ
diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png
index 8c8abde..85eb020 100644
Binary files a/src-tauri/icons/ios/AppIcon-20x20@2x-1.png and b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png differ
diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x.png b/src-tauri/icons/ios/AppIcon-20x20@2x.png
index 8c8abde..85eb020 100644
Binary files a/src-tauri/icons/ios/AppIcon-20x20@2x.png and b/src-tauri/icons/ios/AppIcon-20x20@2x.png differ
diff --git a/src-tauri/icons/ios/AppIcon-20x20@3x.png b/src-tauri/icons/ios/AppIcon-20x20@3x.png
index 3ad37ec..beeed82 100644
Binary files a/src-tauri/icons/ios/AppIcon-20x20@3x.png and b/src-tauri/icons/ios/AppIcon-20x20@3x.png differ
diff --git a/src-tauri/icons/ios/AppIcon-29x29@1x.png b/src-tauri/icons/ios/AppIcon-29x29@1x.png
index 6fd11ef..5c76c30 100644
Binary files a/src-tauri/icons/ios/AppIcon-29x29@1x.png and b/src-tauri/icons/ios/AppIcon-29x29@1x.png differ
diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png
index c3db696..785984b 100644
Binary files a/src-tauri/icons/ios/AppIcon-29x29@2x-1.png and b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png differ
diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x.png b/src-tauri/icons/ios/AppIcon-29x29@2x.png
index c3db696..785984b 100644
Binary files a/src-tauri/icons/ios/AppIcon-29x29@2x.png and b/src-tauri/icons/ios/AppIcon-29x29@2x.png differ
diff --git a/src-tauri/icons/ios/AppIcon-29x29@3x.png b/src-tauri/icons/ios/AppIcon-29x29@3x.png
index 611fa42..6aea6f4 100644
Binary files a/src-tauri/icons/ios/AppIcon-29x29@3x.png and b/src-tauri/icons/ios/AppIcon-29x29@3x.png differ
diff --git a/src-tauri/icons/ios/AppIcon-40x40@1x.png b/src-tauri/icons/ios/AppIcon-40x40@1x.png
index 8c8abde..85eb020 100644
Binary files a/src-tauri/icons/ios/AppIcon-40x40@1x.png and b/src-tauri/icons/ios/AppIcon-40x40@1x.png differ
diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png
index 4465082..27c0803 100644
Binary files a/src-tauri/icons/ios/AppIcon-40x40@2x-1.png and b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png differ
diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x.png b/src-tauri/icons/ios/AppIcon-40x40@2x.png
index 4465082..27c0803 100644
Binary files a/src-tauri/icons/ios/AppIcon-40x40@2x.png and b/src-tauri/icons/ios/AppIcon-40x40@2x.png differ
diff --git a/src-tauri/icons/ios/AppIcon-40x40@3x.png b/src-tauri/icons/ios/AppIcon-40x40@3x.png
index 4f00304..5fad630 100644
Binary files a/src-tauri/icons/ios/AppIcon-40x40@3x.png and b/src-tauri/icons/ios/AppIcon-40x40@3x.png differ
diff --git a/src-tauri/icons/ios/AppIcon-512@2x.png b/src-tauri/icons/ios/AppIcon-512@2x.png
index ad30755..7f94d19 100644
Binary files a/src-tauri/icons/ios/AppIcon-512@2x.png and b/src-tauri/icons/ios/AppIcon-512@2x.png differ
diff --git a/src-tauri/icons/ios/AppIcon-60x60@2x.png b/src-tauri/icons/ios/AppIcon-60x60@2x.png
index 4f00304..5fad630 100644
Binary files a/src-tauri/icons/ios/AppIcon-60x60@2x.png and b/src-tauri/icons/ios/AppIcon-60x60@2x.png differ
diff --git a/src-tauri/icons/ios/AppIcon-60x60@3x.png b/src-tauri/icons/ios/AppIcon-60x60@3x.png
index b4a4051..49cc68e 100644
Binary files a/src-tauri/icons/ios/AppIcon-60x60@3x.png and b/src-tauri/icons/ios/AppIcon-60x60@3x.png differ
diff --git a/src-tauri/icons/ios/AppIcon-76x76@1x.png b/src-tauri/icons/ios/AppIcon-76x76@1x.png
index 64b6d99..158ba22 100644
Binary files a/src-tauri/icons/ios/AppIcon-76x76@1x.png and b/src-tauri/icons/ios/AppIcon-76x76@1x.png differ
diff --git a/src-tauri/icons/ios/AppIcon-76x76@2x.png b/src-tauri/icons/ios/AppIcon-76x76@2x.png
index e00518a..927b52a 100644
Binary files a/src-tauri/icons/ios/AppIcon-76x76@2x.png and b/src-tauri/icons/ios/AppIcon-76x76@2x.png differ
diff --git a/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
index 4861469..a48a623 100644
Binary files a/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png and b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ
diff --git a/src-tauri/src/discord_rpc.rs b/src-tauri/src/discord_rpc.rs
index d4f18c5..7c4507e 100644
--- a/src-tauri/src/discord_rpc.rs
+++ b/src-tauri/src/discord_rpc.rs
@@ -1,190 +1,213 @@
-use discord_rich_presence::{DiscordIpc, DiscordIpcClient};
-use serde::{Deserialize, Serialize};
-use serde_json::json;
-use std::sync::{Arc, Mutex};
-use std::time::{SystemTime, UNIX_EPOCH};
-
-const DISCORD_CLIENT_ID: &str = "1515682467154100344";
-const GITHUB_REPO: &str = "https://github.com/2latemc/JustAnotherMusicClient";
-const ACTIVITY_NAME: &str = "JustAnotherMusicClient";
-
-#[derive(Debug, Serialize, Deserialize, Clone)]
-pub struct DiscordPresenceData {
- pub title: String,
- pub artist: String,
- pub album: String,
- pub artwork_url: Option,
- pub song_url: Option,
- pub artist_url: Option,
- pub album_url: Option,
- pub duration: u64,
- pub current_time: u64,
- pub is_playing: bool,
-}
-
-pub struct DiscordRpcManager {
- client: Arc>>,
- connected: Arc>,
-}
-
-impl DiscordRpcManager {
- pub fn new() -> Self {
- Self {
- client: Arc::new(Mutex::new(None)),
- connected: Arc::new(Mutex::new(false)),
- }
- }
-
- /// Initialize Discord RPC connection
- pub fn connect(&self) -> Result<(), String> {
- let mut client_lock = self.client.lock().map_err(|e| e.to_string())?;
-
- if client_lock.is_some() {
- return Ok(());
- }
-
- match DiscordIpcClient::new(DISCORD_CLIENT_ID) {
- Ok(mut client) => {
- if let Err(e) = client.connect() {
- return Err(format!("Failed to connect to Discord: {}", e));
- }
- *client_lock = Some(client);
- let mut connected = self.connected.lock().map_err(|e| e.to_string())?;
- *connected = true;
- Ok(())
- }
- Err(e) => Err(format!("Failed to create Discord client: {}", e)),
- }
- }
-
- /// Update Discord presence with current track info
- pub fn update_presence(&self, data: DiscordPresenceData) -> Result<(), String> {
- // Ensure connection exists
- if !*self.connected.lock().map_err(|e| e.to_string())? {
- self.connect()?;
- }
-
- let mut client_lock = self.client.lock().map_err(|e| e.to_string())?;
-
- let client = client_lock
- .as_mut()
- .ok_or("Discord client not initialized")?;
-
- // Calculate progress
- let elapsed = data.current_time;
- let duration = data.duration;
-
- // Keep owned values alive while building the activity
- let state_str = if data.is_playing {
- data.artist.clone()
- } else {
- format!("{} (paused)", data.artist)
- };
-
- let artwork_image = data.artwork_url.clone();
- let artwork_key = artwork_image.as_deref().unwrap_or("app-icon");
-
- let large_text_str = format!("{} - {}", data.title, data.artist);
-
- let start_ts = SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .unwrap_or_default()
- .as_secs() as i64
- - elapsed as i64;
- let end_ts = start_ts + duration as i64;
-
- let mut activity = json!({
- "name": ACTIVITY_NAME,
- "type": 2,
- "details": data.title,
- "state": state_str,
- "assets": {
- "large_image": artwork_key,
- "large_text": large_text_str,
- },
- "buttons": [
- {
- "label": "Download JAMusicClient :O",
- "url": GITHUB_REPO,
- }
- ],
- });
-
- if let Some(song_url) = data.song_url {
- activity["details_url"] = json!(song_url);
- }
-
- if let Some(artist_url) = data.artist_url {
- activity["state_url"] = json!(artist_url);
- }
-
- if let Some(album_url) = data.album_url {
- activity["assets"]["large_url"] = json!(album_url);
- }
-
- if duration > 0 {
- activity["timestamps"] = json!({
- "start": start_ts,
- "end": end_ts,
- });
- }
-
- let payload = json!({
- "cmd": "SET_ACTIVITY",
- "args": {
- "pid": std::process::id(),
- "activity": activity,
- },
- "nonce": format!("jamc-{}-{}", std::process::id(), start_ts),
- });
-
- if let Err(e) = client.send(payload, 1) {
- eprintln!("[Discord RPC] Failed to set activity: {}", e);
- *client_lock = None;
- if let Ok(mut connected) = self.connected.lock() {
- *connected = false;
- }
- }
-
- Ok(())
- }
-
- /// Clear presence (show idle)
- pub fn clear_presence(&self) -> Result<(), String> {
- if !*self.connected.lock().map_err(|e| e.to_string())? {
- return Ok(());
- }
-
- let mut client_lock = self.client.lock().map_err(|e| e.to_string())?;
- let client = client_lock
- .as_mut()
- .ok_or("Discord client not initialized")?;
-
- if let Err(e) = client.clear_activity() {
- eprintln!("[Discord RPC] Failed to clear activity: {}", e);
- *client_lock = None;
- if let Ok(mut connected) = self.connected.lock() {
- *connected = false;
- }
- }
-
- Ok(())
- }
-}
-
-impl Default for DiscordRpcManager {
- fn default() -> Self {
- Self::new()
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn test_discord_rpc_manager_creation() {
- let manager = DiscordRpcManager::new();
- assert!(!*manager.connected.lock().unwrap());
- }
-}
+use discord_rich_presence::{DiscordIpc, DiscordIpcClient};
+use serde::{Deserialize, Serialize};
+use serde_json::json;
+use std::sync::{Arc, Mutex};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+const DISCORD_CLIENT_ID: &str = "1515682467154100344";
+const GITHUB_REPO: &str = "https://github.com/noFAYZ/zuno";
+const ACTIVITY_NAME: &str = "Zuno";
+
+#[derive(Debug, Serialize, Deserialize, Clone)]
+pub struct DiscordPresenceData {
+ pub title: String,
+ pub artist: String,
+ pub album: String,
+ pub artwork_url: Option,
+ pub song_url: Option,
+ pub artist_url: Option,
+ pub album_url: Option,
+ pub duration: u64,
+ pub current_time: u64,
+ pub is_playing: bool,
+}
+
+pub struct DiscordRpcManager {
+ client: Arc>>,
+ connected: Arc>,
+}
+
+impl DiscordRpcManager {
+ pub fn new() -> Self {
+ Self {
+ client: Arc::new(Mutex::new(None)),
+ connected: Arc::new(Mutex::new(false)),
+ }
+ }
+
+ /// Initialize Discord RPC connection
+ pub fn connect(&self) -> Result<(), String> {
+ let mut client_lock = self.client.lock().map_err(|e| e.to_string())?;
+
+ if client_lock.is_some() {
+ return Ok(());
+ }
+
+ match DiscordIpcClient::new(DISCORD_CLIENT_ID) {
+ Ok(mut client) => {
+ if let Err(e) = client.connect() {
+ return Err(format!("Failed to connect to Discord: {}", e));
+ }
+ *client_lock = Some(client);
+ let mut connected = self.connected.lock().map_err(|e| e.to_string())?;
+ *connected = true;
+ Ok(())
+ }
+ Err(e) => Err(format!("Failed to create Discord client: {}", e)),
+ }
+ }
+
+ /// Update Discord presence with current track info
+ pub fn update_presence(&self, data: DiscordPresenceData) -> Result<(), String> {
+ // Ensure connection exists
+ if !*self.connected.lock().map_err(|e| e.to_string())? {
+ self.connect()?;
+ }
+
+ let mut client_lock = self.client.lock().map_err(|e| e.to_string())?;
+
+ let client = client_lock
+ .as_mut()
+ .ok_or("Discord client not initialized")?;
+
+ // Calculate progress
+ let elapsed = data.current_time;
+ let duration = data.duration;
+
+ // Keep owned values alive while building the activity
+ let state_str = if data.is_playing {
+ data.artist.clone()
+ } else {
+ format!("{} (paused)", data.artist)
+ };
+
+ let artwork_image = data.artwork_url.clone();
+ let artwork_key = artwork_image.as_deref().unwrap_or("app-icon");
+
+ let large_text_str = format!("{} - {}", data.title, data.artist);
+
+ let start_ts = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_secs() as i64
+ - elapsed as i64;
+ let end_ts = start_ts + duration as i64;
+
+ /*
+ * The artist, not the app, is the headline.
+ *
+ * `name` is what Discord prints after "Listening to", so sending the app name made
+ * every song read "Listening to Zuno" — the same line for everything, saying nothing
+ * about what is actually playing. The artist goes there and the app name is only the
+ * fallback for a track with no artist, so the line is never empty.
+ */
+ let activity_name = if data.artist.trim().is_empty() {
+ ACTIVITY_NAME.to_string()
+ } else {
+ data.artist.clone()
+ };
+
+ let mut activity = json!({
+ "name": activity_name,
+ "type": 2,
+ "details": data.title,
+ "state": state_str,
+ "assets": {
+ "large_image": artwork_key,
+ "large_text": large_text_str,
+ },
+ "buttons": [
+ {
+ "label": "Get Zuno",
+ "url": GITHUB_REPO,
+ }
+ ],
+ });
+
+ if let Some(song_url) = data.song_url {
+ activity["details_url"] = json!(song_url);
+ }
+
+ if let Some(artist_url) = data.artist_url {
+ activity["state_url"] = json!(artist_url);
+ }
+
+ if let Some(album_url) = data.album_url {
+ activity["assets"]["large_url"] = json!(album_url);
+ }
+
+ /*
+ * Timestamps only while playing.
+ *
+ * Discord does not receive progress updates — it is given a start and an end and runs
+ * the clock itself. Leaving them on a paused track means the bar keeps advancing and
+ * the song keeps "finishing" on other people's screens while the audio sits still, so
+ * pausing had no visible effect at all. Dropping them freezes the presence, which is
+ * what a pause looks like.
+ */
+ if duration > 0 && data.is_playing {
+ activity["timestamps"] = json!({
+ "start": start_ts,
+ "end": end_ts,
+ });
+ }
+
+ let payload = json!({
+ "cmd": "SET_ACTIVITY",
+ "args": {
+ "pid": std::process::id(),
+ "activity": activity,
+ },
+ "nonce": format!("jamc-{}-{}", std::process::id(), start_ts),
+ });
+
+ if let Err(e) = client.send(payload, 1) {
+ eprintln!("[Discord RPC] Failed to set activity: {}", e);
+ *client_lock = None;
+ if let Ok(mut connected) = self.connected.lock() {
+ *connected = false;
+ }
+ }
+
+ Ok(())
+ }
+
+ /// Clear presence (show idle)
+ pub fn clear_presence(&self) -> Result<(), String> {
+ if !*self.connected.lock().map_err(|e| e.to_string())? {
+ return Ok(());
+ }
+
+ let mut client_lock = self.client.lock().map_err(|e| e.to_string())?;
+ let client = client_lock
+ .as_mut()
+ .ok_or("Discord client not initialized")?;
+
+ if let Err(e) = client.clear_activity() {
+ eprintln!("[Discord RPC] Failed to clear activity: {}", e);
+ *client_lock = None;
+ if let Ok(mut connected) = self.connected.lock() {
+ *connected = false;
+ }
+ }
+
+ Ok(())
+ }
+}
+
+impl Default for DiscordRpcManager {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_discord_rpc_manager_creation() {
+ let manager = DiscordRpcManager::new();
+ assert!(!*manager.connected.lock().unwrap());
+ }
+}
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index d96f275..f7021ac 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -1,2379 +1,4339 @@
-// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
-use base64::{engine::general_purpose::STANDARD, Engine as _};
-#[cfg(not(debug_assertions))]
-use portpicker::pick_unused_port;
-use serde::{Deserialize, Serialize};
-use std::collections::HashMap;
-use std::fmt;
-use std::fs::{self, File, OpenOptions};
-use std::io::{Read, Write};
-use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, TcpListener};
-use std::path::{Path, PathBuf};
-use std::sync::{Arc, Mutex, OnceLock};
-use std::thread;
-use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
-
-macro_rules! eprintln {
- ($($arg:tt)*) => {{
- let message = $crate::sanitize_log_message(&format!($($arg)*));
- std::eprintln!("{}", message);
- $crate::append_log_line(format_args!("{}", message));
- }};
-}
-
-#[cfg(not(debug_assertions))]
-use tauri::utils::config::FrontendDist;
-#[cfg(not(debug_assertions))]
-use tauri::utils::config_v1::WindowUrl;
-
-use tauri::{Emitter, Manager};
-
-#[cfg(target_os = "macos")]
-use aes_gcm::{
- aead::{Aead, KeyInit},
- Aes256Gcm, Nonce,
-};
-#[cfg(target_os = "macos")]
-use rand::{rngs::OsRng, RngCore};
-
-#[cfg(target_os = "macos")]
-mod macos_media;
-#[cfg(target_os = "windows")]
-mod windows_media;
-
-mod discord_rpc;
-mod lastfm;
-
-// Keep the legacy service name so existing sign-in credentials survive the product rename.
-const KEYRING_SERVICE: &str = "com.ytmusicdock.app";
-const KEYRING_USER: &str = "youtube-oauth";
-const YOUTUBE_COOKIE_KEYRING_USER: &str = "youtube-music-cookie";
-#[cfg(target_os = "macos")]
-const YOUTUBE_COOKIE_ENCRYPTION_KEY_USER: &str = "youtube-music-cookie-encryption-key-v1";
-#[cfg(target_os = "macos")]
-const YOUTUBE_COOKIE_ENCRYPTED_FILE: &str = "youtube-music-session-v1.bin";
-const YOUTUBE_LOGIN_WINDOW: &str = "youtube-music-login";
-const YOUTUBE_LOGIN_URL: &str = "https://accounts.google.com/ServiceLogin?service=youtube&continue=https%3A%2F%2Fmusic.youtube.com%2F";
-const YOUTUBE_PLAYER_API_URL: &str = "https://www.youtube.com/youtubei/v1/player?key=AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8&prettyPrint=false";
-const YOUTUBE_MUSIC_PLAYER_API_URL: &str = "https://music.youtube.com/youtubei/v1/player?key=AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8&prettyPrint=false";
-#[cfg(target_os = "macos")]
-const MACOS_LOGIN_USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15";
-const YOUTUBE_COOKIE_CHUNK_SIZE: usize = 900;
-const YOUTUBE_COOKIE_MAX_CHUNKS: usize = 16;
-const DEFAULT_CACHE_MAX_BYTES: u64 = 4 * 1024 * 1024 * 1024;
-const CURRENT_LOG_FILE_NAME: &str = "current.log";
-
-static APP_LOG_FILE: OnceLock>> = OnceLock::new();
-
-struct CacheLock(Mutex<()>);
-struct AppSettingsLock(Mutex<()>);
-
-#[derive(Deserialize, Serialize)]
-#[serde(rename_all = "camelCase")]
-struct CacheSettings {
- max_bytes: u64,
-}
-
-#[derive(Deserialize, Serialize)]
-#[serde(rename_all = "camelCase")]
-struct CacheEntry {
- key: String,
- value: String,
- updated_at_ms: u64,
- last_accessed_ms: u64,
-}
-
-#[derive(Serialize)]
-#[serde(rename_all = "camelCase")]
-struct CacheStats {
- max_bytes: u64,
- used_bytes: u64,
- entry_count: usize,
-}
-
-#[derive(Serialize)]
-struct CacheWriteResult {
- changed: bool,
-}
-
-#[derive(Serialize)]
-#[serde(rename_all = "camelCase")]
-struct LocalAudioFile {
- path: String,
- title: String,
- album: Option,
- duration_sec: Option,
-}
-
-fn cache_error(message: impl Into) -> CommandError {
- CommandError {
- message: message.into(),
- }
-}
-
-fn signed_googlevideo_local_address(url: &url::Url) -> Option {
- if !url
- .host_str()
- .is_some_and(|host| host.ends_with(".googlevideo.com"))
- {
- return None;
- }
-
- let signed_ip = url.query_pairs().find_map(|(key, value)| {
- (key == "ip")
- .then(|| value.parse::().ok())
- .flatten()
- })?;
-
- Some(match signed_ip {
- IpAddr::V4(_) => IpAddr::V4(Ipv4Addr::UNSPECIFIED),
- IpAddr::V6(_) => IpAddr::V6(Ipv6Addr::UNSPECIFIED),
- })
-}
-
-fn local_audio_mime_type(path: &Path) -> &'static str {
- match path
- .extension()
- .and_then(|extension| extension.to_str())
- .unwrap_or_default()
- .to_ascii_lowercase()
- .as_str()
- {
- "mp3" => "audio/mpeg",
- "m4a" | "mp4" => "audio/mp4",
- "aac" => "audio/aac",
- "flac" => "audio/flac",
- "wav" => "audio/wav",
- "ogg" | "oga" => "audio/ogg",
- "opus" => "audio/opus",
- "webm" => "audio/webm",
- _ => "application/octet-stream",
- }
-}
-
-fn is_local_audio_file(path: &Path) -> bool {
- matches!(
- path.extension()
- .and_then(|extension| extension.to_str())
- .unwrap_or_default()
- .to_ascii_lowercase()
- .as_str(),
- "mp3" | "m4a" | "mp4" | "aac" | "flac" | "wav" | "ogg" | "oga" | "opus" | "webm"
- )
-}
-
-fn local_audio_title(path: &Path) -> String {
- path.file_stem()
- .and_then(|name| name.to_str())
- .filter(|name| !name.trim().is_empty())
- .unwrap_or("Untitled")
- .to_string()
-}
-
-fn scan_local_audio_path(path: &Path, files: &mut Vec) -> Result<(), CommandError> {
- let metadata = match fs::metadata(path) {
- Ok(metadata) => metadata,
- Err(_) => return Ok(()),
- };
-
- if metadata.is_file() {
- if is_local_audio_file(path) {
- files.push(LocalAudioFile {
- path: path.to_string_lossy().to_string(),
- title: local_audio_title(path),
- album: path
- .parent()
- .and_then(|parent| parent.file_name())
- .and_then(|name| name.to_str())
- .map(|name| name.to_string()),
- duration_sec: None,
- });
- }
- return Ok(());
- }
-
- if !metadata.is_dir() {
- return Ok(());
- }
-
- let entries = fs::read_dir(path).map_err(|error| CommandError {
- message: format!("local audio directory read failed: {error}"),
- })?;
-
- for entry in entries.flatten() {
- scan_local_audio_path(&entry.path(), files)?;
- }
-
- Ok(())
-}
-
-#[tauri::command]
-fn local_audio_scan(paths: Vec) -> Result, CommandError> {
- let mut files = Vec::new();
- for path in paths {
- let trimmed_path = path.trim();
- if trimmed_path.is_empty() {
- continue;
- }
- scan_local_audio_path(Path::new(trimmed_path), &mut files)?;
- }
- files.sort_by(|left, right| left.path.to_lowercase().cmp(&right.path.to_lowercase()));
- files.dedup_by(|left, right| left.path == right.path);
- Ok(files)
-}
-
-#[tauri::command]
-fn local_audio_read(path: String) -> Result {
- let path = PathBuf::from(path);
- if !path.is_file() || !is_local_audio_file(&path) {
- return Err(CommandError {
- message: "local audio file is unavailable.".to_string(),
- });
- }
- let bytes = fs::read(&path).map_err(|error| CommandError {
- message: format!("local audio read failed: {error}"),
- })?;
- Ok(AudioPayload {
- body_base64: STANDARD.encode(bytes),
- mime_type: local_audio_mime_type(&path).to_string(),
- })
-}
-
-fn cache_root(app: &tauri::AppHandle) -> Result {
- app.path()
- .app_cache_dir()
- .map(|path| path.join("data-cache-v1"))
- .map_err(|error| cache_error(format!("cache directory unavailable: {error}")))
-}
-
-fn cache_entries_dir(app: &tauri::AppHandle) -> Result {
- Ok(cache_root(app)?.join("entries"))
-}
-
-fn cache_settings_path(app: &tauri::AppHandle) -> Result {
- Ok(cache_root(app)?.join("settings.json"))
-}
-
-fn now_ms() -> u64 {
- SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .unwrap_or_default()
- .as_millis() as u64
-}
-
-fn cache_key_hash(key: &str) -> u64 {
- let mut hash = 0xcbf29ce484222325_u64;
- for byte in key.as_bytes() {
- hash ^= u64::from(*byte);
- hash = hash.wrapping_mul(0x100000001b3);
- }
- hash
-}
-
-fn cache_entry_path(app: &tauri::AppHandle, key: &str) -> Result {
- Ok(cache_entries_dir(app)?.join(format!("{:016x}.json", cache_key_hash(key))))
-}
-
-fn write_json_file(path: &Path, value: &T) -> Result<(), CommandError> {
- if let Some(parent) = path.parent() {
- fs::create_dir_all(parent)
- .map_err(|error| cache_error(format!("cache directory creation failed: {error}")))?;
- }
- let bytes = serde_json::to_vec(value)
- .map_err(|error| cache_error(format!("cache serialization failed: {error}")))?;
- let temp_path = path.with_extension("tmp");
- fs::write(&temp_path, bytes)
- .map_err(|error| cache_error(format!("cache write failed: {error}")))?;
- if path.exists() {
- fs::remove_file(path)
- .map_err(|error| cache_error(format!("cache replacement failed: {error}")))?;
- }
- fs::rename(&temp_path, path)
- .map_err(|error| cache_error(format!("cache finalize failed: {error}")))
-}
-
-fn app_settings_path(app: &tauri::AppHandle) -> Result {
- app.path()
- .app_data_dir()
- .map(|path| path.join("settings-v1.json"))
- .map_err(|error| CommandError {
- message: format!("application settings directory unavailable: {error}"),
- })
-}
-
-fn read_app_settings(
- app: &tauri::AppHandle,
-) -> Result, CommandError> {
- let path = app_settings_path(app)?;
- if !path.exists() {
- return Ok(HashMap::new());
- }
-
- let bytes = fs::read(path).map_err(|error| CommandError {
- message: format!("application settings read failed: {error}"),
- })?;
- if bytes.iter().all(|byte| byte.is_ascii_whitespace()) {
- return Ok(HashMap::new());
- }
- serde_json::from_slice(&bytes).map_err(|error| CommandError {
- message: format!("application settings parse failed: {error}"),
- })
-}
-
-#[tauri::command]
-fn app_setting_get(
- app: tauri::AppHandle,
- lock: tauri::State<'_, AppSettingsLock>,
- key: String,
-) -> Result, CommandError> {
- let _guard = lock.0.lock().map_err(|_| CommandError {
- message: "application settings lock unavailable".to_string(),
- })?;
- Ok(read_app_settings(&app)?.remove(&key))
-}
-
-#[tauri::command]
-fn app_setting_set(
- app: tauri::AppHandle,
- lock: tauri::State<'_, AppSettingsLock>,
- key: String,
- value: serde_json::Value,
-) -> Result<(), CommandError> {
- let _guard = lock.0.lock().map_err(|_| CommandError {
- message: "application settings lock unavailable".to_string(),
- })?;
- let mut settings = read_app_settings(&app)?;
- settings.insert(key, value);
- write_json_file(&app_settings_path(&app)?, &settings)
-}
-
-#[tauri::command]
-fn app_setting_remove(
- app: tauri::AppHandle,
- lock: tauri::State<'_, AppSettingsLock>,
- key: String,
-) -> Result<(), CommandError> {
- let _guard = lock.0.lock().map_err(|_| CommandError {
- message: "application settings lock unavailable".to_string(),
- })?;
- let mut settings = read_app_settings(&app)?;
- settings.remove(&key);
- write_json_file(&app_settings_path(&app)?, &settings)
-}
-
-fn current_log_path(app: &tauri::AppHandle) -> Result {
- app.path()
- .app_log_dir()
- .map(|path| path.join(CURRENT_LOG_FILE_NAME))
- .map_err(|error| CommandError {
- message: format!("log directory unavailable: {error}"),
- })
-}
-
-fn initialize_app_log(app: &tauri::AppHandle) -> Result<(), CommandError> {
- let log_path = current_log_path(app)?;
- let log_dir = log_path.parent().ok_or_else(|| CommandError {
- message: "log directory unavailable".to_string(),
- })?;
-
- fs::create_dir_all(log_dir).map_err(|error| CommandError {
- message: format!("log directory creation failed: {error}"),
- })?;
-
- if let Ok(entries) = fs::read_dir(log_dir) {
- for entry in entries.flatten() {
- let path = entry.path();
- if path != log_path
- && path
- .extension()
- .is_some_and(|extension| extension.eq_ignore_ascii_case("log"))
- {
- let _ = fs::remove_file(path);
- }
- }
- }
-
- let file = OpenOptions::new()
- .create(true)
- .write(true)
- .truncate(true)
- .open(&log_path)
- .map_err(|error| CommandError {
- message: format!("log file creation failed: {error}"),
- })?;
-
- let log_file = APP_LOG_FILE.get_or_init(|| Mutex::new(None));
- if let Ok(mut guard) = log_file.lock() {
- *guard = Some(file);
- }
-
- append_log_line(format_args!(
- "[internal][tauri][info] log initialized path={}",
- log_path.display()
- ));
- Ok(())
-}
-
-pub(crate) fn append_log_line(args: fmt::Arguments<'_>) {
- let Some(log_file) = APP_LOG_FILE.get() else {
- return;
- };
- let Ok(mut guard) = log_file.lock() else {
- return;
- };
- let Some(file) = guard.as_mut() else {
- return;
- };
- let _ = writeln!(file, "{args}");
-}
-
-pub(crate) fn sanitize_log_message(message: &str) -> String {
- message
- .split_whitespace()
- .map(sanitize_log_token)
- .collect::>()
- .join(" ")
-}
-
-fn sanitize_log_token(token: &str) -> String {
- if let Some((key, value)) = token.split_once('=') {
- let normalized_key = key
- .trim_matches(|character: char| !character.is_ascii_alphanumeric() && character != '_')
- .to_ascii_lowercase();
- if normalized_key.contains("cookie")
- || normalized_key.contains("authorization")
- || normalized_key.contains("credential")
- || normalized_key.contains("token")
- || normalized_key.contains("secret")
- || normalized_key.contains("password")
- || normalized_key.contains("signature")
- || normalized_key.contains("cipher")
- || normalized_key.contains("visitor")
- {
- return format!("{key}=[redacted]");
- }
- if value.starts_with("http://") || value.starts_with("https://") {
- return format!("{key}={}", sanitize_log_url(value));
- }
- }
-
- if token.starts_with("http://") || token.starts_with("https://") {
- return sanitize_log_url(token);
- }
-
- token.to_string()
-}
-
-fn sanitize_log_url(value: &str) -> String {
- match url::Url::parse(value) {
- Ok(mut parsed) => {
- parsed.set_query(None);
- parsed.set_fragment(None);
- format!(
- "{}{}",
- parsed.as_str().trim_end_matches('/'),
- if value.contains('?') {
- "?[redacted]"
- } else {
- ""
- }
- )
- }
- Err(_) => "[redacted-url]".to_string(),
- }
-}
-
-#[tauri::command]
-fn open_current_log(app: tauri::AppHandle) -> Result<(), CommandError> {
- let log_path = current_log_path(&app)?;
- if !log_path.exists() {
- initialize_app_log(&app)?;
- }
- tauri_plugin_opener::open_path(&log_path, None::<&str>).map_err(|error| CommandError {
- message: format!("unable to open log file: {error}"),
- })
-}
-
-#[tauri::command]
-fn app_settings_clear(
- app: tauri::AppHandle,
- lock: tauri::State<'_, AppSettingsLock>,
-) -> Result<(), CommandError> {
- let _guard = lock.0.lock().map_err(|_| CommandError {
- message: "application settings lock unavailable".to_string(),
- })?;
- let path = app_settings_path(&app)?;
- if path.exists() {
- fs::remove_file(path).map_err(|error| CommandError {
- message: format!("application settings clear failed: {error}"),
- })?;
- }
- Ok(())
-}
-
-fn read_cache_settings(app: &tauri::AppHandle) -> Result {
- let path = cache_settings_path(app)?;
- if !path.exists() {
- return Ok(CacheSettings {
- max_bytes: DEFAULT_CACHE_MAX_BYTES,
- });
- }
- let bytes = fs::read(path)
- .map_err(|error| cache_error(format!("cache settings read failed: {error}")))?;
- serde_json::from_slice(&bytes)
- .map_err(|error| cache_error(format!("cache settings parse failed: {error}")))
-}
-
-fn cache_files(app: &tauri::AppHandle) -> Result, CommandError> {
- let directory = cache_entries_dir(app)?;
- if !directory.exists() {
- return Ok(Vec::new());
- }
- let files = fs::read_dir(directory)
- .map_err(|error| cache_error(format!("cache directory read failed: {error}")))?
- .filter_map(|entry| entry.ok().map(|entry| entry.path()))
- .filter(|path| {
- path.extension()
- .is_some_and(|extension| extension == "json")
- })
- .collect::>();
- Ok(files)
-}
-
-fn read_cache_entry(path: &Path) -> Result {
- let bytes =
- fs::read(path).map_err(|error| cache_error(format!("cache entry read failed: {error}")))?;
- serde_json::from_slice(&bytes)
- .map_err(|error| cache_error(format!("cache entry parse failed: {error}")))
-}
-
-fn calculate_cache_stats(app: &tauri::AppHandle) -> Result {
- let files = cache_files(app)?;
- let used_bytes = files
- .iter()
- .filter_map(|path| fs::metadata(path).ok().map(|metadata| metadata.len()))
- .sum();
- Ok(CacheStats {
- max_bytes: read_cache_settings(app)?.max_bytes,
- used_bytes,
- entry_count: files.len(),
- })
-}
-
-fn enforce_cache_limit(app: &tauri::AppHandle) -> Result<(), CommandError> {
- let max_bytes = read_cache_settings(app)?.max_bytes;
- let mut entries = cache_files(app)?
- .into_iter()
- .filter_map(|path| {
- let size = fs::metadata(&path).ok()?.len();
- let last_accessed_ms = read_cache_entry(&path).ok()?.last_accessed_ms;
- Some((path, size, last_accessed_ms))
- })
- .collect::>();
- let mut used_bytes = entries.iter().map(|(_, size, _)| *size).sum::();
- entries.sort_by_key(|(_, _, last_accessed_ms)| *last_accessed_ms);
-
- for (path, size, _) in entries {
- if used_bytes <= max_bytes {
- break;
- }
- if fs::remove_file(path).is_ok() {
- used_bytes = used_bytes.saturating_sub(size);
- }
- }
- Ok(())
-}
-
-#[tauri::command]
-fn cache_get(
- app: tauri::AppHandle,
- lock: tauri::State<'_, CacheLock>,
- key: String,
-) -> Result, CommandError> {
- let _guard = lock
- .0
- .lock()
- .map_err(|_| cache_error("cache lock unavailable"))?;
- let path = cache_entry_path(&app, &key)?;
- if !path.exists() {
- return Ok(None);
- }
- let mut entry = match read_cache_entry(&path) {
- Ok(entry) if entry.key == key => entry,
- Ok(_) => return Ok(None),
- Err(_) => {
- let _ = fs::remove_file(path);
- return Ok(None);
- }
- };
- entry.last_accessed_ms = now_ms();
- let value = entry.value.clone();
- write_json_file(&path, &entry)?;
- Ok(Some(value))
-}
-
-#[tauri::command]
-fn cache_set(
- app: tauri::AppHandle,
- lock: tauri::State<'_, CacheLock>,
- key: String,
- value: String,
-) -> Result {
- let _guard = lock
- .0
- .lock()
- .map_err(|_| cache_error("cache lock unavailable"))?;
- let path = cache_entry_path(&app, &key)?;
- let existing = if path.exists() {
- read_cache_entry(&path)
- .ok()
- .filter(|entry| entry.key == key)
- } else {
- None
- };
- let changed = existing.as_ref().map_or(true, |entry| entry.value != value);
- let timestamp = now_ms();
- let entry = CacheEntry {
- key,
- value,
- updated_at_ms: if changed {
- timestamp
- } else {
- existing
- .as_ref()
- .map(|entry| entry.updated_at_ms)
- .unwrap_or(timestamp)
- },
- last_accessed_ms: timestamp,
- };
- write_json_file(&path, &entry)?;
- enforce_cache_limit(&app)?;
- Ok(CacheWriteResult { changed })
-}
-
-#[tauri::command]
-fn cache_stats(
- app: tauri::AppHandle,
- lock: tauri::State<'_, CacheLock>,
-) -> Result {
- let _guard = lock
- .0
- .lock()
- .map_err(|_| cache_error("cache lock unavailable"))?;
- calculate_cache_stats(&app)
-}
-
-#[tauri::command]
-fn cache_set_max_bytes(
- app: tauri::AppHandle,
- lock: tauri::State<'_, CacheLock>,
- max_bytes: u64,
-) -> Result {
- let _guard = lock
- .0
- .lock()
- .map_err(|_| cache_error("cache lock unavailable"))?;
- write_json_file(&cache_settings_path(&app)?, &CacheSettings { max_bytes })?;
- enforce_cache_limit(&app)?;
- calculate_cache_stats(&app)
-}
-
-#[tauri::command]
-fn cache_clear(
- app: tauri::AppHandle,
- lock: tauri::State<'_, CacheLock>,
-) -> Result {
- let _guard = lock
- .0
- .lock()
- .map_err(|_| cache_error("cache lock unavailable"))?;
- let entries = cache_entries_dir(&app)?;
- if entries.exists() {
- fs::remove_dir_all(&entries)
- .map_err(|error| cache_error(format!("cache clear failed: {error}")))?;
- }
- fs::create_dir_all(entries)
- .map_err(|error| cache_error(format!("cache directory creation failed: {error}")))?;
- calculate_cache_stats(&app)
-}
-
-#[tauri::command]
-fn greet(name: &str) -> String {
- format!("Hello, {}! You've been greeted from Rust!", name)
-}
-
-#[tauri::command]
-fn quit_app(app: tauri::AppHandle) {
- eprintln!("[internal][tauri][info] quit_app invoked");
- app.exit(0);
-}
-
-#[tauri::command]
-fn frontend_log(level: String, context: String, payload: String) {
- eprintln!("[internal][frontend][{}] {} {}", level, context, payload);
-}
-
-fn youtube_keyring_entry() -> Result {
- keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER).map_err(|error| CommandError {
- message: format!("credential store unavailable: {error}"),
- })
-}
-
-fn youtube_cookie_keyring_entry() -> Result {
- keyring::Entry::new(KEYRING_SERVICE, YOUTUBE_COOKIE_KEYRING_USER).map_err(|error| {
- CommandError {
- message: format!("credential store unavailable: {error}"),
- }
- })
-}
-
-fn youtube_cookie_chunk_entry(index: usize) -> Result {
- keyring::Entry::new(
- KEYRING_SERVICE,
- &format!("{YOUTUBE_COOKIE_KEYRING_USER}-{index}"),
- )
- .map_err(|error| CommandError {
- message: format!("credential store unavailable: {error}"),
- })
-}
-
-fn save_youtube_music_cookie_entries(cookie: &str) -> Result<(), CommandError> {
- let chunks = cookie
- .as_bytes()
- .chunks(YOUTUBE_COOKIE_CHUNK_SIZE)
- .map(|chunk| std::str::from_utf8(chunk).expect("YouTube cookie header must be UTF-8"))
- .collect::>();
-
- if chunks.len() > YOUTUBE_COOKIE_MAX_CHUNKS {
- return Err(CommandError {
- message: "YouTube Music session is too large for secure storage.".to_string(),
- });
- }
-
- eprintln!(
- "[internal][tauri][info] save_youtube_music_cookie chunks={} bytes={}",
- chunks.len(),
- cookie.len()
- );
- delete_youtube_music_cookie_entries()?;
- for (index, chunk) in chunks.iter().enumerate() {
- youtube_cookie_chunk_entry(index)?
- .set_password(chunk)
- .map_err(|error| CommandError {
- message: format!("YouTube Music session chunk {index} save failed: {error}"),
- })?;
- }
- youtube_cookie_keyring_entry()?
- .set_password(&format!("chunks:{}", chunks.len()))
- .map_err(|error| CommandError {
- message: format!("YouTube Music session manifest save failed: {error}"),
- })?;
- Ok(())
-}
-
-#[cfg(target_os = "macos")]
-fn youtube_cookie_encryption_key_entry() -> Result {
- keyring::Entry::new(KEYRING_SERVICE, YOUTUBE_COOKIE_ENCRYPTION_KEY_USER).map_err(|error| {
- CommandError {
- message: format!("credential store unavailable: {error}"),
- }
- })
-}
-
-#[cfg(target_os = "macos")]
-fn youtube_cookie_encrypted_file(app: &tauri::AppHandle) -> Result {
- app.path()
- .app_data_dir()
- .map(|path| path.join(YOUTUBE_COOKIE_ENCRYPTED_FILE))
- .map_err(|error| CommandError {
- message: format!("application data directory unavailable: {error}"),
- })
-}
-
-#[cfg(target_os = "macos")]
-fn load_or_create_cookie_encryption_key() -> Result<[u8; 32], CommandError> {
- let entry = youtube_cookie_encryption_key_entry()?;
- match entry.get_password() {
- Ok(encoded) => {
- let decoded = STANDARD.decode(encoded).map_err(|error| CommandError {
- message: format!("stored session encryption key is invalid: {error}"),
- })?;
- decoded.try_into().map_err(|_| CommandError {
- message: "stored session encryption key has an invalid length.".to_string(),
- })
- }
- Err(keyring::Error::NoEntry) => {
- let mut key = [0_u8; 32];
- OsRng.fill_bytes(&mut key);
- entry
- .set_password(&STANDARD.encode(key))
- .map_err(|error| CommandError {
- message: format!("session encryption key save failed: {error}"),
- })?;
- Ok(key)
- }
- Err(error) => Err(CommandError {
- message: format!("session encryption key load failed: {error}"),
- }),
- }
-}
-
-#[cfg(target_os = "macos")]
-fn save_youtube_music_cookie(app: &tauri::AppHandle, cookie: &str) -> Result<(), CommandError> {
- let key = load_or_create_cookie_encryption_key()?;
- let cipher = Aes256Gcm::new_from_slice(&key).map_err(|error| CommandError {
- message: format!("session encryption setup failed: {error}"),
- })?;
- let mut nonce_bytes = [0_u8; 12];
- OsRng.fill_bytes(&mut nonce_bytes);
- let encrypted = cipher
- .encrypt(Nonce::from_slice(&nonce_bytes), cookie.as_bytes())
- .map_err(|error| CommandError {
- message: format!("session encryption failed: {error}"),
- })?;
-
- let path = youtube_cookie_encrypted_file(app)?;
- if let Some(parent) = path.parent() {
- fs::create_dir_all(parent).map_err(|error| CommandError {
- message: format!("session directory creation failed: {error}"),
- })?;
- }
- let mut contents = Vec::with_capacity(nonce_bytes.len() + encrypted.len());
- contents.extend_from_slice(&nonce_bytes);
- contents.extend_from_slice(&encrypted);
- fs::write(path, contents).map_err(|error| CommandError {
- message: format!("encrypted session save failed: {error}"),
- })
-}
-
-#[cfg(not(target_os = "macos"))]
-fn save_youtube_music_cookie(_app: &tauri::AppHandle, cookie: &str) -> Result<(), CommandError> {
- save_youtube_music_cookie_entries(cookie)
-}
-
-fn delete_youtube_music_cookie_entries() -> Result<(), CommandError> {
- match youtube_cookie_keyring_entry()?.delete_credential() {
- Ok(()) | Err(keyring::Error::NoEntry) => {}
- Err(error) => {
- return Err(CommandError {
- message: format!("YouTube Music session manifest delete failed: {error}"),
- });
- }
- }
-
- for index in 0..YOUTUBE_COOKIE_MAX_CHUNKS {
- match youtube_cookie_chunk_entry(index)?.delete_credential() {
- Ok(()) | Err(keyring::Error::NoEntry) => {}
- Err(error) => {
- return Err(CommandError {
- message: format!("YouTube Music session chunk {index} delete failed: {error}"),
- });
- }
- }
- }
- Ok(())
-}
-
-#[tauri::command]
-fn save_youtube_credentials(credentials_json: String) -> Result<(), CommandError> {
- youtube_keyring_entry()?
- .set_password(&credentials_json)
- .map_err(|error| CommandError {
- message: format!("credential save failed: {error}"),
- })
-}
-
-#[tauri::command]
-fn load_youtube_credentials() -> Result, CommandError> {
- match youtube_keyring_entry()?.get_password() {
- Ok(credentials) => Ok(Some(credentials)),
- Err(keyring::Error::NoEntry) => Ok(None),
- Err(error) => Err(CommandError {
- message: format!("credential load failed: {error}"),
- }),
- }
-}
-
-#[tauri::command]
-fn delete_youtube_credentials() -> Result<(), CommandError> {
- match youtube_keyring_entry()?.delete_credential() {
- Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
- Err(error) => Err(CommandError {
- message: format!("credential delete failed: {error}"),
- }),
- }
-}
-
-fn load_youtube_music_cookie_entries() -> Result , CommandError> {
- match youtube_cookie_keyring_entry()?.get_password() {
- Ok(manifest) if manifest.starts_with("chunks:") => {
- let chunk_count = manifest
- .trim_start_matches("chunks:")
- .parse::()
- .map_err(|error| CommandError {
- message: format!("invalid YouTube Music session manifest: {error}"),
- })?;
- if chunk_count == 0 || chunk_count > YOUTUBE_COOKIE_MAX_CHUNKS {
- return Err(CommandError {
- message: "invalid YouTube Music session chunk count.".to_string(),
- });
- }
-
- let mut cookie = String::new();
- for index in 0..chunk_count {
- let chunk = youtube_cookie_chunk_entry(index)?
- .get_password()
- .map_err(|error| CommandError {
- message: format!(
- "YouTube Music session chunk {index} load failed: {error}"
- ),
- })?;
- cookie.push_str(&chunk);
- }
- eprintln!(
- "[internal][tauri][info] load_youtube_music_cookie assembled chunks={} bytes={}",
- chunk_count,
- cookie.len(),
- );
- Ok(Some(cookie))
- }
- Ok(cookie) => {
- eprintln!(
- "[internal][tauri][info] load_youtube_music_cookie found legacy credential bytes={}",
- cookie.len()
- );
- Ok(Some(cookie))
- }
- Err(keyring::Error::NoEntry) => {
- eprintln!("[internal][tauri][info] load_youtube_music_cookie no credential");
- Ok(None)
- }
- Err(error) => Err(CommandError {
- message: format!("YouTube Music session load failed: {error}"),
- }),
- }
-}
-
-#[cfg(target_os = "macos")]
-fn load_encrypted_youtube_music_cookie(
- app: &tauri::AppHandle,
-) -> Result, CommandError> {
- let path = youtube_cookie_encrypted_file(app)?;
- let contents = match fs::read(path) {
- Ok(contents) => contents,
- Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
- Err(error) => {
- return Err(CommandError {
- message: format!("encrypted session load failed: {error}"),
- })
- }
- };
- if contents.len() <= 12 {
- return Err(CommandError {
- message: "encrypted session file is invalid.".to_string(),
- });
- }
- let key = load_or_create_cookie_encryption_key()?;
- let cipher = Aes256Gcm::new_from_slice(&key).map_err(|error| CommandError {
- message: format!("session decryption setup failed: {error}"),
- })?;
- let decrypted = cipher
- .decrypt(Nonce::from_slice(&contents[..12]), &contents[12..])
- .map_err(|error| CommandError {
- message: format!("session decryption failed: {error}"),
- })?;
- String::from_utf8(decrypted)
- .map(Some)
- .map_err(|error| CommandError {
- message: format!("decrypted session is invalid: {error}"),
- })
-}
-
-#[tauri::command]
-fn load_youtube_music_cookie(app: tauri::AppHandle) -> Result , CommandError> {
- #[cfg(target_os = "macos")]
- {
- if let Some(cookie) = load_encrypted_youtube_music_cookie(&app)? {
- return Ok(Some(cookie));
- }
- if let Some(cookie) = load_youtube_music_cookie_entries()? {
- save_youtube_music_cookie(&app, &cookie)?;
- delete_youtube_music_cookie_entries()?;
- return Ok(Some(cookie));
- }
- return Ok(None);
- }
-
- #[cfg(not(target_os = "macos"))]
- {
- let _ = app;
- load_youtube_music_cookie_entries()
- }
-}
-
-#[cfg(any(target_os = "macos", test))]
-fn cookie_domain_matches(host: &str, cookie_domain: Option<&str>) -> bool {
- let Some(cookie_domain) = cookie_domain else {
- return false;
- };
- let cookie_domain = cookie_domain.trim_start_matches('.');
-
- host.eq_ignore_ascii_case(cookie_domain)
- || host
- .strip_suffix(cookie_domain)
- .is_some_and(|prefix| prefix.ends_with('.'))
-}
-
-#[tauri::command]
-async fn sign_in_youtube_music(app: tauri::AppHandle) -> Result {
- eprintln!("[internal][tauri][info] sign_in_youtube_music start");
- if let Some(existing) = app.get_webview_window(YOUTUBE_LOGIN_WINDOW) {
- eprintln!("[internal][tauri][info] sign_in_youtube_music closing existing login window");
- let _ = existing.close();
- }
-
- let login_url = YOUTUBE_LOGIN_URL.parse().map_err(|error| CommandError {
- message: format!("invalid YouTube Music sign-in URL: {error}"),
- })?;
- let blank_url = "about:blank".parse().map_err(|error| CommandError {
- message: format!("invalid blank login URL: {error}"),
- })?;
- let window_builder = tauri::WebviewWindowBuilder::new(
- &app,
- YOUTUBE_LOGIN_WINDOW,
- tauri::WebviewUrl::External(blank_url),
- )
- .title("Sign in to YouTube Music")
- .inner_size(520.0, 760.0);
- #[cfg(target_os = "macos")]
- let window_builder = window_builder.user_agent(MACOS_LOGIN_USER_AGENT);
- let window = window_builder.build().map_err(|error| CommandError {
- message: format!("unable to open YouTube Music sign-in: {error}"),
- })?;
- eprintln!("[internal][tauri][info] sign_in_youtube_music login window created");
- window
- .clear_all_browsing_data()
- .map_err(|error| CommandError {
- message: format!("unable to clear previous YouTube Music sign-in data: {error}"),
- })?;
- eprintln!("[internal][tauri][info] sign_in_youtube_music cleared login webview data");
- window.navigate(login_url).map_err(|error| CommandError {
- message: format!("unable to navigate to YouTube Music sign-in: {error}"),
- })?;
- eprintln!("[internal][tauri][info] sign_in_youtube_music navigated to Google sign-in");
-
- #[cfg(not(target_os = "macos"))]
- let cookie_url: url::Url =
- "https://music.youtube.com/"
- .parse()
- .map_err(|error| CommandError {
- message: format!("invalid YouTube Music cookie URL: {error}"),
- })?;
-
- for poll in 1..=300 {
- #[cfg(target_os = "macos")]
- let cookies = window
- .cookies()
- .map_err(|error| CommandError {
- message: format!("unable to read YouTube Music session: {error}"),
- })?
- .into_iter()
- .filter(|cookie| cookie_domain_matches("music.youtube.com", cookie.domain()))
- .collect::>();
- #[cfg(not(target_os = "macos"))]
- let cookies = window
- .cookies_for_url(cookie_url.clone())
- .map_err(|error| CommandError {
- message: format!("unable to read YouTube Music session: {error}"),
- })?;
- let cookie_names = cookies
- .iter()
- .map(|cookie| cookie.name())
- .collect::>();
- let has_auth_cookie = ["SAPISID", "__Secure-1PAPISID", "__Secure-3PAPISID"]
- .iter()
- .any(|name| cookie_names.contains(name));
- let on_music_page = window
- .url()
- .map(|url| url.domain() == Some("music.youtube.com"))
- .unwrap_or(false);
- let signed_in = has_auth_cookie && on_music_page;
- let current_url = window
- .url()
- .map(|url| url.to_string())
- .unwrap_or_else(|error| format!("[url unavailable: {error}]"));
- let cookie_metadata = cookies
- .iter()
- .map(|cookie| {
- format!(
- "{}(domain={:?},path={:?},secure={:?},http_only={:?})",
- cookie.name(),
- cookie.domain(),
- cookie.path(),
- cookie.secure(),
- cookie.http_only()
- )
- })
- .collect::>();
- eprintln!(
- "[internal][tauri][debug] sign_in_youtube_music poll={} url={} cookie_count={} cookies={:?} has_auth_cookie={} on_music_page={} signed_in={}",
- poll,
- current_url,
- cookies.len(),
- cookie_metadata,
- has_auth_cookie,
- on_music_page,
- signed_in
- );
-
- if signed_in {
- let cookie_header = cookies
- .iter()
- .map(|cookie| format!("{}={}", cookie.name(), cookie.value()))
- .collect::>()
- .join("; ");
- eprintln!(
- "[internal][tauri][info] sign_in_youtube_music detected session poll={} cookie_count={} credential_bytes={}",
- poll,
- cookies.len(),
- cookie_header.len()
- );
- save_youtube_music_cookie(&app, &cookie_header)?;
- eprintln!("[internal][tauri][info] sign_in_youtube_music credential saved");
- let _ = window.close();
- eprintln!("[internal][tauri][info] sign_in_youtube_music login window close requested");
- return Ok(cookie_header);
- }
-
- if app.get_webview_window(YOUTUBE_LOGIN_WINDOW).is_none() {
- eprintln!(
- "[internal][tauri][warn] sign_in_youtube_music cancelled poll={}",
- poll
- );
- return Err(CommandError {
- message: "YouTube Music sign-in was cancelled.".to_string(),
- });
- }
- thread::sleep(Duration::from_secs(1));
- }
-
- let _ = window.close();
- eprintln!("[internal][tauri][warn] sign_in_youtube_music timed out");
- Err(CommandError {
- message: "YouTube Music sign-in timed out.".to_string(),
- })
-}
-
-#[tauri::command]
-async fn delete_youtube_music_cookie(app: tauri::AppHandle) -> Result<(), CommandError> {
- eprintln!("[internal][tauri][info] delete_youtube_music_cookie start");
- if let Some(window) = app.get_webview_window(YOUTUBE_LOGIN_WINDOW) {
- let _ = window.clear_all_browsing_data();
- let _ = window.close();
- }
-
- #[cfg(target_os = "macos")]
- {
- let path = youtube_cookie_encrypted_file(&app)?;
- match fs::remove_file(path) {
- Ok(()) => {}
- Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
- Err(error) => {
- return Err(CommandError {
- message: format!("encrypted session delete failed: {error}"),
- })
- }
- }
- }
-
- #[cfg(not(target_os = "macos"))]
- delete_youtube_music_cookie_entries()?;
- eprintln!("[internal][tauri][info] delete_youtube_music_cookie complete");
- Ok(())
-}
-
-#[derive(Serialize)]
-struct CommandError {
- message: String,
-}
-
-#[derive(Serialize)]
-#[serde(rename_all = "camelCase")]
-struct AudioPayload {
- body_base64: String,
- mime_type: String,
-}
-
-#[derive(serde::Deserialize)]
-struct ProxyHttpRequestInput {
- url: String,
- method: String,
- headers: HashMap,
- body_base64: Option,
- timeout_ms: Option,
-}
-
-#[derive(Serialize)]
-struct ProxyHttpResponse {
- status: u16,
- headers: HashMap,
- body_base64: String,
-}
-
-#[derive(Clone)]
-struct MediaItem {
- bytes: Arc>,
- mime_type: String,
-}
-
-struct MediaServer {
- origin: String,
- items: Arc>>,
-}
-
-#[derive(Serialize)]
-#[serde(rename_all = "camelCase")]
-struct AudioSourcePayload {
- url: String,
- mime_type: String,
- byte_length: usize,
-}
-
-static MEDIA_SERVER: OnceLock = OnceLock::new();
-
-fn collect_json_renderer_counts(value: &serde_json::Value, counts: &mut HashMap) {
- match value {
- serde_json::Value::Object(object) => {
- for (key, child) in object {
- if key.ends_with("Renderer")
- || key.ends_with("Continuation")
- || key.ends_with("Command")
- {
- *counts.entry(key.clone()).or_default() += 1;
- }
- collect_json_renderer_counts(child, counts);
- }
- }
- serde_json::Value::Array(items) => {
- for item in items {
- collect_json_renderer_counts(item, counts);
- }
- }
- _ => {}
- }
-}
-
-fn media_server() -> Result<&'static MediaServer, CommandError> {
- if let Some(server) = MEDIA_SERVER.get() {
- return Ok(server);
- }
-
- let listener = TcpListener::bind(("127.0.0.1", 0)).map_err(|error| CommandError {
- message: format!("media server bind failed: {error}"),
- })?;
- let port = listener.local_addr().map_err(|error| CommandError {
- message: format!("media server local address failed: {error}"),
- })?.port();
- let items = Arc::new(Mutex::new(HashMap::::new()));
- let thread_items = Arc::clone(&items);
- thread::spawn(move || {
- for stream in listener.incoming() {
- match stream {
- Ok(stream) => {
- let items = Arc::clone(&thread_items);
- thread::spawn(move || handle_media_request(stream, items));
- }
- Err(error) => {
- eprintln!("[internal][tauri][warn] media server accept failed error={}", error);
- }
- }
- }
- });
-
- let server = MediaServer {
- origin: format!("http://127.0.0.1:{port}"),
- items,
- };
- let _ = MEDIA_SERVER.set(server);
- MEDIA_SERVER.get().ok_or_else(|| CommandError {
- message: "media server initialization failed".into(),
- })
-}
-
-fn handle_media_request(
- mut stream: std::net::TcpStream,
- items: Arc>>,
-) {
- let mut buffer = [0_u8; 4096];
- let read_len = match stream.read(&mut buffer) {
- Ok(len) => len,
- Err(error) => {
- eprintln!("[internal][tauri][warn] media server read failed error={}", error);
- return;
- }
- };
- let request = String::from_utf8_lossy(&buffer[..read_len]);
- let mut lines = request.lines();
- let request_line = lines.next().unwrap_or_default();
- let mut request_parts = request_line.split_whitespace();
- let method = request_parts.next().unwrap_or_default();
- let path = request_parts.next().unwrap_or_default();
- if method != "GET" && method != "HEAD" {
- let _ = stream.write_all(b"HTTP/1.1 405 Method Not Allowed\r\nContent-Length: 0\r\n\r\n");
- return;
- }
-
- let range_header = lines.find_map(|line| {
- line.strip_prefix("Range:")
- .or_else(|| line.strip_prefix("range:"))
- .map(str::trim)
- .map(str::to_string)
- });
- let key = path
- .trim_start_matches("/audio/")
- .split('?')
- .next()
- .unwrap_or_default();
- let item = match items.lock().ok().and_then(|items| items.get(key).cloned()) {
- Some(item) => item,
- None => {
- let _ = stream.write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n");
- return;
- }
- };
-
- let total_len = item.bytes.len();
- let (status, start, end) = parse_media_range(range_header.as_deref(), total_len)
- .unwrap_or(("200 OK", 0, total_len.saturating_sub(1)));
- let body_len = if total_len == 0 { 0 } else { end - start + 1 };
- let content_range = if status.starts_with("206") {
- format!("Content-Range: bytes {start}-{end}/{total_len}\r\n")
- } else {
- String::new()
- };
- let headers = format!(
- "HTTP/1.1 {status}\r\nContent-Type: {}\r\nAccept-Ranges: bytes\r\n{}Content-Length: {body_len}\r\nConnection: close\r\n\r\n",
- item.mime_type,
- content_range,
- );
- let _ = stream.write_all(headers.as_bytes());
- if method == "HEAD" || total_len == 0 {
- return;
- }
- let _ = stream.write_all(&item.bytes[start..=end]);
-}
-
-fn parse_media_range(range_header: Option<&str>, total_len: usize) -> Option<(&'static str, usize, usize)> {
- let value = range_header?.strip_prefix("bytes=")?;
- if total_len == 0 {
- return None;
- }
- let (start_raw, end_raw) = value.split_once('-')?;
- let start = if start_raw.is_empty() {
- let suffix_len = end_raw.parse::().ok()?;
- total_len.saturating_sub(suffix_len)
- } else {
- start_raw.parse::().ok()?
- };
- let end = if end_raw.is_empty() {
- total_len - 1
- } else {
- end_raw.parse::().ok()?.min(total_len - 1)
- };
- (start <= end && start < total_len).then_some(("206 Partial Content", start, end))
-}
-
-#[tauri::command]
-async fn fetch_audio_bytes(url: String, track_id: String) -> Result, CommandError> {
- let started_at = Instant::now();
- eprintln!(
- "[internal][tauri][info] fetch_audio_bytes start url={} track_id={}",
- url, track_id
- );
- let request_url = url::Url::parse(&url).map_err(|error| CommandError {
- message: format!("audio URL parse failed: {error}"),
- })?;
- let mut client_builder = reqwest::Client::builder();
- if let Some(local_address) = signed_googlevideo_local_address(&request_url) {
- eprintln!(
- "[internal][tauri][info] fetch_audio_bytes forcing signed IP family family={}",
- if local_address.is_ipv6() { "ipv6" } else { "ipv4" }
- );
- client_builder = client_builder.local_address(local_address);
- }
- let client = client_builder.build().map_err(|error| CommandError {
- message: format!("audio HTTP client creation failed: {error}"),
- })?;
- let response = client
- .get(&url)
- .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36")
- .header("Accept", "*/*")
- .header("Accept-Language", "en-US,en;q=0.9")
- .header("Accept-Encoding", "identity;q=1, *;q=0")
- .header("Range", "bytes=0-")
- .header("Origin", "https://www.youtube.com")
- .header("Referer", &format!("https://www.youtube.com/watch?v={}", track_id))
- .header("Sec-Fetch-Dest", "audio")
- .header("Sec-Fetch-Mode", "no-cors")
- .header("Sec-Fetch-Site", "cross-site")
- .send()
- .await
- .map_err(|error| {
- eprintln!(
- "[internal][tauri][error] fetch_audio_bytes request failed url={} error={}",
- url, error
- );
- CommandError {
- message: format!("request failed: {error}"),
- }
- })?;
-
- if !response.status().is_success() {
- eprintln!(
- "[internal][tauri][warn] fetch_audio_bytes non-success url={} status={}",
- url,
- response.status()
- );
- return Err(CommandError {
- message: format!("request returned {}", response.status()),
- });
- }
-
- let body = response.bytes().await.map_err(|error| {
- eprintln!(
- "[internal][tauri][error] fetch_audio_bytes body read failed url={} error={}",
- url, error
- );
- CommandError {
- message: format!("read body failed: {error}"),
- }
- })?;
-
- eprintln!(
- "[internal][tauri][info] fetch_audio_bytes success url={} bytes={} duration_ms={}",
- url,
- body.len(),
- started_at.elapsed().as_millis()
- );
-
- Ok(body.to_vec())
-}
-
-#[tauri::command]
-async fn fetch_audio_source(
- url: String,
- track_id: String,
- mime_type: String,
-) -> Result {
- let bytes = fetch_audio_bytes(url, track_id.clone()).await?;
- if bytes.len() >= 12 && &bytes[4..8] != b"ftyp" {
- let preview = String::from_utf8_lossy(&bytes[..bytes.len().min(120)]).replace('\n', " ");
- return Err(CommandError {
- message: format!("Audio download was not an MP4 file. Response started with: {preview}"),
- });
- }
-
- let server = media_server()?;
- let key = format!(
- "{}-{}",
- track_id,
- SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .map(|duration| duration.as_millis())
- .unwrap_or_default()
- );
- let byte_length = bytes.len();
- {
- let mut items = server.items.lock().map_err(|_| CommandError {
- message: "media server cache lock poisoned".into(),
- })?;
- items.insert(
- key.clone(),
- MediaItem {
- bytes: Arc::new(bytes),
- mime_type: mime_type.clone(),
- },
- );
- }
-
- Ok(AudioSourcePayload {
- url: format!("{}/audio/{}", server.origin, key),
- mime_type,
- byte_length,
- })
-}
-
-#[tauri::command]
-async fn fetch_youtube_music_audio(video_id: String) -> Result {
- let started_at = Instant::now();
- eprintln!(
- "[internal][tauri][info] fetch_youtube_music_audio start video_id={}",
- video_id
- );
-
- let client = reqwest::Client::new();
-
- // Mobile and TV clients are preferred because they are more likely to
- // return direct media URLs that do not require player-JavaScript deciphering.
- let api_attempts = vec![
- ("YouTube iOS", YOUTUBE_PLAYER_API_URL, create_ios_context()),
- (
- "YouTube ANDROID",
- YOUTUBE_PLAYER_API_URL,
- create_android_context(),
- ),
- ("YouTube TV", YOUTUBE_PLAYER_API_URL, create_tv_context()),
- ("YouTube WEB", YOUTUBE_PLAYER_API_URL, create_web_context()),
- (
- "YouTube Music WEB_REMIX",
- YOUTUBE_MUSIC_PLAYER_API_URL,
- create_web_remix_context(),
- ),
- ];
-
- let mut failures = Vec::new();
- for (attempt_name, api_url, context) in api_attempts {
- eprintln!(
- "[internal][tauri][info] fetch_youtube_music_audio trying {} video_id={}",
- attempt_name, video_id
- );
-
- match try_youtube_api(&client, &api_url, &context, &video_id, &attempt_name).await {
- Ok(audio_bytes) => {
- eprintln!(
- "[internal][tauri][info] fetch_youtube_music_audio success video_id={} attempt={} bytes={} duration_ms={}",
- video_id,
- attempt_name,
- audio_bytes.body_base64.len(),
- started_at.elapsed().as_millis()
- );
- return Ok(audio_bytes);
- }
- Err(error) => {
- eprintln!(
- "[internal][tauri][error] fetch_youtube_music_audio attempt failed video_id={} attempt={} error={}",
- video_id, attempt_name, error.message
- );
- failures.push(format!("{attempt_name}: {}", error.message));
- }
- }
- }
-
- eprintln!(
- "[internal][tauri][error] fetch_youtube_music_audio all attempts failed video_id={}",
- video_id
- );
- Err(CommandError {
- message: format!("all YouTube API attempts failed: {}", failures.join("; ")),
- })
-}
-
-fn create_web_remix_context() -> serde_json::Value {
- serde_json::json!({
- "client": {
- "clientName": "WEB_REMIX",
- "clientVersion": "1.20250506.00.00",
- "hl": "en",
- "gl": "US",
- "platform": "DESKTOP",
- "osName": "Windows",
- "osVersion": "10.0",
- "browserName": "Chrome",
- "browserVersion": "135.0.0.0",
- "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36"
- }
- })
-}
-
-fn create_web_context() -> serde_json::Value {
- serde_json::json!({
- "client": {
- "clientName": "WEB",
- "clientVersion": "2.20260206.01.00",
- "hl": "en",
- "gl": "US",
- "platform": "DESKTOP",
- "osName": "Windows",
- "osVersion": "10.0",
- "browserName": "Chrome",
- "browserVersion": "135.0.0.0",
- "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36"
- }
- })
-}
-
-fn create_ios_context() -> serde_json::Value {
- serde_json::json!({
- "client": {
- "clientName": "IOS",
- "clientVersion": "20.11.6",
- "hl": "en",
- "gl": "US",
- "deviceModel": "iPhone10,4",
- "osName": "iPhone",
- "osVersion": "16.7.7.20H330",
- "userAgent": "com.google.ios.youtube/20.11.6 (iPhone10,4; U; CPU iOS 16_7_7 like Mac OS X)"
- }
- })
-}
-
-fn create_android_context() -> serde_json::Value {
- serde_json::json!({
- "client": {
- "clientName": "ANDROID",
- "clientVersion": "21.03.36",
- "hl": "en",
- "gl": "US",
- "platform": "MOBILE",
- "osName": "Android",
- "osVersion": "16",
- "androidSdkVersion": 36,
- "userAgent": "com.google.android.youtube/21.03.36(Linux; U; Android 16; en_US; SM-S908E Build/TP1A.220624.014) gzip"
- }
- })
-}
-
-fn create_tv_context() -> serde_json::Value {
- serde_json::json!({
- "client": {
- "clientName": "TVHTML5",
- "clientVersion": "7.20260311.12.00",
- "hl": "en",
- "gl": "US",
- "platform": "TV",
- "osName": "Linux",
- "userAgent": "Mozilla/5.0 (ChromiumStylePlatform) Cobalt/Version"
- }
- })
-}
-
-async fn try_youtube_api(
- client: &reqwest::Client,
- api_url: &str,
- context: &serde_json::Value,
- video_id: &str,
- attempt_name: &str,
-) -> Result {
- let request_body = serde_json::json!({
- "context": context,
- "videoId": video_id,
- "racyCheckOk": true,
- "contentCheckOk": true
- });
-
- let request_body_str = serde_json::to_string(&request_body).map_err(|error| CommandError {
- message: format!("json serialize failed: {error}"),
- })?;
-
- let referer = if attempt_name.contains("Music") {
- "https://music.youtube.com/"
- } else {
- "https://www.youtube.com/"
- };
-
- let user_agent = if attempt_name.contains("iOS") {
- "com.google.ios.youtube/20.11.6 (iPhone10,4; U; CPU iOS 16_7_7 like Mac OS X)"
- } else if attempt_name.contains("ANDROID") {
- "com.google.android.youtube/21.03.36(Linux; U; Android 16; en_US; SM-S908E Build/TP1A.220624.014) gzip"
- } else if attempt_name.contains("TV") {
- "Mozilla/5.0 (ChromiumStylePlatform) Cobalt/Version"
- } else {
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36"
- };
- let client_name = if attempt_name.contains("iOS") {
- "5"
- } else if attempt_name.contains("ANDROID") {
- "3"
- } else if attempt_name.contains("Music") {
- "67"
- } else if attempt_name.contains("TV") {
- "7"
- } else {
- "1"
- };
- let client_version = context
- .get("client")
- .and_then(|client| client.get("clientVersion"))
- .and_then(|version| version.as_str())
- .unwrap_or_default();
-
- eprintln!(
- "[internal][tauri][debug] YOUTUBE API REQUEST - {} url={} body_bytes={}",
- attempt_name,
- api_url,
- request_body_str.len()
- );
-
- let response = client
- .post(api_url)
- .header("Content-Type", "application/json")
- .header("User-Agent", user_agent)
- .header("Accept", "application/json")
- .header("Accept-Language", "en-US,en;q=0.9")
- .header("X-YouTube-Client-Name", client_name)
- .header("X-YouTube-Client-Version", client_version)
- .header("Referer", referer)
- .header("Origin", referer.trim_end_matches('/'))
- .body(request_body_str)
- .send()
- .await
- .map_err(|error| CommandError {
- message: format!("api request failed: {error}"),
- })?;
-
- let response_status = response.status();
- let response_text = response.text().await.map_err(|error| CommandError {
- message: format!("response read failed: {error}"),
- })?;
- if !response_status.is_success() {
- let response_preview = response_text.chars().take(500).collect::();
- return Err(CommandError {
- message: format!("api request returned {response_status}: {response_preview}"),
- });
- }
-
- eprintln!(
- "[internal][tauri][debug] YOUTUBE API RESPONSE - {} - response_bytes={}",
- attempt_name,
- response_text.len()
- );
-
- let response_json: serde_json::Value =
- serde_json::from_str(&response_text).map_err(|error| CommandError {
- message: format!("json parse failed: {error}"),
- })?;
- let visitor_data = response_json
- .get("responseContext")
- .and_then(|context| context.get("visitorData"))
- .and_then(|value| value.as_str());
-
- // LOG PARSED RESPONSE STRUCTURE
- eprintln!(
- "[internal][tauri][debug] YOUTUBE API RESPONSE - {} - PARSED STRUCTURE",
- attempt_name
- );
- eprintln!(
- "[internal][tauri][debug] YOUTUBE API RESPONSE - {} - TOP LEVEL KEYS: {:?}",
- attempt_name,
- response_json
- .as_object()
- .map(|obj| obj.keys().collect::>())
- .unwrap_or_default()
- );
-
- // Check for playability status first
- if let Some(playability_status) = response_json.get("playabilityStatus") {
- let status = playability_status
- .get("status")
- .and_then(|value| value.as_str())
- .unwrap_or("unknown");
- let reason = playability_status
- .get("reason")
- .and_then(|value| value.as_str())
- .unwrap_or_default();
- eprintln!(
- "[internal][tauri][debug] YOUTUBE API RESPONSE - {} - PLAYABILITY STATUS status={} has_reason={}",
- attempt_name, status, !reason.is_empty()
- );
-
- if status != "OK" {
- eprintln!(
- "[internal][tauri][warn] YOUTUBE API RESPONSE - {} - VIDEO NOT PLAYABLE: status={} has_reason={}",
- attempt_name, status, !reason.is_empty()
- );
- return Err(CommandError {
- message: format!("video not playable: {status}"),
- });
- }
- }
-
- // Check for video details
- if let Some(video_details) = response_json.get("videoDetails") {
- let duration_seconds = video_details
- .get("lengthSeconds")
- .and_then(|value| value.as_str())
- .unwrap_or_default();
- eprintln!(
- "[internal][tauri][debug] YOUTUBE API RESPONSE - {} - VIDEO DETAILS has_title={} duration_seconds={}",
- attempt_name,
- video_details.get("title").is_some(),
- duration_seconds
- );
- }
-
- // Check for streaming data existence
- let has_streaming_data = response_json.get("streamingData").is_some();
- eprintln!(
- "[internal][tauri][debug] YOUTUBE API RESPONSE - {} - HAS STREAMING DATA: {}",
- attempt_name, has_streaming_data
- );
-
- if has_streaming_data {
- if let Some(streaming_data) = response_json.get("streamingData") {
- eprintln!(
- "[internal][tauri][debug] YOUTUBE API RESPONSE - {} - STREAMING DATA KEYS: {:?}",
- attempt_name,
- streaming_data
- .as_object()
- .map(|obj| obj.keys().collect::>())
- .unwrap_or_default()
- );
-
- // Log adaptive formats if they exist
- if let Some(adaptive_formats) = streaming_data.get("adaptiveFormats") {
- if let Some(formats_array) = adaptive_formats.as_array() {
- eprintln!(
- "[internal][tauri][debug] YOUTUBE API RESPONSE - {} - ADAPTIVE FORMATS COUNT: {}",
- attempt_name,
- formats_array.len()
- );
-
- // Count audio vs video formats
- let mut audio_count = 0;
- let mut video_count = 0;
- let mut audio_with_url = 0;
- let mut video_with_url = 0;
-
- for format in formats_array {
- if let Some(format_obj) = format.as_object() {
- if let Some(mime_type) =
- format_obj.get("mimeType").and_then(|m| m.as_str())
- {
- if mime_type.contains("audio") {
- audio_count += 1;
- if format_obj.get("url").is_some() {
- audio_with_url += 1;
- }
- } else if mime_type.contains("video") {
- video_count += 1;
- if format_obj.get("url").is_some() {
- video_with_url += 1;
- }
- }
- }
- }
- }
-
- eprintln!(
- "[internal][tauri][debug] YOUTUBE API RESPONSE - {} - FORMAT SUMMARY: audio_total={}, audio_with_url={}, video_total={}, video_with_url={}",
- attempt_name,
- audio_count,
- audio_with_url,
- video_count,
- video_with_url
- );
- }
- }
-
- // Log regular formats if they exist
- if let Some(formats) = streaming_data.get("formats") {
- if let Some(formats_array) = formats.as_array() {
- eprintln!(
- "[internal][tauri][debug] YOUTUBE API RESPONSE - {} - REGULAR FORMATS COUNT: {}",
- attempt_name,
- formats_array.len()
- );
- }
- }
- }
- }
-
- // Look for streaming data in the response
- let streaming_data = response_json
- .get("streamingData")
- .and_then(|sd| sd.get("adaptiveFormats"))
- .and_then(|af| af.as_array())
- .ok_or_else(|| {
- eprintln!(
- "[internal][tauri][error] YOUTUBE API RESPONSE - {} - NO STREAMING DATA FOUND",
- attempt_name
- );
- CommandError {
- message: "no streaming data found".to_string(),
- }
- })?;
-
- // Ciphered formats require YouTube's player JavaScript. This backend only
- // accepts direct URLs instead of sending an invalid encrypted signature.
- let mut best_audio_url: Option = None;
- let mut best_mime_type: Option = None;
- let mut best_is_mp4 = false;
- let mut best_bitrate: u32 = 0;
-
- for format in streaming_data {
- if let Some(format_obj) = format.as_object() {
- if let (Some(mime_type), Some(bitrate)) = (
- format_obj.get("mimeType"),
- format_obj.get("bitrate").and_then(|b| b.as_u64()),
- ) {
- if let Some(mime_str) = mime_type.as_str() {
- let is_mp4 = mime_str.starts_with("audio/mp4");
- let is_better = is_mp4 && !best_is_mp4
- || is_mp4 == best_is_mp4 && bitrate > best_bitrate as u64;
- if mime_str.starts_with("audio/") && is_better {
- if let Some(url) = format_obj.get("url").and_then(|u| u.as_str()) {
- best_audio_url = Some(url.to_string());
- best_mime_type = Some(
- mime_str
- .split(';')
- .next()
- .unwrap_or("audio/mp4")
- .to_string(),
- );
- best_is_mp4 = is_mp4;
- best_bitrate = bitrate as u32;
- }
- }
- }
- }
- }
- }
-
- let audio_url = best_audio_url.ok_or_else(|| CommandError {
- message: "no suitable audio format found".to_string(),
- })?;
- let mime_type = best_mime_type.unwrap_or_else(|| "audio/mp4".to_string());
-
- eprintln!(
- "[internal][tauri][debug] Attempting to download audio from URL (first 200 chars): {}",
- audio_url.chars().take(200).collect::()
- );
-
- let audio_url_parsed = url::Url::parse(&audio_url).map_err(|error| CommandError {
- message: format!("audio URL parse failed: {error}"),
- })?;
- let mut audio_client_builder = reqwest::Client::builder();
- if let Some(local_address) = signed_googlevideo_local_address(&audio_url_parsed) {
- eprintln!(
- "[internal][tauri][info] fetch_youtube_music_audio forcing signed IP family attempt={} family={}",
- attempt_name,
- if local_address.is_ipv6() { "ipv6" } else { "ipv4" }
- );
- audio_client_builder = audio_client_builder.local_address(local_address);
- }
- let audio_client = audio_client_builder.build().map_err(|error| CommandError {
- message: format!("audio HTTP client creation failed: {error}"),
- })?;
-
- // Download the audio
- let mut audio_request = audio_client
- .get(&audio_url)
- .header("User-Agent", user_agent)
- .header("Accept", "*/*")
- .header("Accept-Language", "en-US,en;q=0.9")
- .header("Accept-Encoding", "identity;q=1, *;q=0")
- .header("Range", "bytes=0-");
-
- if let Some(visitor_data) = visitor_data {
- audio_request = audio_request.header("X-Goog-Visitor-Id", visitor_data);
- }
-
- if !attempt_name.contains("iOS") && !attempt_name.contains("ANDROID") {
- audio_request = audio_request
- .header("Referer", referer)
- .header("Origin", referer.trim_end_matches('/'))
- .header("Sec-Fetch-Dest", "audio")
- .header("Sec-Fetch-Mode", "no-cors")
- .header("Sec-Fetch-Site", "cross-site");
- }
-
- let audio_response = audio_request.send().await.map_err(|error| CommandError {
- message: format!("download failed: {error}"),
- })?;
-
- if !audio_response.status().is_success() {
- return Err(CommandError {
- message: format!("download returned {}", audio_response.status()),
- });
- }
-
- let audio_body = audio_response.bytes().await.map_err(|error| CommandError {
- message: format!("download body read failed: {error}"),
- })?;
-
- Ok(AudioPayload {
- body_base64: STANDARD.encode(audio_body),
- mime_type,
- })
-}
-
-#[tauri::command]
-async fn proxy_http_request(
- input: ProxyHttpRequestInput,
-) -> Result {
- let started_at = Instant::now();
- let request_url = url::Url::parse(&input.url).map_err(|error| CommandError {
- message: format!("invalid URL: {error}"),
- })?;
- let request_target = format!(
- "{}://{}{}",
- request_url.scheme(),
- request_url.host_str().unwrap_or("unknown"),
- request_url.path()
- );
- eprintln!(
- "[internal][tauri][info] proxy_http_request start method={} url={} headers={} has_body={}",
- input.method,
- request_target,
- input.headers.len(),
- input.body_base64.is_some()
- );
-
- eprintln!("[internal][tauri][debug] proxy_http_request headers:");
- for (key, value) in &input.headers {
- let normalized_key = key.to_ascii_lowercase();
- let safe_value = if normalized_key == "authorization" || normalized_key == "cookie" {
- "[redacted]"
- } else {
- value
- };
- eprintln!(" {}: {}", key, safe_value);
- }
- let method = reqwest::Method::from_bytes(input.method.as_bytes()).map_err(|error| {
- eprintln!(
- "[internal][tauri][error] proxy_http_request invalid method={} error={}",
- input.method, error
- );
- CommandError {
- message: format!("invalid method: {error}"),
- }
- })?;
-
- let mut client_builder = reqwest::Client::builder()
- .user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36");
-
- if let Some(timeout_ms) = input.timeout_ms {
- client_builder = client_builder.timeout(Duration::from_millis(timeout_ms));
- }
-
- if let Some(local_address) = signed_googlevideo_local_address(&request_url) {
- eprintln!(
- "[internal][tauri][info] proxy_http_request forcing signed IP family url={} family={}",
- request_target,
- if local_address.is_ipv6() { "ipv6" } else { "ipv4" }
- );
- client_builder = client_builder.local_address(local_address);
- }
-
- let client = client_builder.build().map_err(|error| CommandError {
- message: format!("HTTP client creation failed: {error}"),
- })?;
- let mut request = client.request(method, &input.url);
-
- for (key, value) in &input.headers {
- request = request.header(key, value);
- }
-
- if let Some(body_base64) = input.body_base64 {
- let bytes = STANDARD.decode(body_base64).map_err(|error| {
- eprintln!(
- "[internal][tauri][error] proxy_http_request body decode failed url={} error={}",
- input.url, error
- );
- CommandError {
- message: format!("invalid body encoding: {error}"),
- }
- })?;
- request = request.body(bytes);
- }
-
- let response = request.send().await.map_err(|error| {
- eprintln!(
- "[internal][tauri][error] proxy_http_request request failed url={} error={}",
- input.url, error
- );
- CommandError {
- message: format!("request failed: {error}"),
- }
- })?;
-
- let status = response.status().as_u16();
- let mut headers = HashMap::new();
- for (key, value) in response.headers() {
- if let Ok(value_str) = value.to_str() {
- headers.insert(key.to_string(), value_str.to_string());
- }
- }
-
- let body = response.bytes().await.map_err(|error| {
- eprintln!(
- "[internal][tauri][error] proxy_http_request body read failed url={} error={}",
- input.url, error
- );
- CommandError {
- message: format!("read body failed: {error}"),
- }
- })?;
-
- if request_url.path().ends_with("/browse") && status < 400 {
- if let Ok(json) = serde_json::from_slice::(&body) {
- let top_level_keys = json
- .as_object()
- .map(|object| object.keys().cloned().collect::>())
- .unwrap_or_default();
- let mut renderer_counts = HashMap::new();
- collect_json_renderer_counts(&json, &mut renderer_counts);
- eprintln!(
- "[internal][tauri][debug] proxy_http_request browse_shape top_level_keys={:?} renderer_counts={:?}",
- top_level_keys, renderer_counts
- );
- }
- }
-
- if status >= 400 {
- eprintln!(
- "[internal][tauri][warn] proxy_http_request error_response method={} url={} status={} bytes={}",
- input.method,
- request_target,
- status,
- body.len()
- );
- }
-
- eprintln!(
- "[internal][tauri][info] proxy_http_request success method={} url={} status={} bytes={} duration_ms={}",
- input.method,
- request_target,
- status,
- body.len(),
- started_at.elapsed().as_millis()
- );
-
- Ok(ProxyHttpResponse {
- status,
- headers,
- body_base64: STANDARD.encode(body),
- })
-}
-
-#[tauri::command]
-fn discord_rpc_update(
- discord_manager: tauri::State<
- '_,
- std::sync::Arc>,
- >,
- title: String,
- artist: String,
- album: String,
- artwork_url: Option,
- song_url: Option,
- artist_url: Option,
- album_url: Option,
- duration: u64,
- current_time: u64,
- is_playing: bool,
-) -> Result<(), CommandError> {
- let data = discord_rpc::DiscordPresenceData {
- title,
- artist,
- album,
- artwork_url,
- song_url,
- artist_url,
- album_url,
- duration,
- current_time,
- is_playing,
- };
-
- match discord_manager.lock() {
- Ok(manager) => {
- if let Err(e) = manager.update_presence(data) {
- eprintln!("[internal][discord_rpc] failed to update presence: {}", e);
- // Don't return error - Discord might not be running
- }
- }
- Err(e) => {
- eprintln!("[internal][discord_rpc] failed to lock manager: {}", e);
- }
- }
- Ok(())
-}
-
-#[tauri::command]
-fn discord_rpc_clear(
- discord_manager: tauri::State<
- '_,
- std::sync::Arc>,
- >,
-) -> Result<(), CommandError> {
- match discord_manager.lock() {
- Ok(manager) => {
- if let Err(e) = manager.clear_presence() {
- eprintln!("[internal][discord_rpc] failed to clear presence: {}", e);
- }
- }
- Err(e) => {
- eprintln!("[internal][discord_rpc] failed to lock manager: {}", e);
- }
- }
- Ok(())
-}
-
-#[cfg_attr(mobile, tauri::mobile_entry_point)]
-pub fn run() {
- // Initialize Discord RPC manager
- let discord_manager =
- std::sync::Arc::new(std::sync::Mutex::new(discord_rpc::DiscordRpcManager::new()));
-
- #[allow(unused_mut)]
- let mut context = tauri::generate_context!();
- #[cfg(target_os = "linux")]
- {
- for window in &mut context.config_mut().app.windows {
- if window.label == "main" {
- window.decorations = true;
- }
- }
- }
- #[allow(unused_mut)]
- let mut builder = tauri::Builder::default()
- .manage(CacheLock(Mutex::new(())))
- .manage(AppSettingsLock(Mutex::new(())))
- .manage(discord_manager)
- .plugin(tauri_plugin_autostart::Builder::new().build())
- .plugin(tauri_plugin_updater::Builder::new().build())
- .plugin(tauri_plugin_process::init())
- .plugin(tauri_plugin_dialog::init())
- .plugin(tauri_plugin_opener::init());
-
- #[cfg(not(debug_assertions))]
- {
- let port = pick_unused_port().expect("failed to find an unused localhost port");
- let url: url::Url = format!("http://localhost:{}", port)
- .parse()
- .expect("failed to parse localhost url");
- let _window_url = WindowUrl::External(url.clone());
-
- context.config_mut().build.frontend_dist = Some(FrontendDist::Url(url));
- builder = builder.plugin(tauri_plugin_localhost::Builder::new(port).build());
- }
-
- #[cfg(target_os = "windows")]
- let builder = builder.manage(windows_media::WindowsMediaSession::new());
- #[cfg(target_os = "macos")]
- let builder = builder.manage(macos_media::MacosMediaSession::new());
-
- builder
- .setup(|app| {
- if let Err(error) = initialize_app_log(app.handle()) {
- std::eprintln!("[internal][tauri][warn] {}", error.message);
- }
- Ok(())
- })
- .on_window_event(move |window, event| match event {
- tauri::WindowEvent::CloseRequested { api, .. } => {
- eprintln!(
- "[internal][tauri][info] window close requested label={}",
- window.label()
- );
- if window.label() == "main" {
- api.prevent_close();
- window.app_handle().exit(0);
- }
- }
- tauri::WindowEvent::Focused(false) => {
- if window.label() == "main" {
- let app = window.app_handle().clone();
- thread::spawn(move || {
- thread::sleep(Duration::from_millis(100));
-
- if let Some(main) = app.get_webview_window("main") {
- if let Ok(true) = main.is_focused() {
- return;
- }
- }
-
- if let Some(mini) = app.get_webview_window("mini-player") {
- if let Ok(true) = mini.is_focused() {
- return;
- }
- }
-
- let _ = app.emit("main-window-backgrounded", ());
- });
- }
- }
- tauri::WindowEvent::Focused(true) => {
- if window.label() == "main" {
- let _ = window.app_handle().emit("window-focused", ());
- }
- }
- _ => {}
- })
- .invoke_handler(tauri::generate_handler![
- greet,
- quit_app,
- frontend_log,
- app_setting_get,
- app_setting_set,
- app_setting_remove,
- app_settings_clear,
- open_current_log,
- fetch_audio_bytes,
- fetch_audio_source,
- fetch_youtube_music_audio,
- proxy_http_request,
- save_youtube_credentials,
- load_youtube_credentials,
- delete_youtube_credentials,
- load_youtube_music_cookie,
- sign_in_youtube_music,
- delete_youtube_music_cookie,
- cache_get,
- cache_set,
- cache_stats,
- cache_set_max_bytes,
- cache_clear,
- local_audio_scan,
- local_audio_read,
- lastfm::lastfm_auth_token,
- lastfm::lastfm_complete_auth,
- lastfm::lastfm_disconnect,
- lastfm::lastfm_get_session,
- lastfm::lastfm_scrobble,
- lastfm::lastfm_update_now_playing,
- discord_rpc_update,
- discord_rpc_clear,
- #[cfg(target_os = "macos")]
- macos_media::update_macos_media_session,
- #[cfg(target_os = "windows")]
- windows_media::update_windows_media_session
- ])
- .run(context)
- .expect("error while running tauri application");
-}
-
-#[cfg(test)]
-mod tests {
- use super::cookie_domain_matches;
-
- #[test]
- fn cookie_domain_matches_exact_and_parent_domains() {
- assert!(cookie_domain_matches(
- "music.youtube.com",
- Some("music.youtube.com")
- ));
- assert!(cookie_domain_matches(
- "music.youtube.com",
- Some(".youtube.com")
- ));
- assert!(cookie_domain_matches(
- "music.youtube.com",
- Some("youtube.com")
- ));
- }
-
- #[test]
- fn cookie_domain_rejects_unrelated_and_partial_domains() {
- assert!(!cookie_domain_matches(
- "music.youtube.com",
- Some("accounts.google.com")
- ));
- assert!(!cookie_domain_matches(
- "music.youtube.com",
- Some("notyoutube.com")
- ));
- assert!(!cookie_domain_matches("music.youtube.com", None));
- }
-}
+// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
+use base64::{engine::general_purpose::STANDARD, Engine as _};
+#[cfg(not(debug_assertions))]
+use portpicker::pick_unused_port;
+use serde::{Deserialize, Serialize};
+use std::collections::HashMap;
+use std::fmt;
+use std::fs::{self, File, OpenOptions};
+use std::io::{Read, Write};
+use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, TcpListener};
+use std::path::{Path, PathBuf};
+use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
+use std::sync::{Arc, Mutex, OnceLock};
+use std::thread;
+use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
+
+macro_rules! eprintln {
+ ($($arg:tt)*) => {{
+ let message = $crate::sanitize_log_message(&format!($($arg)*));
+ std::eprintln!("{}", message);
+ $crate::append_log_line(format_args!("{}", message));
+ }};
+}
+
+#[cfg(not(debug_assertions))]
+use tauri::utils::config::FrontendDist;
+#[cfg(not(debug_assertions))]
+use tauri::utils::config_v1::WindowUrl;
+
+use tauri::{Emitter, Manager};
+
+#[cfg(target_os = "macos")]
+use aes_gcm::{
+ aead::{Aead, KeyInit},
+ Aes256Gcm, Nonce,
+};
+#[cfg(target_os = "macos")]
+use rand::{rngs::OsRng, RngCore};
+
+#[cfg(target_os = "macos")]
+mod macos_media;
+#[cfg(target_os = "windows")]
+mod windows_media;
+
+mod discord_rpc;
+mod lastfm;
+
+// Keep the legacy service name so existing sign-in credentials survive the product rename.
+const KEYRING_SERVICE: &str = "com.ytmusicdock.app";
+
+/// Durable settings store. Also the marker the app-data migration checks for.
+const APP_SETTINGS_FILE_NAME: &str = "settings-v1.json";
+
+/// Copies the pre-rename app-data directory into the current one, once.
+///
+/// Runs on every start but does nothing after the first: the presence of a settings file in
+/// the new location is the "already migrated" marker. Deliberately a copy rather than a
+/// move, so a half-finished run cannot destroy the only copy of the user's settings — the
+/// old directory is left untouched for them to delete when they are satisfied.
+///
+/// Only the roaming data directory is migrated. Caches, logs and the webview profile live in
+/// the local data directory and all regenerate on their own; copying them would mean moving
+/// hundreds of megabytes to no benefit.
+fn migrate_legacy_app_data(app: &tauri::AppHandle) {
+ let Ok(new_dir) = app.path().app_data_dir() else {
+ return;
+ };
+ // Marker: anything already written here means this ran before, or the user is new.
+ if new_dir.join(APP_SETTINGS_FILE_NAME).exists() {
+ return;
+ }
+ let Some(base) = new_dir.parent() else {
+ return;
+ };
+ let legacy_dir = base.join(LEGACY_BUNDLE_IDENTIFIER);
+ if !legacy_dir.is_dir() || legacy_dir == new_dir {
+ return;
+ }
+
+ if let Err(error) = copy_dir_contents(&legacy_dir, &new_dir) {
+ eprintln!("[internal][tauri][warn] legacy app data migration failed: {error}");
+ return;
+ }
+ eprintln!(
+ "[internal][tauri][info] migrated app data from {}",
+ legacy_dir.display()
+ );
+}
+
+fn copy_dir_contents(from: &std::path::Path, to: &std::path::Path) -> std::io::Result<()> {
+ std::fs::create_dir_all(to)?;
+ for entry in std::fs::read_dir(from)? {
+ let entry = entry?;
+ let target = to.join(entry.file_name());
+ if entry.file_type()?.is_dir() {
+ copy_dir_contents(&entry.path(), &target)?;
+ } else {
+ std::fs::copy(entry.path(), &target)?;
+ }
+ }
+ Ok(())
+}
+
+/// Bundle identifier used before the rename to Zuno.
+///
+/// Tauri derives the app-data directory from the identifier, so changing it points the app
+/// at an empty folder and strands every stored preference — including user-created local
+/// playlists. `migrate_legacy_app_data` copies the old directory across once.
+///
+/// Sign-in credentials are unaffected: they live in the OS keyring under `KEYRING_SERVICE`,
+/// which is deliberately decoupled from the identifier.
+const LEGACY_BUNDLE_IDENTIFIER: &str = "com.justanothermusicclient.desktop";
+const KEYRING_USER: &str = "youtube-oauth";
+const YOUTUBE_COOKIE_KEYRING_USER: &str = "youtube-music-cookie";
+#[cfg(target_os = "macos")]
+const YOUTUBE_COOKIE_ENCRYPTION_KEY_USER: &str = "youtube-music-cookie-encryption-key-v1";
+#[cfg(target_os = "macos")]
+const YOUTUBE_COOKIE_ENCRYPTED_FILE: &str = "youtube-music-session-v1.bin";
+const YOUTUBE_LOGIN_WINDOW: &str = "youtube-music-login";
+const YOUTUBE_LOGIN_URL: &str = "https://accounts.google.com/ServiceLogin?service=youtube&continue=https%3A%2F%2Fmusic.youtube.com%2F";
+/// Storage partition for the sign-in webview, so clearing it cannot touch the app's own.
+///
+/// `clear_all_browsing_data` is a *profile*-wide operation on every platform — WebView2 calls
+/// `ClearBrowsingDataAll` on the profile, WKWebView empties the shared default data store — and
+/// every webview in the process shares one profile by default. The login window used to clear
+/// that shared profile twice per sign-in cycle, which wiped the main window's localStorage:
+/// the downloads manifest, local playlists, play history, tabs, shortcuts, the lot. Worse, a
+/// lost manifest made the next launch delete every downloaded file as an orphan.
+const YOUTUBE_LOGIN_DATA_DIR: &str = "youtube-login-webview";
+/// The same partition across launches, so the login window is not a fresh profile every time.
+#[cfg(target_os = "macos")]
+const YOUTUBE_LOGIN_DATA_STORE_ID: [u8; 16] = [
+ 0x7a, 0x75, 0x6e, 0x6f, 0x6c, 0x6f, 0x67, 0x69, 0x6e, 0x77, 0x65, 0x62, 0x76, 0x69, 0x65, 0x77,
+];
+const YOUTUBE_PLAYER_API_URL: &str = "https://www.youtube.com/youtubei/v1/player?key=AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8&prettyPrint=false";
+const YOUTUBE_MUSIC_PLAYER_API_URL: &str = "https://music.youtube.com/youtubei/v1/player?key=AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8&prettyPrint=false";
+#[cfg(target_os = "macos")]
+const MACOS_LOGIN_USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15";
+const YOUTUBE_COOKIE_CHUNK_SIZE: usize = 900;
+const YOUTUBE_COOKIE_MAX_CHUNKS: usize = 16;
+/// How often a rotation of `YOUTUBE_SLOW_PERSIST_COOKIES` is written back to secure storage.
+///
+/// Not on every response: Google rotates SIDCC on almost all of them, and the Windows
+/// credential store takes sixteen writes per save. Every *other* rotation is written at once —
+/// see `YOUTUBE_SLOW_PERSIST_COOKIES` for why that distinction is the whole game.
+const YOUTUBE_COOKIE_PERSIST_INTERVAL: Duration = Duration::from_secs(300);
+/// Cookies whose rotation may be persisted late, because nothing authenticates with them.
+///
+/// Everything else — `__Secure-*PSIDTS` above all — is written the moment it changes. Google
+/// retires the superseded value, so quitting inside the throttle window left a dead credential
+/// on disk and the next launch presented it: the session looked lost overnight when it was only
+/// lost between the last rotation and the last write.
+const YOUTUBE_SLOW_PERSIST_COOKIES: [&str; 3] = ["SIDCC", "__Secure-1PSIDCC", "__Secure-3PSIDCC"];
+/// Seconds a silent renewal may spend before giving up and asking the user.
+///
+/// Generous enough for a cold WebView2 start plus the Google redirect chain, because the cost of
+/// being a second too impatient is a sign-in prompt the user did not need.
+const YOUTUBE_SILENT_REFRESH_POLLS: u32 = 25;
+const DEFAULT_CACHE_MAX_BYTES: u64 = 4 * 1024 * 1024 * 1024;
+const CURRENT_LOG_FILE_NAME: &str = "current.log";
+
+static APP_LOG_FILE: OnceLock>> = OnceLock::new();
+
+struct CacheLock(Mutex<()>);
+struct AppSettingsLock(Mutex<()>);
+
+/// The live YouTube cookie, kept current from every response that rotates it.
+///
+/// The signed-in session used to be a snapshot taken once at sign-in and replayed forever,
+/// with every `Set-Cookie` thrown away. Google rotates `SIDCC` and `__Secure-*PSIDTS`
+/// continuously and stops trusting a session that keeps presenting stale ones, so the app
+/// quietly lost its authentication overnight while still looking signed in. This is the one
+/// authoritative copy: `proxy_http_request` stamps outgoing requests from it and folds every
+/// `Set-Cookie` back into it.
+#[derive(Default)]
+struct CookieJarState {
+ cookie: Option,
+ persisted_at: Option,
+}
+
+struct YoutubeCookieJar(Mutex);
+
+fn parse_cookie_header(header: &str) -> Vec<(String, String)> {
+ header
+ .split(';')
+ .filter_map(|part| part.trim().split_once('='))
+ .map(|(name, value)| (name.trim().to_string(), value.trim().to_string()))
+ .collect()
+}
+
+fn serialize_cookie_pairs(pairs: &[(String, String)]) -> String {
+ pairs
+ .iter()
+ .map(|(name, value)| format!("{name}={value}"))
+ .collect::>()
+ .join("; ")
+}
+
+/// The leading `name=value` of a `Set-Cookie` value.
+///
+/// The attributes after it describe where a browser should send the cookie, and this jar has
+/// exactly one destination.
+fn split_set_cookie(set_cookie: &str) -> Option<(&str, &str)> {
+ let (name, value) = set_cookie.split(';').next()?.split_once('=')?;
+ let name = name.trim();
+ if name.is_empty() {
+ return None;
+ }
+ Some((name, value.trim()))
+}
+
+/// Folds one `Set-Cookie` value into the jar, reporting whether anything changed.
+fn apply_set_cookie(pairs: &mut Vec<(String, String)>, set_cookie: &str) -> bool {
+ let Some((name, value)) = split_set_cookie(set_cookie) else {
+ return false;
+ };
+ let name = name.to_string();
+ let value = value.to_string();
+
+ // Google clears a cookie by echoing it back empty or as a tombstone rather than by
+ // omitting it. Keeping those would keep presenting a value the server has retired.
+ if value.is_empty() || value == "EXPIRED" || value == "deleted" {
+ let before = pairs.len();
+ pairs.retain(|(existing, _)| existing != &name);
+ return pairs.len() != before;
+ }
+
+ match pairs.iter_mut().find(|(existing, _)| existing == &name) {
+ Some(entry) if entry.1 == value => false,
+ Some(entry) => {
+ entry.1 = value;
+ true
+ }
+ None => {
+ pairs.push((name, value));
+ true
+ }
+ }
+}
+
+fn is_youtube_cookie_host(url: &url::Url) -> bool {
+ url.host_str()
+ .is_some_and(|host| host == "youtube.com" || host.ends_with(".youtube.com"))
+}
+
+/// Whether this rotation may wait for `YOUTUBE_COOKIE_PERSIST_INTERVAL` before being stored.
+fn is_slow_persist_cookie(set_cookie: &str) -> bool {
+ split_set_cookie(set_cookie)
+ .is_some_and(|(name, _)| YOUTUBE_SLOW_PERSIST_COOKIES.contains(&name))
+}
+
+/// Which Google account a cookie header belongs to, as far as the app needs to care.
+///
+/// Used for one question: did signing in again land on the same account? A re-mint of a lapsed
+/// session keeps this value, so answering "same" is what lets a renewal keep the cached library
+/// and the chosen channel instead of resyncing from scratch. It is a fingerprint, never logged
+/// and never handed to the frontend — only the boolean answer is.
+fn cookie_account_identity(cookie: &str) -> Option {
+ let pairs = parse_cookie_header(cookie);
+ ["SAPISID", "__Secure-3PAPISID", "__Secure-1PAPISID"]
+ .iter()
+ .find_map(|name| {
+ pairs
+ .iter()
+ .find(|(existing, value)| existing == name && !value.is_empty())
+ .map(|(_, value)| value.clone())
+ })
+}
+
+/// Merges rotated cookies into the jar, returning the new header when it changed.
+fn refresh_youtube_cookie_jar(
+ app: &tauri::AppHandle,
+ jar: &YoutubeCookieJar,
+ set_cookies: &[String],
+) -> Option {
+ let (merged, should_persist) = {
+ let mut state = jar.0.lock().ok()?;
+ // No stored session means an anonymous request; it has no jar to update.
+ let mut pairs = parse_cookie_header(state.cookie.as_deref()?);
+ // Every cookie is applied; `any` would stop at the first change and drop the rest.
+ let mut changed = false;
+ let mut credential_changed = false;
+ for set_cookie in set_cookies {
+ if apply_set_cookie(&mut pairs, set_cookie) {
+ changed = true;
+ credential_changed |= !is_slow_persist_cookie(set_cookie);
+ }
+ }
+ if !changed {
+ return None;
+ }
+
+ let merged = serialize_cookie_pairs(&pairs);
+ state.cookie = Some(merged.clone());
+ // A rotated credential is written now; only the noisy ones wait for the interval.
+ let should_persist = credential_changed
+ || state
+ .persisted_at
+ .is_none_or(|at| at.elapsed() >= YOUTUBE_COOKIE_PERSIST_INTERVAL);
+ if should_persist {
+ state.persisted_at = Some(Instant::now());
+ }
+ (merged, should_persist)
+ };
+
+ // Outside the guard: the keyring write is slow and every other request wants the jar.
+ if should_persist {
+ match save_youtube_music_cookie(app, &merged) {
+ Ok(()) => eprintln!(
+ "[internal][tauri][info] youtube cookie rotated and persisted bytes={}",
+ merged.len()
+ ),
+ Err(error) => eprintln!(
+ "[internal][tauri][warn] youtube cookie persist failed: {}",
+ error.message
+ ),
+ }
+ }
+ Some(merged)
+}
+
+#[derive(Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct CacheSettings {
+ max_bytes: u64,
+}
+
+#[derive(Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct CacheEntry {
+ key: String,
+ value: String,
+ updated_at_ms: u64,
+ last_accessed_ms: u64,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct CacheStats {
+ max_bytes: u64,
+ used_bytes: u64,
+ entry_count: usize,
+}
+
+#[derive(Serialize)]
+struct CacheWriteResult {
+ changed: bool,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct LocalAudioFile {
+ path: String,
+ title: String,
+ album: Option,
+ duration_sec: Option,
+}
+
+fn cache_error(message: impl Into) -> CommandError {
+ CommandError {
+ message: message.into(),
+ }
+}
+
+fn signed_googlevideo_local_address(url: &url::Url) -> Option {
+ if !url
+ .host_str()
+ .is_some_and(|host| host.ends_with(".googlevideo.com"))
+ {
+ return None;
+ }
+
+ let signed_ip = url.query_pairs().find_map(|(key, value)| {
+ (key == "ip")
+ .then(|| value.parse::().ok())
+ .flatten()
+ })?;
+
+ Some(match signed_ip {
+ IpAddr::V4(_) => IpAddr::V4(Ipv4Addr::UNSPECIFIED),
+ IpAddr::V6(_) => IpAddr::V6(Ipv6Addr::UNSPECIFIED),
+ })
+}
+
+fn local_audio_mime_type(path: &Path) -> &'static str {
+ match path
+ .extension()
+ .and_then(|extension| extension.to_str())
+ .unwrap_or_default()
+ .to_ascii_lowercase()
+ .as_str()
+ {
+ "mp3" => "audio/mpeg",
+ "m4a" | "mp4" => "audio/mp4",
+ "aac" => "audio/aac",
+ "flac" => "audio/flac",
+ "wav" => "audio/wav",
+ "ogg" | "oga" => "audio/ogg",
+ "opus" => "audio/opus",
+ "webm" => "audio/webm",
+ _ => "application/octet-stream",
+ }
+}
+
+fn is_local_audio_file(path: &Path) -> bool {
+ matches!(
+ path.extension()
+ .and_then(|extension| extension.to_str())
+ .unwrap_or_default()
+ .to_ascii_lowercase()
+ .as_str(),
+ "mp3" | "m4a" | "mp4" | "aac" | "flac" | "wav" | "ogg" | "oga" | "opus" | "webm"
+ )
+}
+
+fn local_audio_title(path: &Path) -> String {
+ path.file_stem()
+ .and_then(|name| name.to_str())
+ .filter(|name| !name.trim().is_empty())
+ .unwrap_or("Untitled")
+ .to_string()
+}
+
+fn scan_local_audio_path(path: &Path, files: &mut Vec) -> Result<(), CommandError> {
+ let metadata = match fs::metadata(path) {
+ Ok(metadata) => metadata,
+ Err(_) => return Ok(()),
+ };
+
+ if metadata.is_file() {
+ if is_local_audio_file(path) {
+ files.push(LocalAudioFile {
+ path: path.to_string_lossy().to_string(),
+ title: local_audio_title(path),
+ album: path
+ .parent()
+ .and_then(|parent| parent.file_name())
+ .and_then(|name| name.to_str())
+ .map(|name| name.to_string()),
+ duration_sec: None,
+ });
+ }
+ return Ok(());
+ }
+
+ if !metadata.is_dir() {
+ return Ok(());
+ }
+
+ let entries = fs::read_dir(path).map_err(|error| CommandError {
+ message: format!("local audio directory read failed: {error}"),
+ })?;
+
+ for entry in entries.flatten() {
+ scan_local_audio_path(&entry.path(), files)?;
+ }
+
+ Ok(())
+}
+
+#[tauri::command]
+fn local_audio_scan(paths: Vec) -> Result, CommandError> {
+ let mut files = Vec::new();
+ for path in paths {
+ let trimmed_path = path.trim();
+ if trimmed_path.is_empty() {
+ continue;
+ }
+ scan_local_audio_path(Path::new(trimmed_path), &mut files)?;
+ }
+ files.sort_by(|left, right| left.path.to_lowercase().cmp(&right.path.to_lowercase()));
+ files.dedup_by(|left, right| left.path == right.path);
+ Ok(files)
+}
+
+#[derive(Serialize, Deserialize, Default)]
+#[serde(rename_all = "camelCase")]
+struct LocalAudioTags {
+ title: Option,
+ artist: Option,
+ album: Option,
+ album_artist: Option,
+ genre: Option,
+ track_number: Option,
+ year: Option,
+}
+
+/// Reads real tags from a file.
+///
+/// The scanner falls back to the filename and parent folder, which is fine for a quick list
+/// but wrong often enough that an editor needs the actual values — otherwise "save" would
+/// write the guessed name back into the file as if it were the truth.
+/// Reads a user-chosen text file. Used for playlist import.
+///
+/// Size-capped: the picker lets someone choose any file at all, and reading a multi-gigabyte
+/// one into a string to discover it is not a playlist would take the app down with it.
+#[tauri::command]
+fn read_text_file(path: String) -> Result {
+ const MAX_IMPORT_BYTES: u64 = 16 * 1024 * 1024;
+
+ let path = PathBuf::from(path);
+ let metadata = fs::metadata(&path)
+ .map_err(|error| cache_error(format!("file read failed: {error}")))?;
+ if metadata.len() > MAX_IMPORT_BYTES {
+ return Err(cache_error("that file is too large to be a playlist"));
+ }
+
+ fs::read_to_string(&path).map_err(|error| cache_error(format!("file read failed: {error}")))
+}
+
+/// Writes a user-chosen text file. Used for playlist export.
+#[tauri::command]
+fn write_text_file(path: String, contents: String) -> Result<(), CommandError> {
+ let path = PathBuf::from(path);
+ if let Some(parent) = path.parent() {
+ fs::create_dir_all(parent)
+ .map_err(|error| cache_error(format!("directory creation failed: {error}")))?;
+ }
+ fs::write(&path, contents).map_err(|error| cache_error(format!("file write failed: {error}")))
+}
+
+#[tauri::command]
+fn local_audio_read_tags(path: String) -> Result {
+ use lofty::file::TaggedFileExt;
+ use lofty::tag::Accessor;
+
+ let path = PathBuf::from(path);
+ if !path.is_file() || !is_local_audio_file(&path) {
+ return Err(cache_error("local audio file is unavailable."));
+ }
+
+ let tagged = lofty::read_from_path(&path)
+ .map_err(|error| cache_error(format!("tag read failed: {error}")))?;
+ let Some(tag) = tagged.primary_tag().or_else(|| tagged.first_tag()) else {
+ return Ok(LocalAudioTags::default());
+ };
+
+ Ok(LocalAudioTags {
+ title: tag.title().map(|value| value.to_string()),
+ artist: tag.artist().map(|value| value.to_string()),
+ album: tag.album().map(|value| value.to_string()),
+ album_artist: tag
+ .get_string(lofty::tag::ItemKey::AlbumArtist)
+ .map(|value| value.to_string()),
+ genre: tag.genre().map(|value| value.to_string()),
+ track_number: tag.track(),
+ // Year lives under a plain item key rather than an Accessor method in lofty 0.24.
+ year: tag
+ .get_string(lofty::tag::ItemKey::Year)
+ .and_then(|value| value.trim().get(..4).and_then(|text| text.parse().ok())),
+ })
+}
+
+#[tauri::command]
+fn local_audio_write_tags(path: String, tags: LocalAudioTags) -> Result<(), CommandError> {
+ use lofty::config::WriteOptions;
+ use lofty::file::{AudioFile, TaggedFileExt};
+ use lofty::tag::{Accessor, ItemKey, Tag};
+
+ let path = PathBuf::from(path);
+ if !path.is_file() || !is_local_audio_file(&path) {
+ return Err(cache_error("local audio file is unavailable."));
+ }
+
+ let mut tagged = lofty::read_from_path(&path)
+ .map_err(|error| cache_error(format!("tag read failed: {error}")))?;
+
+ // A file with no tag at all still needs somewhere to put these, so one is created in the
+ // format that file type expects rather than failing the edit.
+ let tag_type = tagged.primary_tag_type();
+ if tagged.primary_tag().is_none() {
+ tagged.insert_tag(Tag::new(tag_type));
+ }
+ let Some(tag) = tagged.primary_tag_mut() else {
+ return Err(cache_error("this file cannot store tags"));
+ };
+
+ fn apply(tag: &mut Tag, key: ItemKey, value: Option<&String>) {
+ match value.map(|text| text.trim()).filter(|text| !text.is_empty()) {
+ // An empty field is an instruction to clear the tag, not to leave it alone.
+ Some(text) => {
+ let _ = tag.insert_text(key, text.to_string());
+ }
+ None => {
+ let _ = tag.remove_key(key);
+ }
+ }
+ }
+
+ apply(tag, ItemKey::TrackTitle, tags.title.as_ref());
+ apply(tag, ItemKey::TrackArtist, tags.artist.as_ref());
+ apply(tag, ItemKey::AlbumTitle, tags.album.as_ref());
+ apply(tag, ItemKey::AlbumArtist, tags.album_artist.as_ref());
+ apply(tag, ItemKey::Genre, tags.genre.as_ref());
+
+ match tags.track_number {
+ Some(number) => tag.set_track(number),
+ None => tag.remove_track(),
+ }
+ match tags.year {
+ Some(year) => {
+ let _ = tag.insert_text(ItemKey::Year, year.to_string());
+ }
+ None => {
+ let _ = tag.remove_key(ItemKey::Year);
+ }
+ }
+
+ tagged
+ .save_to_path(&path, WriteOptions::default())
+ .map_err(|error| cache_error(format!("tag write failed: {error}")))?;
+
+ eprintln!(
+ "[internal][tauri][info] local_audio_write_tags path={}",
+ path.display()
+ );
+ Ok(())
+}
+
+/// Holds the active folder watcher. Replaced wholesale whenever the watched set changes.
+struct LocalAudioWatcher(Mutex>);
+
+/// Watches folders for changes and tells the frontend to rescan.
+///
+/// Deliberately coarse: it emits one debounced "something changed" event rather than a diff.
+/// The scan is cheap, and a precise change feed would have to model renames, temp files and
+/// editors that write-then-replace — all of which produce the same user-visible outcome.
+#[tauri::command]
+fn local_audio_watch(
+ app: tauri::AppHandle,
+ state: tauri::State<'_, LocalAudioWatcher>,
+ paths: Vec,
+) -> Result<(), CommandError> {
+ use notify::{RecursiveMode, Watcher};
+
+ let mut guard = state
+ .0
+ .lock()
+ .map_err(|_| cache_error("watcher lock unavailable"))?;
+ // Dropping the previous watcher unregisters every path it held.
+ *guard = None;
+
+ if paths.is_empty() {
+ return Ok(());
+ }
+
+ let emitter = app.clone();
+ let last_emit = Arc::new(Mutex::new(Instant::now() - Duration::from_secs(60)));
+ let mut watcher = notify::recommended_watcher(move |event: notify::Result| {
+ let Ok(event) = event else { return };
+ if !matches!(
+ event.kind,
+ notify::EventKind::Create(_) | notify::EventKind::Remove(_) | notify::EventKind::Modify(_)
+ ) {
+ return;
+ }
+
+ // A single save can fire several events; one rescan per second is plenty.
+ if let Ok(mut last) = last_emit.lock() {
+ if last.elapsed() < Duration::from_millis(1000) {
+ return;
+ }
+ *last = Instant::now();
+ }
+ let _ = emitter.emit("local-audio-changed", ());
+ })
+ .map_err(|error| cache_error(format!("watcher creation failed: {error}")))?;
+
+ for path in &paths {
+ let candidate = Path::new(path.trim());
+ if !candidate.exists() {
+ continue;
+ }
+ if let Err(error) = watcher.watch(candidate, RecursiveMode::Recursive) {
+ eprintln!(
+ "[internal][tauri][warn] local_audio_watch failed path={} error={}",
+ path, error
+ );
+ }
+ }
+
+ *guard = Some(watcher);
+ eprintln!(
+ "[internal][tauri][info] local_audio_watch watching {} paths",
+ paths.len()
+ );
+ Ok(())
+}
+
+#[tauri::command]
+fn local_audio_unwatch(state: tauri::State<'_, LocalAudioWatcher>) -> Result<(), CommandError> {
+ let mut guard = state
+ .0
+ .lock()
+ .map_err(|_| cache_error("watcher lock unavailable"))?;
+ *guard = None;
+ Ok(())
+}
+
+#[tauri::command]
+fn local_audio_read(path: String) -> Result {
+ let path = PathBuf::from(path);
+ if !path.is_file() || !is_local_audio_file(&path) {
+ return Err(CommandError {
+ message: "local audio file is unavailable.".to_string(),
+ });
+ }
+ let bytes = fs::read(&path).map_err(|error| CommandError {
+ message: format!("local audio read failed: {error}"),
+ })?;
+ Ok(AudioPayload {
+ body_base64: STANDARD.encode(bytes),
+ mime_type: local_audio_mime_type(&path).to_string(),
+ })
+}
+
+fn cache_root(app: &tauri::AppHandle) -> Result {
+ app.path()
+ .app_cache_dir()
+ .map(|path| path.join("data-cache-v1"))
+ .map_err(|error| cache_error(format!("cache directory unavailable: {error}")))
+}
+
+fn cache_entries_dir(app: &tauri::AppHandle) -> Result {
+ Ok(cache_root(app)?.join("entries"))
+}
+
+fn cache_settings_path(app: &tauri::AppHandle) -> Result {
+ Ok(cache_root(app)?.join("settings.json"))
+}
+
+fn now_ms() -> u64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_millis() as u64
+}
+
+fn cache_key_hash(key: &str) -> u64 {
+ let mut hash = 0xcbf29ce484222325_u64;
+ for byte in key.as_bytes() {
+ hash ^= u64::from(*byte);
+ hash = hash.wrapping_mul(0x100000001b3);
+ }
+ hash
+}
+
+fn cache_entry_path(app: &tauri::AppHandle, key: &str) -> Result {
+ Ok(cache_entries_dir(app)?.join(format!("{:016x}.json", cache_key_hash(key))))
+}
+
+fn write_json_file(path: &Path, value: &T) -> Result<(), CommandError> {
+ if let Some(parent) = path.parent() {
+ fs::create_dir_all(parent)
+ .map_err(|error| cache_error(format!("cache directory creation failed: {error}")))?;
+ }
+ let bytes = serde_json::to_vec(value)
+ .map_err(|error| cache_error(format!("cache serialization failed: {error}")))?;
+ let temp_path = path.with_extension("tmp");
+ fs::write(&temp_path, bytes)
+ .map_err(|error| cache_error(format!("cache write failed: {error}")))?;
+ if path.exists() {
+ fs::remove_file(path)
+ .map_err(|error| cache_error(format!("cache replacement failed: {error}")))?;
+ }
+ fs::rename(&temp_path, path)
+ .map_err(|error| cache_error(format!("cache finalize failed: {error}")))
+}
+
+fn app_settings_path(app: &tauri::AppHandle) -> Result {
+ app.path()
+ .app_data_dir()
+ .map(|path| path.join(APP_SETTINGS_FILE_NAME))
+ .map_err(|error| CommandError {
+ message: format!("application settings directory unavailable: {error}"),
+ })
+}
+
+fn read_app_settings(
+ app: &tauri::AppHandle,
+) -> Result, CommandError> {
+ let path = app_settings_path(app)?;
+ if !path.exists() {
+ return Ok(HashMap::new());
+ }
+
+ let bytes = fs::read(path).map_err(|error| CommandError {
+ message: format!("application settings read failed: {error}"),
+ })?;
+ if bytes.iter().all(|byte| byte.is_ascii_whitespace()) {
+ return Ok(HashMap::new());
+ }
+ serde_json::from_slice(&bytes).map_err(|error| CommandError {
+ message: format!("application settings parse failed: {error}"),
+ })
+}
+
+#[tauri::command]
+fn app_setting_get(
+ app: tauri::AppHandle,
+ lock: tauri::State<'_, AppSettingsLock>,
+ key: String,
+) -> Result, CommandError> {
+ let _guard = lock.0.lock().map_err(|_| CommandError {
+ message: "application settings lock unavailable".to_string(),
+ })?;
+ Ok(read_app_settings(&app)?.remove(&key))
+}
+
+#[tauri::command]
+fn app_setting_set(
+ app: tauri::AppHandle,
+ lock: tauri::State<'_, AppSettingsLock>,
+ key: String,
+ value: serde_json::Value,
+) -> Result<(), CommandError> {
+ let _guard = lock.0.lock().map_err(|_| CommandError {
+ message: "application settings lock unavailable".to_string(),
+ })?;
+ let mut settings = read_app_settings(&app)?;
+ settings.insert(key, value);
+ write_json_file(&app_settings_path(&app)?, &settings)
+}
+
+#[tauri::command]
+fn app_setting_remove(
+ app: tauri::AppHandle,
+ lock: tauri::State<'_, AppSettingsLock>,
+ key: String,
+) -> Result<(), CommandError> {
+ let _guard = lock.0.lock().map_err(|_| CommandError {
+ message: "application settings lock unavailable".to_string(),
+ })?;
+ let mut settings = read_app_settings(&app)?;
+ settings.remove(&key);
+ write_json_file(&app_settings_path(&app)?, &settings)
+}
+
+fn current_log_path(app: &tauri::AppHandle) -> Result {
+ app.path()
+ .app_log_dir()
+ .map(|path| path.join(CURRENT_LOG_FILE_NAME))
+ .map_err(|error| CommandError {
+ message: format!("log directory unavailable: {error}"),
+ })
+}
+
+fn initialize_app_log(app: &tauri::AppHandle) -> Result<(), CommandError> {
+ let log_path = current_log_path(app)?;
+ let log_dir = log_path.parent().ok_or_else(|| CommandError {
+ message: "log directory unavailable".to_string(),
+ })?;
+
+ fs::create_dir_all(log_dir).map_err(|error| CommandError {
+ message: format!("log directory creation failed: {error}"),
+ })?;
+
+ if let Ok(entries) = fs::read_dir(log_dir) {
+ for entry in entries.flatten() {
+ let path = entry.path();
+ if path != log_path
+ && path
+ .extension()
+ .is_some_and(|extension| extension.eq_ignore_ascii_case("log"))
+ {
+ let _ = fs::remove_file(path);
+ }
+ }
+ }
+
+ let file = OpenOptions::new()
+ .create(true)
+ .write(true)
+ .truncate(true)
+ .open(&log_path)
+ .map_err(|error| CommandError {
+ message: format!("log file creation failed: {error}"),
+ })?;
+
+ let log_file = APP_LOG_FILE.get_or_init(|| Mutex::new(None));
+ if let Ok(mut guard) = log_file.lock() {
+ *guard = Some(file);
+ }
+
+ append_log_line(format_args!(
+ "[internal][tauri][info] log initialized path={}",
+ log_path.display()
+ ));
+ Ok(())
+}
+
+pub(crate) fn append_log_line(args: fmt::Arguments<'_>) {
+ let Some(log_file) = APP_LOG_FILE.get() else {
+ return;
+ };
+ let Ok(mut guard) = log_file.lock() else {
+ return;
+ };
+ let Some(file) = guard.as_mut() else {
+ return;
+ };
+ let _ = writeln!(file, "{args}");
+}
+
+pub(crate) fn sanitize_log_message(message: &str) -> String {
+ message
+ .split_whitespace()
+ .map(sanitize_log_token)
+ .collect::>()
+ .join(" ")
+}
+
+fn sanitize_log_token(token: &str) -> String {
+ if let Some((key, value)) = token.split_once('=') {
+ let normalized_key = key
+ .trim_matches(|character: char| !character.is_ascii_alphanumeric() && character != '_')
+ .to_ascii_lowercase();
+ if normalized_key.contains("cookie")
+ || normalized_key.contains("authorization")
+ || normalized_key.contains("credential")
+ || normalized_key.contains("token")
+ || normalized_key.contains("secret")
+ || normalized_key.contains("password")
+ || normalized_key.contains("signature")
+ || normalized_key.contains("cipher")
+ || normalized_key.contains("visitor")
+ {
+ return format!("{key}=[redacted]");
+ }
+ if value.starts_with("http://") || value.starts_with("https://") {
+ return format!("{key}={}", sanitize_log_url(value));
+ }
+ }
+
+ if token.starts_with("http://") || token.starts_with("https://") {
+ return sanitize_log_url(token);
+ }
+
+ token.to_string()
+}
+
+/// Query parameters kept verbatim when a URL is logged.
+///
+/// An allowlist, not a blocklist: googlevideo grows new parameters constantly and a blocklist
+/// would silently start writing down the next credential it invents. Everything here is
+/// descriptive — expiry, format, size, client identity — and none of it authenticates anything.
+const LOGGABLE_URL_PARAMS: &[&str] = &[
+ "expire", "id", "itag", "source", "mime", "clen", "dur", "lmt", "c", "cver", "mn", "ms",
+ "mv", "mt", "fvip", "keepalive", "ratebypass", "requiressl", "gir", "sq", "rn", "ver",
+ "xpc", "spc", "vprv", "svpuc", "txp", "range", "met", "rqh", "aitags", "sabr", "pcm2",
+];
+
+/// Renders a URL for the log with its credentials removed but its diagnostics intact.
+///
+/// The previous behaviour dropped the whole query string, which made a signed media URL
+/// unreadable — the parameters that explain a 403 (expiry, itag, client version) were discarded
+/// alongside the ones that must never be written down. Withheld values are replaced by their
+/// length, which answers the question actually being asked of them ("is it present and
+/// plausible, or missing and truncated?") without recording the secret itself.
+fn sanitize_log_url(value: &str) -> String {
+ let Ok(parsed) = url::Url::parse(value) else {
+ return "[redacted-url]".to_string();
+ };
+
+ let base = format!(
+ "{}://{}{}",
+ parsed.scheme(),
+ parsed.host_str().unwrap_or("?"),
+ parsed.path()
+ );
+
+ let query: Vec = parsed
+ .query_pairs()
+ .map(|(key, value)| {
+ if LOGGABLE_URL_PARAMS.contains(&key.as_ref()) {
+ format!("{key}={value}")
+ } else {
+ format!("{key}=[{}ch]", value.len())
+ }
+ })
+ .collect();
+
+ if query.is_empty() {
+ base
+ } else {
+ format!("{base}?{}", query.join("&"))
+ }
+}
+
+#[tauri::command]
+fn open_current_log(app: tauri::AppHandle) -> Result<(), CommandError> {
+ let log_path = current_log_path(&app)?;
+ if !log_path.exists() {
+ initialize_app_log(&app)?;
+ }
+ tauri_plugin_opener::open_path(&log_path, None::<&str>).map_err(|error| CommandError {
+ message: format!("unable to open log file: {error}"),
+ })
+}
+
+#[tauri::command]
+fn app_settings_clear(
+ app: tauri::AppHandle,
+ lock: tauri::State<'_, AppSettingsLock>,
+) -> Result<(), CommandError> {
+ let _guard = lock.0.lock().map_err(|_| CommandError {
+ message: "application settings lock unavailable".to_string(),
+ })?;
+ let path = app_settings_path(&app)?;
+ if path.exists() {
+ fs::remove_file(path).map_err(|error| CommandError {
+ message: format!("application settings clear failed: {error}"),
+ })?;
+ }
+ Ok(())
+}
+
+fn read_cache_settings(app: &tauri::AppHandle) -> Result {
+ let path = cache_settings_path(app)?;
+ if !path.exists() {
+ return Ok(CacheSettings {
+ max_bytes: DEFAULT_CACHE_MAX_BYTES,
+ });
+ }
+ let bytes = fs::read(path)
+ .map_err(|error| cache_error(format!("cache settings read failed: {error}")))?;
+ serde_json::from_slice(&bytes)
+ .map_err(|error| cache_error(format!("cache settings parse failed: {error}")))
+}
+
+fn cache_files(app: &tauri::AppHandle) -> Result, CommandError> {
+ let directory = cache_entries_dir(app)?;
+ if !directory.exists() {
+ return Ok(Vec::new());
+ }
+ let files = fs::read_dir(directory)
+ .map_err(|error| cache_error(format!("cache directory read failed: {error}")))?
+ .filter_map(|entry| entry.ok().map(|entry| entry.path()))
+ .filter(|path| {
+ path.extension()
+ .is_some_and(|extension| extension == "json")
+ })
+ .collect::>();
+ Ok(files)
+}
+
+fn read_cache_entry(path: &Path) -> Result {
+ let bytes =
+ fs::read(path).map_err(|error| cache_error(format!("cache entry read failed: {error}")))?;
+ serde_json::from_slice(&bytes)
+ .map_err(|error| cache_error(format!("cache entry parse failed: {error}")))
+}
+
+fn calculate_cache_stats(app: &tauri::AppHandle) -> Result {
+ let files = cache_files(app)?;
+ let used_bytes = files
+ .iter()
+ .filter_map(|path| fs::metadata(path).ok().map(|metadata| metadata.len()))
+ .sum();
+ Ok(CacheStats {
+ max_bytes: read_cache_settings(app)?.max_bytes,
+ used_bytes,
+ entry_count: files.len(),
+ })
+}
+
+fn enforce_cache_limit(app: &tauri::AppHandle) -> Result<(), CommandError> {
+ let max_bytes = read_cache_settings(app)?.max_bytes;
+ let mut entries = cache_files(app)?
+ .into_iter()
+ .filter_map(|path| {
+ let size = fs::metadata(&path).ok()?.len();
+ let last_accessed_ms = read_cache_entry(&path).ok()?.last_accessed_ms;
+ Some((path, size, last_accessed_ms))
+ })
+ .collect::>();
+ let mut used_bytes = entries.iter().map(|(_, size, _)| *size).sum::();
+ entries.sort_by_key(|(_, _, last_accessed_ms)| *last_accessed_ms);
+
+ for (path, size, _) in entries {
+ if used_bytes <= max_bytes {
+ break;
+ }
+ if fs::remove_file(path).is_ok() {
+ used_bytes = used_bytes.saturating_sub(size);
+ }
+ }
+ Ok(())
+}
+
+#[tauri::command]
+fn cache_get(
+ app: tauri::AppHandle,
+ lock: tauri::State<'_, CacheLock>,
+ key: String,
+) -> Result, CommandError> {
+ let _guard = lock
+ .0
+ .lock()
+ .map_err(|_| cache_error("cache lock unavailable"))?;
+ let path = cache_entry_path(&app, &key)?;
+ if !path.exists() {
+ return Ok(None);
+ }
+ let mut entry = match read_cache_entry(&path) {
+ Ok(entry) if entry.key == key => entry,
+ Ok(_) => return Ok(None),
+ Err(_) => {
+ let _ = fs::remove_file(path);
+ return Ok(None);
+ }
+ };
+ entry.last_accessed_ms = now_ms();
+ let value = entry.value.clone();
+ write_json_file(&path, &entry)?;
+ Ok(Some(value))
+}
+
+#[tauri::command]
+fn cache_set(
+ app: tauri::AppHandle,
+ lock: tauri::State<'_, CacheLock>,
+ key: String,
+ value: String,
+) -> Result {
+ let _guard = lock
+ .0
+ .lock()
+ .map_err(|_| cache_error("cache lock unavailable"))?;
+ let path = cache_entry_path(&app, &key)?;
+ let existing = if path.exists() {
+ read_cache_entry(&path)
+ .ok()
+ .filter(|entry| entry.key == key)
+ } else {
+ None
+ };
+ let changed = existing.as_ref().map_or(true, |entry| entry.value != value);
+ let timestamp = now_ms();
+ let entry = CacheEntry {
+ key,
+ value,
+ updated_at_ms: if changed {
+ timestamp
+ } else {
+ existing
+ .as_ref()
+ .map(|entry| entry.updated_at_ms)
+ .unwrap_or(timestamp)
+ },
+ last_accessed_ms: timestamp,
+ };
+ write_json_file(&path, &entry)?;
+ enforce_cache_limit(&app)?;
+ Ok(CacheWriteResult { changed })
+}
+
+#[tauri::command]
+fn cache_stats(
+ app: tauri::AppHandle,
+ lock: tauri::State<'_, CacheLock>,
+) -> Result {
+ let _guard = lock
+ .0
+ .lock()
+ .map_err(|_| cache_error("cache lock unavailable"))?;
+ calculate_cache_stats(&app)
+}
+
+#[tauri::command]
+fn cache_set_max_bytes(
+ app: tauri::AppHandle,
+ lock: tauri::State<'_, CacheLock>,
+ max_bytes: u64,
+) -> Result {
+ let _guard = lock
+ .0
+ .lock()
+ .map_err(|_| cache_error("cache lock unavailable"))?;
+ write_json_file(&cache_settings_path(&app)?, &CacheSettings { max_bytes })?;
+ enforce_cache_limit(&app)?;
+ calculate_cache_stats(&app)
+}
+
+#[tauri::command]
+fn cache_clear(
+ app: tauri::AppHandle,
+ lock: tauri::State<'_, CacheLock>,
+) -> Result {
+ let _guard = lock
+ .0
+ .lock()
+ .map_err(|_| cache_error("cache lock unavailable"))?;
+ let entries = cache_entries_dir(&app)?;
+ if entries.exists() {
+ fs::remove_dir_all(&entries)
+ .map_err(|error| cache_error(format!("cache clear failed: {error}")))?;
+ }
+ fs::create_dir_all(entries)
+ .map_err(|error| cache_error(format!("cache directory creation failed: {error}")))?;
+ calculate_cache_stats(&app)
+}
+
+#[tauri::command]
+fn greet(name: &str) -> String {
+ format!("Hello, {}! You've been greeted from Rust!", name)
+}
+
+/// Key written by the frontend's durable settings layer. Read here rather than pushed from
+/// JS so the close handler answers correctly even before the webview has finished booting.
+const MINIMIZE_TO_TRAY_SETTING: &str = "minimize-to-tray";
+
+fn minimize_to_tray_enabled(app: &tauri::AppHandle) -> bool {
+ read_app_settings(app)
+ .ok()
+ .and_then(|settings| settings.get(MINIMIZE_TO_TRAY_SETTING).and_then(|v| v.as_bool()))
+ .unwrap_or(false)
+}
+
+fn show_main_window(app: &tauri::AppHandle) {
+ if let Some(window) = app.get_webview_window("main") {
+ let _ = window.show();
+ let _ = window.unminimize();
+ let _ = window.set_focus();
+ }
+}
+
+/// Hides to the tray when the user asked for that, otherwise exits.
+///
+/// Both the titlebar close button and the OS close request come through here so they cannot
+/// disagree — a window that vanishes from one and quits from the other is the classic
+/// minimize-to-tray bug.
+fn close_or_hide_main_window(app: &tauri::AppHandle) {
+ if minimize_to_tray_enabled(app) {
+ if let Some(window) = app.get_webview_window("main") {
+ let _ = window.hide();
+ }
+ eprintln!("[internal][tauri][info] main window hidden to tray");
+ return;
+ }
+ app.exit(0);
+}
+
+fn build_tray(app: &tauri::AppHandle) -> tauri::Result<()> {
+ use tauri::menu::{Menu, MenuItem};
+ use tauri::tray::{TrayIconBuilder, TrayIconEvent};
+
+ let show = MenuItem::with_id(app, "tray-show", "Show Zuno", true, None::<&str>)?;
+ let quit = MenuItem::with_id(app, "tray-quit", "Quit Zuno", true, None::<&str>)?;
+ let menu = Menu::with_items(app, &[&show, &quit])?;
+
+ TrayIconBuilder::with_id("main-tray")
+ .icon(app.default_window_icon().cloned().ok_or_else(|| {
+ tauri::Error::AssetNotFound("default window icon".to_string())
+ })?)
+ .tooltip("Zuno")
+ .menu(&menu)
+ // The menu is for the right-click; a left click should just bring the window back.
+ .show_menu_on_left_click(false)
+ .on_menu_event(|app, event| match event.id().as_ref() {
+ "tray-show" => show_main_window(app),
+ // The only path that always exits, whatever the setting says.
+ "tray-quit" => app.exit(0),
+ _ => {}
+ })
+ .on_tray_icon_event(|tray, event| {
+ if let TrayIconEvent::Click { button, button_state, .. } = event {
+ if button == tauri::tray::MouseButton::Left
+ && button_state == tauri::tray::MouseButtonState::Up
+ {
+ show_main_window(tray.app_handle());
+ }
+ }
+ })
+ .build(app)?;
+ Ok(())
+}
+
+#[tauri::command]
+fn quit_app(app: tauri::AppHandle) {
+ eprintln!("[internal][tauri][info] quit_app invoked");
+ close_or_hide_main_window(&app);
+}
+
+#[tauri::command]
+fn frontend_log(level: String, context: String, payload: String) {
+ eprintln!("[internal][frontend][{}] {} {}", level, context, payload);
+}
+
+fn youtube_keyring_entry() -> Result {
+ keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER).map_err(|error| CommandError {
+ message: format!("credential store unavailable: {error}"),
+ })
+}
+
+fn youtube_cookie_keyring_entry() -> Result {
+ keyring::Entry::new(KEYRING_SERVICE, YOUTUBE_COOKIE_KEYRING_USER).map_err(|error| {
+ CommandError {
+ message: format!("credential store unavailable: {error}"),
+ }
+ })
+}
+
+fn youtube_cookie_chunk_entry(index: usize) -> Result {
+ keyring::Entry::new(
+ KEYRING_SERVICE,
+ &format!("{YOUTUBE_COOKIE_KEYRING_USER}-{index}"),
+ )
+ .map_err(|error| CommandError {
+ message: format!("credential store unavailable: {error}"),
+ })
+}
+
+fn save_youtube_music_cookie_entries(cookie: &str) -> Result<(), CommandError> {
+ let chunks = cookie
+ .as_bytes()
+ .chunks(YOUTUBE_COOKIE_CHUNK_SIZE)
+ .map(|chunk| std::str::from_utf8(chunk).expect("YouTube cookie header must be UTF-8"))
+ .collect::>();
+
+ if chunks.len() > YOUTUBE_COOKIE_MAX_CHUNKS {
+ return Err(CommandError {
+ message: "YouTube Music session is too large for secure storage.".to_string(),
+ });
+ }
+
+ eprintln!(
+ "[internal][tauri][info] save_youtube_music_cookie chunks={} bytes={}",
+ chunks.len(),
+ cookie.len()
+ );
+ delete_youtube_music_cookie_entries()?;
+ for (index, chunk) in chunks.iter().enumerate() {
+ youtube_cookie_chunk_entry(index)?
+ .set_password(chunk)
+ .map_err(|error| CommandError {
+ message: format!("YouTube Music session chunk {index} save failed: {error}"),
+ })?;
+ }
+ youtube_cookie_keyring_entry()?
+ .set_password(&format!("chunks:{}", chunks.len()))
+ .map_err(|error| CommandError {
+ message: format!("YouTube Music session manifest save failed: {error}"),
+ })?;
+ Ok(())
+}
+
+#[cfg(target_os = "macos")]
+fn youtube_cookie_encryption_key_entry() -> Result {
+ keyring::Entry::new(KEYRING_SERVICE, YOUTUBE_COOKIE_ENCRYPTION_KEY_USER).map_err(|error| {
+ CommandError {
+ message: format!("credential store unavailable: {error}"),
+ }
+ })
+}
+
+#[cfg(target_os = "macos")]
+fn youtube_cookie_encrypted_file(app: &tauri::AppHandle) -> Result {
+ app.path()
+ .app_data_dir()
+ .map(|path| path.join(YOUTUBE_COOKIE_ENCRYPTED_FILE))
+ .map_err(|error| CommandError {
+ message: format!("application data directory unavailable: {error}"),
+ })
+}
+
+#[cfg(target_os = "macos")]
+fn load_or_create_cookie_encryption_key() -> Result<[u8; 32], CommandError> {
+ let entry = youtube_cookie_encryption_key_entry()?;
+ match entry.get_password() {
+ Ok(encoded) => {
+ let decoded = STANDARD.decode(encoded).map_err(|error| CommandError {
+ message: format!("stored session encryption key is invalid: {error}"),
+ })?;
+ decoded.try_into().map_err(|_| CommandError {
+ message: "stored session encryption key has an invalid length.".to_string(),
+ })
+ }
+ Err(keyring::Error::NoEntry) => {
+ let mut key = [0_u8; 32];
+ OsRng.fill_bytes(&mut key);
+ entry
+ .set_password(&STANDARD.encode(key))
+ .map_err(|error| CommandError {
+ message: format!("session encryption key save failed: {error}"),
+ })?;
+ Ok(key)
+ }
+ Err(error) => Err(CommandError {
+ message: format!("session encryption key load failed: {error}"),
+ }),
+ }
+}
+
+#[cfg(target_os = "macos")]
+fn save_youtube_music_cookie(app: &tauri::AppHandle, cookie: &str) -> Result<(), CommandError> {
+ let key = load_or_create_cookie_encryption_key()?;
+ let cipher = Aes256Gcm::new_from_slice(&key).map_err(|error| CommandError {
+ message: format!("session encryption setup failed: {error}"),
+ })?;
+ let mut nonce_bytes = [0_u8; 12];
+ OsRng.fill_bytes(&mut nonce_bytes);
+ let encrypted = cipher
+ .encrypt(Nonce::from_slice(&nonce_bytes), cookie.as_bytes())
+ .map_err(|error| CommandError {
+ message: format!("session encryption failed: {error}"),
+ })?;
+
+ let path = youtube_cookie_encrypted_file(app)?;
+ if let Some(parent) = path.parent() {
+ fs::create_dir_all(parent).map_err(|error| CommandError {
+ message: format!("session directory creation failed: {error}"),
+ })?;
+ }
+ let mut contents = Vec::with_capacity(nonce_bytes.len() + encrypted.len());
+ contents.extend_from_slice(&nonce_bytes);
+ contents.extend_from_slice(&encrypted);
+ fs::write(path, contents).map_err(|error| CommandError {
+ message: format!("encrypted session save failed: {error}"),
+ })
+}
+
+#[cfg(not(target_os = "macos"))]
+fn save_youtube_music_cookie(_app: &tauri::AppHandle, cookie: &str) -> Result<(), CommandError> {
+ save_youtube_music_cookie_entries(cookie)
+}
+
+fn delete_youtube_music_cookie_entries() -> Result<(), CommandError> {
+ match youtube_cookie_keyring_entry()?.delete_credential() {
+ Ok(()) | Err(keyring::Error::NoEntry) => {}
+ Err(error) => {
+ return Err(CommandError {
+ message: format!("YouTube Music session manifest delete failed: {error}"),
+ });
+ }
+ }
+
+ for index in 0..YOUTUBE_COOKIE_MAX_CHUNKS {
+ match youtube_cookie_chunk_entry(index)?.delete_credential() {
+ Ok(()) | Err(keyring::Error::NoEntry) => {}
+ Err(error) => {
+ return Err(CommandError {
+ message: format!("YouTube Music session chunk {index} delete failed: {error}"),
+ });
+ }
+ }
+ }
+ Ok(())
+}
+
+/// Clears the OAuth credential left behind by the version of the app that used one.
+///
+/// Nothing writes that entry any more — sign-in is entirely cookie-based — but an install old
+/// enough to predate the change may still be holding one in the keyring. Removing it on sign-out
+/// is the only reason this key is still known about; there is no command for it, because a
+/// command implies a second way of being signed in and there is no such thing.
+fn delete_legacy_youtube_credentials() {
+ if let Ok(entry) = youtube_keyring_entry() {
+ match entry.delete_credential() {
+ Ok(()) => eprintln!("[internal][tauri][info] removed legacy OAuth credential"),
+ Err(keyring::Error::NoEntry) => {}
+ Err(error) => eprintln!(
+ "[internal][tauri][warn] legacy OAuth credential delete failed: {error}"
+ ),
+ }
+ }
+}
+
+fn load_youtube_music_cookie_entries() -> Result, CommandError> {
+ match youtube_cookie_keyring_entry()?.get_password() {
+ Ok(manifest) if manifest.starts_with("chunks:") => {
+ let chunk_count = manifest
+ .trim_start_matches("chunks:")
+ .parse::()
+ .map_err(|error| CommandError {
+ message: format!("invalid YouTube Music session manifest: {error}"),
+ })?;
+ if chunk_count == 0 || chunk_count > YOUTUBE_COOKIE_MAX_CHUNKS {
+ return Err(CommandError {
+ message: "invalid YouTube Music session chunk count.".to_string(),
+ });
+ }
+
+ let mut cookie = String::new();
+ for index in 0..chunk_count {
+ let chunk = youtube_cookie_chunk_entry(index)?
+ .get_password()
+ .map_err(|error| CommandError {
+ message: format!(
+ "YouTube Music session chunk {index} load failed: {error}"
+ ),
+ })?;
+ cookie.push_str(&chunk);
+ }
+ eprintln!(
+ "[internal][tauri][info] load_youtube_music_cookie assembled chunks={} bytes={}",
+ chunk_count,
+ cookie.len(),
+ );
+ Ok(Some(cookie))
+ }
+ Ok(cookie) => {
+ eprintln!(
+ "[internal][tauri][info] load_youtube_music_cookie found legacy credential bytes={}",
+ cookie.len()
+ );
+ Ok(Some(cookie))
+ }
+ Err(keyring::Error::NoEntry) => {
+ eprintln!("[internal][tauri][info] load_youtube_music_cookie no credential");
+ Ok(None)
+ }
+ Err(error) => Err(CommandError {
+ message: format!("YouTube Music session load failed: {error}"),
+ }),
+ }
+}
+
+#[cfg(target_os = "macos")]
+fn load_encrypted_youtube_music_cookie(
+ app: &tauri::AppHandle,
+) -> Result, CommandError> {
+ let path = youtube_cookie_encrypted_file(app)?;
+ let contents = match fs::read(path) {
+ Ok(contents) => contents,
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
+ Err(error) => {
+ return Err(CommandError {
+ message: format!("encrypted session load failed: {error}"),
+ })
+ }
+ };
+ if contents.len() <= 12 {
+ return Err(CommandError {
+ message: "encrypted session file is invalid.".to_string(),
+ });
+ }
+ let key = load_or_create_cookie_encryption_key()?;
+ let cipher = Aes256Gcm::new_from_slice(&key).map_err(|error| CommandError {
+ message: format!("session decryption setup failed: {error}"),
+ })?;
+ let decrypted = cipher
+ .decrypt(Nonce::from_slice(&contents[..12]), &contents[12..])
+ .map_err(|error| CommandError {
+ message: format!("session decryption failed: {error}"),
+ })?;
+ String::from_utf8(decrypted)
+ .map(Some)
+ .map_err(|error| CommandError {
+ message: format!("decrypted session is invalid: {error}"),
+ })
+}
+
+fn read_stored_youtube_music_cookie(app: &tauri::AppHandle) -> Result , CommandError> {
+ #[cfg(target_os = "macos")]
+ {
+ if let Some(cookie) = load_encrypted_youtube_music_cookie(app)? {
+ return Ok(Some(cookie));
+ }
+ if let Some(cookie) = load_youtube_music_cookie_entries()? {
+ save_youtube_music_cookie(app, &cookie)?;
+ delete_youtube_music_cookie_entries()?;
+ return Ok(Some(cookie));
+ }
+ return Ok(None);
+ }
+
+ #[cfg(not(target_os = "macos"))]
+ {
+ let _ = app;
+ load_youtube_music_cookie_entries()
+ }
+}
+
+#[tauri::command]
+fn load_youtube_music_cookie(
+ app: tauri::AppHandle,
+ jar: tauri::State<'_, YoutubeCookieJar>,
+) -> Result , CommandError> {
+ let cookie = read_stored_youtube_music_cookie(&app)?;
+ if let Ok(mut state) = jar.0.lock() {
+ state.cookie = cookie.clone();
+ state.persisted_at = None;
+ }
+ Ok(cookie)
+}
+
+#[cfg(any(target_os = "macos", test))]
+fn cookie_domain_matches(host: &str, cookie_domain: Option<&str>) -> bool {
+ let Some(cookie_domain) = cookie_domain else {
+ return false;
+ };
+ let cookie_domain = cookie_domain.trim_start_matches('.');
+
+ host.eq_ignore_ascii_case(cookie_domain)
+ || host
+ .strip_suffix(cookie_domain)
+ .is_some_and(|prefix| prefix.ends_with('.'))
+}
+
+/// Builds the sign-in webview on its own storage partition.
+///
+/// `loaded` is raised once a navigation finishes, which the silent refresh needs: cookies read
+/// before the page has actually loaded are the same stale ones we already hold.
+fn build_login_window(
+ app: &tauri::AppHandle,
+ visible: bool,
+ loaded: Arc,
+) -> Result {
+ if let Some(existing) = app.get_webview_window(YOUTUBE_LOGIN_WINDOW) {
+ let _ = existing.close();
+ }
+
+ let blank_url = "about:blank".parse().map_err(|error| CommandError {
+ message: format!("invalid blank login URL: {error}"),
+ })?;
+ let window_builder = tauri::WebviewWindowBuilder::new(
+ app,
+ YOUTUBE_LOGIN_WINDOW,
+ tauri::WebviewUrl::External(blank_url),
+ )
+ .title("Sign in to YouTube Music")
+ .visible(visible)
+ .skip_taskbar(!visible)
+ .inner_size(520.0, 760.0)
+ .on_page_load(move |_window, payload| {
+ if payload.event() == tauri::webview::PageLoadEvent::Finished {
+ loaded.store(true, Ordering::Relaxed);
+ }
+ });
+ // See YOUTUBE_LOGIN_DATA_DIR: without its own partition, clearing this window's data
+ // clears the main window's storage too.
+ #[cfg(not(target_os = "macos"))]
+ let window_builder = window_builder.data_directory(
+ app.path()
+ .app_local_data_dir()
+ .map_err(|error| CommandError {
+ message: format!("sign-in data directory unavailable: {error}"),
+ })?
+ .join(YOUTUBE_LOGIN_DATA_DIR),
+ );
+ #[cfg(target_os = "macos")]
+ let window_builder = window_builder
+ .user_agent(MACOS_LOGIN_USER_AGENT)
+ // macOS 14+ only; older versions fall back to the shared store, as they did before.
+ .data_store_identifier(YOUTUBE_LOGIN_DATA_STORE_ID);
+
+ window_builder.build().map_err(|error| CommandError {
+ message: format!("unable to open YouTube Music sign-in: {error}"),
+ })
+}
+
+/// The session cookie as the login webview currently holds it, if it holds one at all.
+fn harvest_session_cookie(
+ window: &tauri::WebviewWindow,
+) -> Result, CommandError> {
+ #[cfg(target_os = "macos")]
+ let cookies = window
+ .cookies()
+ .map_err(|error| CommandError {
+ message: format!("unable to read YouTube Music session: {error}"),
+ })?
+ .into_iter()
+ .filter(|cookie| cookie_domain_matches("music.youtube.com", cookie.domain()))
+ .collect::>();
+ #[cfg(not(target_os = "macos"))]
+ let cookies = {
+ let cookie_url: url::Url = "https://music.youtube.com/"
+ .parse()
+ .map_err(|error| CommandError {
+ message: format!("invalid YouTube Music cookie URL: {error}"),
+ })?;
+ window
+ .cookies_for_url(cookie_url)
+ .map_err(|error| CommandError {
+ message: format!("unable to read YouTube Music session: {error}"),
+ })?
+ };
+
+ let cookie_names = cookies
+ .iter()
+ .map(|cookie| cookie.name())
+ .collect::>();
+ let has_auth_cookie = ["SAPISID", "__Secure-1PAPISID", "__Secure-3PAPISID"]
+ .iter()
+ .any(|name| cookie_names.contains(name));
+ let on_music_page = window
+ .url()
+ .map(|url| url.domain() == Some("music.youtube.com"))
+ .unwrap_or(false);
+ if !has_auth_cookie || !on_music_page {
+ return Ok(None);
+ }
+
+ Ok(Some(
+ cookies
+ .iter()
+ .map(|cookie| format!("{}={}", cookie.name(), cookie.value()))
+ .collect::>()
+ .join("; "),
+ ))
+}
+
+fn store_session_cookie(
+ app: &tauri::AppHandle,
+ jar: &YoutubeCookieJar,
+ cookie: &str,
+) -> Result<(), CommandError> {
+ save_youtube_music_cookie(app, cookie)?;
+ if let Ok(mut state) = jar.0.lock() {
+ state.cookie = Some(cookie.to_string());
+ state.persisted_at = Some(Instant::now());
+ }
+ Ok(())
+}
+
+/// The outcome of an interactive sign-in.
+///
+/// `account_changed` is what stops a renewal from costing the user their library. Signing in
+/// again is usually not a new account at all — it is the same person recovering a session that
+/// lapsed, and wiping the cache and the chosen channel for that is a resync nobody asked for.
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct SignInResult {
+ cookie: String,
+ account_changed: bool,
+}
+
+#[tauri::command]
+async fn sign_in_youtube_music(
+ app: tauri::AppHandle,
+ jar: tauri::State<'_, YoutubeCookieJar>,
+) -> Result {
+ eprintln!("[internal][tauri][info] sign_in_youtube_music start");
+ // Captured before the window opens: the jar still holds whoever was signed in, even if the
+ // session behind it has lapsed, which is exactly the value the new one is compared against.
+ let previous_identity = jar
+ .0
+ .lock()
+ .ok()
+ .and_then(|state| state.cookie.as_deref().and_then(cookie_account_identity));
+ /*
+ * Deliberately *not* cleared here any more.
+ *
+ * The partition is now the app's durable record of who is signed in — the thing
+ * refresh_youtube_music_cookie renews the session from without troubling the user. Wiping
+ * it on every sign-in threw that away and made a full interactive login the only way back.
+ * Sign-out still clears it, which is where "forget me" belongs.
+ */
+ let window = build_login_window(&app, true, Arc::new(AtomicBool::new(false)))?;
+ eprintln!("[internal][tauri][info] sign_in_youtube_music login window created");
+
+ let login_url = YOUTUBE_LOGIN_URL.parse().map_err(|error| CommandError {
+ message: format!("invalid YouTube Music sign-in URL: {error}"),
+ })?;
+ window.navigate(login_url).map_err(|error| CommandError {
+ message: format!("unable to navigate to YouTube Music sign-in: {error}"),
+ })?;
+ eprintln!("[internal][tauri][info] sign_in_youtube_music navigated to Google sign-in");
+
+ for poll in 1..=300 {
+ if let Some(cookie_header) = harvest_session_cookie(&window)? {
+ let account_changed = previous_identity.is_none()
+ || previous_identity != cookie_account_identity(&cookie_header);
+ eprintln!(
+ "[internal][tauri][info] sign_in_youtube_music detected session poll={} credential_bytes={} account_changed={}",
+ poll,
+ cookie_header.len(),
+ account_changed
+ );
+ store_session_cookie(&app, &jar, &cookie_header)?;
+ let _ = window.close();
+ return Ok(SignInResult {
+ cookie: cookie_header,
+ account_changed,
+ });
+ }
+
+ if app.get_webview_window(YOUTUBE_LOGIN_WINDOW).is_none() {
+ eprintln!(
+ "[internal][tauri][warn] sign_in_youtube_music cancelled poll={}",
+ poll
+ );
+ return Err(CommandError {
+ message: "YouTube Music sign-in was cancelled.".to_string(),
+ });
+ }
+ thread::sleep(Duration::from_secs(1));
+ }
+
+ let _ = window.close();
+ eprintln!("[internal][tauri][warn] sign_in_youtube_music timed out");
+ Err(CommandError {
+ message: "YouTube Music sign-in timed out.".to_string(),
+ })
+}
+
+/// Renews the session from the sign-in partition without involving the user.
+///
+/// Navigates to `YOUTUBE_LOGIN_URL` — the same Google entry point the interactive sign-in uses,
+/// and the reason this works at all. Loading music.youtube.com directly does *not* re-mint
+/// anything: presented with a retired `__Secure-*PSIDTS` it simply renders the signed-out page,
+/// so this used to harvest the dead cookie back, report a renewal, and leave the app to discover
+/// seconds later that it was still signed out. Only the accounts.google.com round trip reissues
+/// the YouTube-domain cookies from the Google session the partition still holds.
+///
+/// The landing check in `harvest_session_cookie` does double duty here: reaching
+/// music.youtube.com *is* the proof that Google accepted the session without asking anything.
+/// When it needs a password or a second factor the redirect stops short, nothing is harvested,
+/// and `None` says what it has always said — the sign-in genuinely lapsed and only the user can
+/// fix it. Hidden throughout, because a window that needs interaction nobody can see is a window
+/// that never finishes.
+#[tauri::command]
+async fn refresh_youtube_music_cookie(
+ app: tauri::AppHandle,
+ jar: tauri::State<'_, YoutubeCookieJar>,
+) -> Result, CommandError> {
+ eprintln!("[internal][tauri][info] refresh_youtube_music_cookie start");
+ let loaded = Arc::new(AtomicBool::new(false));
+ let window = build_login_window(&app, false, loaded.clone())?;
+
+ let login_url = YOUTUBE_LOGIN_URL.parse().map_err(|error| CommandError {
+ message: format!("invalid YouTube Music sign-in URL: {error}"),
+ })?;
+ window.navigate(login_url).map_err(|error| CommandError {
+ message: format!("unable to renew the YouTube Music session silently: {error}"),
+ })?;
+
+ for poll in 1..=YOUTUBE_SILENT_REFRESH_POLLS {
+ // Only after a load: harvesting early returns the same stale cookie we came in with,
+ // because nothing has been through a round trip to Google yet.
+ if loaded.load(Ordering::Relaxed) {
+ if let Some(cookie_header) = harvest_session_cookie(&window)? {
+ eprintln!(
+ "[internal][tauri][info] refresh_youtube_music_cookie renewed poll={} credential_bytes={}",
+ poll,
+ cookie_header.len()
+ );
+ store_session_cookie(&app, &jar, &cookie_header)?;
+ let _ = window.close();
+ return Ok(Some(cookie_header));
+ }
+ }
+ thread::sleep(Duration::from_secs(1));
+ }
+
+ let _ = window.close();
+ eprintln!("[internal][tauri][info] refresh_youtube_music_cookie found no usable session");
+ Ok(None)
+}
+
+#[tauri::command]
+async fn delete_youtube_music_cookie(
+ app: tauri::AppHandle,
+ jar: tauri::State<'_, YoutubeCookieJar>,
+) -> Result<(), CommandError> {
+ eprintln!("[internal][tauri][info] delete_youtube_music_cookie start");
+ if let Ok(mut state) = jar.0.lock() {
+ state.cookie = None;
+ state.persisted_at = None;
+ }
+ delete_legacy_youtube_credentials();
+ /*
+ * Sign-out is the one place the sign-in partition is wiped, because it is the only place
+ * the user has said "forget me". The window is built purely to reach the profile behind
+ * it — clearing it is what makes the next sign-in offer a fresh account chooser instead of
+ * silently resuming the account that just left.
+ */
+ if let Ok(window) = build_login_window(&app, false, Arc::new(AtomicBool::new(false))) {
+ let _ = window.clear_all_browsing_data();
+ // The clear is asynchronous underneath and reports through a handler nobody waits on;
+ // closing the webview out from under it can leave the profile half-cleared.
+ thread::sleep(Duration::from_millis(750));
+ let _ = window.close();
+ }
+
+ #[cfg(target_os = "macos")]
+ {
+ let path = youtube_cookie_encrypted_file(&app)?;
+ match fs::remove_file(path) {
+ Ok(()) => {}
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
+ Err(error) => {
+ return Err(CommandError {
+ message: format!("encrypted session delete failed: {error}"),
+ })
+ }
+ }
+ }
+
+ #[cfg(not(target_os = "macos"))]
+ delete_youtube_music_cookie_entries()?;
+ eprintln!("[internal][tauri][info] delete_youtube_music_cookie complete");
+ Ok(())
+}
+
+#[derive(Serialize)]
+struct CommandError {
+ message: String,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct AudioPayload {
+ body_base64: String,
+ mime_type: String,
+}
+
+#[derive(serde::Deserialize)]
+struct ProxyHttpRequestInput {
+ url: String,
+ method: String,
+ headers: HashMap,
+ body_base64: Option,
+ timeout_ms: Option,
+}
+
+#[derive(Serialize)]
+struct ProxyHttpResponse {
+ status: u16,
+ headers: HashMap,
+ body_base64: String,
+ /// The rotated cookie, when this response changed it. Absent means "unchanged".
+ #[serde(skip_serializing_if = "Option::is_none")]
+ cookie: Option,
+}
+
+#[derive(Clone)]
+/**
+ * A media body, which may still be arriving.
+ *
+ * Ranges are fetched in parallel and therefore land out of order, so "how much can be served"
+ * is the leading run of present chunks — not how many bytes have arrived in total. A request
+ * for a range past that prefix waits for it rather than being answered short, which is what
+ * lets an `` element start on the first chunk while the rest is still downloading.
+ */
+struct MediaBuffer {
+ /// Sized on creation; each slot's length is its own chunk size, so no stride is stored.
+ chunks: Vec>>,
+ total: usize,
+ /// Set when the download gave up. The server answers 503 rather than waiting out the clock.
+ failed: bool,
+}
+
+impl MediaBuffer {
+ fn complete(bytes: Vec) -> Self {
+ let total = bytes.len();
+ Self { chunks: vec![Some(bytes)], total, failed: false }
+ }
+
+ fn pending(total: usize, chunk_count: usize) -> Self {
+ Self { chunks: vec![None; chunk_count], total, failed: false }
+ }
+
+ /// Bytes servable from offset 0 without a hole.
+ fn contiguous_len(&self) -> usize {
+ let mut len = 0;
+ for chunk in &self.chunks {
+ match chunk {
+ Some(bytes) => len += bytes.len(),
+ None => break,
+ }
+ }
+ len
+ }
+
+ /// Replaces everything with one finished body, for the whole-file fallback.
+ fn adopt_complete(&mut self, bytes: Vec) {
+ self.total = bytes.len();
+ self.chunks = vec![Some(bytes)];
+ }
+
+ fn put(&mut self, index: usize, bytes: Vec) {
+ if index < self.chunks.len() {
+ self.chunks[index] = Some(bytes);
+ }
+ }
+
+ /// Copies `start..=end` out of the chunk list, which may straddle several chunks.
+ fn read(&self, start: usize, end: usize) -> Vec {
+ let mut out = Vec::with_capacity(end.saturating_sub(start) + 1);
+ let mut offset = 0;
+ for chunk in &self.chunks {
+ let Some(bytes) = chunk else { break };
+ let chunk_end = offset + bytes.len();
+ if chunk_end > start && offset <= end {
+ let from = start.saturating_sub(offset);
+ let to = (end - offset + 1).min(bytes.len());
+ if from < to {
+ out.extend_from_slice(&bytes[from..to]);
+ }
+ }
+ offset = chunk_end;
+ if offset > end {
+ break;
+ }
+ }
+ out
+ }
+}
+
+/// Cloned per request so the handler can release the map lock before it waits on bytes.
+#[derive(Clone)]
+struct MediaItem {
+ buffer: Arc