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:
|
||||
interval: weekly
|
||||
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
|
||||
directory: "/"
|
||||
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
|
||||
needs: [build, msi]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
outputs:
|
||||
package_list: ${{ steps.set-package-list.outputs.package_list }}
|
||||
steps:
|
||||
|
|
|
|||
2
.gitignore
vendored
|
|
@ -40,3 +40,5 @@ openspec/
|
|||
.agents
|
||||
go.work*
|
||||
.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)
|
||||
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
|
||||
ENV XX_VERSION=a5592eab7a57895e8d385394ff12241bc65ecd50
|
||||
|
|
@ -26,7 +26,7 @@ COPY --from=xx-build /out/ /usr/bin/
|
|||
|
||||
########################################################################################################################
|
||||
### 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
|
||||
|
||||
# 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)
|
||||
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 / /
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
|
|
@ -82,7 +82,7 @@ EOT
|
|||
|
||||
########################################################################################################################
|
||||
### 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
|
||||
COPY --from=xx / /
|
||||
WORKDIR /workspace
|
||||
|
|
@ -139,7 +139,7 @@ COPY --from=build /out /
|
|||
|
||||
########################################################################################################################
|
||||
### 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 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)."
|
||||
173
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)
|
||||
[](https://nightly.link/navidrome/navidrome/workflows/pipeline/master)
|
||||
[](https://github.com/navidrome/navidrome/releases/latest)
|
||||
[](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)
|
||||
A [Tauri](https://tauri.app) desktop app that wraps
|
||||
[Navidrome](https://www.navidrome.org) — your self-hosted music server — into a
|
||||
native macOS/Windows application.
|
||||
|
||||
Navidrome is an open source web-based music collection server and streamer. It gives you freedom to listen to your
|
||||
music collection from any browser or mobile device. It's like your personal Spotify!
|
||||
The server is bundled as a sidecar binary and managed automatically, so there's
|
||||
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.
|
||||
Please use [releases](https://github.com/navidrome/navidrome/releases) instead of
|
||||
the `master` branch in order to get a stable set of binaries.
|
||||
## What it does
|
||||
|
||||
## [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,
|
||||
please file a [GitHub issue](https://github.com/navidrome/navidrome/issues) or join the discussion in our
|
||||
[Subreddit](https://www.reddit.com/r/navidrome/). If you want to contribute to the project in any other way
|
||||
([ui/backend dev](https://www.navidrome.org/docs/developers/),
|
||||
[translations](https://www.navidrome.org/docs/developers/translations/),
|
||||
[themes](https://www.navidrome.org/docs/developers/creating-themes)), please join the chat in our
|
||||
[Discord server](https://discord.gg/xh7j7yF).
|
||||
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).
|
||||
|
||||
## 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
|
||||
[officially supported, cloud-hosted solution](https://www.navidrome.org/docs/installation/managed/#pikapods).
|
||||
A share of the revenue helps fund the development of Navidrome at no additional cost for you.
|
||||
```sh
|
||||
# one-time: Node >= 24 (see .nvmrc), Go >= 1.26, Rust
|
||||
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
|
||||
|
||||
- Handles very **large music collections**
|
||||
- Streams virtually **any audio format** available
|
||||
- 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**
|
||||
To wipe Navidrome's persistent state (database, cache) and start fresh:
|
||||
|
||||
## Translations
|
||||
```sh
|
||||
just clean-state
|
||||
```
|
||||
|
||||
Navidrome uses [POEditor](https://poeditor.com/) for translations, and we are always looking
|
||||
for [more contributors](https://www.navidrome.org/docs/developers/translations/)
|
||||
## Building a distributable
|
||||
|
||||
<a href="https://poeditor.com/">
|
||||
<img height="32" src="https://github.com/user-attachments/assets/c19b1d2b-01e1-4682-a007-12356c42147c">
|
||||
</a>
|
||||
```sh
|
||||
just bundle # or: ./desktop/scripts/build.sh
|
||||
```
|
||||
|
||||
## Documentation
|
||||
All documentation can be found in the project's website: https://www.navidrome.org/docs.
|
||||
Here are some useful direct links:
|
||||
This produces the platform's packaged app under
|
||||
`desktop/src-tauri/target/release/bundle/` (e.g. `macos/Quintodrome.app` on
|
||||
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.
|
||||
|
||||
- [Overview](https://www.navidrome.org/docs/overview/)
|
||||
- [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/)
|
||||
> Artifacts are unsigned (no Apple/Windows code-signing certs), so users will
|
||||
> see Gatekeeper/SmartScreen prompts.
|
||||
|
||||
## Screenshots
|
||||
## Configuration
|
||||
|
||||
<p align="left">
|
||||
<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">
|
||||
<img height="550" src="https://raw.githubusercontent.com/navidrome/navidrome/master/.github/screenshots/ss-mobile-album-view.png">
|
||||
<img width="550" src="https://raw.githubusercontent.com/navidrome/navidrome/master/.github/screenshots/ss-desktop-player.png">
|
||||
</p>
|
||||
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 (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
|
||||
}
|
||||
|
||||
//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] })
|
||||
|
||||
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))
|
||||
copy(timesShuffled, timesDecreasingChronologically)
|
||||
//nolint:gosec // test-only shuffle of backup times, no cryptographic requirement
|
||||
rand.Shuffle(len(timesShuffled), func(i, j int) {
|
||||
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
|
||||
- linux_386
|
||||
- linux_amd64
|
||||
- linux_arm_v5
|
||||
- linux_arm_v6
|
||||
- linux_arm_v7
|
||||
- linux_arm_5
|
||||
- linux_arm_6
|
||||
- linux_arm_7
|
||||
- linux_arm64
|
||||
- linux_riscv64
|
||||
- windows_386
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ mkdir -p "$(dirname "$output")"
|
|||
# Build the source folder name based on GOOS, GOARCH and GOARM.
|
||||
source="${GOOS}_${GOARCH}"
|
||||
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
|
||||
|
||||
# Copy the output to the desired location
|
||||
|
|
|
|||