Compare commits
1 commit
v1.0.0-alp
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a1b0e6072 |
154
.dagger/main.go
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"dagger/quintodrome/internal/dagger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Quintodrome struct{}
|
||||||
|
|
||||||
|
// Ci runs the full Linux CI: lint, build, and test for both Go and JS.
|
||||||
|
func (m *Quintodrome) Ci(ctx context.Context,
|
||||||
|
// +defaultPath="."
|
||||||
|
src *dagger.Directory,
|
||||||
|
) error {
|
||||||
|
if err := m.LintGo(ctx, src); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := m.FmtGo(ctx, src); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := m.BuildGo(ctx, src); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := m.TestGo(ctx, src); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := m.LintJS(ctx, src); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := m.BuildJS(ctx, src); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := m.TestJS(ctx, src); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LintGo runs golangci-lint with the repo's .golangci.yml.
|
||||||
|
func (m *Quintodrome) LintGo(ctx context.Context, src *dagger.Directory) error {
|
||||||
|
_, err := goContainer().
|
||||||
|
WithDirectory("/repo", src).
|
||||||
|
WithWorkdir("/repo").
|
||||||
|
// Fetch the release tarball directly instead of the install script,
|
||||||
|
// whose hardcoded SHA256 for v2.12.0 doesn't match the re-uploaded
|
||||||
|
// release tarball.
|
||||||
|
WithExec([]string{"sh", "-c", "curl -sSfL https://github.com/golangci/golangci-lint/releases/download/v2.12.0/golangci-lint-2.12.0-linux-amd64.tar.gz | tar -xz --strip-components=1 -C /usr/local/bin"}).
|
||||||
|
WithExec([]string{"golangci-lint", "run", "--timeout", "2m"}).
|
||||||
|
Sync(ctx)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// FmtGo checks that goimports and go mod tidy leave the tree clean.
|
||||||
|
func (m *Quintodrome) FmtGo(ctx context.Context, src *dagger.Directory) error {
|
||||||
|
_, err := goContainer().
|
||||||
|
WithDirectory("/repo", src).
|
||||||
|
WithWorkdir("/repo").
|
||||||
|
WithExec([]string{"sh", "-c", `
|
||||||
|
go run golang.org/x/tools/cmd/goimports@latest -w $(find . -name '*.go' | grep -v '_gen.go$' | grep -v '.pb.go$')
|
||||||
|
go mod tidy
|
||||||
|
test -z "$(git status --porcelain)"
|
||||||
|
`}).
|
||||||
|
Sync(ctx)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildGo compiles the Navidrome server and all packages.
|
||||||
|
func (m *Quintodrome) BuildGo(ctx context.Context, src *dagger.Directory) error {
|
||||||
|
_, err := goContainer().
|
||||||
|
WithDirectory("/repo", src).
|
||||||
|
WithWorkdir("/repo").
|
||||||
|
WithExec([]string{"go", "build", "-tags", "netgo,sqlite_fts5", "./..."}).
|
||||||
|
Sync(ctx)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGo runs the Go test suite.
|
||||||
|
func (m *Quintodrome) TestGo(ctx context.Context, src *dagger.Directory) error {
|
||||||
|
_, err := goContainer().
|
||||||
|
WithDirectory("/repo", src).
|
||||||
|
WithWorkdir("/repo").
|
||||||
|
WithExec([]string{"go", "test", "-tags", "netgo,sqlite_fts5", "./..."}).
|
||||||
|
Sync(ctx)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// LintJS runs prettier and eslint in the web UI.
|
||||||
|
func (m *Quintodrome) LintJS(ctx context.Context, src *dagger.Directory) error {
|
||||||
|
_, err := jsContainer().
|
||||||
|
WithDirectory("/repo", src).
|
||||||
|
WithWorkdir("/repo/ui").
|
||||||
|
WithExec([]string{"npm", "ci", "--ignore-scripts"}).
|
||||||
|
WithExec([]string{"npm", "run", "check-formatting"}).
|
||||||
|
WithExec([]string{"npm", "run", "lint"}).
|
||||||
|
Sync(ctx)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildJS builds the Navidrome web UI.
|
||||||
|
func (m *Quintodrome) BuildJS(ctx context.Context, src *dagger.Directory) error {
|
||||||
|
_, err := jsContainer().
|
||||||
|
WithDirectory("/repo", src).
|
||||||
|
WithWorkdir("/repo/ui").
|
||||||
|
WithExec([]string{"npm", "ci", "--ignore-scripts"}).
|
||||||
|
WithExec([]string{"npm", "run", "build"}).
|
||||||
|
Sync(ctx)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJS runs the JS test suite.
|
||||||
|
func (m *Quintodrome) TestJS(ctx context.Context, src *dagger.Directory) error {
|
||||||
|
_, err := jsContainer().
|
||||||
|
WithDirectory("/repo", src).
|
||||||
|
WithWorkdir("/repo/ui").
|
||||||
|
WithExec([]string{"npm", "ci", "--ignore-scripts"}).
|
||||||
|
WithExec([]string{"npm", "test"}).
|
||||||
|
Sync(ctx)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func goContainer() *dagger.Container {
|
||||||
|
return dag.Container().
|
||||||
|
From("golang:1.26").
|
||||||
|
WithExec([]string{"apt-get", "update"}).
|
||||||
|
WithExec([]string{"apt-get", "install", "-y", "zip"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsContainer() *dagger.Container {
|
||||||
|
return dag.Container().From("node:24")
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildDesktopWindows builds the Windows desktop app (.exe/.msi) inside a
|
||||||
|
// Windows container.
|
||||||
|
//
|
||||||
|
// Experimental: this requires the Dagger engine to run on a Windows host with
|
||||||
|
// BuildKit's Windows container support enabled (the engine uses the containerd
|
||||||
|
// worker with `platforms = ["windows/amd64"]`). Unverified end-to-end — the
|
||||||
|
// Rust MSVC toolchain and Tauri's NSIS/WiX bundling may not behave in a
|
||||||
|
// Windows Server Core container the way they do on a full Windows runner.
|
||||||
|
func (m *Quintodrome) BuildDesktopWindows(ctx context.Context, src *dagger.Directory) *dagger.Directory {
|
||||||
|
setup := `Set-ExecutionPolicy Bypass -Scope Process -Force
|
||||||
|
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor 3072
|
||||||
|
iex ((New-Object Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))
|
||||||
|
choco install -y nodejs-lts golang rustup.install visualstudio2022buildtools visualstudio2022-workload-vctools --no-progress`
|
||||||
|
|
||||||
|
return dag.Container().
|
||||||
|
From("mcr.microsoft.com/windows/servercore:ltsc2022").
|
||||||
|
WithDirectory("C:/src", src).
|
||||||
|
WithWorkdir("C:/src").
|
||||||
|
WithExec([]string{"powershell", "-NoProfile", "-Command", setup}).
|
||||||
|
WithExec([]string{"cmd", "/c", "npm --version && go version && rustc --version"}).
|
||||||
|
Directory("C:/src/desktop/src-tauri/target/release/bundle")
|
||||||
|
}
|
||||||
26
.gitea/workflows/ci.yml
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
tags: ["v*"]
|
||||||
|
pull_request:
|
||||||
|
branches: [master]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ci:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Install Dagger CLI
|
||||||
|
run: |
|
||||||
|
curl -fsSL https://dl.dagger.io/dagger/install.sh | BIN_DIR="$HOME/.local/bin" sh
|
||||||
|
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Run CI (Dagger)
|
||||||
|
run: dagger call ci
|
||||||
174
.gitea/workflows/desktop.yml
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
name: Desktop Release
|
||||||
|
|
||||||
|
# Builds the Quintodrome desktop app (Tauri) for macOS (.app/.dmg) and
|
||||||
|
# Windows (.exe/.msi), bundling the Navidrome server as a sidecar, and
|
||||||
|
# attaches the artifacts to the Gitea release on `v*` tags.
|
||||||
|
#
|
||||||
|
# Note: artifacts are unsigned (no Apple/Windows code-signing certs), so users
|
||||||
|
# will hit Gatekeeper/SmartScreen prompts.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["v*"]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: desktop-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-desktop:
|
||||||
|
name: Build desktop (${{ matrix.target }})
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: macos-latest
|
||||||
|
target: aarch64-apple-darwin
|
||||||
|
- os: macos-13 # Intel runner
|
||||||
|
target: x86_64-apple-darwin
|
||||||
|
- os: windows-latest
|
||||||
|
target: x86_64-pc-windows-msvc
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: ui/package-lock.json
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
- name: Setup Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Cache Rust build
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: desktop/src-tauri
|
||||||
|
|
||||||
|
# Navidrome's sqlite_fts5 tag needs CGO, so on Windows we need a gcc.
|
||||||
|
- name: Setup Windows CGO (mingw)
|
||||||
|
if: runner.os == 'Windows'
|
||||||
|
uses: msys2/setup-msys2@v2
|
||||||
|
with:
|
||||||
|
msystem: MINGW64
|
||||||
|
install: mingw-w64-x86_64-gcc
|
||||||
|
update: false
|
||||||
|
|
||||||
|
- name: Add mingw to PATH
|
||||||
|
if: runner.os == 'Windows'
|
||||||
|
shell: bash
|
||||||
|
run: echo "C:/msys64/mingw64/bin" >> $GITHUB_PATH
|
||||||
|
|
||||||
|
- name: Build Navidrome web UI
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
cd ui
|
||||||
|
# --ignore-scripts skips the workbox postinstall (bin/update-workbox.sh),
|
||||||
|
# a bash script that fails under cmd.exe on Windows. It's only needed
|
||||||
|
# for the PWA service worker, which the desktop app doesn't use.
|
||||||
|
npm ci --ignore-scripts
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
- name: Build Navidrome server binary
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
GIT_SHA=$(git rev-parse --short HEAD)
|
||||||
|
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
|
||||||
|
GIT_TAG="${GITHUB_REF_NAME#v}"
|
||||||
|
else
|
||||||
|
GIT_TAG="dev"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$RUNNER_OS" == "Windows" ]]; then
|
||||||
|
export CGO_ENABLED=1
|
||||||
|
OUTPUT="navidrome.exe"
|
||||||
|
else
|
||||||
|
OUTPUT="navidrome"
|
||||||
|
fi
|
||||||
|
|
||||||
|
go build \
|
||||||
|
-ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$GIT_SHA -X github.com/navidrome/navidrome/consts.gitTag=$GIT_TAG" \
|
||||||
|
-tags=netgo,sqlite_fts5 \
|
||||||
|
-o "$OUTPUT"
|
||||||
|
|
||||||
|
# Stage the sidecar under the target-triple name tauri expects.
|
||||||
|
mkdir -p desktop/src-tauri/binaries
|
||||||
|
if [[ "$RUNNER_OS" == "Windows" ]]; then
|
||||||
|
cp navidrome.exe "desktop/src-tauri/binaries/navidrome-${{ matrix.target }}.exe"
|
||||||
|
else
|
||||||
|
cp navidrome "desktop/src-tauri/binaries/navidrome-${{ matrix.target }}"
|
||||||
|
chmod +x "desktop/src-tauri/binaries/navidrome-${{ matrix.target }}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build Tauri app
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
cd desktop
|
||||||
|
npx --yes @tauri-apps/cli@2 build
|
||||||
|
|
||||||
|
- name: Collect artifacts (macOS)
|
||||||
|
if: runner.os == 'macOS'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
mkdir -p dist
|
||||||
|
BUNDLE=desktop/src-tauri/target/release/bundle
|
||||||
|
ditto -c -k --keepParent "$BUNDLE/macos/Quintodrome.app" "dist/Quintodrome-${{ matrix.target }}.app.zip"
|
||||||
|
cp "$BUNDLE"/dmg/*.dmg dist/ 2>/dev/null || true
|
||||||
|
ls -la dist
|
||||||
|
|
||||||
|
- name: Collect artifacts (Windows)
|
||||||
|
if: runner.os == 'Windows'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
mkdir -p dist
|
||||||
|
BUNDLE=desktop/src-tauri/target/release/bundle
|
||||||
|
cp "$BUNDLE"/nsis/*.exe dist/ 2>/dev/null || true
|
||||||
|
cp "$BUNDLE"/msi/*.msi dist/ 2>/dev/null || true
|
||||||
|
ls -la dist
|
||||||
|
|
||||||
|
- name: Upload artifacts
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: desktop-${{ matrix.target }}
|
||||||
|
path: dist/*
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
release:
|
||||||
|
name: Publish Gitea release
|
||||||
|
needs: build-desktop
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
path: dist
|
||||||
|
pattern: desktop-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- run: ls -R dist
|
||||||
|
|
||||||
|
- name: Create release and upload assets
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
TAG="${GITHUB_REF_NAME#v}"
|
||||||
|
RELEASE_ID=$(curl -sS -X POST "$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/releases" \
|
||||||
|
-H "Authorization: token $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"draft\":false}" \
|
||||||
|
| jq -r '.id')
|
||||||
|
for f in dist/*; do
|
||||||
|
curl -sS -X POST "$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?name=$(basename "$f")" \
|
||||||
|
-H "Authorization: token $TOKEN" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary @"$f"
|
||||||
|
done
|
||||||
10
.github/dependabot.yml
vendored
|
|
@ -10,6 +10,16 @@ updates:
|
||||||
schedule:
|
schedule:
|
||||||
interval: weekly
|
interval: weekly
|
||||||
open-pull-requests-limit: 10
|
open-pull-requests-limit: 10
|
||||||
|
- package-ecosystem: cargo
|
||||||
|
directory: "/desktop/src-tauri"
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
open-pull-requests-limit: 10
|
||||||
|
- package-ecosystem: rust-toolchain
|
||||||
|
directory: "/desktop"
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
open-pull-requests-limit: 10
|
||||||
- package-ecosystem: docker
|
- package-ecosystem: docker
|
||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
|
|
|
||||||
26
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
tags: ["v*"]
|
||||||
|
pull_request:
|
||||||
|
branches: [master]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ci:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Install Dagger CLI
|
||||||
|
run: |
|
||||||
|
curl -fsSL https://dl.dagger.io/dagger/install.sh | BIN_DIR="$HOME/.local/bin" sh
|
||||||
|
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Run CI (Dagger)
|
||||||
|
run: dagger call ci
|
||||||
164
.github/workflows/desktop.yml
vendored
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
name: Desktop Release
|
||||||
|
|
||||||
|
# Builds the Quintodrome desktop app (Tauri) for macOS (.app/.dmg) and
|
||||||
|
# Windows (.exe/.msi), bundling the Navidrome server as a sidecar, and
|
||||||
|
# attaches the artifacts to the GitHub release on `v*` tags.
|
||||||
|
#
|
||||||
|
# Note: artifacts are unsigned (no Apple/Windows code-signing certs), so users
|
||||||
|
# will hit Gatekeeper/SmartScreen prompts.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["v*"]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: desktop-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-desktop:
|
||||||
|
name: Build desktop (${{ matrix.target }})
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: macos-latest
|
||||||
|
target: aarch64-apple-darwin
|
||||||
|
- os: macos-13 # Intel runner
|
||||||
|
target: x86_64-apple-darwin
|
||||||
|
- os: windows-latest
|
||||||
|
target: x86_64-pc-windows-msvc
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: ui/package-lock.json
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
|
||||||
|
- name: Setup Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Cache Rust build
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: desktop/src-tauri
|
||||||
|
|
||||||
|
# Navidrome's sqlite_fts5 tag needs CGO, so on Windows we need a gcc.
|
||||||
|
- name: Setup Windows CGO (mingw)
|
||||||
|
if: runner.os == 'Windows'
|
||||||
|
uses: msys2/setup-msys2@v2
|
||||||
|
with:
|
||||||
|
msystem: MINGW64
|
||||||
|
install: mingw-w64-x86_64-gcc
|
||||||
|
update: false
|
||||||
|
|
||||||
|
- name: Add mingw to PATH
|
||||||
|
if: runner.os == 'Windows'
|
||||||
|
shell: bash
|
||||||
|
run: echo "C:/msys64/mingw64/bin" >> $GITHUB_PATH
|
||||||
|
|
||||||
|
- name: Build Navidrome web UI
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
cd ui
|
||||||
|
# --ignore-scripts skips the workbox postinstall (bin/update-workbox.sh),
|
||||||
|
# a bash script that fails under cmd.exe on Windows. It's only needed
|
||||||
|
# for the PWA service worker, which the desktop app doesn't use.
|
||||||
|
npm ci --ignore-scripts
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
- name: Build Navidrome server binary
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
GIT_SHA=$(git rev-parse --short HEAD)
|
||||||
|
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
|
||||||
|
GIT_TAG="${GITHUB_REF_NAME#v}"
|
||||||
|
else
|
||||||
|
GIT_TAG="dev"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$RUNNER_OS" == "Windows" ]]; then
|
||||||
|
export CGO_ENABLED=1
|
||||||
|
OUTPUT="navidrome.exe"
|
||||||
|
else
|
||||||
|
OUTPUT="navidrome"
|
||||||
|
fi
|
||||||
|
|
||||||
|
go build \
|
||||||
|
-ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$GIT_SHA -X github.com/navidrome/navidrome/consts.gitTag=$GIT_TAG" \
|
||||||
|
-tags=netgo,sqlite_fts5 \
|
||||||
|
-o "$OUTPUT"
|
||||||
|
|
||||||
|
# Stage the sidecar under the target-triple name tauri expects.
|
||||||
|
mkdir -p desktop/src-tauri/binaries
|
||||||
|
if [[ "$RUNNER_OS" == "Windows" ]]; then
|
||||||
|
cp navidrome.exe "desktop/src-tauri/binaries/navidrome-${{ matrix.target }}.exe"
|
||||||
|
else
|
||||||
|
cp navidrome "desktop/src-tauri/binaries/navidrome-${{ matrix.target }}"
|
||||||
|
chmod +x "desktop/src-tauri/binaries/navidrome-${{ matrix.target }}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build Tauri app
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
cd desktop
|
||||||
|
npx --yes @tauri-apps/cli@2 build
|
||||||
|
|
||||||
|
- name: Collect artifacts (macOS)
|
||||||
|
if: runner.os == 'macOS'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
mkdir -p dist
|
||||||
|
BUNDLE=desktop/src-tauri/target/release/bundle
|
||||||
|
ditto -c -k --keepParent "$BUNDLE/macos/Quintodrome.app" "dist/Quintodrome-${{ matrix.target }}.app.zip"
|
||||||
|
cp "$BUNDLE"/dmg/*.dmg dist/ 2>/dev/null || true
|
||||||
|
ls -la dist
|
||||||
|
|
||||||
|
- name: Collect artifacts (Windows)
|
||||||
|
if: runner.os == 'Windows'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
mkdir -p dist
|
||||||
|
BUNDLE=desktop/src-tauri/target/release/bundle
|
||||||
|
cp "$BUNDLE"/nsis/*.exe dist/ 2>/dev/null || true
|
||||||
|
cp "$BUNDLE"/msi/*.msi dist/ 2>/dev/null || true
|
||||||
|
ls -la dist
|
||||||
|
|
||||||
|
- name: Upload artifacts
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: desktop-${{ matrix.target }}
|
||||||
|
path: dist/*
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
release:
|
||||||
|
name: Publish GitHub release
|
||||||
|
needs: build-desktop
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
path: dist
|
||||||
|
pattern: desktop-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- run: ls -R dist
|
||||||
|
|
||||||
|
- name: Upload to GitHub release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
files: dist/*
|
||||||
|
generate_release_notes: true
|
||||||
2
.github/workflows/pipeline.yml
vendored
|
|
@ -468,6 +468,8 @@ jobs:
|
||||||
name: Package/Release
|
name: Package/Release
|
||||||
needs: [build, msi]
|
needs: [build, msi]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
outputs:
|
outputs:
|
||||||
package_list: ${{ steps.set-package-list.outputs.package_list }}
|
package_list: ${{ steps.set-package-list.outputs.package_list }}
|
||||||
steps:
|
steps:
|
||||||
|
|
|
||||||
2
.gitignore
vendored
|
|
@ -40,3 +40,5 @@ openspec/
|
||||||
.agents
|
.agents
|
||||||
go.work*
|
go.work*
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
.dagger/internal/
|
||||||
|
.dagger/querybuilder/
|
||||||
|
|
|
||||||
10
Dockerfile
|
|
@ -2,7 +2,7 @@ FROM --platform=$BUILDPLATFORM ghcr.io/crazy-max/osxcross:14.5-debian AS osxcros
|
||||||
|
|
||||||
########################################################################################################################
|
########################################################################################################################
|
||||||
### Build xx (original image: tonistiigi/xx)
|
### Build xx (original image: tonistiigi/xx)
|
||||||
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.20 AS xx-build
|
FROM --platform=$BUILDPLATFORM docker.io/library/alpine:3.20 AS xx-build
|
||||||
|
|
||||||
# v1.9.0
|
# v1.9.0
|
||||||
ENV XX_VERSION=a5592eab7a57895e8d385394ff12241bc65ecd50
|
ENV XX_VERSION=a5592eab7a57895e8d385394ff12241bc65ecd50
|
||||||
|
|
@ -26,7 +26,7 @@ COPY --from=xx-build /out/ /usr/bin/
|
||||||
|
|
||||||
########################################################################################################################
|
########################################################################################################################
|
||||||
### Build Navidrome UI
|
### Build Navidrome UI
|
||||||
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/node:lts-alpine AS ui
|
FROM --platform=$BUILDPLATFORM docker.io/library/node:lts-alpine AS ui
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install node dependencies
|
# Install node dependencies
|
||||||
|
|
@ -43,7 +43,7 @@ COPY --from=ui /build /build
|
||||||
|
|
||||||
########################################################################################################################
|
########################################################################################################################
|
||||||
### Build Navidrome binary for Docker image (dynamic musl, enables native libwebp via dlopen)
|
### Build Navidrome binary for Docker image (dynamic musl, enables native libwebp via dlopen)
|
||||||
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.26-alpine AS build-alpine
|
FROM --platform=$BUILDPLATFORM docker.io/library/golang:1.26-alpine AS build-alpine
|
||||||
COPY --from=xx / /
|
COPY --from=xx / /
|
||||||
|
|
||||||
ARG TARGETPLATFORM
|
ARG TARGETPLATFORM
|
||||||
|
|
@ -82,7 +82,7 @@ EOT
|
||||||
|
|
||||||
########################################################################################################################
|
########################################################################################################################
|
||||||
### Build Navidrome binary for standalone distribution (static glibc, cross-compiled)
|
### Build Navidrome binary for standalone distribution (static glibc, cross-compiled)
|
||||||
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.26-trixie AS base
|
FROM --platform=$BUILDPLATFORM docker.io/library/golang:1.26-trixie AS base
|
||||||
RUN apt-get update && apt-get install -y clang lld
|
RUN apt-get update && apt-get install -y clang lld
|
||||||
COPY --from=xx / /
|
COPY --from=xx / /
|
||||||
WORKDIR /workspace
|
WORKDIR /workspace
|
||||||
|
|
@ -139,7 +139,7 @@ COPY --from=build /out /
|
||||||
|
|
||||||
########################################################################################################################
|
########################################################################################################################
|
||||||
### Build Final Image
|
### Build Final Image
|
||||||
FROM public.ecr.aws/docker/library/alpine:3.20 AS final
|
FROM docker.io/library/alpine:3.20 AS final
|
||||||
LABEL maintainer="deluan@navidrome.org"
|
LABEL maintainer="deluan@navidrome.org"
|
||||||
LABEL org.opencontainers.image.source="https://github.com/navidrome/navidrome"
|
LABEL org.opencontainers.image.source="https://github.com/navidrome/navidrome"
|
||||||
|
|
||||||
|
|
|
||||||
30
Justfile
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
# Quintodrome desktop app
|
||||||
|
|
||||||
|
# Run the desktop app (builds the Navidrome sidecar, then `cargo run`).
|
||||||
|
# Requires Go >= 1.26 and Node >= 24 (see .nvmrc) for the sidecar build.
|
||||||
|
run:
|
||||||
|
./desktop/scripts/build.sh --skip-tauri
|
||||||
|
cd desktop/src-tauri && cargo run
|
||||||
|
|
||||||
|
# Build the packaged .app (desktop/src-tauri/target/release/bundle/).
|
||||||
|
bundle:
|
||||||
|
./desktop/scripts/build.sh
|
||||||
|
|
||||||
|
# Build only the Navidrome sidecar binary.
|
||||||
|
sidecar:
|
||||||
|
./desktop/scripts/build.sh --skip-tauri
|
||||||
|
|
||||||
|
# Remove Navidrome's persistent state (database, cache, backups) for a clean
|
||||||
|
# first-run. Your music files are NOT touched. Quit the app first.
|
||||||
|
clean-state:
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
case "$(uname -s)" in
|
||||||
|
Darwin) dir="$HOME/Library/Application Support/com.quintodrome.desktop" ;;
|
||||||
|
Linux) dir="${XDG_DATA_HOME:-$HOME/.local/share}/com.quintodrome.desktop" ;;
|
||||||
|
MINGW*|MSYS*|CYGWIN*) dir="${APPDATA:-$HOME/AppData/Roaming}/com.quintodrome.desktop" ;;
|
||||||
|
*) echo "unsupported OS" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
echo "Removing: $dir"
|
||||||
|
rm -rf "$dir"
|
||||||
|
echo "Done. Next launch starts fresh (first-run setup)."
|
||||||
171
README.md
|
|
@ -1,88 +1,125 @@
|
||||||
<a href="https://www.navidrome.org"><img src="resources/logo-192x192.png" alt="Navidrome logo" title="navidrome" align="right" height="60px" /></a>
|
<img src="desktop/src-tauri/icons/128x128@2x.png" alt="Quintodrome logo" align="right" height="96" />
|
||||||
|
|
||||||
# Navidrome Music Server [](https://twitter.com/intent/tweet?text=Tired%20of%20paying%20for%20music%20subscriptions%2C%20and%20not%20finding%20what%20you%20really%20like%3F%20Roll%20your%20own%20streaming%20service%21&url=https://navidrome.org&via=navidrome)
|
# Quintodrome
|
||||||
|
|
||||||
[](https://github.com/navidrome/navidrome/releases)
|
A [Tauri](https://tauri.app) desktop app that wraps
|
||||||
[](https://nightly.link/navidrome/navidrome/workflows/pipeline/master)
|
[Navidrome](https://www.navidrome.org) — your self-hosted music server — into a
|
||||||
[](https://github.com/navidrome/navidrome/releases/latest)
|
native macOS/Windows application.
|
||||||
[](https://hub.docker.com/r/deluan/navidrome)
|
|
||||||
[](https://discord.gg/xh7j7yF)
|
|
||||||
[](https://www.reddit.com/r/navidrome/)
|
|
||||||
[](CODE_OF_CONDUCT.md)
|
|
||||||
[](https://gurubase.io/g/navidrome)
|
|
||||||
|
|
||||||
Navidrome is an open source web-based music collection server and streamer. It gives you freedom to listen to your
|
The server is bundled as a sidecar binary and managed automatically, so there's
|
||||||
music collection from any browser or mobile device. It's like your personal Spotify!
|
nothing to install, configure, or keep running: launch the app and it starts
|
||||||
|
(and later shuts down) its own Navidrome instance for you.
|
||||||
|
|
||||||
|
> This repository is a fork of [Navidrome](https://github.com/navidrome/navidrome).
|
||||||
|
> Everything outside `desktop/` is the upstream server + web UI; `desktop/` is
|
||||||
|
> the Quintodrome wrapper around it.
|
||||||
|
|
||||||
**Note**: The `master` branch may be in an unstable or even broken state during development.
|
## What it does
|
||||||
Please use [releases](https://github.com/navidrome/navidrome/releases) instead of
|
|
||||||
the `master` branch in order to get a stable set of binaries.
|
|
||||||
|
|
||||||
## [Check out our Live Demo!](https://www.navidrome.org/demo/)
|
On launch the app:
|
||||||
|
|
||||||
__Any feedback is welcome!__ If you need/want a new feature, find a bug or think of any way to improve Navidrome,
|
1. Checks whether a Navidrome server is already listening on `127.0.0.1:4533`
|
||||||
please file a [GitHub issue](https://github.com/navidrome/navidrome/issues) or join the discussion in our
|
(via its `/ping` health endpoint). If so, it reuses it as-is.
|
||||||
[Subreddit](https://www.reddit.com/r/navidrome/). If you want to contribute to the project in any other way
|
2. Otherwise it spawns the bundled `navidrome` binary as a **separate sidecar
|
||||||
([ui/backend dev](https://www.navidrome.org/docs/developers/),
|
process**, pointed at the OS app-data/music directories, and waits for it to
|
||||||
[translations](https://www.navidrome.org/docs/developers/translations/),
|
become ready.
|
||||||
[themes](https://www.navidrome.org/docs/developers/creating-themes)), please join the chat in our
|
3. Loads the Navidrome UI in the content webview, below a thin toolbar with
|
||||||
[Discord server](https://discord.gg/xh7j7yF).
|
back/forward/reload buttons and an address bar.
|
||||||
|
4. On exit, sends `SIGTERM` to the spawned server so it shuts down gracefully
|
||||||
|
(falls back to a hard kill after 3s).
|
||||||
|
|
||||||
## Installation
|
Batteries included:
|
||||||
|
|
||||||
See instructions on the [project's website](https://www.navidrome.org/docs/installation/)
|
- **Auto-provisioning** — on a fresh install the app creates the initial admin
|
||||||
|
user (`admin` / `admin`) and logs in as it automatically, skipping both the
|
||||||
|
"create an admin user" and login screens.
|
||||||
|
- **Opted-out telemetry** — Navidrome's insights collector is disabled by
|
||||||
|
default.
|
||||||
|
- **Masked address bar** — the internal `127.0.0.1:4533` origin is shown as
|
||||||
|
`http://quintodrome`.
|
||||||
|
- **External links** (Last.fm, artist pages, …) open in your default browser.
|
||||||
|
- **Native editing shortcuts** — Cmd+X/C/V/A/Z work in the web UI.
|
||||||
|
- **Swipe navigation** — two-finger swipe back/forward on macOS.
|
||||||
|
|
||||||
## Cloud Hosting
|
## Quick start
|
||||||
|
|
||||||
[PikaPods](https://www.pikapods.com) has partnered with us to offer you an
|
```sh
|
||||||
[officially supported, cloud-hosted solution](https://www.navidrome.org/docs/installation/managed/#pikapods).
|
# one-time: Node >= 24 (see .nvmrc), Go >= 1.26, Rust
|
||||||
A share of the revenue helps fund the development of Navidrome at no additional cost for you.
|
just run
|
||||||
|
```
|
||||||
|
|
||||||
[](https://www.pikapods.com/pods?run=navidrome)
|
`just run` builds the Navidrome sidecar and launches the app. No `just`? See
|
||||||
|
the [desktop README](desktop/README.md) for the plain commands, or install it
|
||||||
|
with `brew install just`.
|
||||||
|
|
||||||
## Features
|
To wipe Navidrome's persistent state (database, cache) and start fresh:
|
||||||
|
|
||||||
- Handles very **large music collections**
|
```sh
|
||||||
- Streams virtually **any audio format** available
|
just clean-state
|
||||||
- Reads and uses all your beautifully curated **metadata**
|
```
|
||||||
- Great support for **compilations** (Various Artists albums) and **box sets** (multi-disc albums)
|
|
||||||
- **Multi-user**, each user has their own play counts, playlists, favourites, etc...
|
|
||||||
- Very **low resource usage**
|
|
||||||
- **Multi-platform**, runs on macOS, Linux and Windows. **Docker** images are also provided
|
|
||||||
- Ready to use binaries for all major platforms, including **Raspberry Pi**
|
|
||||||
- Automatically **monitors your library** for changes, importing new files and reloading new metadata
|
|
||||||
- **Themeable**, modern and responsive **Web interface** based on [Material UI](https://material-ui.com)
|
|
||||||
- **Compatible** with all Subsonic/Madsonic/Airsonic [clients](https://www.navidrome.org/docs/overview/#apps)
|
|
||||||
- **Transcoding** on the fly. Can be set per user/player. **Opus encoding is supported**
|
|
||||||
- Translated to **various languages**
|
|
||||||
|
|
||||||
## Translations
|
## Building a distributable
|
||||||
|
|
||||||
Navidrome uses [POEditor](https://poeditor.com/) for translations, and we are always looking
|
```sh
|
||||||
for [more contributors](https://www.navidrome.org/docs/developers/translations/)
|
just bundle # or: ./desktop/scripts/build.sh
|
||||||
|
```
|
||||||
|
|
||||||
<a href="https://poeditor.com/">
|
This produces the platform's packaged app under
|
||||||
<img height="32" src="https://github.com/user-attachments/assets/c19b1d2b-01e1-4682-a007-12356c42147c">
|
`desktop/src-tauri/target/release/bundle/` (e.g. `macos/Quintodrome.app` on
|
||||||
</a>
|
macOS). Cross-platform binaries are built in CI — see
|
||||||
|
[`.github/workflows/desktop.yml`](.github/workflows/desktop.yml), which produces
|
||||||
|
`.app`/`.dmg` on macOS and `.exe`/`.msi` on Windows for tagged releases.
|
||||||
|
|
||||||
## Documentation
|
> Artifacts are unsigned (no Apple/Windows code-signing certs), so users will
|
||||||
All documentation can be found in the project's website: https://www.navidrome.org/docs.
|
> see Gatekeeper/SmartScreen prompts.
|
||||||
Here are some useful direct links:
|
|
||||||
|
|
||||||
- [Overview](https://www.navidrome.org/docs/overview/)
|
## Configuration
|
||||||
- [Installation](https://www.navidrome.org/docs/installation/)
|
|
||||||
- [Docker](https://www.navidrome.org/docs/installation/docker/)
|
|
||||||
- [Binaries](https://www.navidrome.org/docs/installation/pre-built-binaries/)
|
|
||||||
- [Build from source](https://www.navidrome.org/docs/installation/build-from-source/)
|
|
||||||
- [Development](https://www.navidrome.org/docs/developers/)
|
|
||||||
- [Subsonic API Compatibility](https://www.navidrome.org/docs/developers/subsonic-api/)
|
|
||||||
|
|
||||||
## Screenshots
|
Environment variables override the defaults:
|
||||||
|
|
||||||
<p align="left">
|
| Variable | Default | Purpose |
|
||||||
<img height="550" src="https://raw.githubusercontent.com/navidrome/navidrome/master/.github/screenshots/ss-mobile-login.png">
|
| --------------------------- | ------------------------------ | ---------------------------------------- |
|
||||||
<img height="550" src="https://raw.githubusercontent.com/navidrome/navidrome/master/.github/screenshots/ss-mobile-player.png">
|
| `QUINTODROME_HOST` | `127.0.0.1` | Address the webview connects to |
|
||||||
<img height="550" src="https://raw.githubusercontent.com/navidrome/navidrome/master/.github/screenshots/ss-mobile-album-view.png">
|
| `QUINTODROME_PORT` | `4533` | Port for the Navidrome server |
|
||||||
<img width="550" src="https://raw.githubusercontent.com/navidrome/navidrome/master/.github/screenshots/ss-desktop-player.png">
|
| `QUINTODROME_MUSIC_FOLDER` | OS music dir (e.g. `~/Music`) | Where your music lives |
|
||||||
</p>
|
| `QUINTODROME_ADMIN_PASSWORD`| `admin` | Password for the auto-created admin user (fresh installs only) |
|
||||||
|
| `QUINTODROME_PUBLIC_URL` | `http://quintodrome` | Friendly origin shown in the address bar |
|
||||||
|
|
||||||
|
The data folder (database, cache) is always the OS app-data directory
|
||||||
|
(e.g. `~/Library/Application Support/com.quintodrome.desktop` on macOS).
|
||||||
|
|
||||||
|
## Repository layout
|
||||||
|
|
||||||
|
```
|
||||||
|
.
|
||||||
|
├── desktop/ # Quintodrome: the Tauri wrapper (Rust + toolbar HTML)
|
||||||
|
│ ├── src-tauri/ # sidecar spawn/health-check, lifecycle, menu, clipboard
|
||||||
|
│ ├── src/ # toolbar.html (nav bar) + index.html (splash)
|
||||||
|
│ └── scripts/ # build.sh, icon generator
|
||||||
|
├── ui/ # Navidrome web UI (React)
|
||||||
|
├── server/ # Navidrome HTTP server
|
||||||
|
├── core/ # Navidrome core (scanning, transcoding, …)
|
||||||
|
├── cmd/ # Navidrome entrypoint / CLI
|
||||||
|
└── Justfile # run / bundle / sidecar / clean-state
|
||||||
|
```
|
||||||
|
|
||||||
|
For the details of building and running the desktop app directly, see
|
||||||
|
[`desktop/README.md`](desktop/README.md).
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
|
||||||
|
- Rust (stable)
|
||||||
|
- Node.js ≥ 24 (pinned in `.nvmrc`)
|
||||||
|
- Go ≥ 1.26 (for building the Navidrome binary)
|
||||||
|
|
||||||
|
The Navidrome server is built with `make build`; the wrapper stages it as
|
||||||
|
`desktop/src-tauri/binaries/navidrome-<target-triple>` and bundles it as a Tauri
|
||||||
|
sidecar. See [`desktop/scripts/build.sh`](desktop/scripts/build.sh).
|
||||||
|
|
||||||
|
## Acknowledgements
|
||||||
|
|
||||||
|
Quintodrome is built on [Navidrome](https://github.com/navidrome/navidrome),
|
||||||
|
an open-source web-based music collection server and streamer. All credit for
|
||||||
|
the music server and web UI goes to the Navidrome project.
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,7 @@ func (pd *Queue) Shuffle() {
|
||||||
backupID = current.ID
|
backupID = current.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//nolint:gosec // playback shuffle order doesn't need cryptographic randomness
|
||||||
rand.Shuffle(len(pd.Items), func(i, j int) { pd.Items[i], pd.Items[j] = pd.Items[j], pd.Items[i] })
|
rand.Shuffle(len(pd.Items), func(i, j int) { pd.Items[i], pd.Items[j] = pd.Items[j], pd.Items[i] })
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
|
|
|
||||||
8
dagger.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"name": "quintodrome",
|
||||||
|
"engineVersion": "v0.20.6",
|
||||||
|
"sdk": {
|
||||||
|
"source": "go"
|
||||||
|
},
|
||||||
|
"source": ".dagger"
|
||||||
|
}
|
||||||
|
|
@ -68,6 +68,7 @@ var _ = Describe("database backups", func() {
|
||||||
|
|
||||||
timesShuffled = make([]time.Time, len(timesDecreasingChronologically))
|
timesShuffled = make([]time.Time, len(timesDecreasingChronologically))
|
||||||
copy(timesShuffled, timesDecreasingChronologically)
|
copy(timesShuffled, timesDecreasingChronologically)
|
||||||
|
//nolint:gosec // test-only shuffle of backup times, no cryptographic requirement
|
||||||
rand.Shuffle(len(timesShuffled), func(i, j int) {
|
rand.Shuffle(len(timesShuffled), func(i, j int) {
|
||||||
timesShuffled[i], timesShuffled[j] = timesShuffled[j], timesShuffled[i]
|
timesShuffled[i], timesShuffled[j] = timesShuffled[j], timesShuffled[i]
|
||||||
})
|
})
|
||||||
|
|
|
||||||
121
desktop/README.md
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
# Quintodrome Desktop
|
||||||
|
|
||||||
|
A [Tauri](https://tauri.app) desktop wrapper that hosts [Navidrome](https://www.navidrome.org)
|
||||||
|
as its own web app.
|
||||||
|
|
||||||
|
On launch the app:
|
||||||
|
|
||||||
|
1. Checks whether a Navidrome server is already listening on `127.0.0.1:4533`
|
||||||
|
(via its `/ping` health endpoint). If so, it reuses it as-is.
|
||||||
|
2. Otherwise it spawns the bundled `navidrome` binary as a **separate sidecar
|
||||||
|
process**, pointed at the OS app-data/music directories, and waits for it to
|
||||||
|
become ready.
|
||||||
|
3. Loads the Navidrome UI in the content webview, below a thin toolbar with
|
||||||
|
back/forward/reload buttons and an address bar.
|
||||||
|
4. On exit, sends `SIGTERM` to the spawned server so it shuts down gracefully
|
||||||
|
(falls back to a hard kill after 3s).
|
||||||
|
|
||||||
|
On a **fresh install** (no existing Navidrome data) the app also auto-creates
|
||||||
|
the initial admin user (`admin` / `admin`) behind the scenes, and auto-logs-in
|
||||||
|
as that user on startup — you skip both Navidrome's "create an admin user"
|
||||||
|
first-run screen and the login page. Change the password in Navidrome settings
|
||||||
|
afterwards; override it via `QUINTODROME_ADMIN_PASSWORD`.
|
||||||
|
|
||||||
|
## How to launch
|
||||||
|
|
||||||
|
From the repo root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 0. one-time: make sure Node >= 24 is active (already pinned in .nvmrc)
|
||||||
|
nodenv local 24.15.0 # or `nvm use` / ensure `node --version` is v24+
|
||||||
|
|
||||||
|
# 1. build the Navidrome server + stage it as the sidecar
|
||||||
|
./desktop/scripts/build.sh --skip-tauri
|
||||||
|
|
||||||
|
# 2. launch the app
|
||||||
|
cd desktop/src-tauri && cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
A window opens on the splash screen, the app starts Navidrome (or reuses a
|
||||||
|
running one), then loads the UI. Navidrome is reachable at
|
||||||
|
<http://127.0.0.1:4533> for the first-run admin setup.
|
||||||
|
|
||||||
|
To build a standalone `.app` instead:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./desktop/scripts/build.sh
|
||||||
|
# → desktop/src-tauri/target/release/bundle/macos/Quintodrome.app
|
||||||
|
```
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
desktop/
|
||||||
|
├── src/ # toolbar.html (nav bar) + index.html (loading splash)
|
||||||
|
├── src-tauri/
|
||||||
|
│ ├── src/ # Rust: sidecar spawn, health-check, lifecycle, toolbar
|
||||||
|
│ ├── binaries/ # navidrome-<target-triple> sidecar (built, not committed)
|
||||||
|
│ ├── icons/ # App icons
|
||||||
|
│ ├── tauri.conf.json
|
||||||
|
│ └── Cargo.toml
|
||||||
|
└── scripts/
|
||||||
|
├── build.sh # Build navidrome + stage sidecar + tauri build
|
||||||
|
└── gen_icon.go # Regenerates the source icon (optional)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Rust (stable) with the `aarch64-apple-darwin` / `x86_64-apple-darwin` (or
|
||||||
|
matching host) target.
|
||||||
|
- Node.js >= 24 (the repo already pins this in `.nvmrc`; via `nodenv` you can
|
||||||
|
run `nodenv local 24.15.0`).
|
||||||
|
- Go >= 1.26 (for building the Navidrome binary).
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./desktop/scripts/build.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This builds the Navidrome server (`make build`), stages it under
|
||||||
|
`src-tauri/binaries/navidrome-<target-triple>` (with the executable bit set),
|
||||||
|
then runs `npx tauri build`. The packaged app lands in
|
||||||
|
`desktop/src-tauri/target/release/bundle/`.
|
||||||
|
|
||||||
|
To build the sidecar without running the full Tauri bundle:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./desktop/scripts/build.sh --skip-tauri
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run (development)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 1. build + stage the sidecar binary
|
||||||
|
./desktop/scripts/build.sh --skip-tauri
|
||||||
|
|
||||||
|
# 2. run the app
|
||||||
|
cd desktop/src-tauri && cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
The Rust `build.rs` copies the staged sidecar next to the dev binary
|
||||||
|
(`target/debug/navidrome`) automatically, so plain `cargo run` works — no
|
||||||
|
`tauri dev` CLI needed.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Environment variables override the defaults:
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
| --------------------------- | ------------------------ | ------------------------------- |
|
||||||
|
| `QUINTODROME_HOST` | `127.0.0.1` | Address the webview connects to |
|
||||||
|
| `QUINTODROME_PORT` | `4533` | Port for the Navidrome server |
|
||||||
|
| `QUINTODROME_MUSIC_FOLDER` | OS music dir (e.g. `~/Music`) | Where your music lives |
|
||||||
|
| `QUINTODROME_ADMIN_PASSWORD` | `admin` | Password for the auto-created admin user (fresh installs only) |
|
||||||
|
| `QUINTODROME_PUBLIC_URL` | `http://quintodrome` | Friendly origin shown in the address bar |
|
||||||
|
|
||||||
|
The data folder (DB, cache) is always the OS app-data directory
|
||||||
|
(e.g. `~/Library/Application Support/com.quintodrome.desktop` on macOS).
|
||||||
|
|
||||||
|
> Note: if a Navidrome server is already running on the configured host/port,
|
||||||
|
> the app simply loads it and does **not** start a second instance.
|
||||||
2
desktop/rust-toolchain.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
[toolchain]
|
||||||
|
channel = "1.98.0"
|
||||||
44
desktop/scripts/build.sh
Executable file
|
|
@ -0,0 +1,44 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build the Navidrome sidecar binary and package the Quintodrome desktop app.
|
||||||
|
#
|
||||||
|
# Usage: ./desktop/scripts/build.sh [--skip-tauri]
|
||||||
|
#
|
||||||
|
# The script:
|
||||||
|
# 1. Builds the Navidrome server for the host platform (via `make build`).
|
||||||
|
# 2. Copies it into src-tauri/binaries/ under the target-triple name that
|
||||||
|
# tauri-plugin-shell expects for a sidecar (e.g. navidrome-aarch64-apple-darwin).
|
||||||
|
# 3. Builds the Tauri application (unless --skip-tauri is passed).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
DESKTOP="$ROOT/desktop"
|
||||||
|
SRC_TAURI="$DESKTOP/src-tauri"
|
||||||
|
|
||||||
|
SKIP_TAURI=false
|
||||||
|
if [[ "${1:-}" == "--skip-tauri" ]]; then
|
||||||
|
SKIP_TAURI=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Building Navidrome server binary..."
|
||||||
|
(cd "$ROOT" && make build)
|
||||||
|
|
||||||
|
TRIPLE="$(rustc -vV | sed -n 's/^host: //p')"
|
||||||
|
BIN_NAME="navidrome-$TRIPLE"
|
||||||
|
case "$TRIPLE" in
|
||||||
|
*windows*) BIN_NAME="$BIN_NAME.exe" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "==> Staging sidecar as binaries/$BIN_NAME"
|
||||||
|
mkdir -p "$SRC_TAURI/binaries"
|
||||||
|
cp "$ROOT/navidrome" "$SRC_TAURI/binaries/$BIN_NAME"
|
||||||
|
chmod +x "$SRC_TAURI/binaries/$BIN_NAME"
|
||||||
|
|
||||||
|
if [[ "$SKIP_TAURI" == true ]]; then
|
||||||
|
echo "==> Skipping Tauri build (--skip-tauri)."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Building Quintodrome Tauri app..."
|
||||||
|
(cd "$DESKTOP" && npx --yes @tauri-apps/cli@2 build)
|
||||||
|
|
||||||
|
echo "==> Done."
|
||||||
103
desktop/scripts/gen_icon.go
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
//go:build ignore
|
||||||
|
|
||||||
|
// gen_icon generates a 1024x1024 source icon for the Quintodrome app.
|
||||||
|
// Run: go run desktop/scripts/gen_icon.go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/png"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
const size = 1024
|
||||||
|
const ss = 4 // supersampling factor per axis
|
||||||
|
|
||||||
|
func lerp(a, b, t float64) float64 { return a + (b-a)*t }
|
||||||
|
|
||||||
|
type rgb struct{ r, g, b float64 }
|
||||||
|
|
||||||
|
func mix(a, b rgb, t float64) rgb {
|
||||||
|
return rgb{lerp(a.r, b.r, t), lerp(a.g, b.g, t), lerp(a.b, b.b, t)}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
bgTop = rgb{58, 42, 94}
|
||||||
|
bgBottom = rgb{23, 21, 28}
|
||||||
|
circleTop = rgb{168, 85, 247}
|
||||||
|
circleBottom = rgb{124, 58, 237}
|
||||||
|
white = rgb{255, 255, 255}
|
||||||
|
)
|
||||||
|
|
||||||
|
func inTriangle(px, py, ax, ay, bx, by, cx, cy float64) bool {
|
||||||
|
sign := func(x1, y1, x2, y2, x3, y3 float64) float64 {
|
||||||
|
return (x1-x3)*(y2-y3) - (x2-x3)*(y1-y3)
|
||||||
|
}
|
||||||
|
d1 := sign(px, py, ax, ay, bx, by)
|
||||||
|
d2 := sign(px, py, bx, by, cx, cy)
|
||||||
|
d3 := sign(px, py, cx, cy, ax, ay)
|
||||||
|
neg := d1 < 0 || d2 < 0 || d3 < 0
|
||||||
|
pos := d1 > 0 || d2 > 0 || d3 > 0
|
||||||
|
return !(neg && pos)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sample(x, y float64) rgb {
|
||||||
|
// background vertical gradient
|
||||||
|
c := mix(bgTop, bgBottom, y/size)
|
||||||
|
|
||||||
|
// centered circle
|
||||||
|
cx, cy, cr := float64(size)/2, float64(size)/2, float64(size)*0.31
|
||||||
|
dx, dy := x-cx, y-cy
|
||||||
|
if d := dx*dx + dy*dy; d <= cr*cr {
|
||||||
|
t := (y - (cy - cr)) / (2 * cr)
|
||||||
|
circle := mix(circleTop, circleBottom, t)
|
||||||
|
c = circle
|
||||||
|
}
|
||||||
|
|
||||||
|
// play triangle
|
||||||
|
tx := float64(size) * 0.512
|
||||||
|
ax, ay := tx-float64(size)*0.145, float64(size)*0.342
|
||||||
|
bx, by := tx-float64(size)*0.145, float64(size)*0.658
|
||||||
|
cxv, cyv := tx+float64(size)*0.165, float64(size)*0.5
|
||||||
|
if inTriangle(x, y, ax, ay, bx, by, cxv, cyv) {
|
||||||
|
c = white
|
||||||
|
}
|
||||||
|
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, size, size))
|
||||||
|
for y := 0; y < size; y++ {
|
||||||
|
for x := 0; x < size; x++ {
|
||||||
|
var acc rgb
|
||||||
|
for sy := 0; sy < ss; sy++ {
|
||||||
|
for sx := 0; sx < ss; sx++ {
|
||||||
|
fx := float64(x) + (float64(sx)+0.5)/ss
|
||||||
|
fy := float64(y) + (float64(sy)+0.5)/ss
|
||||||
|
s := sample(fx, fy)
|
||||||
|
acc.r += s.r
|
||||||
|
acc.g += s.g
|
||||||
|
acc.b += s.b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n := float64(ss * ss)
|
||||||
|
img.SetRGBA(x, y, color.RGBA{
|
||||||
|
R: uint8(acc.r / n),
|
||||||
|
G: uint8(acc.g / n),
|
||||||
|
B: uint8(acc.b / n),
|
||||||
|
A: 255,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Create(os.Args[len(os.Args)-1])
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
if err := png.Encode(f, img); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
4
desktop/src-tauri/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
binaries/*
|
||||||
|
!binaries/.gitkeep
|
||||||
|
target/
|
||||||
|
gen/
|
||||||
5207
desktop/src-tauri/Cargo.lock
generated
Normal file
34
desktop/src-tauri/Cargo.toml
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
[package]
|
||||||
|
name = "quintodrome"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Desktop wrapper that hosts Navidrome"
|
||||||
|
authors = ["Quintodrome"]
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "quintodrome_lib"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = ["unstable"] }
|
||||||
|
tauri-plugin-shell = "2"
|
||||||
|
tauri-plugin-opener = "2"
|
||||||
|
serde_json = "1"
|
||||||
|
thiserror = "1"
|
||||||
|
|
||||||
|
[target.'cfg(unix)'.dependencies]
|
||||||
|
libc = "0.2"
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
|
objc2 = "0.6"
|
||||||
|
objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSEvent"] }
|
||||||
|
objc2-web-kit = { version = "0.3", features = ["WKWebView"] }
|
||||||
|
block2 = "0.6"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
# This feature is used for production builds or when `devPath` points to the
|
||||||
|
# filesystem and the built-in dev server is disabled.
|
||||||
|
custom-protocol = ["tauri/custom-protocol"]
|
||||||
3
desktop/src-tauri/build.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
10
desktop/src-tauri/capabilities/default.json
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Default capability for the Quintodrome desktop window",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": ["core:default", "core:event:default", "shell:allow-open"],
|
||||||
|
"remote": {
|
||||||
|
"urls": ["http://127.0.0.1:4533", "http://localhost:4533"]
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
desktop/src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
desktop/src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
BIN
desktop/src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
desktop/src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
desktop/src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
desktop/src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
desktop/src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
desktop/src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
desktop/src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
desktop/src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
desktop/src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
desktop/src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 3 KiB |
BIN
desktop/src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
desktop/src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
desktop/src-tauri/icons/icon.icns
Normal file
BIN
desktop/src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
desktop/src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
335
desktop/src-tauri/src/lib.rs
Normal file
|
|
@ -0,0 +1,335 @@
|
||||||
|
mod server;
|
||||||
|
|
||||||
|
use server::{Navidrome, ServerState};
|
||||||
|
use tauri::{
|
||||||
|
Emitter, LogicalPosition, LogicalSize, Manager, RunEvent, Url, WebviewBuilder, WebviewUrl,
|
||||||
|
WindowEvent,
|
||||||
|
};
|
||||||
|
use tauri_plugin_opener::OpenerExt;
|
||||||
|
|
||||||
|
const TOOLBAR_HEIGHT: f64 = 60.0;
|
||||||
|
const DEFAULT_PUBLIC_URL: &str = "http://quintodrome";
|
||||||
|
|
||||||
|
/// Maps the internal Navidrome origin (e.g. `http://127.0.0.1:4533`) to a
|
||||||
|
/// friendlier, user-facing origin shown in the address bar.
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct UrlMask {
|
||||||
|
real: String,
|
||||||
|
public: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UrlMask {
|
||||||
|
fn new(host: &str, port: u16) -> Self {
|
||||||
|
let real = format!("http://{host}:{port}");
|
||||||
|
let public = std::env::var("QUINTODROME_PUBLIC_URL")
|
||||||
|
.unwrap_or_else(|_| DEFAULT_PUBLIC_URL.to_string());
|
||||||
|
Self { real, public }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mask(&self, url: &str) -> String {
|
||||||
|
url.strip_prefix(&self.real)
|
||||||
|
.map(|rest| format!("{}{}", self.public, rest))
|
||||||
|
.unwrap_or_else(|| url.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unmask(&self, url: &str) -> String {
|
||||||
|
url.strip_prefix(&self.public)
|
||||||
|
.map(|rest| format!("{}{}", self.real, rest))
|
||||||
|
.unwrap_or_else(|| url.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn back(app: tauri::AppHandle) {
|
||||||
|
if let Some(content) = app.get_webview("content") {
|
||||||
|
let _ = content.eval("history.back()");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn forward(app: tauri::AppHandle) {
|
||||||
|
if let Some(content) = app.get_webview("content") {
|
||||||
|
let _ = content.eval("history.forward()");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn reload(app: tauri::AppHandle) {
|
||||||
|
if let Some(content) = app.get_webview("content") {
|
||||||
|
let _ = content.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn navigate(app: tauri::AppHandle, url: String) {
|
||||||
|
let url = app.state::<UrlMask>().unmask(&url);
|
||||||
|
if let Some(content) = app.get_webview("content") {
|
||||||
|
if let Ok(url) = Url::parse(&url) {
|
||||||
|
let _ = content.navigate(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enables two-finger swipe-to-navigate (back/forward) in the content webview.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn enable_swipe_navigation(app: &tauri::AppHandle) {
|
||||||
|
if let Some(content) = app.get_webview("content") {
|
||||||
|
let _ = content.with_webview(|webview| unsafe {
|
||||||
|
let view: &objc2_web_kit::WKWebView = &*webview.inner().cast();
|
||||||
|
view.setAllowsBackForwardNavigationGestures(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keeps the address bar in sync with the content webview's real URL.
|
||||||
|
fn start_url_poller(app: &tauri::AppHandle, mask: UrlMask) {
|
||||||
|
let handle = app.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let mut last: Option<String> = None;
|
||||||
|
loop {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(400));
|
||||||
|
let Some(content) = handle.get_webview("content") else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Ok(url) = content.url() else { continue };
|
||||||
|
if url.scheme() != "http" && url.scheme() != "https" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if url.host_str() == Some("tauri.localhost") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let masked = mask.mask(url.as_str());
|
||||||
|
if last.as_deref() != Some(masked.as_str()) {
|
||||||
|
if let Some(toolbar) = handle.get_webview("main") {
|
||||||
|
let _ = toolbar.emit("url-changed", masked.clone());
|
||||||
|
}
|
||||||
|
last = Some(masked);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Positions the toolbar strip at the top and the content webview below it.
|
||||||
|
fn layout(app: &tauri::AppHandle) {
|
||||||
|
let Some(window) = app.get_window("main") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Ok(size) = window.inner_size() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Ok(scale) = window.scale_factor() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let width = size.width as f64 / scale;
|
||||||
|
let height = size.height as f64 / scale;
|
||||||
|
|
||||||
|
if let Some(toolbar) = app.get_webview("main") {
|
||||||
|
let _ = toolbar.set_auto_resize(false);
|
||||||
|
let _ = toolbar.set_position(LogicalPosition::new(0.0, 0.0));
|
||||||
|
let _ = toolbar.set_size(LogicalSize::new(width, TOOLBAR_HEIGHT));
|
||||||
|
}
|
||||||
|
if let Some(content) = app.get_webview("content") {
|
||||||
|
let _ = content.set_auto_resize(false);
|
||||||
|
let _ = content.set_position(LogicalPosition::new(0.0, TOOLBAR_HEIGHT));
|
||||||
|
let _ = content.set_size(LogicalSize::new(width, (height - TOOLBAR_HEIGHT).max(0.0)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Installs a local NSEvent monitor that intercepts Cmd+X/C/V/A/Z and
|
||||||
|
/// dispatches the matching native editing selector to the first responder.
|
||||||
|
///
|
||||||
|
/// Menu key equivalents don't reach the webviews (content or devtools) in
|
||||||
|
/// Tauri on macOS, so we intercept the key events at the app level instead —
|
||||||
|
/// this is the only reliable way to make cut/copy/paste work in the devtools
|
||||||
|
/// console.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn install_edit_shortcut_monitor(handle: &tauri::AppHandle) {
|
||||||
|
use objc2::runtime::{AnyObject, Sel};
|
||||||
|
use objc2::MainThreadMarker;
|
||||||
|
use objc2_app_kit::{NSApplication, NSEvent, NSEventMask, NSEventModifierFlags};
|
||||||
|
use std::ptr::NonNull;
|
||||||
|
|
||||||
|
let Some(mtm) = MainThreadMarker::new() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let app = NSApplication::sharedApplication(mtm);
|
||||||
|
let handle = handle.clone();
|
||||||
|
|
||||||
|
let block = block2::RcBlock::new(move |event: NonNull<NSEvent>| -> *mut NSEvent {
|
||||||
|
let event = unsafe { event.as_ref() };
|
||||||
|
let flags = event.modifierFlags();
|
||||||
|
if !flags.contains(NSEventModifierFlags::Command) {
|
||||||
|
return event as *const NSEvent as *mut NSEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = event
|
||||||
|
.charactersIgnoringModifiers()
|
||||||
|
.map(|s| s.to_string().to_lowercase())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let selector: Option<&'static std::ffi::CStr> = match key.as_str() {
|
||||||
|
"x" => Some(c"cut:"),
|
||||||
|
"c" => Some(c"copy:"),
|
||||||
|
"v" => Some(c"paste:"),
|
||||||
|
"a" => Some(c"selectAll:"),
|
||||||
|
"z" => {
|
||||||
|
if flags.contains(NSEventModifierFlags::Shift) {
|
||||||
|
Some(c"redo:")
|
||||||
|
} else {
|
||||||
|
Some(c"undo:")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(selector) = selector else {
|
||||||
|
return event as *const NSEvent as *mut NSEvent;
|
||||||
|
};
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
let _: bool = app.sendAction_to_from(
|
||||||
|
Sel::register(selector),
|
||||||
|
None::<&AnyObject>,
|
||||||
|
None::<&AnyObject>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectAll:/undo:/redo: don't dispatch through the responder chain in
|
||||||
|
// WKWebView, so drive the content webview via execCommand too — but
|
||||||
|
// only when an editable element is actually focused, otherwise we'd
|
||||||
|
// select the whole document.
|
||||||
|
let js = match key.as_str() {
|
||||||
|
"a" => Some(
|
||||||
|
"(function(){var e=document.activeElement;if(e&&(e.tagName==='INPUT'||e.tagName==='TEXTAREA'||e.isContentEditable))document.execCommand('selectAll')})()",
|
||||||
|
),
|
||||||
|
"z" if flags.contains(NSEventModifierFlags::Shift) => {
|
||||||
|
Some("document.execCommand('redo')")
|
||||||
|
}
|
||||||
|
"z" => Some("document.execCommand('undo')"),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(js) = js {
|
||||||
|
if let Some(content) = handle.get_webview("content") {
|
||||||
|
let _ = content.eval(js);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::ptr::null_mut()
|
||||||
|
});
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
let monitor =
|
||||||
|
NSEvent::addLocalMonitorForEventsMatchingMask_handler(NSEventMask::KeyDown, &block);
|
||||||
|
if let Some(monitor) = monitor {
|
||||||
|
std::mem::forget(monitor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
pub fn run() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_shell::init())
|
||||||
|
.plugin(tauri_plugin_opener::init())
|
||||||
|
// Native menu so Edit > Cut/Copy/Paste work via clicks.
|
||||||
|
.menu(|handle| tauri::menu::Menu::default(handle))
|
||||||
|
.manage(ServerState::default())
|
||||||
|
.invoke_handler(tauri::generate_handler![back, forward, reload, navigate])
|
||||||
|
.setup(|app| {
|
||||||
|
let handle = app.handle().clone();
|
||||||
|
|
||||||
|
let host = std::env::var("QUINTODROME_HOST")
|
||||||
|
.unwrap_or_else(|_| server::DEFAULT_HOST.to_string());
|
||||||
|
let port = std::env::var("QUINTODROME_PORT")
|
||||||
|
.ok()
|
||||||
|
.and_then(|p| p.parse::<u16>().ok())
|
||||||
|
.unwrap_or(server::DEFAULT_PORT);
|
||||||
|
|
||||||
|
let navidrome = Navidrome::new(host.clone(), port);
|
||||||
|
let navidrome_url = navidrome.url.clone();
|
||||||
|
|
||||||
|
let mask = UrlMask::new(&host, port);
|
||||||
|
app.manage(mask.clone());
|
||||||
|
|
||||||
|
// The primary webview ("main") is the toolbar; the Navidrome content
|
||||||
|
// lives in a child webview below it.
|
||||||
|
let content = WebviewBuilder::new("content", WebviewUrl::App("index.html".into()))
|
||||||
|
.on_new_window({
|
||||||
|
let handle = handle.clone();
|
||||||
|
move |url, _features| {
|
||||||
|
// Open external links (Last.fm, etc.) in the default
|
||||||
|
// browser instead of a new in-app window.
|
||||||
|
if url.scheme() == "http" || url.scheme() == "https" {
|
||||||
|
let _ = handle.opener().open_url(url.to_string(), None::<&str>);
|
||||||
|
}
|
||||||
|
tauri::webview::NewWindowResponse::Deny
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(window) = app.get_window("main") {
|
||||||
|
let _ = window.add_child(
|
||||||
|
content,
|
||||||
|
LogicalPosition::new(0.0, TOOLBAR_HEIGHT),
|
||||||
|
LogicalSize::new(800.0, 600.0),
|
||||||
|
);
|
||||||
|
let handle = handle.clone();
|
||||||
|
window.on_window_event(move |event| {
|
||||||
|
if let WindowEvent::Resized(_) = event {
|
||||||
|
layout(&handle);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
layout(app.handle());
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
enable_swipe_navigation(app.handle());
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
install_edit_shortcut_monitor(app.handle());
|
||||||
|
|
||||||
|
if let Some(toolbar) = app.get_webview("main") {
|
||||||
|
let _ = toolbar.emit("url-changed", mask.mask(&navidrome_url));
|
||||||
|
}
|
||||||
|
|
||||||
|
start_url_poller(app.handle(), mask);
|
||||||
|
|
||||||
|
// Detect/start Navidrome off the main thread, then load it.
|
||||||
|
let content_url = navidrome_url.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
if let Err(err) = server::ensure_running(&handle, &navidrome) {
|
||||||
|
eprintln!("quintodrome: {err}");
|
||||||
|
if let Some(content) = handle.get_webview("content") {
|
||||||
|
let message = serde_json::to_string(&err.to_string()).unwrap();
|
||||||
|
let _ = content.eval(&format!(
|
||||||
|
"document.getElementById('spinner').hidden = true;\
|
||||||
|
document.getElementById('status').hidden = true;\
|
||||||
|
var e = document.getElementById('error');\
|
||||||
|
e.hidden = false;\
|
||||||
|
e.textContent = 'Failed to start Navidrome: ' + {message};"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace (not push) so the splash page isn't left in history —
|
||||||
|
// otherwise "back" would return to the loading screen.
|
||||||
|
if let Some(content) = handle.get_webview("content") {
|
||||||
|
let js = format!(
|
||||||
|
"location.replace({})",
|
||||||
|
serde_json::to_string(&content_url).unwrap()
|
||||||
|
);
|
||||||
|
let _ = content.eval(&js);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.build(tauri::generate_context!())
|
||||||
|
.expect("error while building Quintodrome")
|
||||||
|
.run(|app, event| {
|
||||||
|
if let RunEvent::Exit = event {
|
||||||
|
server::shutdown(&app.state::<ServerState>());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
5
desktop/src-tauri/src/main.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
quintodrome_lib::run();
|
||||||
|
}
|
||||||
239
desktop/src-tauri/src/server.rs
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
use std::{
|
||||||
|
io::{Read, Write},
|
||||||
|
net::{TcpStream, ToSocketAddrs},
|
||||||
|
sync::Mutex,
|
||||||
|
thread,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
|
use tauri::{AppHandle, Manager};
|
||||||
|
use tauri_plugin_shell::{
|
||||||
|
process::{CommandChild, CommandEvent},
|
||||||
|
ShellExt,
|
||||||
|
};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
pub const DEFAULT_HOST: &str = "127.0.0.1";
|
||||||
|
pub const DEFAULT_PORT: u16 = 4533;
|
||||||
|
|
||||||
|
const READY_TIMEOUT: Duration = Duration::from_secs(60);
|
||||||
|
const GRACEFUL_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(3);
|
||||||
|
const HEALTH_PATH: &str = "/ping";
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum ServerError {
|
||||||
|
#[error("could not resolve the application data folder")]
|
||||||
|
DataFolder,
|
||||||
|
#[error("could not resolve the music folder")]
|
||||||
|
MusicFolder,
|
||||||
|
#[error("failed to spawn navidrome: {0}")]
|
||||||
|
Spawn(#[from] tauri_plugin_shell::Error),
|
||||||
|
#[error("navidrome did not become ready within {READY_TIMEOUT:?}")]
|
||||||
|
Timeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Holds the handle to the Navidrome child process so it can be shut down
|
||||||
|
/// gracefully when the app exits. It is `None` when Navidrome was already
|
||||||
|
/// running before the app started.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct ServerState {
|
||||||
|
child: Mutex<Option<CommandChild>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Navidrome {
|
||||||
|
pub host: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Navidrome {
|
||||||
|
pub fn new(host: String, port: u16) -> Self {
|
||||||
|
let url = format!("http://{host}:{port}");
|
||||||
|
Self { host, port, url }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensures a Navidrome server is reachable at `navidrome.url`. If one is
|
||||||
|
/// already listening, it is reused as-is. Otherwise a bundled Navidrome binary
|
||||||
|
/// is spawned as a separate child process and we wait for it to become ready.
|
||||||
|
pub fn ensure_running(handle: &AppHandle, navidrome: &Navidrome) -> Result<(), ServerError> {
|
||||||
|
if is_ready(&navidrome.host, navidrome.port) {
|
||||||
|
eprintln!(
|
||||||
|
"quintodrome: navidrome is already running at {}",
|
||||||
|
navidrome.url
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!("quintodrome: navidrome is not running, starting it...");
|
||||||
|
spawn(handle, navidrome)?;
|
||||||
|
wait_until_ready(&navidrome.host, navidrome.port)?;
|
||||||
|
eprintln!("quintodrome: navidrome is ready at {}", navidrome.url);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn(handle: &AppHandle, navidrome: &Navidrome) -> Result<(), ServerError> {
|
||||||
|
let data_folder = handle
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|_| ServerError::DataFolder)?;
|
||||||
|
std::fs::create_dir_all(&data_folder).ok();
|
||||||
|
|
||||||
|
let music_folder = match std::env::var_os("QUINTODROME_MUSIC_FOLDER") {
|
||||||
|
Some(path) if !path.is_empty() => path.into(),
|
||||||
|
_ => handle
|
||||||
|
.path()
|
||||||
|
.audio_dir()
|
||||||
|
.map_err(|_| ServerError::MusicFolder)?,
|
||||||
|
};
|
||||||
|
std::fs::create_dir_all(&music_folder).ok();
|
||||||
|
|
||||||
|
let data_folder = data_folder.to_string_lossy().to_string();
|
||||||
|
let music_folder = music_folder.to_string_lossy().to_string();
|
||||||
|
let port = navidrome.port.to_string();
|
||||||
|
|
||||||
|
let args = vec![
|
||||||
|
"--nobanner".to_string(),
|
||||||
|
"--address".to_string(),
|
||||||
|
navidrome.host.clone(),
|
||||||
|
"--port".to_string(),
|
||||||
|
port,
|
||||||
|
"--datafolder".to_string(),
|
||||||
|
data_folder,
|
||||||
|
"--musicfolder".to_string(),
|
||||||
|
music_folder,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Auto-provision the first admin user on a fresh install. Navidrome only
|
||||||
|
// honors this during initial setup (when no users/data exist), so an
|
||||||
|
// existing installation is left untouched.
|
||||||
|
let admin_password =
|
||||||
|
std::env::var("QUINTODROME_ADMIN_PASSWORD").unwrap_or_else(|_| "admin".to_string());
|
||||||
|
|
||||||
|
let (mut rx, child) = handle
|
||||||
|
.shell()
|
||||||
|
.sidecar("navidrome")?
|
||||||
|
.env(
|
||||||
|
"ND_DEVAUTOCREATEADMINPASSWORD",
|
||||||
|
admin_password.as_str(),
|
||||||
|
)
|
||||||
|
.env("ND_ENABLEINSIGHTSCOLLECTOR", "false")
|
||||||
|
.env("ND_DEVAUTOLOGINUSERNAME", "admin")
|
||||||
|
.args(args)
|
||||||
|
.spawn()?;
|
||||||
|
|
||||||
|
let pid = child.pid();
|
||||||
|
eprintln!("quintodrome: started navidrome (pid {pid})");
|
||||||
|
|
||||||
|
*handle.state::<ServerState>().child.lock().unwrap() = Some(child);
|
||||||
|
|
||||||
|
// Forward Navidrome's output to our own stdout/stderr.
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
while let Some(event) = rx.recv().await {
|
||||||
|
match event {
|
||||||
|
CommandEvent::Stdout(line) => {
|
||||||
|
print!("{}", String::from_utf8_lossy(&line));
|
||||||
|
}
|
||||||
|
CommandEvent::Stderr(line) => {
|
||||||
|
eprint!("{}", String::from_utf8_lossy(&line));
|
||||||
|
}
|
||||||
|
CommandEvent::Terminated(payload) => {
|
||||||
|
eprintln!(
|
||||||
|
"quintodrome: navidrome exited with code {:?}",
|
||||||
|
payload.code
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wait_until_ready(host: &str, port: u16) -> Result<(), ServerError> {
|
||||||
|
let deadline = Instant::now() + READY_TIMEOUT;
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
if is_ready(host, port) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_millis(250));
|
||||||
|
}
|
||||||
|
Err(ServerError::Timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Performs a lightweight HTTP health check against Navidrome's `/ping`
|
||||||
|
/// endpoint. A successful `200` response means the server is up and serving.
|
||||||
|
fn is_ready(host: &str, port: u16) -> bool {
|
||||||
|
let addr = match (host, port).to_socket_addrs().ok().and_then(|mut it| it.next()) {
|
||||||
|
Some(addr) => addr,
|
||||||
|
None => return false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut stream = match TcpStream::connect_timeout(&addr, Duration::from_millis(500)) {
|
||||||
|
Ok(stream) => stream,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = stream.set_read_timeout(Some(Duration::from_millis(500)));
|
||||||
|
let _ = stream.set_write_timeout(Some(Duration::from_millis(500)));
|
||||||
|
|
||||||
|
let request = format!(
|
||||||
|
"GET {HEALTH_PATH} HTTP/1.1\r\nHost: {host}:{port}\r\nConnection: close\r\n\r\n"
|
||||||
|
);
|
||||||
|
if stream.write_all(request.as_bytes()).is_err() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut response = Vec::new();
|
||||||
|
let mut buffer = [0u8; 512];
|
||||||
|
loop {
|
||||||
|
match stream.read(&mut buffer) {
|
||||||
|
Ok(0) => break,
|
||||||
|
Ok(n) => response.extend_from_slice(&buffer[..n]),
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = String::from_utf8_lossy(&response);
|
||||||
|
response.starts_with("HTTP/1.1 200") || response.starts_with("HTTP/1.0 200")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends SIGTERM to the spawned Navidrome process (so it can shut down
|
||||||
|
/// gracefully) and falls back to a hard kill if it does not exit in time.
|
||||||
|
pub fn shutdown(state: &ServerState) {
|
||||||
|
let child = match state.child.lock().unwrap().take() {
|
||||||
|
Some(child) => child,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let pid = child.pid();
|
||||||
|
eprintln!("quintodrome: stopping navidrome (pid {pid})");
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
unsafe {
|
||||||
|
libc::kill(pid as libc::pid_t, libc::SIGTERM);
|
||||||
|
}
|
||||||
|
|
||||||
|
let deadline = Instant::now() + GRACEFUL_SHUTDOWN_TIMEOUT;
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
if !process_alive(pid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(err) = child.kill() {
|
||||||
|
eprintln!("quintodrome: failed to kill navidrome: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn process_alive(pid: u32) -> bool {
|
||||||
|
unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn process_alive(_pid: u32) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
41
desktop/src-tauri/tauri.conf.json
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "Quintodrome",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "com.quintodrome.desktop",
|
||||||
|
"build": {
|
||||||
|
"frontendDist": "../src"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"withGlobalTauri": true,
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"label": "main",
|
||||||
|
"title": "Quintodrome",
|
||||||
|
"width": 1280,
|
||||||
|
"height": 800,
|
||||||
|
"minWidth": 800,
|
||||||
|
"minHeight": 600,
|
||||||
|
"resizable": true,
|
||||||
|
"fullscreen": false,
|
||||||
|
"center": true,
|
||||||
|
"url": "toolbar.html"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": "all",
|
||||||
|
"icon": [
|
||||||
|
"icons/32x32.png",
|
||||||
|
"icons/128x128.png",
|
||||||
|
"icons/128x128@2x.png",
|
||||||
|
"icons/icon.icns",
|
||||||
|
"icons/icon.ico"
|
||||||
|
],
|
||||||
|
"externalBin": ["binaries/navidrome"]
|
||||||
|
}
|
||||||
|
}
|
||||||
67
desktop/src/index.html
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Quintodrome</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #1b1b1f;
|
||||||
|
color: #e4e4e7;
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 420px;
|
||||||
|
}
|
||||||
|
.spinner {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
border: 3px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-top-color: #a855f7;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#status {
|
||||||
|
color: #a1a1aa;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
#error {
|
||||||
|
color: #f87171;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-top: 12px;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="spinner" id="spinner"></div>
|
||||||
|
<p id="status">Starting Navidrome…</p>
|
||||||
|
<p id="error" hidden></p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
91
desktop/src/toolbar.html
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Quintodrome</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 10px;
|
||||||
|
background: #232329;
|
||||||
|
border-bottom: 1px solid #34343c;
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: #d4d4d8;
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
background: #34343c;
|
||||||
|
}
|
||||||
|
button:active {
|
||||||
|
background: #3f3f46;
|
||||||
|
}
|
||||||
|
#url {
|
||||||
|
flex: 1;
|
||||||
|
height: 42px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border: 1px solid #34343c;
|
||||||
|
border-radius: 21px;
|
||||||
|
background: #1b1b1f;
|
||||||
|
color: #e4e4e7;
|
||||||
|
font-size: 15px;
|
||||||
|
outline: none;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
#url:focus {
|
||||||
|
border-color: #a855f7;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<button id="back" title="Back">←</button>
|
||||||
|
<button id="forward" title="Forward">→</button>
|
||||||
|
<button id="reload" title="Reload">⟳</button>
|
||||||
|
<input id="url" type="text" spellcheck="false" placeholder="http://quintodrome" />
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const { invoke } = window.__TAURI__.core;
|
||||||
|
const { listen } = window.__TAURI__.event;
|
||||||
|
const urlInput = document.getElementById("url");
|
||||||
|
|
||||||
|
document.getElementById("back").addEventListener("click", () => invoke("back"));
|
||||||
|
document.getElementById("forward").addEventListener("click", () => invoke("forward"));
|
||||||
|
document.getElementById("reload").addEventListener("click", () => invoke("reload"));
|
||||||
|
|
||||||
|
urlInput.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
invoke("navigate", { url: urlInput.value.trim() });
|
||||||
|
urlInput.blur();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
listen("url-changed", (event) => {
|
||||||
|
urlInput.value = event.payload;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -15,9 +15,9 @@ builds:
|
||||||
- darwin_arm64
|
- darwin_arm64
|
||||||
- linux_386
|
- linux_386
|
||||||
- linux_amd64
|
- linux_amd64
|
||||||
- linux_arm_v5
|
- linux_arm_5
|
||||||
- linux_arm_v6
|
- linux_arm_6
|
||||||
- linux_arm_v7
|
- linux_arm_7
|
||||||
- linux_arm64
|
- linux_arm64
|
||||||
- linux_riscv64
|
- linux_riscv64
|
||||||
- windows_386
|
- windows_386
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,9 @@ mkdir -p "$(dirname "$output")"
|
||||||
# Build the source folder name based on GOOS, GOARCH and GOARM.
|
# Build the source folder name based on GOOS, GOARCH and GOARM.
|
||||||
source="${GOOS}_${GOARCH}"
|
source="${GOOS}_${GOARCH}"
|
||||||
if [ "$GOARCH" = "arm" ]; then
|
if [ "$GOARCH" = "arm" ]; then
|
||||||
source="${source}_${GOARM}"
|
# GOARM is bare ("5", "6", "7"); the Docker build output uses a "v" prefix
|
||||||
|
# (e.g. linux_arm_v5), so add it back here to match.
|
||||||
|
source="${source}_v${GOARM}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Copy the output to the desired location
|
# Copy the output to the desired location
|
||||||
|
|
|
||||||